Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pending-preferred-focus-child.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': patch
---

`setFocusedChild` queues a preferred-child request whose target is not registered or focusable yet, instead of dropping it. A React child effect runs before its element is attached to the focus tree, so a group's remembered child could not be set up front. Mirrors `focus()`, which already queues for the same reason.
48 changes: 48 additions & 0 deletions packages/react-lightning/src/focus/FocusManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,54 @@ describe('FocusManager', () => {
expect(focusedSpy).not.toHaveBeenCalled();
});

it('fulfills the request once the target element registers', () => {
const root = createMockElement(1, 'root');
const groupA = createMockElement(2, 'groupA');
const groupB = createMockElement(3, 'groupB');
const a1 = createMockElement(4, 'a1');
const b1 = createMockElement(5, 'b1');
const b2 = createMockElement(6, 'b2');

focusManager.addElement(root, null);
focusManager.addElement(groupA, root);
focusManager.addElement(groupB, root);
focusManager.addElement(a1, groupA, { autoFocus: true });
focusManager.addElement(b1, groupB, { autoFocus: true });

focusManager.focus(a1);
expect(focusManager.focusPath).toEqual([root, groupA, a1]);

// b2 hasn't registered yet: a React child effect runs before the element
// is attached to the focus tree. The preference must not be dropped.
focusManager.setFocusedChild(b2);
expect(focusManager.focusPath).toEqual([root, groupA, a1]);

focusManager.addElement(b2, groupB);

// Entering groupB now lands on b2 rather than b1's autoFocus pick.
focusManager.focus(groupB);
expect(focusManager.focusPath).toEqual([root, groupB, b2]);
});

it('cancels a pending preference when the target is removed', () => {
const root = createMockElement(1, 'root');
const groupB = createMockElement(2, 'groupB');
const b1 = createMockElement(3, 'b1');
const b2 = createMockElement(4, 'b2');

focusManager.addElement(root, null);
focusManager.addElement(groupB, root);
focusManager.addElement(b1, groupB, { autoFocus: true });

focusManager.setFocusedChild(b2);
focusManager.removeElement(b2);

// Re-registering must not retroactively apply the cancelled preference.
focusManager.addElement(b2, groupB);
focusManager.focus(groupB);
expect(focusManager.focusPath).toEqual([root, groupB, b1]);
});

it('is a no-op for an unknown element', () => {
const root = createMockElement(1, 'root');
const child = createMockElement(2, 'child');
Expand Down
70 changes: 60 additions & 10 deletions packages/react-lightning/src/focus/FocusManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ export class FocusManager<
*/
private _pendingFocus: T | null = null;

/**
* Preferred-child requests whose target was not yet registered (or not yet
* focusable) when `setFocusedChild()` was called — a React child effect runs
* before its element is attached to the focus tree. Fulfilled per element the
* moment it becomes usable, so a group's remembered child can be set up front
* rather than after mount. Unlike focus, a preference is not exclusive: one
* per element, each resolving against its own parent.
*/
private _pendingPreferredChildren: Set<T> = new Set();

public get activeLayer(): FocusLayer<T> {
if (this._focusStack.length === 0) {
throw new Error('No more focus stacks! This should not occur');
Expand Down Expand Up @@ -272,8 +282,10 @@ export class FocusManager<

this._recalculateFocusPath();

// If a focus request was waiting on this element to register, fulfill it
// now that it's in the tree (and possibly focusable).
// If a focus request or preferred-child preference was waiting on this
// element to register, fulfill it now that it's in the tree (and possibly
// focusable).
this._tryFulfillPendingPreferredChild(child);
this._tryFulfillPendingFocus(child);
}

Expand All @@ -294,6 +306,8 @@ export class FocusManager<
this._pendingFocus = null;
}

this._pendingPreferredChildren.delete(element);

this._forAllNodes(element, (node) => {
this._removeNode(node, true);
});
Expand Down Expand Up @@ -356,11 +370,17 @@ export class FocusManager<
public setFocusedChild(element: T): void {
const node = this.activeLayer.elements.get(element);

if (!node) {
// Not registered yet, or registered but not focusable yet. Queue the
// preference instead of dropping it; it resolves once the element is ready.
if (!node || !element.focusable) {
this._pendingPreferredChildren.add(element);

return;
}

if (!element.focusable || hasExternalRedirect(node)) {
this._pendingPreferredChildren.delete(element);

if (hasExternalRedirect(node)) {
return;
}

Expand All @@ -373,9 +393,11 @@ export class FocusManager<
}

public pushLayer(): void {
// A pending focus targets the layer it was requested in; drop it on a
// layer change so it can't fulfill against the wrong layer.
// A pending focus or preferred-child preference targets the layer it was
// requested in; drop it on a layer change so it can't fulfill against the
// wrong layer.
this._pendingFocus = null;
this._pendingPreferredChildren.clear();

// Store the current layer before creating new one
const previousLayer = this.activeLayer;
Expand Down Expand Up @@ -417,9 +439,11 @@ export class FocusManager<
return;
}

// A pending focus targets the layer it was requested in; drop it on a
// layer change so it can't fulfill against the wrong layer.
// A pending focus or preferred-child preference targets the layer it was
// requested in; drop it on a layer change so it can't fulfill against the
// wrong layer.
this._pendingFocus = null;
this._pendingPreferredChildren.clear();

// Get current layer info before popping
const currentLayer = this.activeLayer;
Expand Down Expand Up @@ -482,6 +506,31 @@ export class FocusManager<
* Fulfill a queued {@link focus} request for `element` if it is now
* registered and focusable. No-op otherwise (it stays queued).
*/
/**
* Apply a queued {@link setFocusedChild} preference for `element` if it is
* now registered and focusable. No-op otherwise (it stays queued).
*/
private _tryFulfillPendingPreferredChild(element: T): void {
if (!this._pendingPreferredChildren.has(element)) {
return;
}

const node = this.activeLayer.elements.get(element);

if (!node || !element.focusable) {
return;
}

this._pendingPreferredChildren.delete(element);

if (hasExternalRedirect(node) || node.parent.focusedElement === node) {
return;
}

node.parent.focusedElement = node;
this._recalculateFocusPath();
}

private _tryFulfillPendingFocus(element: T): void {
if (this._pendingFocus !== element) {
return;
Expand Down Expand Up @@ -619,9 +668,10 @@ export class FocusManager<
this._checkFocusableChildren(currentNode.parent);
this._recalculateFocusPath();

// A queued focus request may have been waiting on this element to
// become focusable.
// A queued focus request or preferred-child preference may have been
// waiting on this element to become focusable.
if (isFocusable) {
this._tryFulfillPendingPreferredChild(element);
this._tryFulfillPendingFocus(element);
}
}),
Expand Down
Loading