From 7cd466dd995b747961c734d7db5c3a613caec019 Mon Sep 17 00:00:00 2001 From: Ruud Date: Thu, 8 Oct 2026 17:08:05 +0200 Subject: [PATCH] feat(focus): focus keys, destination keys and exits --- .changeset/focus-batch-initial-focus.md | 5 + .changeset/focus-entry-scopes-repick.md | 5 + .../focus-keys-destination-keys-exits.md | 5 + .changeset/native-id-focus-key.md | 5 + .changeset/pressable-focusable-false.md | 5 + .../remember-as-nested-and-refocus-hold.md | 5 + .changeset/use-focus-single-registration.md | 5 + .../src/components/Canvas/CanvasRoot.tsx | 4 +- .../react-lightning/src/focus/FocusGroup.tsx | 62 +- .../src/focus/FocusKeyManager.ts | 64 +- .../src/focus/FocusKeys.spec.ts | 159 +++ .../src/focus/FocusManager.spec.ts | 12 +- .../react-lightning/src/focus/FocusManager.ts | 983 ++++++++++++++++-- .../src/focus/FocusRegions.spec.ts | 743 +++++++++++++ .../react-lightning/src/focus/useFocus.tsx | 159 ++- packages/react-lightning/src/index.ts | 2 +- .../src/exports/FocusGroup.tsx | 1 + .../src/exports/Pressable.tsx | 6 +- .../src/exports/View.tsx | 1 + 19 files changed, 2062 insertions(+), 169 deletions(-) create mode 100644 .changeset/focus-batch-initial-focus.md create mode 100644 .changeset/focus-entry-scopes-repick.md create mode 100644 .changeset/focus-keys-destination-keys-exits.md create mode 100644 .changeset/native-id-focus-key.md create mode 100644 .changeset/pressable-focusable-false.md create mode 100644 .changeset/remember-as-nested-and-refocus-hold.md create mode 100644 .changeset/use-focus-single-registration.md create mode 100644 packages/react-lightning/src/focus/FocusKeys.spec.ts create mode 100644 packages/react-lightning/src/focus/FocusRegions.spec.ts diff --git a/.changeset/focus-batch-initial-focus.md b/.changeset/focus-batch-initial-focus.md new file mode 100644 index 00000000..082b598b --- /dev/null +++ b/.changeset/focus-batch-initial-focus.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': minor +--- + +`FocusManager` takes `batchInitialFocus` (on in `CanvasRoot`): the first focus pick and initial focus claims wait for the registrations of the current tick, a claim whose winner has no content yet holds the default pick for up to a second, and a focused item that goes away while its list re-renders is repicked once the new items are in. Entering a group at `first` picks the top-left item instead of the first registered, `last-focused` in `destinationKeys` skips a group that never had focus, an outer `last-focused` group returns to the exact remembered item through nested `first` groups, removal repick falls to the next sibling on ties, and a group that unmounts while focused sends no `onFocusLeave`. A reparented element that stays on the focus path is no longer blurred and refocused. diff --git a/.changeset/focus-entry-scopes-repick.md b/.changeset/focus-entry-scopes-repick.md new file mode 100644 index 00000000..b764a1fb --- /dev/null +++ b/.changeset/focus-entry-scopes-repick.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': minor +--- + +The focus engine implements the native focus semantics itself: `focusEntry` (`first`, `last-focused`, `spatial`), `rememberAs`, scopes with `initialFocus` claims (`refocusInitial()`), nearest-by-geometry repick when the focused element is removed, and `onFocusEnter` / `onFocusLeave` per group. Groups that use none of them behave as before. diff --git a/.changeset/focus-keys-destination-keys-exits.md b/.changeset/focus-keys-destination-keys-exits.md new file mode 100644 index 00000000..eca4549d --- /dev/null +++ b/.changeset/focus-keys-destination-keys-exits.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': minor +--- + +Focus groups can be registered under a string `focusKey`, so other groups can name them without holding a ref. `destinationKeys` forwards arriving focus to the first mounted key (`first` and `last-focused` stand for the group's own first and remembered child), `exits` sends a move that finds nothing inside the group to a key per direction, and `FocusManager.focusByKey` focuses a keyed element. diff --git a/.changeset/native-id-focus-key.md b/.changeset/native-id-focus-key.md new file mode 100644 index 00000000..96240084 --- /dev/null +++ b/.changeset/native-id-focus-key.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-native-lightning': minor +--- + +A focusable View, Pressable or FocusGroup registers its `nativeID` as its focus key, so `destinationKeys`, `exits` and `focusByKey` on react-lightning's focus groups can name it. diff --git a/.changeset/pressable-focusable-false.md b/.changeset/pressable-focusable-false.md new file mode 100644 index 00000000..5d38e7bd --- /dev/null +++ b/.changeset/pressable-focusable-false.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-native-lightning': patch +--- + +`Pressable` honours `focusable={false}`: it registers as an inactive focus target instead of a focusable one. diff --git a/.changeset/remember-as-nested-and-refocus-hold.md b/.changeset/remember-as-nested-and-refocus-hold.md new file mode 100644 index 00000000..dd09d14d --- /dev/null +++ b/.changeset/remember-as-nested-and-refocus-hold.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +A group's `rememberAs` now swaps the whole remembered chain, not only its direct child: a value that was never visited enters at its first item, and a value that comes back returns to the item it remembered by focus key, which still names the right item after a recycling list reused its cells. `refocusInitial` also holds back the repick of the focused item while its target is still mounting, so focus doesn't land on a surviving neighbour on the way (bounded to one second). diff --git a/.changeset/use-focus-single-registration.md b/.changeset/use-focus-single-registration.md new file mode 100644 index 00000000..45da4c0c --- /dev/null +++ b/.changeset/use-focus-single-registration.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +`useFocus` registers once with a single options object and applies later changes in one effect that compares them, instead of an effect per option. `FocusGroup` passes `focusEntry`, `rememberAs`, `scope`, `active`, `initialFocus`, `onFocusEnter` and `onFocusLeave` straight to it, and its traps no longer re-apply on every render. The focus key manager no longer allocates per directional key. diff --git a/packages/react-lightning/src/components/Canvas/CanvasRoot.tsx b/packages/react-lightning/src/components/Canvas/CanvasRoot.tsx index ef3c05cb..e2280f92 100644 --- a/packages/react-lightning/src/components/Canvas/CanvasRoot.tsx +++ b/packages/react-lightning/src/components/Canvas/CanvasRoot.tsx @@ -14,7 +14,9 @@ type Props = Omit & { width?: number; height?: number }; export const CanvasRoot: FC = ({ width, height, children, keyMap }) => { const ref = useRef(null); - const [focusManager] = useState(() => new FocusManager()); + const [focusManager] = useState( + () => new FocusManager({ batchInitialFocus: true }), + ); const [focusKeyManager] = useState(() => new FocusKeyManager(focusManager)); const rootContext = useContext(LightningRootContext); diff --git a/packages/react-lightning/src/focus/FocusGroup.tsx b/packages/react-lightning/src/focus/FocusGroup.tsx index 645e5027..512ab9e8 100644 --- a/packages/react-lightning/src/focus/FocusGroup.tsx +++ b/packages/react-lightning/src/focus/FocusGroup.tsx @@ -3,15 +3,25 @@ import { type ForwardRefExoticComponent, forwardRef, useEffect, useRef, useState import { useCombinedRef } from '../hooks/useCombinedRef'; import type { KeyEvent, LightningElement, LightningViewElementProps } from '../types'; import { FocusGroupContext } from './FocusGroupContext'; +import type { FocusEntry, FocusExits } from './FocusManager'; import { useFocus } from './useFocus'; import { useFocusKeyManager } from './useFocusKeyManager'; -import { useFocusManager } from './useFocusManager'; export interface FocusGroupProps extends Omit { autoFocus?: boolean; disable?: boolean; focusRedirect?: boolean; destinations?: (LightningElement | null)[]; + focusKey?: string; + destinationKeys?: readonly string[]; + exits?: FocusExits; + focusEntry?: FocusEntry; + rememberAs?: string; + scope?: boolean; + active?: boolean; + initialFocus?: number; + onFocusEnter?: () => void; + onFocusLeave?: () => void; trapFocusUp?: boolean; trapFocusRight?: boolean; trapFocusDown?: boolean; @@ -24,6 +34,12 @@ export interface FocusGroupProps extends Omit void; } +const markFocusGroup = (element: LightningElement | null) => { + if (element) { + element.isFocusGroup = true; + } +}; + export const FocusGroup: ForwardRefExoticComponent = forwardRef< LightningElement, FocusGroupProps @@ -34,6 +50,16 @@ export const FocusGroup: ForwardRefExoticComponent = forwardRef disable, focusRedirect, destinations, + focusKey, + destinationKeys, + exits, + focusEntry, + rememberAs, + scope, + active, + initialFocus, + onFocusEnter, + onFocusLeave, trapFocusUp, trapFocusRight, trapFocusDown, @@ -46,8 +72,14 @@ export const FocusGroup: ForwardRefExoticComponent = forwardRef }, ref, ) => { - const focusManager = useFocusManager(); const focusKeyManager = useFocusKeyManager(); + const traps = { + up: trapFocusUp ?? false, + right: trapFocusRight ?? false, + down: trapFocusDown ?? false, + left: trapFocusLeft ?? false, + }; + const { ref: focusRef, focused } = useFocus({ autoFocus, active: !disable, @@ -55,17 +87,21 @@ export const FocusGroup: ForwardRefExoticComponent = forwardRef destinations, onChildFocused, allowOffscreen, + focusKey, + destinationKeys, + exits, + traps, + focusEntry, + rememberAs, + scope, + scopeActive: active, + initialFocus, + onFocusEnter, + onFocusLeave, }); const [viewElement, setViewElement] = useState(null); const viewRef = useRef(null); - const combinedRef = useCombinedRef(ref, focusRef, viewRef); - - const traps = { - up: trapFocusUp ?? false, - right: trapFocusRight ?? false, - down: trapFocusDown ?? false, - left: trapFocusLeft ?? false, - }; + const combinedRef = useCombinedRef(ref, focusRef, viewRef, markFocusGroup); const handleFocusKeyDown = (event: KeyEvent) => { if (!viewRef.current) { @@ -79,12 +115,6 @@ export const FocusGroup: ForwardRefExoticComponent = forwardRef const finalStyle = typeof style === 'function' ? style(focused) : style; - useEffect(() => { - if (viewElement) { - focusManager.setTraps(viewElement, traps); - } - }, [focusManager, viewElement, traps]); - useEffect(() => { if (viewRef.current) { viewRef.current.isFocusGroup = true; diff --git a/packages/react-lightning/src/focus/FocusKeyManager.ts b/packages/react-lightning/src/focus/FocusKeyManager.ts index cd52cd3e..bc12d1e5 100644 --- a/packages/react-lightning/src/focus/FocusKeyManager.ts +++ b/packages/react-lightning/src/focus/FocusKeyManager.ts @@ -2,7 +2,7 @@ import { Keys } from '../input/Keys'; import type { KeyEvent, LightningElement } from '../types'; import { findClosestElement, resolveDirectionalTarget } from '../utils/findClosestElement'; import { Direction } from './Direction'; -import type { FocusManager, FocusNode } from './FocusManager'; +import type { FocusExits, FocusManager, FocusNode } from './FocusManager'; /** Lazily maps FocusNode children to their elements without allocating an array */ function* childElements(children: FocusNode[]): Iterable { @@ -12,7 +12,11 @@ function* childElements(children: FocusNode[]): Iterable[]): Iterable = { + [Direction.Up]: 'up', + [Direction.Right]: 'right', + [Direction.Down]: 'down', + [Direction.Left]: 'left', +}; + +function isInside(element: LightningElement, group: LightningElement): boolean { + for ( + let current: LightningElement | null | undefined = element; + current; + current = current.parent + ) { + if (current === group) { + return true; + } + } + + return false; +} + export class FocusKeyManager { private _focusManager: FocusManager; @@ -33,14 +58,22 @@ export class FocusKeyManager { } const key = event.remoteKey; - const direction = Array.isArray(key) - ? key.map((k) => this._getKeyDirection(k)).find((dir) => dir != null) - : this._getKeyDirection(key); + let direction: Direction | null = null; + + if (Array.isArray(key)) { + for (let i = 0; i < key.length && direction == null; i++) { + direction = this._getKeyDirection(key[i] as Keys); + } + } else { + direction = this._getKeyDirection(key); + } if (direction == null) { return true; } + this._focusManager.endInitialClaims(); + return this._tryFocusNext(element, event, direction); }; @@ -107,9 +140,9 @@ export class FocusKeyManager { closestElement, focusNode.parent.element, direction, - (child) => this._focusableChildElements(child), - (child) => this._isRedirect(child), - (child) => this._getAllowOffscreen(child), + this._focusableChildElements, + this._isRedirect, + this._getAllowOffscreen, ); this._focusManager.focus(target); @@ -117,6 +150,15 @@ export class FocusKeyManager { return false; } + const exitKey = focusNode.exits?.[EXIT_NAMES[direction] as keyof FocusExits]; + const exitTarget = exitKey ? this._focusManager.getElementByKey(exitKey) : null; + + if (exitTarget?.focusable && !isInside(exitTarget, element)) { + this._focusManager.focus(exitTarget); + + return false; + } + const { traps } = focusNode; if ( @@ -148,6 +190,10 @@ export class FocusKeyManager { private _isRedirect = (element: LightningElement): boolean => { const node = this._focusManager.getFocusNode(element); - return !!node?.focusRedirect; + if (!node || node.focusEntry === 'spatial') { + return false; + } + + return node.focusRedirect || node.focusEntry === 'first' || node.focusEntry === 'last-focused'; }; } diff --git a/packages/react-lightning/src/focus/FocusKeys.spec.ts b/packages/react-lightning/src/focus/FocusKeys.spec.ts new file mode 100644 index 00000000..5fee93ff --- /dev/null +++ b/packages/react-lightning/src/focus/FocusKeys.spec.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Keys } from '../input/Keys'; +import { createMockElement, type MockElement } from '../mocks/createMockElement'; +import type { KeyEvent, LightningElement } from '../types'; +import { FocusKeyManager } from './FocusKeyManager'; +import { FocusManager } from './FocusManager'; + +// The mock elements have no geometry, so every move finds nothing inside the group. +vi.mock('../utils/findClosestElement', () => ({ + findClosestElement: () => null, + resolveDirectionalTarget: (_from: unknown, target: unknown) => target, +})); + +const group = (id: number, name: string) => { + const element = createMockElement(id, name); + element.isFocusGroup = true; + + return element; +}; + +describe('focus keys', () => { + let focusManager: FocusManager; + + beforeEach(() => { + focusManager = new FocusManager(); + }); + + it('focuses an element by key', () => { + const play = createMockElement(1, 'play'); + focusManager.addElement(play); + focusManager.setFocusKey(play, 'play'); + + expect(focusManager.getElementByKey('play')).toBe(play); + expect(focusManager.focusByKey('play')).toBe(true); + expect(focusManager.focusByKey('missing')).toBe(false); + }); + + it('frees a key when its element is removed', () => { + const play = createMockElement(1, 'play'); + focusManager.addElement(play); + focusManager.setFocusKey(play, 'play'); + focusManager.removeElement(play); + + expect(focusManager.getElementByKey('play')).toBeNull(); + }); + + it('keeps a key with the last element to claim it', () => { + const a = createMockElement(1, 'a'); + const b = createMockElement(2, 'b'); + focusManager.addElement(a); + focusManager.addElement(b); + focusManager.setFocusKey(a, 'same'); + focusManager.setFocusKey(b, 'same'); + focusManager.removeElement(a); + + expect(focusManager.getElementByKey('same')).toBe(b); + }); + + describe('destinationKeys', () => { + const build = () => { + const start = createMockElement(1, 'start'); + const region = group(2, 'region'); + const first = createMockElement(3, 'first'); + const second = createMockElement(4, 'second'); + focusManager.addElement(start); + focusManager.addElement(region, null, { focusRedirect: true }); + focusManager.addElement(first, region); + focusManager.addElement(second, region); + focusManager.setFocusKey(second, 'second'); + focusManager.focus(start); + + return { region, first, second }; + }; + + it('forwards arriving focus to the first mounted key', () => { + const { region, second } = build(); + focusManager.setDestinationKeys(region, ['missing', 'second']); + focusManager.focus(region); + + expect(second.focused).toBe(true); + }); + + it('resolves the first sentinel to the first focusable child', () => { + const { region, first } = build(); + focusManager.setDestinationKeys(region, ['first']); + focusManager.focus(region); + + expect(first.focused).toBe(true); + }); + + it('resolves last-focused to the remembered child, then falls back', () => { + const { region, first, second } = build(); + focusManager.focus(second); + focusManager.focus(createMockElementOutside(focusManager)); + focusManager.setDestinationKeys(region, ['last-focused', 'first']); + focusManager.focus(region); + + expect(second.focused).toBe(true); + expect(first.focused).toBe(false); + }); + + it('falls back to the normal child when no key is mounted', () => { + const { region, first } = build(); + focusManager.setDestinationKeys(region, ['nope']); + focusManager.focus(region); + + expect(first.focused).toBe(true); + }); + }); + + describe('exits', () => { + it('goes to the exit key when nothing lies inside in that direction', () => { + const keyManager = new FocusKeyManager( + focusManager as unknown as FocusManager, + ); + const region = group(1, 'region'); + const tile = createMockElement(2, 'tile'); + const target = createMockElement(3, 'target'); + focusManager.addElement(region); + focusManager.addElement(tile, region); + focusManager.addElement(target); + focusManager.setFocusKey(target, 'target'); + focusManager.setExits(region, { down: 'target' }); + focusManager.focus(tile); + + const event = { remoteKey: Keys.Down } as KeyEvent; + + expect(keyManager.handleKeyDown(region as unknown as LightningElement, event)).toBe(false); + expect(target.focused).toBe(true); + }); + + it('lets the key through when the exit key is not mounted', () => { + const keyManager = new FocusKeyManager( + focusManager as unknown as FocusManager, + ); + const region = group(1, 'region'); + const tile = createMockElement(2, 'tile'); + focusManager.addElement(region); + focusManager.addElement(tile, region); + focusManager.setExits(region, { down: 'gone' }); + focusManager.focus(tile); + + const event = { remoteKey: Keys.Down } as KeyEvent; + + expect(keyManager.handleKeyDown(region as unknown as LightningElement, event)).toBe(true); + expect(tile.focused).toBe(true); + }); + }); +}); + +let outsideId = 100; + +function createMockElementOutside(focusManager: FocusManager) { + const element = createMockElement(outsideId++, 'outside'); + focusManager.addElement(element); + + return element; +} diff --git a/packages/react-lightning/src/focus/FocusManager.spec.ts b/packages/react-lightning/src/focus/FocusManager.spec.ts index fb9dd22c..dec2288c 100644 --- a/packages/react-lightning/src/focus/FocusManager.spec.ts +++ b/packages/react-lightning/src/focus/FocusManager.spec.ts @@ -1,9 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - createMockElement, - type MockElement, -} from '../mocks/createMockElement'; +import { createMockElement, type MockElement } from '../mocks/createMockElement'; import { FocusManager } from './FocusManager'; describe('FocusManager', () => { @@ -288,12 +285,7 @@ describe('FocusManager', () => { focusManager.addElement(modalGrandChild, modalChild); focusManager.addElement(modalChild, modal); - expect(focusManager.focusPath).toEqual([ - parent, - modal, - modalChild, - modalGrandChild, - ]); + expect(focusManager.focusPath).toEqual([parent, modal, modalChild, modalGrandChild]); }); describe('setFocusedChild', () => { diff --git a/packages/react-lightning/src/focus/FocusManager.ts b/packages/react-lightning/src/focus/FocusManager.ts index a66ec285..d2908775 100644 --- a/packages/react-lightning/src/focus/FocusManager.ts +++ b/packages/react-lightning/src/focus/FocusManager.ts @@ -27,6 +27,8 @@ export type FocusNode = Omit, 'element'> & { autoFocus: boolean; focusRedirect: boolean; destinations: (T | null)[] | null; + destinationKeys?: readonly string[] | null; + exits?: FocusExits | null; traps: Traps; hasFocusableChildren: boolean; /** When true, focus navigation can target non-visible children (e.g. clipped items in a virtualized list). */ @@ -39,6 +41,50 @@ export type FocusNode = Omit, 'element'> & { * must not steal focus on launch). */ focusRestorationExcluded: boolean; + focusEntry?: FocusEntry | null; + rememberAs?: string | null; + remembered?: Map> | null; + /** Keyed by focus key because a recycling list reuses its cells, so a remembered node can show another item later. */ + rememberedLeafKeys?: Map | null; + scope?: boolean; + scopeActive?: boolean; + scopeClaimed?: boolean; + scopeClaimTarget?: FocusNode | null; + scopeRefocusUntil?: number; + initialFocus?: number | null; +}; + +export type FocusEntry = 'first' | 'last-focused' | 'spatial'; + +export type FocusRect = { x: number; y: number; w: number; h: number }; + +export type FocusMeasure = (element: T, out: FocusRect) => boolean; + +export type FocusElementOptions = { + autoFocus?: boolean; + focusRedirect?: boolean; + destinations?: (T | null)[] | null; + traps?: Traps; + allowOffscreen?: boolean; + focusRestorationExcluded?: boolean; + destinationKeys?: readonly string[] | null; + exits?: FocusExits | null; + focusKey?: string | null; + focusEntry?: FocusEntry | null; + rememberAs?: string | null; + scope?: boolean; + scopeActive?: boolean; + initialFocus?: number | null; + onChildFocused?: ((child: T) => void) | null; + onFocusEnter?: (() => void) | null; + onFocusLeave?: (() => void) | null; +}; + +export type FocusExits = { + up?: string; + right?: string; + down?: string; + left?: string; }; type FocusLayer = { @@ -78,6 +124,33 @@ function hasExternalRedirect(node: FocusNode }); } +function defaultMeasure(element: unknown, out: FocusRect): boolean { + const el = element as { + node?: { w: number; h: number }; + getRelativePosition?: (relativeTo: null) => { x: number; y: number }; + }; + + if (!el.node || !el.getRelativePosition) { + return false; + } + + const { x, y } = el.getRelativePosition(null); + out.x = x; + out.y = y; + out.w = el.node.w; + out.h = el.node.h; + + return true; +} + +const CLAIM_HOLD_MS = 1000; + +interface DeferredRepick { + parent: FocusNode; + rect: FocusRect; + fallback: FocusNode | null; +} + export class FocusManager< T extends Focusable & { id: number; @@ -106,6 +179,23 @@ export class FocusManager< * per element, each resolving against its own parent. */ private _pendingPreferredChildren: Set = new Set(); + private _keyedElements: Map = new Map(); + private _elementKeys: Map = new Map(); + private _boundaryHandlers: Map void; leave?: () => void }> = new Map(); + private _initialGroups: FocusNode[] = []; + private _claiming: Set> = new Set(); + private _currentScope: FocusNode | null = null; + private _measure: FocusMeasure; + private _batchInitialFocus: boolean; + private _initialFocusPending = false; + private _deferredRepicks: DeferredRepick[] = []; + private _rememberingNodes = 0; + private _claimHoldUntil = 0; + private _claimHoldTimer: ReturnType | null = null; + private _refocusTimer: ReturnType | null = null; + private _scratchRect: FocusRect = { x: 0, y: 0, w: 0, h: 0 }; + private _lastRect: FocusRect = { x: 0, y: 0, w: 0, h: 0 }; + private _lastRectNode: FocusNode | null = null; public get activeLayer(): FocusLayer { if (this._focusStack.length === 0) { @@ -119,7 +209,10 @@ export class FocusManager< return this.activeLayer.focusPath; } - public constructor() { + /** Off by default because focus then resolves in a microtask instead of inside `addElement`. */ + public constructor(options?: { measure?: FocusMeasure; batchInitialFocus?: boolean }) { + this._measure = options?.measure ?? defaultMeasure; + this._batchInitialFocus = options?.batchInitialFocus ?? false; this._focusStack = [ { root: { @@ -155,29 +248,8 @@ export class FocusManager< return null; } - public addElement( - child: T, - parent?: T | null, - options?: { - autoFocus?: boolean; - focusRedirect?: boolean; - destinations?: (T | null)[] | null; - traps?: Traps; - allowOffscreen?: boolean; - focusRestorationExcluded?: boolean; - }, - ): void { + public addElement(child: T, parent?: T | null, options?: FocusElementOptions): void { const autoFocus = options?.autoFocus ?? false; - const focusRedirect = options?.focusRedirect ?? false; - const destinations = options?.destinations ?? null; - const allowOffscreen = options?.allowOffscreen ?? false; - const focusRestorationExcluded = options?.focusRestorationExcluded ?? false; - const traps = options?.traps ?? { - up: false, - right: false, - down: false, - left: false, - }; const { elements, root } = this.activeLayer; let parentNode: FocusNode | RootNode | null = null; @@ -215,13 +287,6 @@ export class FocusManager< let childNode = elements.get(child); if (childNode) { - childNode.autoFocus = autoFocus; - childNode.focusRedirect = focusRedirect; - childNode.destinations = destinations; - childNode.traps = traps; - childNode.allowOffscreen = allowOffscreen; - childNode.focusRestorationExcluded = focusRestorationExcluded; - // If the child node already exists, we need to remove it from its current parent if (childNode.parent !== parentNode) { const index = childNode.parent.children.indexOf(childNode); @@ -244,19 +309,11 @@ export class FocusManager< } } } else { - // If the child node doesn't exist, we need to create it - childNode = this._createFocusNode( - child, - parentNode, - autoFocus, - focusRedirect, - destinations, - traps, - allowOffscreen, - focusRestorationExcluded, - ); + childNode = this._createFocusNode(child, parentNode); } + this._applyOptions(childNode, options, false); + if (parentNode.children.indexOf(childNode) === -1) { parentNode.children.push(childNode); } @@ -280,13 +337,339 @@ export class FocusManager< } } - this._recalculateFocusPath(); + this._recalculateAfterAdd(); // 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); + this._runClaimsAfterAdd(); + } + + /** Unlike the `set*` methods this replaces every option, so a field left out is cleared. */ + public updateElement(element: T, options: FocusElementOptions): void { + const node = this.activeLayer.elements.get(element); + + this._forAllNodes(element, (found) => { + if (found !== node) { + this._applyFields(found, options); + } + }); + + if (node) { + this._applyOptions(node, options, true); + this._recalculateFocusPath(); + this._runClaims(); + } + } + + public setHandlers( + element: T, + handlers: Pick, 'onChildFocused' | 'onFocusEnter' | 'onFocusLeave'>, + ): void { + this.setOnChildFocused(element, handlers.onChildFocused ?? undefined); + this._setBoundaryHandlers(element, handlers.onFocusEnter, handlers.onFocusLeave); + } + + private _applyFields(node: FocusNode, o: FocusElementOptions | undefined): void { + node.autoFocus = o?.autoFocus ?? false; + node.focusRedirect = o?.focusRedirect ?? false; + node.destinations = o?.destinations ?? null; + node.traps = o?.traps ?? node.traps; + node.allowOffscreen = o?.allowOffscreen ?? false; + node.focusRestorationExcluded = o?.focusRestorationExcluded ?? false; + node.destinationKeys = o?.destinationKeys?.length ? o.destinationKeys : null; + node.exits = o?.exits && Object.keys(o.exits).length ? o.exits : null; + node.focusEntry = o?.focusEntry ?? null; + } + + private _applyOptions( + node: FocusNode, + o: FocusElementOptions | undefined, + isUpdate: boolean, + ): void { + const { element } = node; + + this._applyFields(node, o); + + // `set*` callers (and updates from the polyfill plugin) rely on a plain + // addElement not wiping what they set, so only an update clears these. + if (isUpdate || o?.focusKey !== undefined) { + this.setFocusKey(element, o?.focusKey); + } + + if (isUpdate || o?.onChildFocused !== undefined) { + this.setOnChildFocused(element, o?.onChildFocused ?? undefined); + } + + if (isUpdate || o?.onFocusEnter !== undefined || o?.onFocusLeave !== undefined) { + this._setBoundaryHandlers(element, o?.onFocusEnter, o?.onFocusLeave); + } + + this._applyRememberAs(node, o?.rememberAs ?? null); + this._applyInitialFocus(node, o?.initialFocus ?? null); + this._applyScope(node, !!o?.scope, o?.scopeActive ?? true); + } + + private _setBoundaryHandlers( + element: T, + enter?: (() => void) | null, + leave?: (() => void) | null, + ): void { + if (enter || leave) { + this._boundaryHandlers.set(element, { enter: enter ?? undefined, leave: leave ?? undefined }); + } else { + this._boundaryHandlers.delete(element); + } + } + + private _applyRememberAs(node: FocusNode, next: string | null): void { + const previous = node.rememberAs ?? null; + + if (previous === next) { + return; + } + + node.rememberAs = next; + this._rememberingNodes += (next === null ? 0 : 1) - (previous === null ? 0 : 1); + + if (!node.remembered && !node.focusedElement) { + return; + } + + const remembered = (node.remembered ??= new Map()); + + if (node.focusedElement) { + remembered.set(previous ?? '', node.focusedElement); + } + + const restored = remembered.get(next ?? ''); + const usable = + restored && + restored.parent === node && + this.activeLayer.elements.get(restored.element) === restored && + this._isEffectivelyFocusable(restored) + ? restored + : null; + + if (!usable) { + node.focusedElement = this._findNextBestFocus(node, undefined, true); + this._resetFocusedChain(node.focusedElement); + + return; + } + + node.focusedElement = usable; + this._restoreLeafByKey(node, node.rememberedLeafKeys?.get(next ?? '')); + } + + private _resetFocusedChain(from: FocusNode | null): void { + for (let nested = from; nested?.children.length; ) { + nested = nested.focusedElement = this._findNextBestFocus(nested, undefined, true); + } + } + + private _restoreLeafByKey(node: FocusNode, key: string | undefined): void { + const element = key === undefined ? undefined : this._keyedElements.get(key); + const leaf = element && this.activeLayer.elements.get(element); + + if (!leaf || !this._isEffectivelyFocusable(leaf)) { + return; + } + + for (let curr: FocusNode | RootNode = leaf; !isRootNode(curr); curr = curr.parent) { + if (curr.parent === node) { + for (let link: FocusNode = leaf; link !== curr; link = link.parent as FocusNode) { + (link.parent as FocusNode).focusedElement = link; + } + + node.focusedElement = curr; + + return; + } + } + } + + private _applyInitialFocus(node: FocusNode, priority: number | null): void { + node.initialFocus = priority; + + const index = this._initialGroups.indexOf(node); + + if (priority === null) { + if (index !== -1) { + this._initialGroups.splice(index, 1); + } + } else if (index === -1) { + this._initialGroups.push(node); + } + } + + private _applyScope(node: FocusNode, scope: boolean, active: boolean): void { + const wasActive = !!node.scope && !!node.scopeActive; + + node.scope = scope; + node.scopeActive = scope && active; + + if (!scope) { + this._claiming.delete(node); + + return; + } + + if (!active) { + this._claiming.delete(node); + } + + if (wasActive || !active) { + return; + } + + this._currentScope = node; + + if (!node.scopeClaimed) { + node.scopeClaimed = true; + this._claiming.add(node); + } else if (!node.element.focused && node.hasFocusableChildren) { + this._focusNode(node); + } + } + + private _nearestScope(node: FocusNode): FocusNode | null { + let curr = node.parent; + + while (!isRootNode(curr)) { + if (curr.scope) { + return curr; + } + + curr = curr.parent; + } + + return null; + } + + private _runClaims(): void { + if (this._claiming.size === 0) { + return; + } + + for (const scope of this._claiming) { + this._runClaim(scope); + } + } + + private _awaitsDestination(node: FocusNode): boolean { + for (const key of node.destinationKeys ?? []) { + if (key === 'first' || key === 'last-focused') { + continue; + } + + const element = this._keyedElements.get(key); + + if (element && this.activeLayer.elements.has(element)) { + return !element.focusable; + } + } + + return false; + } + + private _bestClaim(scope: FocusNode): FocusNode | null { + const { elements } = this.activeLayer; + let best: FocusNode | null = null; + + for (const group of this._initialGroups) { + if ( + elements.get(group.element) !== group || + this._nearestScope(group) !== scope || + (best && (group.initialFocus ?? 0) <= (best.initialFocus ?? 0)) + ) { + continue; + } + + best = group; + } + + return best; + } + + /** Holds the default pick back while a claim's winner has no content, so the first item doesn't flash focus. Bounded so focus can't be stranded. */ + private _holdsForClaim(): boolean { + if (this._claiming.size === 0 || this.activeLayer.focusPath.length > 0) { + return false; + } + + let pending = false; + + for (const scope of this._claiming) { + const best = scope.scopeActive ? this._bestClaim(scope) : null; + + if (best && scope.scopeClaimTarget !== best) { + pending = true; + break; + } + } + + if (!pending) { + return false; + } + + const now = Date.now(); + + if (this._claimHoldUntil === 0) { + this._claimHoldUntil = now + CLAIM_HOLD_MS; + this._claimHoldTimer = setTimeout(() => { + this._claimHoldTimer = null; + this._recalculateFocusPath(); + }, CLAIM_HOLD_MS); + } + + return now < this._claimHoldUntil; + } + + private _runClaim(scope: FocusNode): void { + if (!scope.scopeActive || this.activeLayer.elements.get(scope.element) !== scope) { + return; + } + + const best = this._bestClaim(scope); + + if (!best || scope.scopeClaimTarget === best) { + return; + } + + if (!this._isEffectivelyFocusable(best) || this._awaitsDestination(best)) { + return; + } + + scope.scopeClaimTarget = best; + this._focusNode(best); + } + + public get hasInitialClaim(): boolean { + return this._claiming.size > 0; + } + + public endInitialClaims(): void { + if (this._claiming.size === 0) { + return; + } + + this._claiming.clear(); + } + + public refocusInitial(): void { + const scope = this._currentScope; + + if (!scope || this.activeLayer.elements.get(scope.element) !== scope) { + return; + } + + scope.scopeClaimTarget = null; + scope.scopeRefocusUntil = Date.now() + CLAIM_HOLD_MS; + this._claiming.add(scope); + this._runClaim(scope); } private _forAllNodes(element: T, callback: (node: FocusNode) => void): void { @@ -301,7 +684,59 @@ export class FocusManager< } } + /** One element per key, the last to register wins. */ + public setFocusKey(element: T, key?: string | null): void { + const previous = this._elementKeys.get(element); + + if (previous === key) { + return; + } + + if (previous !== undefined) { + this._elementKeys.delete(element); + + if (this._keyedElements.get(previous) === element) { + this._keyedElements.delete(previous); + } + } + + if (key) { + this._elementKeys.set(element, key); + this._keyedElements.set(key, element); + } + } + + public getElementByKey(key: string): T | null { + return this._keyedElements.get(key) ?? null; + } + + public focusByKey(key: string): boolean { + const element = this._keyedElements.get(key); + + if (!element || !this.activeLayer.elements.has(element)) { + return false; + } + + this.focus(element); + + return element.focused; + } + + public setDestinationKeys(element: T, keys?: readonly string[] | null): void { + this._forAllNodes(element, (node) => { + node.destinationKeys = keys?.length ? keys : null; + }); + } + + public setExits(element: T, exits?: FocusExits | null): void { + this._forAllNodes(element, (node) => { + node.exits = exits && Object.keys(exits).length ? exits : null; + }); + } + public removeElement(element: T): void { + this.setFocusKey(element, null); + if (this._pendingFocus === element) { this._pendingFocus = null; } @@ -609,28 +1044,19 @@ export class FocusManager< return prevNode; } - private _createFocusNode( - element: T, - parent: FocusNode | RootNode, - autoFocus = false, - focusRedirect = false, - destinations: (T | null)[] | null = null, - traps: Traps = { up: false, right: false, down: false, left: false }, - allowOffscreen = false, - focusRestorationExcluded = false, - ) { + private _createFocusNode(element: T, parent: FocusNode | RootNode) { const node: FocusNode = { element, children: [], parent, focusedElement: null, - autoFocus, - focusRedirect, - destinations, - traps, + autoFocus: false, + focusRedirect: false, + destinations: null, + traps: { up: false, right: false, down: false, left: false }, hasFocusableChildren: false, - allowOffscreen, - focusRestorationExcluded, + allowOffscreen: false, + focusRestorationExcluded: false, focusCommitted: false, }; @@ -659,14 +1085,25 @@ export class FocusManager< if (!currentNode.parent.focusedElement) { currentNode.parent.focusedElement = this._findNextBestFocus(currentNode.parent); } else if (!isFocusable && currentNode.parent.focusedElement === currentNode) { - currentNode.parent.focusedElement = this._findNextBestFocus( - currentNode.parent, - currentNode, - ); + if (this._canDeferRepick(currentNode)) { + this._deferRepick(currentNode); + } else { + currentNode.parent.focusedElement = this._findNearestFocus( + currentNode.parent, + currentNode, + ); + } } - this._checkFocusableChildren(currentNode.parent); - this._recalculateFocusPath(); + if (!this._isRepickDeferred(currentNode.parent)) { + this._checkFocusableChildren(currentNode.parent); + + if (this._batchInitialFocus && isFocusable) { + this._runClaims(); + } + + this._recalculateFocusPath(); + } // A queued focus request or preferred-child preference may have been // waiting on this element to become focusable. @@ -674,6 +1111,8 @@ export class FocusManager< this._tryFulfillPendingPreferredChild(element); this._tryFulfillPendingFocus(element); } + + this._runClaims(); }), element.on('focusChanged', (_, isFocused) => { if (isFocused && !element.focused) { @@ -710,12 +1149,27 @@ export class FocusManager< * redirected (or aborted on a cycle) and the caller should stop; false when * nothing resolved and the caller should focus `node` normally. */ + private *_destinationCandidates(node: FocusNode): Iterable { + for (const key of node.destinationKeys ?? []) { + if (key === 'first') { + yield this._findNextBestFocus(node, undefined, true)?.element ?? null; + } else if (key === 'last-focused') { + // focusedElement also holds the default pick of a group that never had focus. + yield node.focusCommitted ? (node.focusedElement?.element ?? null) : null; + } else { + yield this._keyedElements.get(key) ?? null; + } + } + + yield* node.destinations ?? []; + } + private _redirectToDestination(node: FocusNode, visitedRedirects?: Set): boolean { - if (!node.destinations) { + if (!node.destinations && !node.destinationKeys) { return false; } - for (const destination of node.destinations) { + for (const destination of this._destinationCandidates(node)) { // A destination can hold a stale ref (e.g. a recycled list cell that // unmounted after setDestinations); skip it like native TVFocusGuideView // drops invalid node handles, so focus falls back to the normal child. @@ -755,7 +1209,7 @@ export class FocusManager< // returns to its selected item). `autoFocus` is the separate first-then- // remember mechanism, resolved via the group's `focusedElement` below. if ( - childNode.destinations && + (childNode.destinations || childNode.destinationKeys) && this._redirectToDestination(childNode, visitedRedirects) ) { return; @@ -764,10 +1218,23 @@ export class FocusManager< let currParent = childNode.parent; let currChild: FocusNode | RootNode = childNode; - if (currChild.children.length && !currChild.focusedElement) { + if ( + currChild.children.length && + (!currChild.focusedElement || currChild.focusEntry === 'first') + ) { currChild.focusedElement = this._findNextBestFocus(currChild, undefined, true); } + // A nested group that always enters at its first child starts over too, + // unless the group being entered returns to the exact item it remembered. + if (currChild.focusEntry !== 'last-focused') { + for (let nested = currChild.focusedElement; nested; nested = nested.focusedElement) { + if (nested.focusEntry === 'first' && nested.children.length) { + nested.focusedElement = this._findNextBestFocus(nested, undefined, true); + } + } + } + // Focus has now explicitly arrived at this node, so mark its subtree as // committed: a later-mounting autoFocus sibling must not steal it on // registration (see addElement / focusCommitted). @@ -823,24 +1290,32 @@ export class FocusManager< } private _removeNode(node: FocusNode, isTopMostParentNode: boolean) { + // A region that unmounts while focused hears no leave. + this._boundaryHandlers.delete(node.element); + // Remove all the children too for (const child of node.children) { this._removeNode(child, false); } + // Picked while the node is still in its parent's children, so the next one is relative to where it was. + if (isTopMostParentNode && node.parent.focusedElement === node) { + if (this._canDeferRepick(node)) { + this._deferRepick(node); + } else { + node.parent.focusedElement = this._findNearestFocus(node.parent, node); + } + } + const removeIndex = node.parent.children.indexOf(node); if (removeIndex !== -1) { node.parent.children.splice(removeIndex, 1); } - if (isTopMostParentNode && node.parent.focusedElement === node) { - node.parent.focusedElement = this._findNextBestFocus(node.parent, node); - } - this.activeLayer.elements.delete(node.element); - if (isTopMostParentNode) { + if (isTopMostParentNode && !this._isRepickDeferred(node.parent)) { // Removing a child can empty a focus-group parent; recompute so its // effective focusability and the ancestor chain update. if (!isRootNode(node.parent) && node.parent.element.isFocusGroup) { @@ -854,6 +1329,26 @@ export class FocusManager< this._childFocusEventHandlers.delete(node.element); } + if (node.initialFocus != null) { + this._applyInitialFocus(node, null); + } + + if (node.rememberAs != null) { + this._rememberingNodes--; + } + + if (node.scope) { + this._claiming.delete(node); + + if (this._currentScope === node) { + this._currentScope = null; + } + } + + if (this._lastRectNode === node) { + this._lastRectNode = null; + } + this._removeEventListeners(node); } @@ -864,10 +1359,20 @@ export class FocusManager< return false; } - return !node.element.isFocusGroup || node.hasFocusableChildren; + // An empty redirect with destinations is a target too, it forwards on arrival. + return ( + !node.element.isFocusGroup || + node.hasFocusableChildren || + (node.focusRedirect && !!(node.destinationKeys || node.destinations)) + ); } private _checkFocusableChildren(parentNode: FocusNode | RootNode) { + // The new items of a re-rendered list aren't focusable yet, so the settle checks later. + if (this._isRepickDeferred(parentNode)) { + return; + } + const previous = parentNode.hasFocusableChildren; const children = parentNode.children; const childrenLength = children.length; @@ -956,13 +1461,13 @@ export class FocusManager< return null; } - let bestMatch: FocusNode | null = null; - let relativeIndex = -1; - - if (relativeNode) { - relativeIndex = parent.children.indexOf(relativeNode); + if (!relativeNode) { + return this._findFirstFocus(parent, includeRestorationExcluded); } + let bestMatch: FocusNode | null = null; + let relativeIndex = parent.children.indexOf(relativeNode); + // Loop through from the beginning of the children, even if we want to // select an element relative to the relative node. This is to prevent // having to loop through twice. @@ -987,10 +1492,259 @@ export class FocusManager< return bestMatch; } + /** Children register in mount order, which isn't visual order once a list recycles its cells. */ + private _findFirstFocus( + parent: FocusNode | RootNode, + includeRestorationExcluded: boolean, + ): FocusNode | null { + const scratch = this._scratchRect; + let firstEligible: FocusNode | null = null; + let best: FocusNode | null = null; + let bestX = 0; + let bestY = 0; + + for (let i = 0; i < parent.children.length; i++) { + const child = parent.children[i] as FocusNode; + + if ( + !this._isEffectivelyFocusable(child) || + hasExternalRedirect(child) || + (!includeRestorationExcluded && child.focusRestorationExcluded) + ) { + continue; + } + + firstEligible ??= child; + + if (!this._measure(child.element, scratch)) { + continue; + } + + if (!best || scratch.y < bestY || (scratch.y === bestY && scratch.x < bestX)) { + best = child; + bestX = scratch.x; + bestY = scratch.y; + } + } + + return best ?? firstEligible; + } + + private _ownsLastRect(node: FocusNode): boolean { + for (let curr: FocusNode | null = this._lastRectNode; curr; ) { + if (curr === node) { + return true; + } + + curr = isRootNode(curr.parent) ? null : curr.parent; + } + + return false; + } + + private _findNearestFocus( + parent: FocusNode | RootNode, + relativeNode: FocusNode, + ): FocusNode | null { + if (!this._ownsLastRect(relativeNode)) { + return this._findNextBestFocus(parent, relativeNode); + } + + return ( + this._nearestTo(parent, relativeNode, this._lastRect) ?? + this._findNextBestFocus(parent, relativeNode) + ); + } + + /** The focusable child whose centre is closest to `last`; on a tie the one further along wins. */ + private _nearestTo( + parent: FocusNode | RootNode, + exclude: FocusNode | null, + last: FocusRect, + ): FocusNode | null { + const scratch = this._scratchRect; + const cx = last.x + last.w / 2; + const cy = last.y + last.h / 2; + let best: FocusNode | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + let bestAfter = false; + + for (let i = 0; i < parent.children.length; i++) { + const child = parent.children[i] as FocusNode; + + if ( + child === exclude || + child.focusRestorationExcluded || + !this._isEffectivelyFocusable(child) || + hasExternalRedirect(child) || + !this._measure(child.element, scratch) + ) { + continue; + } + + const dx = scratch.x + scratch.w / 2 - cx; + const dy = scratch.y + scratch.h / 2 - cy; + const distance = dx * dx + dy * dy; + const after = dx > 0 || dy > 0; + + if (distance < bestDistance || (distance === bestDistance && after && !bestAfter)) { + best = child; + bestDistance = distance; + bestAfter = after; + } + } + + return best; + } + + private _recalculateAfterAdd(): void { + if (!this._batchInitialFocus) { + this._recalculateFocusPath(); + + return; + } + + if (this.activeLayer.focusPath.length > 0) { + this._recalculateFocusPath(); + } else { + this._scheduleSettle(); + } + } + + private _runClaimsAfterAdd(): void { + if (this._batchInitialFocus) { + this._scheduleSettle(); + } else { + this._runClaims(); + } + } + + /** Bounded, so a target that never mounts can't strand focus. */ + private _refocusPending(node: FocusNode | RootNode): boolean { + for (let curr = node; !isRootNode(curr); curr = curr.parent) { + if (curr.scope) { + return ( + this._claiming.has(curr) && + !curr.scopeClaimTarget && + (curr.scopeRefocusUntil ?? 0) > Date.now() + ); + } + } + + return false; + } + + private _canDeferRepick(node: FocusNode): boolean { + return ( + this._batchInitialFocus && + !isRootNode(node.parent) && + this.activeLayer.focusPath.includes(node.element) && + (this._ownsLastRect(node) || this._refocusPending(node)) + ); + } + + private _deferRepick(node: FocusNode): void { + this._deferredRepicks.push({ + parent: node.parent as FocusNode, + rect: { ...this._lastRect }, + fallback: this._findNearestFocus(node.parent, node), + }); + this._scheduleSettle(); + } + + private _isRepickDeferred(parent: FocusNode | RootNode): boolean { + for (let i = 0; i < this._deferredRepicks.length; i++) { + if (this._deferredRepicks[i]?.parent === parent) { + return true; + } + } + + return false; + } + + private _settleRepicks(): void { + const repicks = this._deferredRepicks; + + if (repicks.length === 0) { + return; + } + + this._deferredRepicks = []; + + for (const repick of repicks) { + const { parent, rect, fallback } = repick; + + if (this.activeLayer.elements.get(parent.element) !== parent) { + continue; + } + + const current = parent.focusedElement; + + if (current && this.activeLayer.elements.get(current.element) === current) { + continue; + } + + if (this._refocusPending(parent)) { + this._deferredRepicks.push(repick); + this._armRefocusTimer(parent); + + continue; + } + + const registered = fallback && this.activeLayer.elements.get(fallback.element) === fallback; + + parent.focusedElement = + this._nearestTo(parent, null, rect) ?? + (registered ? fallback : this._findFirstFocus(parent, false)); + this._checkFocusableChildren(parent); + } + } + + private _armRefocusTimer(scope: FocusNode | RootNode): void { + if (this._refocusTimer !== null) { + return; + } + + let until = 0; + + for (let curr = scope; !isRootNode(curr); curr = curr.parent) { + if (curr.scope) { + until = curr.scopeRefocusUntil ?? 0; + break; + } + } + + this._refocusTimer = setTimeout( + () => { + this._refocusTimer = null; + this._scheduleSettle(); + }, + Math.max(0, until - Date.now()) + 1, + ); + } + + private _scheduleSettle(): void { + if (this._initialFocusPending) { + return; + } + + this._initialFocusPending = true; + queueMicrotask(() => { + this._initialFocusPending = false; + this._settleRepicks(); + this._recalculateFocusPath(); + this._runClaims(); + }); + } + private _recalculateFocusPath(): void { const layer = this.activeLayer; const oldPath = layer.focusPath; + if (this._batchInitialFocus && this._holdsForClaim()) { + return; + } + // Quick check: walk the focused chain and compare against old path. // If every element matches and lengths are equal, nothing changed. let curr: FocusNode | null = layer.root.focusedElement; @@ -1034,7 +1788,9 @@ export class FocusManager< for (let i = oldPath.length - 1; i >= divergenceIndex; i--) { const removedFocus = oldPath[i]; - if (removedFocus?.focused) { + // A reparented element can sit at a different index in the new path + // without having lost focus. + if (removedFocus?.focused && !newPath.includes(removedFocus)) { removedFocus.blur(); this._eventEmitter.emit('blurred', removedFocus); } @@ -1058,15 +1814,80 @@ export class FocusManager< this._bubbleFocusEvent('focus', newLeaf, newPath, divergenceIndex); } + if (leafChanged && newLeaf) { + this._rememberLeafRect(layer, newLeaf); + + if (this._rememberingNodes > 0) { + this._rememberLeafKey(layer, newPath, newLeaf); + } + } + + if (this._boundaryHandlers.size > 0) { + for (let i = oldPath.length - 1; i >= divergenceIndex; i--) { + if (!newPath.includes(oldPath[i] as T)) { + this._boundaryHandlers.get(oldPath[i] as T)?.leave?.(); + } + } + + for (let i = divergenceIndex; i < newPath.length; i++) { + if (!oldPath.includes(newPath[i] as T)) { + this._boundaryHandlers.get(newPath[i] as T)?.enter?.(); + } + } + } + layer.focusPath = newPath; + + if (this._claimHoldUntil !== 0 && newPath.length > 0) { + this._claimHoldUntil = 0; + + if (this._claimHoldTimer !== null) { + clearTimeout(this._claimHoldTimer); + this._claimHoldTimer = null; + } + } + this._eventEmitter.emit('focusPathChanged', newPath); } + private _rememberLeafKey(layer: FocusLayer, path: T[], leaf: T): void { + const key = this._elementKeys.get(leaf); + + for (const element of path) { + const node = layer.elements.get(element); + + if (node?.rememberAs == null) { + continue; + } + + const keys = (node.rememberedLeafKeys ??= new Map()); + + if (key === undefined) { + keys.delete(node.rememberAs); + } else { + keys.set(node.rememberAs, key); + } + } + } + + private _rememberLeafRect(layer: FocusLayer, leaf: T): void { + const node = layer.elements.get(leaf); + + if (node && this._measure(leaf, this._lastRect)) { + this._lastRectNode = node; + } + } + /** * tvOS/web bubble focus through plain wrapper views; the focus path only reaches focus nodes, * so deliver to the remaining ancestors (skip at/past `divergenceIndex`, they fired their own). */ - private _bubbleFocusEvent(type: 'focus' | 'blur', target: T, path: T[], divergenceIndex: number): void { + private _bubbleFocusEvent( + type: 'focus' | 'blur', + target: T, + path: T[], + divergenceIndex: number, + ): void { let curr: T | null | undefined = target.parent; while (curr) { diff --git a/packages/react-lightning/src/focus/FocusRegions.spec.ts b/packages/react-lightning/src/focus/FocusRegions.spec.ts new file mode 100644 index 00000000..acee5a0d --- /dev/null +++ b/packages/react-lightning/src/focus/FocusRegions.spec.ts @@ -0,0 +1,743 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Keys } from '../input/Keys'; +import { createMockElement, type MockElement } from '../mocks/createMockElement'; +import type { KeyEvent, LightningElement } from '../types'; +import { FocusKeyManager } from './FocusKeyManager'; +import { FocusManager, type FocusRect } from './FocusManager'; + +vi.mock('../utils/findClosestElement', () => ({ + findClosestElement: () => null, + resolveDirectionalTarget: (_from: unknown, target: unknown) => target, +})); + +const group = (id: number, name: string) => { + const element = createMockElement(id, name); + element.isFocusGroup = true; + + return element; +}; + +describe('focus regions', () => { + let fm: FocusManager; + let nextId = 1; + const leaf = (name: string) => createMockElement(nextId++, name); + const grp = (name: string) => group(nextId++, name); + + beforeEach(() => { + fm = new FocusManager(); + nextId = 1; + }); + + describe('focusEntry', () => { + const build = (focusEntry?: 'first' | 'last-focused' | 'spatial') => { + const outside = leaf('outside'); + const region = grp('region'); + const a = leaf('a'); + const b = leaf('b'); + fm.addElement(outside); + fm.addElement(region, null, { focusEntry }); + fm.addElement(a, region); + fm.addElement(b, region); + fm.focus(b); + fm.focus(outside); + + return { region, a, b }; + }; + + it('first enters at the first child even when another was focused', () => { + const { region, a } = build('first'); + fm.focus(region); + + expect(a.focused).toBe(true); + }); + + it('last-focused enters at the remembered child', () => { + const { region, b } = build('last-focused'); + fm.focus(region); + + expect(b.focused).toBe(true); + }); + + it('last-focused falls back to the first child when nothing is remembered', () => { + const region = grp('region'); + const a = leaf('a'); + const b = leaf('b'); + fm.addElement(region, null, { focusEntry: 'last-focused' }); + fm.addElement(a, region); + fm.addElement(b, region); + fm.focusByKey('none'); + fm.focus(region); + + expect(a.focused).toBe(true); + }); + + it('spatial keeps the remembered child for programmatic focus', () => { + const { region, b } = build('spatial'); + fm.focus(region); + + expect(b.focused).toBe(true); + }); + + it('first resets a nested first group on the way in', () => { + const outer = grp('outer'); + const inner = grp('inner'); + const x = leaf('x'); + const y = leaf('y'); + const outside = leaf('outside'); + fm.addElement(outside); + fm.addElement(outer); + fm.addElement(inner, outer, { focusEntry: 'first' }); + fm.addElement(x, inner); + fm.addElement(y, inner); + fm.focus(y); + fm.focus(outside); + fm.focus(outer); + + expect(x.focused).toBe(true); + }); + + it('stops directional descent at first and last-focused regions', () => { + const km = new FocusKeyManager(fm as unknown as FocusManager); + const first = grp('first'); + const last = grp('last'); + const spatial = grp('spatial'); + fm.addElement(first, null, { focusEntry: 'first' }); + fm.addElement(last, null, { focusEntry: 'last-focused' }); + fm.addElement(spatial, null, { focusEntry: 'spatial', focusRedirect: true }); + fm.addElement(leaf('c1'), first); + fm.addElement(leaf('c2'), last); + fm.addElement(leaf('c3'), spatial); + + const isRedirect = (km as unknown as { _isRedirect: (e: MockElement) => boolean }) + ._isRedirect; + + expect(isRedirect(first)).toBe(true); + expect(isRedirect(last)).toBe(true); + expect(isRedirect(spatial)).toBe(false); + }); + }); + + describe('rememberAs', () => { + it('restores the child remembered under the value', () => { + const outside = leaf('outside'); + const row = grp('row'); + const a = leaf('a'); + const b = leaf('b'); + fm.addElement(outside); + fm.addElement(row, null, { rememberAs: 'one' }); + fm.addElement(a, row); + fm.addElement(b, row); + fm.focus(b); + fm.focus(outside); + + fm.updateElement(row, { rememberAs: 'two' }); + fm.focus(row); + + expect(a.focused).toBe(true); + + fm.focus(outside); + fm.updateElement(row, { rememberAs: 'one' }); + fm.focus(row); + + expect(b.focused).toBe(true); + }); + + it('moves focus to the first child when the new value has none', () => { + const row = grp('row'); + const a = leaf('a'); + const b = leaf('b'); + fm.addElement(row, null, { rememberAs: 'one' }); + fm.addElement(a, row); + fm.addElement(b, row); + fm.focus(b); + + fm.updateElement(row, { rememberAs: 'two' }); + + expect(a.focused).toBe(true); + }); + + it('forgets a nested list position when the new value has nothing remembered', () => { + const outside = leaf('outside'); + const region = grp('region'); + const list = grp('list'); + const a = leaf('a'); + const b = leaf('b'); + fm.addElement(outside); + fm.addElement(region, null, { rememberAs: 'one', focusEntry: 'last-focused' }); + fm.addElement(list, region); + fm.addElement(a, list); + fm.addElement(b, list); + fm.focus(b); + fm.focus(outside); + + fm.updateElement(region, { rememberAs: 'two', focusEntry: 'last-focused' }); + fm.focus(region); + + expect(a.focused).toBe(true); + }); + + it('restores a nested leaf by its key when the list recycled its cells', () => { + const outside = leaf('outside'); + const region = grp('region'); + const list = grp('list'); + const a = leaf('a'); + const b = leaf('b'); + fm.addElement(outside); + fm.addElement(region, null, { rememberAs: 'one', focusEntry: 'last-focused' }); + fm.addElement(list, region); + fm.addElement(a, list, { focusKey: 'one-a' }); + fm.addElement(b, list, { focusKey: 'one-b' }); + fm.focus(b); + fm.focus(outside); + + fm.updateElement(a, { focusKey: 'two-a' }); + fm.updateElement(b, { focusKey: 'two-b' }); + fm.updateElement(region, { rememberAs: 'two', focusEntry: 'last-focused' }); + fm.focus(region); + + expect(a.focused).toBe(true); + + fm.focus(outside); + fm.updateElement(a, { focusKey: 'one-b' }); + fm.updateElement(b, { focusKey: 'one-a' }); + fm.updateElement(region, { rememberAs: 'one', focusEntry: 'last-focused' }); + fm.focus(region); + + expect(a.focused).toBe(true); + }); + }); + + describe('scopes and initial focus', () => { + const pressDown = (target: MockElement) => { + const km = new FocusKeyManager(fm as unknown as FocusManager); + + km.handleKeyDown(target as unknown as LightningElement, { remoteKey: Keys.Down } as KeyEvent); + }; + + const region = (scope: MockElement, priority: number, withChild = true) => { + const r = grp(`r${priority}`); + fm.addElement(r, scope, { initialFocus: priority }); + const child = leaf(`c${priority}`); + + if (withChild) { + fm.addElement(child, r); + } + + return { r, child }; + }; + + it('claims the highest priority', () => { + const scope = grp('scope'); + fm.addElement(scope, null, { scope: true }); + const low = region(scope, 1); + const high = region(scope, 5); + + expect(high.child.focused).toBe(true); + expect(low.child.focused).toBe(false); + }); + + it('breaks a tie towards the first registered', () => { + const scope = grp('scope'); + fm.addElement(scope, null, { scope: true }); + const first = region(scope, 2); + const second = region(scope, 2); + + expect(first.child.focused).toBe(true); + expect(second.child.focused).toBe(false); + }); + + it('lets a higher priority mounting later take over', () => { + const scope = grp('scope'); + fm.addElement(scope, null, { scope: true }); + const low = region(scope, 1); + + expect(low.child.focused).toBe(true); + + const high = region(scope, 3); + + expect(high.child.focused).toBe(true); + }); + + it('stops taking over after the first directional key', () => { + const scope = grp('scope'); + fm.addElement(scope, null, { scope: true }); + const low = region(scope, 1); + pressDown(low.r); + const high = region(scope, 3); + + expect(low.child.focused).toBe(true); + expect(high.child.focused).toBe(false); + }); + + it('waits for the winner to have focusable content', () => { + const scope = grp('scope'); + fm.addElement(scope, null, { scope: true }); + const early = region(scope, 1); + const late = region(scope, 4, false); + + expect(early.child.focused).toBe(true); + + const lateChild = leaf('late'); + fm.addElement(lateChild, late.r); + + expect(lateChild.focused).toBe(true); + }); + + it('waits when the scope has only an empty winner', () => { + const scope = grp('scope'); + fm.addElement(scope, null, { scope: true }); + const only = region(scope, 2, false); + const child = leaf('child'); + + expect(fm.focusPath).toEqual([]); + + fm.addElement(child, only.r); + + expect(child.focused).toBe(true); + }); + + it('does not claim while inactive, and claims once when activated', () => { + const scope = grp('scope'); + fm.addElement(scope, null, { scope: true, scopeActive: false }); + const low = region(scope, 1); + const high = region(scope, 5); + + expect(high.child.focused).toBe(false); + + fm.updateElement(scope, { scope: true, scopeActive: true }); + + expect(high.child.focused).toBe(true); + expect(low.child.focused).toBe(false); + }); + + it('restores the remembered item when re-activated', () => { + const scope = grp('scope'); + fm.addElement(scope, null, { scope: true }); + const first = region(scope, 2); + const other = leaf('other'); + fm.addElement(other, first.r); + pressDown(first.r); + fm.focus(other); + const outside = leaf('outside'); + fm.addElement(outside); + fm.updateElement(scope, { scope: true, scopeActive: false }); + fm.focus(outside); + fm.updateElement(scope, { scope: true, scopeActive: true }); + + expect(other.focused).toBe(true); + }); + + it('refocusInitial restarts the claim on the last activated scope', () => { + const scope = grp('scope'); + fm.addElement(scope, null, { scope: true }); + const a = region(scope, 1); + const b = region(scope, 1); + pressDown(a.r); + fm.focus(b.child); + fm.refocusInitial(); + + expect(a.child.focused).toBe(true); + }); + + it('leaves a nested scope to claim for itself', () => { + const outer = grp('outer'); + const inner = grp('inner'); + fm.addElement(outer, null, { scope: true }); + fm.addElement(inner, outer, { scope: true }); + const outerRegion = region(outer, 1); + const innerRegion = region(inner, 1); + + expect(outerRegion.child.focused || innerRegion.child.focused).toBe(true); + expect(fm.focusPath).toContain(innerRegion.child); + }); + }); + + describe('removal repick', () => { + const rects = new Map(); + let geo: FocusManager; + + beforeEach(() => { + rects.clear(); + geo = new FocusManager({ + measure: (element, out) => { + const rect = rects.get(element); + + if (!rect) { + return false; + } + + Object.assign(out, rect); + + return true; + }, + }); + }); + + it('moves focus to the nearest remaining item, not the next by index', () => { + const row = grp('row'); + const a = leaf('a'); + const b = leaf('b'); + const c = leaf('c'); + const d = leaf('d'); + rects.set(a, { x: 0, y: 0, w: 10, h: 10 }); + rects.set(b, { x: 100, y: 0, w: 10, h: 10 }); + rects.set(c, { x: 20, y: 0, w: 10, h: 10 }); + rects.set(d, { x: 200, y: 0, w: 10, h: 10 }); + geo.addElement(row); + [a, b, c, d].forEach((e) => geo.addElement(e, row)); + geo.focus(b); + geo.removeElement(b); + + expect(c.focused).toBe(true); + expect(geo.focusPath).toEqual([row, c]); + }); + + it('falls back to the index pick without geometry', () => { + const row = grp('row'); + const a = leaf('a'); + const b = leaf('b'); + const c = leaf('c'); + geo.addElement(row); + [a, b, c].forEach((e) => geo.addElement(e, row)); + geo.focus(b); + geo.removeElement(b); + + expect(c.focused).toBe(true); + }); + + it('lands on the next sibling when an unfocused remembered child goes away', () => { + const row = grp('row'); + const a = leaf('a'); + const b = leaf('b'); + const c = leaf('c'); + const out = leaf('out'); + geo.addElement(out); + geo.addElement(row); + [a, b, c].forEach((e) => geo.addElement(e, row)); + geo.focus(b); + geo.focus(out); + geo.removeElement(b); + geo.focus(row); + + expect(c.focused).toBe(true); + }); + }); + + describe('enter and leave', () => { + it('fires once per boundary crossing', () => { + const outside = leaf('outside'); + const region = grp('region'); + const a = leaf('a'); + const b = leaf('b'); + const onFocusEnter = vi.fn(); + const onFocusLeave = vi.fn(); + fm.addElement(outside); + fm.addElement(region, null, { onFocusEnter, onFocusLeave }); + fm.addElement(a, region); + fm.addElement(b, region); + fm.focus(outside); + onFocusEnter.mockClear(); + onFocusLeave.mockClear(); + + fm.focus(a); + fm.focus(b); + + expect(onFocusEnter).toHaveBeenCalledTimes(1); + expect(onFocusLeave).not.toHaveBeenCalled(); + + fm.focus(outside); + + expect(onFocusLeave).toHaveBeenCalledTimes(1); + }); + + it('does not fire when a removal repicks inside the region', () => { + const region = grp('region'); + const a = leaf('a'); + const b = leaf('b'); + const onFocusEnter = vi.fn(); + const onFocusLeave = vi.fn(); + fm.addElement(region, null, { onFocusEnter, onFocusLeave }); + fm.addElement(a, region); + fm.addElement(b, region); + fm.focus(a); + onFocusEnter.mockClear(); + fm.removeElement(a); + + expect(b.focused).toBe(true); + expect(onFocusEnter).not.toHaveBeenCalled(); + expect(onFocusLeave).not.toHaveBeenCalled(); + }); + + it('sends no leave when the focused region is removed', () => { + const region = grp('region'); + const a = leaf('a'); + const onFocusLeave = vi.fn(); + fm.addElement(region, null, { onFocusLeave }); + fm.addElement(a, region); + fm.focus(a); + fm.removeElement(region); + + expect(onFocusLeave).not.toHaveBeenCalled(); + }); + }); + + describe('updateElement', () => { + it('replaces every option, clearing a key that was left out', () => { + const a = leaf('a'); + fm.addElement(a, null, { focusKey: 'a', focusEntry: 'first' }); + fm.updateElement(a, {}); + + expect(fm.getElementByKey('a')).toBeNull(); + expect(fm.getFocusNode(a)?.focusEntry).toBeNull(); + }); + + it('swaps handlers without touching the rest', () => { + const region = grp('region'); + const a = leaf('a'); + const first = vi.fn(); + const second = vi.fn(); + fm.addElement(region, null, { focusKey: 'region', onFocusEnter: first }); + fm.addElement(a, region); + first.mockClear(); + fm.setHandlers(region, { onFocusEnter: second }); + const out = leaf('out'); + fm.addElement(out); + fm.focus(out); + fm.focus(a); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + expect(fm.getElementByKey('region')).toBe(region); + }); + }); + + describe('batched initial focus', () => { + const flush = () => new Promise((resolve) => queueMicrotask(resolve)); + let batch: FocusManager; + const rects = new Map(); + + beforeEach(() => { + rects.clear(); + batch = new FocusManager({ + batchInitialFocus: true, + measure: (element, out) => { + const rect = rects.get(element); + + if (!rect) { + return false; + } + + Object.assign(out, rect); + + return true; + }, + }); + }); + + it('lands a claim without the first registered item taking focus', async () => { + const scope = grp('scope'); + const first = leaf('first'); + const region = grp('region'); + const target = leaf('target'); + const firstFocus = vi.spyOn(first, 'focus'); + + batch.addElement(first, scope); + batch.addElement(target, region); + batch.addElement(region, scope, { initialFocus: 1 }); + batch.addElement(scope, null, { scope: true }); + + expect(target.focused).toBe(false); + + await flush(); + + expect(target.focused).toBe(true); + expect(firstFocus).not.toHaveBeenCalled(); + }); + + it('holds the default pick while the claim winner has no content', async () => { + const scope = grp('scope'); + const first = leaf('first'); + const region = grp('region'); + const later = leaf('later'); + + batch.addElement(first, scope); + batch.addElement(region, scope, { initialFocus: 1 }); + batch.addElement(scope, null, { scope: true }); + await flush(); + + expect(first.focused).toBe(false); + + batch.addElement(later, region); + await flush(); + + expect(later.focused).toBe(true); + }); + + it('waits for the new items of a re-rendered list before repicking', async () => { + const scope = grp('scope'); + const row = grp('row'); + const a = leaf('a'); + const b = leaf('b'); + rects.set(a, { x: 0, y: 0, w: 10, h: 10 }); + rects.set(b, { x: 20, y: 0, w: 10, h: 10 }); + batch.addElement(scope, null, { scope: true }); + batch.addElement(row, scope); + batch.addElement(a, row); + batch.addElement(b, row); + await flush(); + batch.focus(b); + + batch.removeElement(a); + batch.removeElement(b); + const c = leaf('c'); + const d = leaf('d'); + rects.set(c, { x: 0, y: 0, w: 10, h: 10 }); + rects.set(d, { x: 20, y: 0, w: 10, h: 10 }); + batch.addElement(c, row); + batch.addElement(d, row); + await flush(); + + expect(d.focused).toBe(true); + }); + + it('keeps the repick back while refocusInitial waits for its target to mount', async () => { + const scope = grp('scope'); + const list = grp('list'); + const top = grp('top'); + const topTile = leaf('topTile'); + const row = leaf('row'); + const cell = leaf('cell'); + rects.set(row, { x: 0, y: 0, w: 10, h: 10 }); + rects.set(cell, { x: 0, y: 20, w: 10, h: 10 }); + batch.addElement(scope, null, { scope: true }); + batch.addElement(list, scope); + batch.addElement(top, list, { initialFocus: 1 }); + batch.addElement(topTile, top); + batch.addElement(row, list); + batch.addElement(cell, list); + await flush(); + batch.focus(cell); + + batch.removeElement(topTile); + batch.removeElement(top); + batch.refocusInitial(); + batch.removeElement(cell); + await flush(); + + expect(row.focused).toBe(false); + + const nextTop = grp('nextTop'); + const nextTile = leaf('nextTile'); + batch.addElement(nextTop, list, { initialFocus: 1 }); + batch.addElement(nextTile, nextTop); + await flush(); + + expect(nextTile.focused).toBe(true); + expect(row.focused).toBe(false); + }); + + it('repicks once the refocusInitial wait runs out', async () => { + vi.useFakeTimers(); + + try { + const scope = grp('scope'); + const list = grp('list'); + const row = leaf('row'); + const cell = leaf('cell'); + rects.set(row, { x: 0, y: 0, w: 10, h: 10 }); + rects.set(cell, { x: 0, y: 20, w: 10, h: 10 }); + batch.addElement(scope, null, { scope: true }); + batch.addElement(list, scope); + batch.addElement(row, list); + batch.addElement(cell, list); + await vi.advanceTimersByTimeAsync(0); + batch.focus(cell); + + batch.refocusInitial(); + batch.removeElement(cell); + await vi.advanceTimersByTimeAsync(10); + + expect(row.focused).toBe(false); + + await vi.advanceTimersByTimeAsync(2000); + + expect(row.focused).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('entry details', () => { + const rects = new Map(); + let geo: FocusManager; + + beforeEach(() => { + rects.clear(); + geo = new FocusManager({ + measure: (element, out) => { + const rect = rects.get(element); + + if (!rect) { + return false; + } + + Object.assign(out, rect); + + return true; + }, + }); + }); + + it('enters first at the top-left item, not the first registered', () => { + const outside = leaf('outside'); + const row = grp('row'); + const right = leaf('right'); + const left = leaf('left'); + rects.set(right, { x: 100, y: 0, w: 10, h: 10 }); + rects.set(left, { x: 0, y: 0, w: 10, h: 10 }); + geo.addElement(outside); + geo.addElement(row, null, { focusEntry: 'first' }); + geo.addElement(right, row); + geo.addElement(left, row); + geo.focus(outside); + geo.focus(row); + + expect(left.focused).toBe(true); + }); + + it('skips last-focused in destinationKeys for a group that never had focus', () => { + const outside = leaf('outside'); + const row = grp('row'); + const a = leaf('a'); + const b = leaf('b'); + geo.addElement(outside); + geo.addElement(row, null, { destinationKeys: ['last-focused', 'b'], focusRedirect: true }); + geo.addElement(a, row); + geo.addElement(b, row, { focusKey: 'b' }); + geo.focus(outside); + geo.focus(row); + + expect(b.focused).toBe(true); + }); + + it('re-enters an outer last-focused region at the exact remembered item', () => { + const outside = leaf('outside'); + const outer = grp('outer'); + const inner = grp('inner'); + const a = leaf('a'); + const b = leaf('b'); + geo.addElement(outside); + geo.addElement(outer, null, { focusEntry: 'last-focused' }); + geo.addElement(inner, outer, { focusEntry: 'first' }); + geo.addElement(a, inner); + geo.addElement(b, inner); + geo.focus(b); + geo.focus(outside); + geo.focus(outer); + + expect(b.focused).toBe(true); + }); + }); +}); diff --git a/packages/react-lightning/src/focus/useFocus.tsx b/packages/react-lightning/src/focus/useFocus.tsx index cd192bdf..2975b799 100644 --- a/packages/react-lightning/src/focus/useFocus.tsx +++ b/packages/react-lightning/src/focus/useFocus.tsx @@ -2,6 +2,8 @@ import { type RefObject, useContext, useEffect, useRef, useSyncExternalStore } f import type { LightningElement } from '../types'; import { FocusGroupContext } from './FocusGroupContext'; +import type { FocusEntry, FocusExits } from './FocusManager'; +import type { Traps } from './Traps'; import { useFocusManager } from './useFocusManager'; export type FocusOptions = { @@ -18,22 +20,86 @@ export type FocusOptions = { * mount-time default. Mirrors tvOS `isTVFocusRestorationExcluded`. */ focusRestorationExcluded?: boolean; + focusKey?: string; + destinationKeys?: readonly string[]; + exits?: FocusExits; + traps?: Traps; + focusEntry?: FocusEntry; + rememberAs?: string; + scope?: boolean; + scopeActive?: boolean; + initialFocus?: number; + onFocusEnter?: () => void; + onFocusLeave?: () => void; }; +const DEFAULT_OPTIONS: FocusOptions = { active: true, autoFocus: false, focusRedirect: false }; + +function sameArray(a?: readonly unknown[] | null, b?: readonly unknown[] | null): boolean { + if (a === b) { + return true; + } + + if (!a || !b || a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +} + +function sameExits(a?: FocusExits, b?: FocusExits): boolean { + return ( + a === b || + (a?.up === b?.up && a?.right === b?.right && a?.down === b?.down && a?.left === b?.left) + ); +} + +function sameTraps(a?: Traps, b?: Traps): boolean { + return ( + a === b || + (!!a === !!b && + a?.up === b?.up && + a?.right === b?.right && + a?.down === b?.down && + a?.left === b?.left) + ); +} + +function sameHandlers(a: FocusOptions, b: FocusOptions): boolean { + return ( + a.onChildFocused === b.onChildFocused && + a.onFocusEnter === b.onFocusEnter && + a.onFocusLeave === b.onFocusLeave + ); +} + +function sameStructure(a: FocusOptions, b: FocusOptions): boolean { + return ( + a.autoFocus === b.autoFocus && + a.focusRedirect === b.focusRedirect && + a.allowOffscreen === b.allowOffscreen && + a.focusRestorationExcluded === b.focusRestorationExcluded && + a.focusKey === b.focusKey && + a.focusEntry === b.focusEntry && + a.rememberAs === b.rememberAs && + a.scope === b.scope && + a.scopeActive === b.scopeActive && + a.initialFocus === b.initialFocus && + sameArray(a.destinations, b.destinations) && + sameArray(a.destinationKeys, b.destinationKeys) && + sameExits(a.exits, b.exits) && + sameTraps(a.traps, b.traps) + ); +} + export function useFocus( - { - active, - autoFocus, - focusRedirect, - destinations, - onChildFocused, - allowOffscreen, - focusRestorationExcluded, - }: FocusOptions = { - active: true, - autoFocus: false, - focusRedirect: false, - }, + options: FocusOptions = DEFAULT_OPTIONS, ): { ref: RefObject; focused: boolean; @@ -56,21 +122,23 @@ export function useFocus( // We need to keep a copy of the ref around for when this hook is unmounted, // so we can properly remove the child element. const elementRef = useRef(null); + const appliedRef = useRef(null); + const activeRef = useRef('unset'); - /* oxlint-disable-next-line react-hooks/exhaustive-deps -- We purposely leave - out the autoFocus/focusRedirect/destinations dependencies here. This will - prevent unnecessary removal and re-addition of the elements to the focus - manager. Those dependencies get updated below in other effects. */ + /* oxlint-disable-next-line react-hooks/exhaustive-deps -- Registration only + re-runs when the manager or parent changes; later option changes are applied + by the update effect below, so the element isn't removed and re-added. */ useEffect(() => { if (ref.current && parentFocusable) { elementRef.current = ref.current; - focusManager.addElement(elementRef.current, parentFocusable, { - autoFocus, - focusRedirect, - destinations, - allowOffscreen, - focusRestorationExcluded, - }); + appliedRef.current = options; + focusManager.addElement(elementRef.current, parentFocusable, options); + + // Re-parenting marks an unfocusable element focusable again; an element + // that is inactive on purpose has to stay out of the search. + if (options.active === false) { + elementRef.current.focusable = false; + } } return () => { @@ -80,41 +148,34 @@ export function useFocus( }; }, [focusManager, parentFocusable]); + // Runs after every render on purpose: it only compares fields and calls the + // manager when something changed, which is cheaper than an effect per option. useEffect(() => { - if (ref.current) { - focusManager.setAutoFocus(ref.current, autoFocus); - } - }, [focusManager, autoFocus]); + const element = ref.current; - useEffect(() => { - if (ref.current) { - focusManager.setFocusRedirect(ref.current, focusRedirect); + if (!element) { + return; } - }, [focusManager, focusRedirect]); - useEffect(() => { - if (ref.current) { - focusManager.setDestinations(ref.current, destinations); + if (activeRef.current !== options.active) { + activeRef.current = options.active; + element.focusable = options.active !== undefined ? options.active : true; } - }, [focusManager, destinations]); - useEffect(() => { - if (ref.current) { - focusManager.setOnChildFocused(ref.current, onChildFocused); - } - }, [focusManager, onChildFocused]); + const applied = appliedRef.current; - useEffect(() => { - if (ref.current) { - focusManager.setAllowOffscreen(ref.current, allowOffscreen); + if (!applied || applied === options) { + return; } - }, [focusManager, allowOffscreen]); - useEffect(() => { - if (ref.current) { - ref.current.focusable = active !== undefined ? active : true; + appliedRef.current = options; + + if (!sameStructure(applied, options)) { + focusManager.updateElement(element, options); + } else if (!sameHandlers(applied, options)) { + focusManager.setHandlers(element, options); } - }, [active]); + }); return { ref, focused }; } diff --git a/packages/react-lightning/src/index.ts b/packages/react-lightning/src/index.ts index 8fb96406..26f5b8a0 100644 --- a/packages/react-lightning/src/index.ts +++ b/packages/react-lightning/src/index.ts @@ -8,7 +8,7 @@ export { LightningViewElement } from './element/LightningViewElement'; export { PARTIAL_STYLE } from './element/partialStyle'; export { FocusGroup, type FocusGroupProps } from './focus/FocusGroup'; export { FocusGroupContext } from './focus/FocusGroupContext'; -export { FocusManager } from './focus/FocusManager'; +export { FocusManager, type FocusEntry, type FocusExits } from './focus/FocusManager'; export { FocusManagerContext } from './focus/FocusManagerContext'; export { focusable } from './focus/focusable'; export { useFocus } from './focus/useFocus'; diff --git a/packages/react-native-lightning/src/exports/FocusGroup.tsx b/packages/react-native-lightning/src/exports/FocusGroup.tsx index 2ccd3700..5a798bd0 100644 --- a/packages/react-native-lightning/src/exports/FocusGroup.tsx +++ b/packages/react-native-lightning/src/exports/FocusGroup.tsx @@ -34,6 +34,7 @@ const FocusGroup: ForwardRefExoticComponent = forwardRef< = focusable< onLongPress, onLayout, focused: _focused, + focusable: _focusable, ...props }, ref, @@ -109,8 +110,9 @@ export const Pressable: ForwardRefExoticComponent = focusable< ); }, 'Pressable(lng)', - ({ active, autoFocus }) => ({ - active, + ({ active, autoFocus, focusable: rnFocusable, nativeID }) => ({ + active: active ?? rnFocusable !== false, autoFocus, + focusKey: nativeID, }), ); diff --git a/packages/react-native-lightning/src/exports/View.tsx b/packages/react-native-lightning/src/exports/View.tsx index b1905b4f..fa3ac1db 100644 --- a/packages/react-native-lightning/src/exports/View.tsx +++ b/packages/react-native-lightning/src/exports/View.tsx @@ -66,6 +66,7 @@ const FocusableView = forwardRef( const { ref: focusRef } = useFocus({ active: isFocusActive(props), focusRestorationExcluded, + focusKey: props.nativeID, }); const combinedRef = useCombinedRef(ref, focusRef);