Skip to content

Commit 0c27aa1

Browse files
committed
✨ feat(base-ui): add FocusScope and drive Tree keyboard through it
Sticky focus scopes with window-level arrow navigation and scope switching (port of mx-admin focus-scope, no zustand / tinykeys). Tree becomes a scope: keys work after pointing into it, ←/→ stay expand / collapse via extra bindings. Claude-Session: https://claude.ai/code/session_01Ee1HGYYoaPTGdAhTmLhz6y
1 parent aad9e03 commit 0c27aa1

19 files changed

Lines changed: 876 additions & 55 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use client';
2+
3+
import { cx } from 'antd-style';
4+
import { createContext, type HTMLAttributes, type Ref, use, useEffect, useId } from 'react';
5+
6+
import { registerScope, setActiveScope, useFocusScopeActive } from './store';
7+
import { styles } from './style';
8+
9+
export interface FocusScopeProps extends HTMLAttributes<HTMLDivElement> {
10+
debugOutline?: boolean;
11+
id?: string;
12+
ref?: Ref<HTMLDivElement>;
13+
}
14+
15+
const FocusScopeIdContext = createContext<string | null>(null);
16+
17+
export const useFocusScopeId = () => use(FocusScopeIdContext);
18+
19+
export const FocusScope = ({
20+
id,
21+
debugOutline = false,
22+
className,
23+
children,
24+
onKeyDown,
25+
...rest
26+
}: FocusScopeProps) => {
27+
const generatedId = useId();
28+
const scopeId = id ?? generatedId;
29+
const isActive = useFocusScopeActive(scopeId);
30+
31+
useEffect(() => registerScope(scopeId), [scopeId]);
32+
33+
return (
34+
<FocusScopeIdContext value={scopeId}>
35+
<div
36+
data-focus-scope={scopeId}
37+
data-scope-active={isActive ? '' : undefined}
38+
tabIndex={-1}
39+
{...rest}
40+
className={cx(styles.root, debugOutline && styles.debugOutline, className)}
41+
onKeyDown={(event) => {
42+
onKeyDown?.(event);
43+
if (event.defaultPrevented) return;
44+
if (event.key === 'Escape') setActiveScope(null);
45+
}}
46+
>
47+
{children}
48+
</div>
49+
</FocusScopeIdContext>
50+
);
51+
};
52+
53+
FocusScope.displayName = 'FocusScope';
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
getActiveScopeId,
5+
getLastFocusedItem,
6+
registerScope,
7+
setActiveScope,
8+
setLastFocusedItem,
9+
} from '../store';
10+
11+
describe('focus scope store', () => {
12+
it('ignores activation of unknown scopes', () => {
13+
setActiveScope('nope');
14+
expect(getActiveScopeId()).toBeNull();
15+
});
16+
17+
it('refcounts registrations of the same id', () => {
18+
const off1 = registerScope('a');
19+
const off2 = registerScope('a');
20+
setActiveScope('a');
21+
off1();
22+
expect(getActiveScopeId()).toBe('a');
23+
off2();
24+
expect(getActiveScopeId()).toBeNull();
25+
});
26+
27+
it('clears last-focused memory when the scope unregisters', () => {
28+
const off = registerScope('b');
29+
setLastFocusedItem('b', 'row-2');
30+
expect(getLastFocusedItem('b')).toBe('row-2');
31+
off();
32+
expect(getLastFocusedItem('b')).toBeNull();
33+
});
34+
35+
it('activates the scope containing a pointerdown target', () => {
36+
const off = registerScope('c');
37+
const el = document.createElement('div');
38+
el.dataset.focusScope = 'c';
39+
const inner = document.createElement('button');
40+
el.append(inner);
41+
document.body.append(el);
42+
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
43+
expect(getActiveScopeId()).toBe('c');
44+
document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
45+
expect(getActiveScopeId()).toBe('c');
46+
off();
47+
el.remove();
48+
});
49+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { act, fireEvent, render, screen } from '@testing-library/react';
2+
3+
import { FocusScope } from '../FocusScope';
4+
import { getActiveScopeId, setActiveScope } from '../store';
5+
import { useScopeArrowNav } from '../useScopeArrowNav';
6+
7+
const List = ({
8+
id,
9+
items,
10+
onItemFocus,
11+
vimKeys,
12+
}: {
13+
id: string;
14+
items: string[];
15+
onItemFocus?: (el: HTMLElement) => void;
16+
vimKeys?: boolean;
17+
}) => {
18+
useScopeArrowNav({ onItemFocus, scopeId: id, vimKeys });
19+
return (
20+
<FocusScope id={id}>
21+
{items.map((item) => (
22+
<div data-scope-item data-id={item} key={item} tabIndex={-1}>
23+
{item}
24+
</div>
25+
))}
26+
<input aria-label={`${id}-input`} />
27+
</FocusScope>
28+
);
29+
};
30+
31+
afterEach(() => act(() => setActiveScope(null)));
32+
33+
describe('useScopeArrowNav', () => {
34+
test('ArrowDown moves focus inside the scope that was pointed into, without focus', () => {
35+
render(<List id="a" items={['a1', 'a2', 'a3']} />);
36+
fireEvent.pointerDown(screen.getByText('a1'));
37+
expect(getActiveScopeId()).toBe('a');
38+
fireEvent.keyDown(window, { key: 'ArrowDown' });
39+
expect(document.activeElement).toBe(screen.getByText('a1'));
40+
fireEvent.keyDown(window, { key: 'ArrowDown' });
41+
expect(document.activeElement).toBe(screen.getByText('a2'));
42+
fireEvent.keyDown(window, { key: 'End' });
43+
expect(document.activeElement).toBe(screen.getByText('a3'));
44+
fireEvent.keyDown(window, { key: 'ArrowDown' });
45+
expect(document.activeElement).toBe(screen.getByText('a1'));
46+
});
47+
48+
test('stays reachable after clicking outside every scope', () => {
49+
render(
50+
<>
51+
<List id="a" items={['a1', 'a2']} />
52+
<button>outside</button>
53+
</>,
54+
);
55+
fireEvent.pointerDown(screen.getByText('a1'));
56+
fireEvent.pointerDown(screen.getByText('outside'));
57+
fireEvent.keyDown(window, { key: 'ArrowDown' });
58+
expect(document.activeElement).toBe(screen.getByText('a1'));
59+
});
60+
61+
test('ignores keys typed into text inputs and already-handled events', () => {
62+
let block = false;
63+
const stop = (event: KeyboardEvent) => block && event.preventDefault();
64+
window.addEventListener('keydown', stop, true);
65+
render(<List id="a" items={['a1', 'a2']} />);
66+
fireEvent.pointerDown(screen.getByText('a1'));
67+
const input = screen.getByLabelText('a-input');
68+
act(() => input.focus());
69+
fireEvent.keyDown(input, { key: 'ArrowDown' });
70+
expect(document.activeElement).toBe(input);
71+
72+
act(() => screen.getByText('a1').focus());
73+
block = true;
74+
fireEvent.keyDown(window, { key: 'ArrowDown' });
75+
window.removeEventListener('keydown', stop, true);
76+
expect(document.activeElement).toBe(screen.getByText('a1'));
77+
});
78+
79+
test('j / k only work with vimKeys', () => {
80+
const { rerender } = render(<List id="a" items={['a1', 'a2']} />);
81+
fireEvent.pointerDown(screen.getByText('a1'));
82+
act(() => screen.getByText('a1').focus());
83+
fireEvent.keyDown(window, { key: 'j' });
84+
expect(document.activeElement).toBe(screen.getByText('a1'));
85+
rerender(<List vimKeys id="a" items={['a1', 'a2']} />);
86+
fireEvent.keyDown(window, { key: 'j' });
87+
expect(document.activeElement).toBe(screen.getByText('a2'));
88+
});
89+
90+
test('only the active scope responds', () => {
91+
render(
92+
<>
93+
<List id="a" items={['a1', 'a2']} />
94+
<List id="b" items={['b1', 'b2']} />
95+
</>,
96+
);
97+
fireEvent.pointerDown(screen.getByText('b1'));
98+
fireEvent.keyDown(window, { key: 'ArrowDown' });
99+
expect(document.activeElement).toBe(screen.getByText('b1'));
100+
});
101+
102+
test('double-mounted scope moves focus once and fans out onItemFocus to both', () => {
103+
const a = vi.fn();
104+
const b = vi.fn();
105+
render(
106+
<>
107+
<List id="a" items={['a1', 'a2']} onItemFocus={a} />
108+
<div hidden>
109+
<List id="a" items={['a1', 'a2']} onItemFocus={b} />
110+
</div>
111+
</>,
112+
);
113+
fireEvent.pointerDown(screen.getAllByText('a1')[0]);
114+
act(() => screen.getAllByText('a1')[0].focus());
115+
fireEvent.keyDown(window, { key: 'ArrowDown' });
116+
expect(document.activeElement).toBe(screen.getAllByText('a2')[0]);
117+
expect(a).toHaveBeenCalledTimes(1);
118+
expect(b).toHaveBeenCalledTimes(1);
119+
});
120+
121+
test('Escape inside the scope deactivates it', () => {
122+
render(<List id="a" items={['a1']} />);
123+
fireEvent.pointerDown(screen.getByText('a1'));
124+
fireEvent.keyDown(screen.getByText('a1'), { key: 'Escape' });
125+
expect(getActiveScopeId()).toBeNull();
126+
});
127+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { act, fireEvent, render, screen } from '@testing-library/react';
2+
3+
import { FocusScope } from '../FocusScope';
4+
import { getActiveScopeId, setActiveScope } from '../store';
5+
import { useScopeArrowNav } from '../useScopeArrowNav';
6+
import { useScopeSwitcher } from '../useScopeSwitcher';
7+
8+
const List = ({ id, items }: { id: string; items: string[] }) => {
9+
useScopeArrowNav({ scopeId: id });
10+
return (
11+
<FocusScope id={id}>
12+
{items.map((item) => (
13+
<div data-scope-item data-id={item} key={item} tabIndex={-1}>
14+
{item}
15+
</div>
16+
))}
17+
</FocusScope>
18+
);
19+
};
20+
21+
const Shell = ({ vimKeys }: { vimKeys?: boolean }) => {
22+
useScopeSwitcher({ vimKeys });
23+
return (
24+
<>
25+
<List id="a" items={['a1', 'a2']} />
26+
<List id="b" items={['b1', 'b2']} />
27+
</>
28+
);
29+
};
30+
31+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
32+
bottom: 10,
33+
height: 10,
34+
left: 0,
35+
right: 10,
36+
toJSON: () => ({}),
37+
top: 0,
38+
width: 10,
39+
x: 0,
40+
y: 0,
41+
});
42+
43+
afterEach(() => act(() => setActiveScope(null)));
44+
45+
describe('useScopeSwitcher', () => {
46+
test('ArrowRight activates the next scope and focuses its first item; no wrap', () => {
47+
render(<Shell />);
48+
fireEvent.pointerDown(screen.getByText('a1'));
49+
fireEvent.keyDown(window, { key: 'ArrowRight' });
50+
expect(getActiveScopeId()).toBe('b');
51+
expect(document.activeElement).toBe(screen.getByText('b1'));
52+
fireEvent.keyDown(window, { key: 'ArrowRight' });
53+
expect(getActiveScopeId()).toBe('b');
54+
});
55+
56+
test('switching back restores the last focused item', () => {
57+
render(<Shell />);
58+
fireEvent.pointerDown(screen.getByText('a1'));
59+
act(() => screen.getByText('a1').focus());
60+
fireEvent.keyDown(window, { key: 'ArrowDown' });
61+
expect(document.activeElement).toBe(screen.getByText('a2'));
62+
fireEvent.keyDown(window, { key: 'ArrowRight' });
63+
fireEvent.keyDown(window, { key: 'ArrowLeft' });
64+
expect(document.activeElement).toBe(screen.getByText('a2'));
65+
});
66+
67+
test('a prevented ArrowRight does not switch', () => {
68+
render(<Shell />);
69+
fireEvent.pointerDown(screen.getByText('a1'));
70+
const stop = (event: KeyboardEvent) => event.preventDefault();
71+
window.addEventListener('keydown', stop, true);
72+
fireEvent.keyDown(window, { key: 'ArrowRight' });
73+
window.removeEventListener('keydown', stop, true);
74+
expect(getActiveScopeId()).toBe('a');
75+
});
76+
77+
test('h / l only with vimKeys', () => {
78+
render(<Shell vimKeys />);
79+
fireEvent.pointerDown(screen.getByText('a1'));
80+
fireEvent.keyDown(window, { key: 'l' });
81+
expect(getActiveScopeId()).toBe('b');
82+
fireEvent.keyDown(window, { key: 'h' });
83+
expect(getActiveScopeId()).toBe('a');
84+
});
85+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Flexbox } from '@lobehub/ui';
2+
import { FocusScope, Text, useFocusScopeActive, useScopeArrowNav, useScopeSwitcher } from '@lobehub/ui/base-ui';
3+
import { cssVar } from 'antd-style';
4+
5+
const List = ({ id, items }: { id: string; items: string[] }) => {
6+
useScopeArrowNav({ scopeId: id, vimKeys: true });
7+
const active = useFocusScopeActive(id);
8+
return (
9+
<FocusScope
10+
debugOutline
11+
id={id}
12+
style={{ borderRadius: 8, flex: 1, minWidth: 0, padding: 8 }}
13+
>
14+
<Text fontSize={12} style={{ paddingInline: 8 }} type="secondary">
15+
{id} {active ? '· active' : ''}
16+
</Text>
17+
{items.map((item) => (
18+
<div
19+
data-scope-item
20+
data-id={item}
21+
key={item}
22+
style={{ borderRadius: 6, cursor: 'default', outline: 'none', padding: '6px 8px' }}
23+
tabIndex={-1}
24+
onBlur={(e) => (e.currentTarget.style.background = '')}
25+
onFocus={(e) => (e.currentTarget.style.background = cssVar.colorFillSecondary)}
26+
>
27+
{item}
28+
</div>
29+
))}
30+
</FocusScope>
31+
);
32+
};
33+
34+
export default () => {
35+
useScopeSwitcher({ vimKeys: true });
36+
return (
37+
<Flexbox gap={16} padding={16}>
38+
<Text type="secondary">
39+
Click into a list, then use ↑ ↓ (j k) to move and ← → (h l) to jump between lists — no
40+
focus required after the first click. Esc releases the scope.
41+
</Text>
42+
<Flexbox horizontal gap={12}>
43+
<List id="inbox" items={['Welcome', 'Release notes', 'Weekly digest']} />
44+
<List id="drafts" items={['Untitled', 'Q3 plan', 'Reply to Sam']} />
45+
<List id="archive" items={['2025 recap', 'Old invoice']} />
46+
</Flexbox>
47+
</Flexbox>
48+
);
49+
};

src/base-ui/FocusScope/domUtils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export const isItemVisible = (el: HTMLElement): boolean => {
2+
if (typeof el.checkVisibility === 'function') {
3+
return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true });
4+
}
5+
for (let node: HTMLElement | null = el; node; node = node.parentElement) {
6+
const style = getComputedStyle(node);
7+
if (style.display === 'none' || style.visibility === 'hidden') return false;
8+
}
9+
return true;
10+
};
11+
12+
export const hasSize = (el: HTMLElement) => {
13+
const rect = el.getBoundingClientRect();
14+
return rect.width > 0 && rect.height > 0;
15+
};
16+
17+
export const isTextInputTarget = (target: EventTarget | null) => {
18+
if (!(target instanceof HTMLElement)) return false;
19+
if (target.isContentEditable) return true;
20+
return ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName);
21+
};
22+
23+
export const hasModifier = (event: KeyboardEvent) => event.metaKey || event.ctrlKey || event.altKey;
24+
25+
export const scopeSelector = (id: string) => `[data-focus-scope="${id.replaceAll('"', '\\"')}"]`;

0 commit comments

Comments
 (0)