Skip to content

Commit 84da859

Browse files
committed
📝 docs: add base-ui FocusScope design spec
Claude-Session: https://claude.ai/code/session_01Ee1HGYYoaPTGdAhTmLhz6y
1 parent 1b1f727 commit 84da859

1 file changed

Lines changed: 119 additions & 0 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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

Comments
 (0)