|
| 1 | +# base-ui FocusScope |
| 2 | + |
| 3 | +Port of `mx-core/apps/admin/src/ui/focus-scope` (Scope + ArrowNav + Switcher) into `@lobehub/ui/base-ui`, and `Tree` rebuilt on top of it. `list-actions` (selection model, action registry) stays in the app layer. |
| 4 | + |
| 5 | +## Model |
| 6 | + |
| 7 | +- A **scope** is a DOM subtree marked `[data-focus-scope="<id>"]`. The **active scope** is the last one the user pointed or focused into (global `pointerdown` / `focusin` capture listeners). It is **sticky**: interacting outside every scope does not clear it; only `Escape` inside a scope or `setActiveScope(null)` does. |
| 8 | +- **Items** inside a scope are `[data-scope-item]` elements (`data-id` optional, used for last-focused memory). |
| 9 | +- **ArrowNav** binds `↑ ↓ Home End` (plus `j k` with `vimKeys`) on `window` and moves DOM focus between the active scope's visible items. Works without the scope holding focus — the point of stickiness. |
| 10 | +- **Switcher** binds `← →` (plus `h l` with `vimKeys`) on `window`, mounted once per app, moving the active scope to its visible left/right sibling in DOM order and restoring that scope's last-focused item. |
| 11 | + |
| 12 | +## Files |
| 13 | + |
| 14 | +``` |
| 15 | +src/base-ui/FocusScope/ |
| 16 | + FocusScope.tsx <div data-focus-scope tabIndex={-1}> + register + Escape |
| 17 | + store.ts module singleton + useSyncExternalStore hooks |
| 18 | + useScopeArrowNav.ts |
| 19 | + useScopeSwitcher.ts |
| 20 | + domUtils.ts isItemVisible, isTextInputTarget, hasSize |
| 21 | + index.ts |
| 22 | + index.mdx |
| 23 | + demos/ twoLists (ArrowNav + Switcher), tree (Tree inside scopes) |
| 24 | + __tests__/ |
| 25 | + store.test.ts |
| 26 | + useScopeArrowNav.test.tsx |
| 27 | + useScopeSwitcher.test.tsx |
| 28 | +``` |
| 29 | + |
| 30 | +No zustand, no tinykeys: the store is a plain object with `subscribe` + `useSyncExternalStore`; key bindings are a raw `keydown` listener keyed on `event.key`. |
| 31 | + |
| 32 | +## store.ts |
| 33 | + |
| 34 | +```ts |
| 35 | +interface FocusScopeState { |
| 36 | + activeScopeId: string | null; |
| 37 | + knownScopes: Map<string, number>; // refcount; two mounts of one id are allowed |
| 38 | + lastFocusedItem: Map<string, string>; // scopeId → data-id |
| 39 | +} |
| 40 | +getActiveScopeId(): string | null |
| 41 | +setActiveScope(id: string | null): void // ignored if id is not registered |
| 42 | +registerScope(id): () => void // attaches document listeners on first call (SSR-safe) |
| 43 | +getLastFocusedItem(scopeId): string | null |
| 44 | +setLastFocusedItem(scopeId, id | null): void |
| 45 | +useActiveScopeId(): string | null |
| 46 | +useFocusScopeActive(id): boolean |
| 47 | +``` |
| 48 | + |
| 49 | +Unregistering the last instance of an id clears it from `activeScopeId` and `lastFocusedItem`. |
| 50 | + |
| 51 | +## FocusScope.tsx |
| 52 | + |
| 53 | +```ts |
| 54 | +interface FocusScopeProps extends HTMLAttributes<HTMLDivElement> { |
| 55 | + id?: string; // default useId() |
| 56 | + debugOutline?: boolean; // default false; dashed colorInfo outline on the active scope |
| 57 | + ref?: Ref<HTMLDivElement>; |
| 58 | +} |
| 59 | +``` |
| 60 | + |
| 61 | +Renders `<div data-focus-scope={id} data-scope-active tabIndex={-1} {...rest}>`. `role`, `className`, `style`, `onKeyDown` pass through — `Tree` renders `<FocusScope role="tree">`. `onKeyDown`: after the caller's handler, `Escape` (not `defaultPrevented`) → `setActiveScope(null)`. |
| 62 | + |
| 63 | +Exports `useFocusScopeId()` (context) so descendants can find their scope id without prop drilling. |
| 64 | + |
| 65 | +## useScopeArrowNav.ts |
| 66 | + |
| 67 | +```ts |
| 68 | +interface UseScopeArrowNavOptions { |
| 69 | + scopeId: string; |
| 70 | + itemSelector?: string; // default '[data-scope-item]' |
| 71 | + enabled?: boolean; // default true |
| 72 | + vimKeys?: boolean; // default false: adds j / k |
| 73 | + onItemFocus?: (el: HTMLElement) => void; |
| 74 | + extra?: Record<string, (event: KeyboardEvent) => void>; // keyed by event.key, same gating |
| 75 | +} |
| 76 | +``` |
| 77 | + |
| 78 | +Listener: `window.addEventListener('keydown', handler, { capture: true })`. Gate, in order: |
| 79 | + |
| 80 | +1. `event.defaultPrevented` → skip. |
| 81 | +2. Modifier held (`meta / ctrl / alt`) → skip (extra keys included; `$mod+…` combos belong to app hotkeys). |
| 82 | +3. Reachable: `getActiveScopeId() === scopeId`, or `document.activeElement` is inside `[data-focus-scope=scopeId]`. |
| 83 | +4. `isTextInputTarget(event.target)` → skip. |
| 84 | +5. Same `KeyboardEvent` already handled by a sibling instance → skip (dedupe for double mounts). |
| 85 | + |
| 86 | +Then `preventDefault()` and run. Scope root resolution and item discovery copy mx-admin: prefer the root containing `activeElement`, else the first visible `[data-focus-scope=id]`; items = `root.querySelectorAll(itemSelector)` filtered by `isItemVisible`. `move(±1)` wraps; no current item → first / last. `focusAt` = `focus({ preventScroll })` + `scrollIntoView({ block: 'nearest' })` + `setLastFocusedItem` + fan out `onItemFocus` to every instance registered on the scope. |
| 87 | + |
| 88 | +Capture phase is deliberate: it runs before the bubble-phase Switcher, so a scope whose `extra` claims `←`/`→` (Tree) wins by `preventDefault`. |
| 89 | + |
| 90 | +## useScopeSwitcher.ts |
| 91 | + |
| 92 | +```ts |
| 93 | +interface UseScopeSwitcherOptions { enabled?: boolean; vimKeys?: boolean } |
| 94 | +``` |
| 95 | + |
| 96 | +Bubble-phase `window` `keydown`. Skips `defaultPrevented`, modifiers, text inputs. Scopes = `[data-focus-scope]` in DOM order, deduped by id, filtered by `isItemVisible` and non-zero size. No wrap. On switch: `setActiveScope(next)` then focus, in order, last-focused item (`[data-id]` still in DOM) → first `[data-scope-item]` → the scope element. Focusing via the same `notifyScopeItemFocus` path as ArrowNav so `onItemFocus` consumers stay in sync. |
| 97 | + |
| 98 | +## Tree on FocusScope |
| 99 | + |
| 100 | +- Root becomes `<FocusScope id={scopeId} role="tree" …>`; new props `scopeId?: string`, `vimKeys?: boolean`. |
| 101 | +- Rows get `data-scope-item` and `data-id={key}`. |
| 102 | +- Delete `Tree`'s `onKeyDown`, `rowsRef`, `focusRow`. `activeKey` (roving `tabIndex`) is set from `onItemFocus` and row `onFocus`. |
| 103 | +- `useScopeArrowNav({ scopeId, itemSelector: '[role="treeitem"]', vimKeys, onItemFocus, extra })` with `extra`: |
| 104 | + - `ArrowRight`: collapsed parent → expand; expanded → focus next row; leaf → no-op (still `preventDefault`, so Switcher does not steal it). |
| 105 | + - `ArrowLeft`: expanded → collapse; else → focus parent row. |
| 106 | + - `Enter`: select. `' '`: check if `checkable`, else select. `'*'`: expand siblings. |
| 107 | +- The "current row" for `extra` handlers is `document.activeElement.closest('[role=treeitem]')`, falling back to `activeKey`. |
| 108 | +- Tree tests change from `fireEvent.keyDown(row)` to `fireEvent.keyDown(window)` after `fireEvent.pointerDown(row)` — that is the behaviour being bought. |
| 109 | + |
| 110 | +## Tests |
| 111 | + |
| 112 | +- `store.test.ts`: refcount register / unregister; `setActiveScope` ignores unknown ids; unregister clears active + last-focused. |
| 113 | +- `useScopeArrowNav.test.tsx`: pointerdown into scope A then `ArrowDown` on window focuses A's next item; clicking outside keeps A reachable; `ArrowDown` from an `<input>` inside A is ignored; an event with `defaultPrevented` is ignored; two instances with one id move focus once. |
| 114 | +- `useScopeSwitcher.test.tsx`: A and B side by side; `ArrowRight` activates B and focuses its first item; back to A restores the last-focused item; `ArrowRight` prevented by a capture listener does nothing. |
| 115 | +- `Tree.test.tsx`: existing keyboard cases rewritten to window-level keys; add "←/→ inside Tree never switches scope" with a sibling scope mounted. |
| 116 | + |
| 117 | +## Out of scope |
| 118 | + |
| 119 | +`useListKeyboard`, `useListSelection`, action registries, `$mod+a` / multi-select extras — app layer. Typeahead. Grid (2-D) navigation. |
0 commit comments