diff --git a/.changeset/pending-preferred-focus-child.md b/.changeset/pending-preferred-focus-child.md new file mode 100644 index 0000000..ac2e89f --- /dev/null +++ b/.changeset/pending-preferred-focus-child.md @@ -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. diff --git a/packages/react-lightning/src/focus/FocusManager.spec.ts b/packages/react-lightning/src/focus/FocusManager.spec.ts index d4d3deb..fb9dd22 100644 --- a/packages/react-lightning/src/focus/FocusManager.spec.ts +++ b/packages/react-lightning/src/focus/FocusManager.spec.ts @@ -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'); diff --git a/packages/react-lightning/src/focus/FocusManager.ts b/packages/react-lightning/src/focus/FocusManager.ts index 1d011da..a66ec28 100644 --- a/packages/react-lightning/src/focus/FocusManager.ts +++ b/packages/react-lightning/src/focus/FocusManager.ts @@ -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 = new Set(); + public get activeLayer(): FocusLayer { if (this._focusStack.length === 0) { throw new Error('No more focus stacks! This should not occur'); @@ -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); } @@ -294,6 +306,8 @@ export class FocusManager< this._pendingFocus = null; } + this._pendingPreferredChildren.delete(element); + this._forAllNodes(element, (node) => { this._removeNode(node, true); }); @@ -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; } @@ -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; @@ -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; @@ -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; @@ -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); } }),