Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/primitives/useFocusManager.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ const App = () => {
};
```

### Event Target

`useFocusManager` binds `keydown` and `keyup` on `document`. A host without a
document, such as a native runtime that reports remote presses through its own
bridge, passes the object to listen on as the second argument. It needs only
`addEventListener` and `removeEventListener` for those two event types (the
`KeyEventTarget` type), and the events it delivers need only the fields the
focus manager reads: `key`, `keyCode` and `repeat` (`KeyEventLike`). Key
handlers then receive the host's event object in place of a `KeyboardEvent`.

```jsx
useFocusManager(undefined, keyBridge);
```

Browser apps are unaffected: leave the argument out and `document` is used.

### Focus Path Tracking

`focusPath` is a signal holding the array of elements that currently have focus, from the focused leaf up to the root. It is imported separately — `useFocusManager` itself returns nothing:
Expand Down
50 changes: 41 additions & 9 deletions src/core/focusManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,37 @@ const handleKeyEvents = (keydown?: KeyboardEvent, keyup?: KeyboardEvent) => {
}
};

export const useFocusManager = (userKeyMap?: Partial<KeyMap>) => {
/**
* What the focus manager reads off a key event. A browser's `KeyboardEvent`
* has these; a host without a DOM raises objects that carry at least them,
* and key handlers receive whichever object the host raised.
*/
export interface KeyEventLike {
readonly key: string;
readonly keyCode: number;
readonly repeat: boolean;
}

/**
* Where {@link useFocusManager} listens for `keydown` and `keyup`: `document`
* in a browser, or anything with the same two methods on a host without one,
* such as a native key bridge.
*/
export interface KeyEventTarget {
addEventListener(
type: 'keydown' | 'keyup',
listener: (event: KeyEventLike) => void,
): void;
removeEventListener(
type: 'keydown' | 'keyup',
listener: (event: KeyEventLike) => void,
): void;
}

export const useFocusManager = (
userKeyMap?: Partial<KeyMap>,
target: KeyEventTarget = document,
) => {
if (userKeyMap) {
flattenKeyMap(userKeyMap, keyMapEntries);
}
Expand All @@ -577,17 +607,19 @@ export const useFocusManager = (userKeyMap?: Partial<KeyMap>) => {
Config.setActiveElement = (elm) =>
ownerContext(() => setActiveElementSignal(elm));

const keyPressHandler = (event: KeyboardEvent) =>
ownerContext(() => handleKeyEvents(event, undefined));
const keyUpHandler = (event: KeyboardEvent) =>
ownerContext(() => handleKeyEvents(undefined, event));
// Handlers are typed as KeyboardEvent throughout; on a host that raises
// its own objects they see those, which carry the fields read here.
const keyPressHandler = (event: KeyEventLike) =>
ownerContext(() => handleKeyEvents(event as KeyboardEvent, undefined));
const keyUpHandler = (event: KeyEventLike) =>
ownerContext(() => handleKeyEvents(undefined, event as KeyboardEvent));

document.addEventListener('keydown', keyPressHandler);
document.addEventListener('keyup', keyUpHandler);
target.addEventListener('keydown', keyPressHandler);
target.addEventListener('keyup', keyUpHandler);

onCleanup(() => {
document.removeEventListener('keydown', keyPressHandler);
document.removeEventListener('keyup', keyUpHandler);
target.removeEventListener('keydown', keyPressHandler);
target.removeEventListener('keyup', keyUpHandler);
suppressedKeys.clear();
});
};
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
export type * from '@solidtv/solid/jsx-runtime';
export * from './core/index.js';
export type * from './core/index.js';
export type { KeyHandler, KeyMap } from './core/focusManager.js';
export type {
KeyEventLike,
KeyEventTarget,
KeyHandler,
KeyMap,
} from './core/focusManager.js';
export { activeElement, setActiveElement } from './core/activeElement.js';
export { setActiveElementCore } from './core/focusManager.js';
export * from './utils.js';
Expand Down
2 changes: 2 additions & 0 deletions src/primitives/useFocusManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export {
printFocusHistory,
getFocusHistory,
type FocusHistoryEntry,
type KeyEventLike,
type KeyEventTarget,
type KeyMap,
} from '../core/focusManager.js';
export { activeElement, setActiveElement } from '../core/activeElement.js';
92 changes: 92 additions & 0 deletions tests/focusManagerTarget.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as v from 'vitest';
import {
useFocusManager,
type KeyEventLike,
type KeyEventTarget,
} from '@solidtv/solid/primitives';
import { renderer, waitForUpdate } from './setup.js';

type Listener = (event: KeyEventLike) => void;

// A host without a document: records the listeners it is handed so the test
// can fire them and check they are gone after cleanup.
class FakeTarget implements KeyEventTarget {
listeners: Record<'keydown' | 'keyup', Listener[]> = {
keydown: [],
keyup: [],
};

addEventListener(type: 'keydown' | 'keyup', listener: Listener) {
this.listeners[type].push(listener);
}

removeEventListener(type: 'keydown' | 'keyup', listener: Listener) {
const list = this.listeners[type];
const idx = list.indexOf(listener);
if (idx !== -1) list.splice(idx, 1);
}

press(type: 'keydown' | 'keyup', key: string) {
const event: KeyEventLike = { key, keyCode: 0, repeat: false };
for (const listener of this.listeners[type].slice()) listener(event);
}
}

async function setup(target: FakeTarget) {
const onEnter = v.vi.fn();
const onEnterRelease = v.vi.fn();
const dispose = renderer.render(() => {
useFocusManager(undefined, target);
return <view autofocus onEnter={onEnter} onEnterRelease={onEnterRelease} />;
}) as unknown as () => void;
await waitForUpdate();
return { onEnter, onEnterRelease, dispose };
}

v.describe('useFocusManager event target', () => {
v.test('binds keydown and keyup on the given target', async () => {
const target = new FakeTarget();
const { onEnter, onEnterRelease, dispose } = await setup(target);

v.assert.equal(target.listeners.keydown.length, 1);
v.assert.equal(target.listeners.keyup.length, 1);

target.press('keydown', 'Enter');
v.assert.equal(onEnter.mock.calls.length, 1);
target.press('keyup', 'Enter');
v.assert.equal(onEnterRelease.mock.calls.length, 1);

dispose();
});

v.test('leaves document alone when a target is given', async () => {
// Test files share this worker's module state (`isolate: false`), and
// other files bind the focus manager to document, so a key dispatched on
// document is not a clean signal here. Check the registration itself.
const addEventListener = v.vi.spyOn(document, 'addEventListener');
const target = new FakeTarget();
const { dispose } = await setup(target);

const keyRegistrations = addEventListener.mock.calls.filter(
([type]) => type === 'keydown' || type === 'keyup',
);
v.assert.equal(keyRegistrations.length, 0);
v.assert.equal(target.listeners.keydown.length, 1);
v.assert.equal(target.listeners.keyup.length, 1);

addEventListener.mockRestore();
dispose();
});

v.test('removes its listeners from the target on cleanup', async () => {
const target = new FakeTarget();
const { onEnter, dispose } = await setup(target);

dispose();
v.assert.equal(target.listeners.keydown.length, 0);
v.assert.equal(target.listeners.keyup.length, 0);

target.press('keydown', 'Enter');
v.assert.equal(onEnter.mock.calls.length, 0);
});
});
Loading