diff --git a/docs/primitives/useFocusManager.md b/docs/primitives/useFocusManager.md
index ee603e0..8943b9f 100644
--- a/docs/primitives/useFocusManager.md
+++ b/docs/primitives/useFocusManager.md
@@ -9,27 +9,19 @@ The `useFocusManager` primitive is designed to handle user input, manage focus p
Import the `useFocusManager` and configure it with your custom key mappings:
```jsx
-import { useFocusManager } from '@solidtv/solid';
+import { useFocusManager } from '@solidtv/solid/primitives';
const App = () => {
- const focusPath = useFocusManager(
- // These are the default, so you can just call useFocusManager()
- {
- Left: ['ArrowLeft', 37],
- Right: ['ArrowRight', 39],
- Up: ['ArrowUp', 38],
- Down: ['ArrowDown', 40],
- Enter: 'Enter',
- Last: 'l',
- },
- // Second param is keyHoldMapEntries
- {
- userKeyHoldMap: {
- EnterHold: 'Enter',
- },
- holdThreshold: 150, //ms for how long to hold for
- },
- );
+ // The defaults are already applied, so you can just call useFocusManager().
+ // Anything you pass is merged over them.
+ useFocusManager({
+ Left: ['ArrowLeft', 37],
+ Right: ['ArrowRight', 39],
+ Up: ['ArrowUp', 38],
+ Down: ['ArrowDown', 40],
+ Enter: 'Enter',
+ Last: 'l',
+ });
// Additional application logic...
};
@@ -37,7 +29,13 @@ const App = () => {
### Focus Path Tracking
-The `useFocusManager` returns a signal, `focusPath`, which is an array of elements that currently have focus. When the `activeElement` changes, the focus path is recalculated. During this process:
+`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:
+
+```jsx
+import { useFocusManager, focusPath } from '@solidtv/solid/primitives';
+```
+
+When the `activeElement` changes, the focus path is recalculated. During this process:
- All elements in focus will have a `focus` state added, and `onFocus(currentFocusedElm, prevFocusedElm, nodeWithCallback)` event is called.
- Elements losing focus will have the `focus` state removed, and `onBlur(currentFocusedElm, prevFocusedElm, nodeWithCallback)` event is called.
@@ -53,12 +51,26 @@ return {/* use hasFocus() */};
When a key is pressed:
-1. The `keyMap` looks for the key name and its corresponding value.
-2. It then looks for `capture${key}` and `captureKey` from top down.
-3. It then calls the `on${key}` handler, searching from focused element back up the tree.
-4. If the key is not handled, it calls the generic `onKeyPress` on the active element and then propagates up through the focus path until the key press is handled.
+1. The `keyMap` resolves the event's `key` (falling back to its `keyCode`) to a mapped event name, e.g. `ArrowLeft` → `Left`. A key with no mapping still propagates, but only to the generic handlers.
+2. **Capture phase**, root → focused leaf: on each element it looks for `onCapture${key}`, then `onCaptureKey`. If the mapping failed, the raw `e.key` is used in place of `${key}`.
+3. **Bubble phase**, focused leaf → root: on each element it looks for `on${key}`, then falls back to `onKeyPress` on that _same_ element before moving to its parent.
+
+Note that step 3 is a single interleaved walk — `onKeyPress` on an element is tried before its parent's `on${key}`, not as a separate pass after the whole tree.
-The keyHandler signature is: `(this: ElementNode, e: Event, elm: ElementNode, finalFocusedElm: ElementNode) => boolean`
+The key handler signature is:
+
+```ts
+type KeyHandler = (
+ this: ElementNode,
+ e: KeyboardEvent,
+ target: ElementNode, // the element whose handler is running
+ handlerElm: ElementNode, // the focused leaf element
+ mappedEvent?: string, // capture-phase handlers only
+) => boolean | void;
+```
+
+`onKeyPress` takes the mapped event name as its second argument instead:
+`(e, mappedKeyEvent, handlerElm, currentFocusedElm)`.
To stop the propagation of a key press, the handler must return `true`. Any other return value or no return value will continue to propagate the key press through the focus path, looking for additional handlers.
@@ -135,15 +147,18 @@ After every `printFocusHistory` call, `window.$f` is set to the DOM div of the m
#### Manual printing (`printFocusHistory`)
-`printFocusHistory(n)` can be called at any time — including directly from the browser DevTools console — to print the last N entries. `count` is required.
+`printFocusHistory(n)` prints the last N entries at any time. `n` is required.
```javascript
-import { printFocusHistory } from '@solidtv/solid';
+import { printFocusHistory } from '@solidtv/solid/primitives';
printFocusHistory(20);
+```
-// Also works directly in the browser DevTools console (no import needed once the app is running)
-printFocusHistory(20);
+It is not attached to `window`, so it is not callable from the DevTools console on its own. If you want it there, assign it yourself during dev setup:
+
+```javascript
+if (import.meta.env.DEV) window.printFocusHistory = printFocusHistory;
```
#### Inspecting the buffer programmatically (`getFocusHistory`)
@@ -151,7 +166,10 @@ printFocusHistory(20);
`getFocusHistory()` returns the full ring buffer as a read-only array of `FocusHistoryEntry` objects. This is useful for custom devtools panels, automated tests, or sending focus traces to a logging service.
```typescript
-import { getFocusHistory, type FocusHistoryEntry } from '@solidtv/solid';
+import {
+ getFocusHistory,
+ type FocusHistoryEntry,
+} from '@solidtv/solid/primitives';
const history: Readonly = getFocusHistory();
```
@@ -176,50 +194,78 @@ Per-element metadata (focus count, last focused timestamp) is stored in a `WeakM
On release of a key:
-1. The `keyMap` looks for the key name and its corresponding value.
-2. It calls the `on${key}Release` handler first.
+1. The `keyMap` resolves the key to a mapped event name, as for a key press.
+2. **Capture phase**, root → leaf: `onCapture${key}Release`, then `onCaptureKeyRelease`.
+3. **Bubble phase**, leaf → root: `on${key}Release`.
-Note: There is no generic `onKeyRelease`.
+Note: there is no generic `onKeyRelease` in the bubble phase — `onKeyPress` is not called for key-ups. `onCaptureKeyRelease` is the only catch-all for a release.
### Hold Key Handling
-Recommended approach to Hold Key Handling is with the [useHold](./useHold.md) primitive as this will not delay any keypress events for elements which do not need Hold.
+Hold gestures are handled by the [useHold](./useHold.md) primitive, which is
+scoped to the elements that need it rather than delaying key-press events
+globally.
-#### DEPRECATED - keyHold will be replaced with useHold
+The global `keyHoldOptions` / `userKeyHoldMap` second parameter has been removed,
+along with the `onKeyHold` and `on${Key}Hold` handlers it dispatched. Move a
+`userKeyHoldMap` entry to `useHold` on the element that owns the gesture:
-You can specify which keys you'd like tracked for Hold events globally as the second param to `useFocusManager`.
+```tsx
+// Before: useFocusManager(keyMap, { userKeyHoldMap: { EnterHold: 'Enter' }, holdThreshold: 1000 })
+//
-1. The `keyHoldMap` looks for the key name and its corresponding value.
-2. It calls the `on${keyHold}` handler after `holdThreshold` || 500 ms.
-3. If the key is not handled, it calls the generic `onKeyHold` on the active element and then propagates up through the focus path until the key press is handled.
+// After:
+const [holdEnter, releaseEnter] = useHold({
+ onHold: openMenu,
+ onEnter: openTile,
+ holdThreshold: 1000,
+});
-The keyHandler signature is: `(this: ElementNode, e: Event, elm: ElementNode, finalFocusedElm: ElementNode) => boolean`
-
-To stop the propagation of a key press, the handler must return `true`. Any other return value or no return value will continue to propagate the key press through the focus path, looking for additional handlers.
+;
+```
### Custom Key Mappings
-You can pass in an array of keys for a single event. The custom keys object will be merged with the default key mapping:
+You can pass in an array of keys for a single event. What you pass is written over the default mapping, so you only need to declare what differs.
+
+Note the direction: the map you pass is `{ EventName: key(s) }`, while the table it merges into is keyed the other way, `{ key: EventName }`. These are the defaults:
```js
-const defaultKeyMap = {
+{
ArrowLeft: 'Left',
ArrowRight: 'Right',
ArrowUp: 'Up',
ArrowDown: 'Down',
Enter: 'Enter',
+ l: 'Last',
' ': 'Space',
Backspace: 'Back',
Escape: 'Escape',
- 37: 'Left',
- 39: 'Right',
- 38: 'Up',
- 40: 'Down',
- 13: 'Enter',
- 32: 'Space',
- 8: 'Back',
- 27: 'Escape',
-};
+}
+```
+
+**No numeric keyCodes are mapped by default.** Devices that report keys by keyCode — or that report a different `key` on key-down and key-up, as LG does for Back — need those added explicitly:
+
+```js
+useFocusManager({
+ Back: [461, 'GoBack', 'Backspace'],
+ Enter: ['Enter', 13],
+});
+```
+
+#### Typing of custom handlers
+
+Only `Left`, `Right`, `Up`, `Down`, `Enter` and `Last` have typed handler props
+(`onLeft`, `onLeftRelease`, `onCaptureLeft`, …). Handlers for any other mapping —
+including the built-in `Back`, `Space` and `Escape`, and anything you add
+yourself — dispatch correctly at runtime, but are only accepted by the compiler
+through `ElementNode`'s `[key: string]: unknown` index signature. You get no
+autocompletion and no argument checking on them, so annotate the handler itself:
+
+```tsx
+const onBack: KeyHandler = (e, target, focused) => { ... };
+
+;
```
### Example
@@ -228,7 +274,7 @@ Here's a complete example of how to use `useFocusManager`:
```jsx
import { createSignal } from 'solid-js';
-import { useFocusManager } from '@solidtv/solid';
+import { useFocusManager } from '@solidtv/solid/primitives';
import { Button } from '@solidtv/solid-ui';
const App = () => {
@@ -242,13 +288,7 @@ const App = () => {
Up: ["ArrowUp", 38],
Down: ["ArrowDown", 40],
Enter: ["Enter", 13],
- } as unknown as KeyMap, {
- userKeyHoldMap: {
- EnterHold: [ 'Enter', 13 ],
- BackHold: [ 'b', 66 ],
- } as unknown as KeyHoldMap,
- holdThreshold: 1000,
- });
+ } as unknown as KeyMap);
return (
diff --git a/docs/primitives/useHold.md b/docs/primitives/useHold.md
index 628d739..a94c434 100644
--- a/docs/primitives/useHold.md
+++ b/docs/primitives/useHold.md
@@ -26,25 +26,52 @@ const [holdRight, releaseRight] = useHold({
#### `UseHoldProps`
-| Prop | Type | Description | Default |
-| --------------------------- | ------------ | --------------------------------------------------------------------- | ------------ |
-| `onHold` | `() => void` | Called once the hold threshold is exceeded. | **Required** |
-| `onEnter` | `() => void` | Called on press or key entry. May be delayed depending on config. | **Required** |
-| `onRelease` | `() => void` | Called after a successful hold is released. | `undefined` |
-| `holdThreshold` | `number` | Time in milliseconds to wait before triggering `onHold`. | `500` |
-| `performOnEnterImmediately` | `boolean` | Whether `onEnter` is triggered immediately or only if released early. | `false` |
+| Prop | Type | Description | Default |
+| --------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
+| `onHold` | `HoldCallback` | Called once the hold threshold is exceeded. | **Required** |
+| `onEnter` | `HoldCallback` | Called on press or key entry. May be delayed depending on config. | **Required** |
+| `onRelease` | `HoldCallback` | Called after a successful hold is released. | `undefined` |
+| `holdThreshold` | `number` | Time in milliseconds to wait before triggering `onHold`. | `500` |
+| `performOnEnterImmediately` | `boolean` | Whether `onEnter` is triggered immediately or only if released early. | `false` |
+| `holdRequiresRepeat` | `boolean` | Whether a hold must be confirmed by an auto-repeat key-down. See [Not every key can report a hold](#not-every-key-can-report-a-hold). | `true` |
+
+Each callback receives the same context a `KeyHandler` gets — the `KeyboardEvent`
+that began the press, the element whose handler ran, and the focused element:
+
+```ts
+type HoldCallback = (
+ e?: KeyboardEvent,
+ target?: ElementNode,
+ handlerElm?: ElementNode,
+) => void;
+```
+
+For `onHold` and for timer-resolved `onEnter`, this is the context captured from
+the originating key-down, since those fire from a timer with no event of their
+own.
---
### Returns
```ts
-[startHold, releaseHold]: [() => boolean, () => boolean]
+[startHold, releaseHold]: [HoldHandler, HoldHandler]
+
+type HoldHandler = (
+ e?: KeyboardEvent,
+ target?: ElementNode,
+ handlerElm?: ElementNode,
+) => boolean;
```
- `startHold`: Call this on a key/button press. Starts the hold timer and conditionally calls `onEnter`.
- `releaseHold`: Call this on a key/button release. Stops the timer and calls `onEnter` or `onRelease` depending on how long it was held.
+> **`startHold` needs the event.** It reads `e.repeat` to detect a hold. Passed
+> directly as a key handler (`onRight={holdRight}`) it receives one. If you wrap
+> it, forward all of the arguments — a wrapper that drops the event yields a
+> primitive that can never detect a hold, silently, and typically only on device.
+
---
### Behavior Summary
@@ -71,3 +98,139 @@ const [onHoldEnter, onHoldRelease] = useHold({
;
```
+
+---
+
+### Holds that move focus
+
+The canonical hold — hold OK to open a context menu — moves focus while the key
+is still physically down. The platform keeps emitting auto-repeat key-downs after
+`onHold` fires, and those would otherwise propagate down the **new** focus path
+and fire whatever just took focus.
+
+`useHold` handles this by latching the key in the focus manager once `onHold`
+fires. Remaining auto-repeats are dropped until the key is released, and the
+latch delivers `onRelease` even though the key-up now propagates somewhere the
+holding element can no longer see. Suppression lifts on key-up, or on the next
+fresh (non-repeat) key-down so a swallowed key-up cannot wedge a key.
+
+Non-repeat key-downs are never suppressed, so a real press always gets through.
+
+If you implement hold behavior without this primitive, the same latch is
+available directly:
+
+```ts
+import {
+ suppressKeyUntilRelease,
+ releaseKeySuppression,
+} from '@solidtv/solid/primitives';
+
+suppressKeyUntilRelease(event, () => console.log('key released'));
+```
+
+Pass the `KeyboardEvent` rather than a key name where you have one. `key` is not a
+stable identity for a physical key across key-down and key-up — webOS reports
+Back as:
+
+```
+key-down { key: 'GoBack', keyCode: 461, repeat: false }
+key-up { key: 'Unidentified', keyCode: 461 }
+```
+
+Given the event, a key is matched on both its name and its keyCode, so a key-up
+that renames the key still lifts the suppression. Such a key must also be mapped
+by keyCode for its release to route at all:
+
+```tsx
+useFocusManager({ Back: [461, 'GoBack'] });
+```
+
+---
+
+### Not every key can report a hold
+
+A hold is detected from what the device sends between press and release. Some
+keys send nothing usable, and on those a hold is **not detectable at all** — by
+this primitive or any other.
+
+Verified on an LG remote: the Back button emits its key-down and key-up
+back-to-back the instant it is pressed, before the user has let go. Nothing
+distinguishes a tap from a five-second hold, because both produce exactly the
+same two events at exactly the same time.
+
+There is no setting that recovers a hold here. `holdRequiresRepeat: false` does
+not help: the key-up arrives immediately and cancels the hold timer long before
+it can fire, so the press always resolves as a tap. That is the correct outcome —
+the alternative would be firing `onHold` on a plain tap.
+
+**Design around it.** If a gesture must work on every device, do not put it on a
+key that cannot report one. Put the hold on OK/Enter, which does report a real
+press duration on the remotes tested, and give Back a plain `onEnter` action.
+
+The three signals a key can offer, in the order the primitive prefers them:
+
+| Signal | Hold detectable? | |
+| --------------------------- | ---------------- | ------------------------------------------------------------------------------ |
+| Auto-repeat key-downs | Yes | Confirms the key is still down. The default and most reliable path. |
+| Key-up only on real release | Yes | No key-up by the threshold means still held — set `holdRequiresRepeat: false`. |
+| Key-up immediately on press | **No** | Tap and hold are indistinguishable. LG Back behaves this way. |
+
+If a press delivers no key-up and no auto-repeat by the threshold, it is
+ambiguous, and by default resolves as a **tap** — which is what keeps taps
+working on remotes that swallow key-up entirely (webOS OK). Set
+`holdRequiresRepeat: false` for a key you know reports key-up only on real
+release, so the timer alone resolves the hold:
+
+```tsx
+const [holdEnter, releaseEnter] = useHold({
+ onHold: openContextMenu,
+ onEnter: openTile,
+ holdRequiresRepeat: false, // this key emits no auto-repeat
+});
+```
+
+Confirm behavior per key on real hardware before relying on it — it varies by
+key and by device, not just by platform. Logging the raw events is enough:
+
+```tsx
+ {
+ console.log('down', e.key, e.keyCode, e.repeat, performance.now());
+ return false;
+ }}
+ onCaptureKeyRelease={(e) => {
+ console.log('up', e.key, e.keyCode, performance.now());
+ return false;
+ }}
+/>
+```
+
+If the `up` line appears at press time rather than release time, that key cannot
+report a hold.
+
+---
+
+### Caveat: `startHold` stops propagation
+
+`startHold` returns `true`, which ends the focus manager's bubble phase —
+**ancestor handlers for that key will not run.** Attaching `useHold` to a row
+removes that row's subtree from every ancestor `onEnter`.
+
+This is structural rather than incidental: whether the press was a tap isn't
+known until key-up or until the timer fires, by which point the propagation pass
+is long over, so a deferred tap cannot be handed back to ancestors.
+
+If an ancestor performs work on that key — a root-level handler resolving an
+`href`, or a container doing analytics — invoke it from `onEnter` yourself:
+
+```ts
+const onEnter: HoldCallback = (e, target, focused) => {
+ for (let elm = target?.parent; elm; elm = elm.parent) {
+ if (
+ typeof elm.onEnter === 'function' &&
+ elm.onEnter(e, elm, focused) === true
+ )
+ return;
+ }
+};
+```
diff --git a/docs/primitives/useMouse.md b/docs/primitives/useMouse.md
index 978be3b..b955469 100644
--- a/docs/primitives/useMouse.md
+++ b/docs/primitives/useMouse.md
@@ -43,7 +43,7 @@ To use this, pass the `customStates` option to `useMouse`.
When an element is clicked, `useMouse` handles the interaction as follows:
1. **`onMouseClick`**: If the element has an `onMouseClick` handler, it is called directly with the mouse event and the element instance. `onMouseClick` is mouse-specific and never fires from a keyboard/remote Enter.
-2. **Everything else**: The element is focused (`setFocus()`) and a synthetic `Enter` key event (keydown + keyup) is dispatched. This routes through the `focusManager` exactly like a remote/keyboard Enter press — the capture phase, leaf→root bubbling, return-value propagation (a handler returning `true` stops it), per-element throttling, and keyHold all apply. In practice this means a click and a remote Enter invoke the same `onEnter` path.
+2. **Everything else**: The element is focused (`setFocus()`) and a synthetic `Enter` key event (keydown + keyup) is dispatched. This routes through the `focusManager` exactly like a remote/keyboard Enter press — the capture phase, leaf→root bubbling, return-value propagation (a handler returning `true` stops it), and per-element throttling all apply. In practice this means a click and a remote Enter invoke the same `onEnter` path.
Example of handling clicks:
diff --git a/package.json b/package.json
index 423921e..2f1839b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@solidtv/solid",
- "version": "1.4.0",
+ "version": "1.5.0",
"description": "SolidTV",
"type": "module",
"exports": {
diff --git a/src/core/focusKeyTypes.ts b/src/core/focusKeyTypes.ts
index 8357c52..6504072 100644
--- a/src/core/focusKeyTypes.ts
+++ b/src/core/focusKeyTypes.ts
@@ -28,13 +28,6 @@ export interface FocusNode {
handlerElm: ElementNode,
currentFocusedElm: ElementNode,
) => KeyHandlerReturn;
- onKeyHold?: (
- this: ElementNode,
- e: KeyboardEvent,
- mappedKeyEvent: string | undefined,
- handlerElm: ElementNode,
- currentFocusedElm: ElementNode,
- ) => KeyHandlerReturn;
}
export type KeyNameOrKeyCode = string | number;
@@ -52,10 +45,6 @@ export interface KeyMap extends DefaultKeyMap {
[key: string]: KeyNameOrKeyCode | KeyNameOrKeyCode[] | null;
}
-export interface DefaultKeyHoldMap {
- EnterHold: KeyNameOrKeyCode | KeyNameOrKeyCode[] | null;
-}
-
export type EventHandlers