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 = { [K in keyof Map as `on${Capitalize}`]?: KeyHandler; } & { @@ -67,8 +56,6 @@ export type EventHandlers = { onCaptureKeyRelease?: KeyHandler; }; -export interface KeyHoldMap extends DefaultKeyHoldMap {} - export type KeyHandlerReturn = boolean | void; export type KeyHandler = ( @@ -83,8 +70,3 @@ export type ForwardFocusHandler = ( this: ElementNode, elm: ElementNode, ) => boolean | void; - -export type KeyHoldOptions = { - userKeyHoldMap: Partial; - holdThreshold?: number; -}; diff --git a/src/core/focusManager.ts b/src/core/focusManager.ts index e151c19..8a6f412 100644 --- a/src/core/focusManager.ts +++ b/src/core/focusManager.ts @@ -3,11 +3,7 @@ import { Config, isDev } from './config.js'; import { IRendererNode } from './dom-renderer/domRendererTypes.js'; export type * from './focusKeyTypes.js'; import { ElementNode } from './elementNode.js'; -import type { - KeyNameOrKeyCode, - KeyHoldOptions, - KeyMap, -} from './focusKeyTypes.js'; +import type { KeyNameOrKeyCode, KeyMap } from './focusKeyTypes.js'; import { isFunction } from './utils.js'; import { activeElement, @@ -30,9 +26,7 @@ const keyMapEntries: KeyMapEntries = { Escape: 'Escape', }; -const keyHoldMapEntries: Record = { - // Enter: 'EnterHold', -}; +const keyOf = (e: KeyboardEvent): KeyNameOrKeyCode => e.key || e.keyCode; const flattenKeyMap = ( keyMap: Partial, @@ -333,7 +327,6 @@ const runBubblePhase = ( fp: ElementNode[], e: KeyboardEvent, mappedEvent: string | undefined, - isHold: boolean, isUp: boolean, sameKey: boolean, currentTime: number, @@ -344,11 +337,9 @@ const runBubblePhase = ( ? `on${mappedEvent}Release` : `on${mappedEvent}` : undefined; - const fallbackHandlerKey: 'onKeyHold' | 'onKeyPress' | undefined = isUp + const fallbackHandlerKey: 'onKeyPress' | undefined = isUp ? undefined - : isHold - ? 'onKeyHold' - : 'onKeyPress'; + : 'onKeyPress'; let lastHandlerSeen: ElementNode | undefined; @@ -387,11 +378,10 @@ const runBubblePhase = ( const propagateKeyPress = ( e: KeyboardEvent, mappedEvent?: string, - isHold: boolean = false, isUp: boolean = false, ): boolean => { const currentTime = performance.now(); - const key = e.key || e.keyCode; + const key = keyOf(e); const sameKey = lastInputKey === key; lastInputKey = key; @@ -426,7 +416,6 @@ const propagateKeyPress = ( fp, e, mappedEvent, - isHold, isUp, sameKey, currentTime, @@ -434,7 +423,7 @@ const propagateKeyPress = ( if (handled) return true; if (isDev && Config.keyDebug && !isUp) { - const detail = `key="${e.key}", mappedEvent=${mappedEvent}, isHold=${isHold}, isUp=${isUp}`; + const detail = `key="${e.key}", mappedEvent=${mappedEvent}, isUp=${isUp}`; if (lastHandlerSeen) { console.log(`Keypress bubbled, ${detail}`, lastHandlerSeen); } else { @@ -445,58 +434,132 @@ const propagateKeyPress = ( return false; }; -const DEFAULT_KEY_HOLD_THRESHOLD = 500; // ms -const keyHoldTimeouts: { [key: KeyNameOrKeyCode]: number | true } = {}; +// `key` is not a stable identity for a physical key across key-down and key-up. +// webOS reports Back's key-down as { key: 'GoBack', keyCode: 461 } and its +// key-up as { key: 'Unidentified', keyCode: 461 } — same key, two names, with +// only the keyCode shared. So a key is identified by *every* name its event +// carries, and two events are the same key if any identity matches. +const UNIDENTIFIED = 'Unidentified'; + +const keyIdentities = ( + keyOrEvent: KeyboardEvent | KeyNameOrKeyCode, +): KeyNameOrKeyCode[] => { + if (typeof keyOrEvent !== 'object') return [keyOrEvent]; + const ids: KeyNameOrKeyCode[] = []; + // 'Unidentified' names no particular key. Treating it as an identity would + // conflate every key that reports it. + if (keyOrEvent.key && keyOrEvent.key !== UNIDENTIFIED) + ids.push(keyOrEvent.key); + if (keyOrEvent.keyCode) ids.push(keyOrEvent.keyCode); + return ids; +}; -const handleKeyEvents = ( - delay: number, - keydown?: KeyboardEvent, - keyup?: KeyboardEvent, -) => { +// Keys whose auto-repeat key-downs are dropped before propagation. +// +// A hold action typically moves focus (opening a context menu, say) while the +// key is still physically down. Two things then go wrong, and neither can be +// fixed by the element that owns the hold, because it is no longer in the focus +// path: the trailing auto-repeats propagate down the *new* focus path and fire +// whatever just took focus, and the key-up goes there too, so the owner's +// release handler never runs. +// +// Both are propagation concerns, so the latch lives here. The release callback +// is what lets a suppressor still learn about a key-up it can no longer receive +// through the focus path. +type Suppression = { + ids: KeyNameOrKeyCode[]; + onRelease?: () => void; +}; + +// Indexed under every identity of the suppressed key, so a key-up naming the +// key differently still finds it. Entries for one key share a Suppression. +const suppressedKeys = new Map(); + +const findSuppression = (ids: KeyNameOrKeyCode[]): Suppression | undefined => { + for (const id of ids) { + const found = suppressedKeys.get(id); + if (found) return found; + } + return undefined; +}; + +const liftSuppression = (ids: KeyNameOrKeyCode[]): void => { + const found = findSuppression(ids); + if (!found) return; + // Drop every alias, not just the one that matched. + for (const id of found.ids) suppressedKeys.delete(id); + found.onRelease?.(); +}; + +/** + * Drop auto-repeat key-downs for `keyOrEvent` until the key is released. + * + * Suppression is lifted by the key's key-up, or by the next fresh (non-repeat) + * key-down — the latter so platforms that swallow key-up (webOS) can't wedge a + * key permanently. Non-repeat key-downs are never suppressed. `onRelease` runs + * when suppression lifts, whichever way it lifts, and is delivered regardless of + * where focus has moved in the meantime. + * + * Pass the `KeyboardEvent` where you have one: the key is then matched on both + * its name and its keyCode, which is what lets a key-up reporting a different + * `key` for the same physical key (webOS Back) still lift the suppression. + * + * `useHold` calls this itself when a hold fires; call it directly only when + * implementing hold behavior outside that primitive. + */ +export const suppressKeyUntilRelease = ( + keyOrEvent: KeyboardEvent | KeyNameOrKeyCode, + onRelease?: () => void, +): void => { + const ids = keyIdentities(keyOrEvent); + if (ids.length === 0) return; + const suppression: Suppression = { ids, onRelease }; + for (const id of ids) suppressedKeys.set(id, suppression); +}; + +/** + * Lift suppression added by {@link suppressKeyUntilRelease} early, running its + * release callback. + */ +export const releaseKeySuppression = ( + keyOrEvent: KeyboardEvent | KeyNameOrKeyCode, +): void => { + liftSuppression(keyIdentities(keyOrEvent)); +}; + +const handleKeyEvents = (keydown?: KeyboardEvent, keyup?: KeyboardEvent) => { if (keydown) { - const key: KeyNameOrKeyCode = keydown.key || keydown.keyCode; - const mappedKeyHoldEvent = - keyHoldMapEntries[keydown.key] || keyHoldMapEntries[keydown.keyCode]; - const mappedKeyEvent = - keyMapEntries[keydown.key] || keyMapEntries[keydown.keyCode]; - if (mappedKeyHoldEvent) { - if (!keyHoldTimeouts[key]) { - keyHoldTimeouts[key] = window.setTimeout(() => { - keyHoldTimeouts[key] = true; - propagateKeyPress(keydown, mappedKeyHoldEvent, true); - }, delay); - } - return; + const ids = keyIdentities(keydown); + if (keydown.repeat) { + if (findSuppression(ids)) return; + } else { + // A fresh press starts a new gesture, so the previous one is over even + // though its key-up never arrived. Settle it before handling this press. + liftSuppression(ids); } - propagateKeyPress(keydown, mappedKeyEvent, false); + propagateKeyPress( + keydown, + keyMapEntries[keydown.key] || keyMapEntries[keydown.keyCode], + ); } else if (keyup) { - const key: KeyNameOrKeyCode = keyup.key || keyup.keyCode; - const mappedKeyEvent = - keyMapEntries[keyup.key] || keyMapEntries[keyup.keyCode]; - if (keyHoldTimeouts[key] === true) { - delete keyHoldTimeouts[key]; - } else if (keyHoldTimeouts[key]) { - clearTimeout(keyHoldTimeouts[key]); - delete keyHoldTimeouts[key]; - // trigger key down event when hold didn't finish - propagateKeyPress(keyup, mappedKeyEvent, false); - } - - propagateKeyPress(keyup, mappedKeyEvent, false, true); + // The key is up: whatever was suppressing its repeats is done. Settle it + // before propagating, so a suppressor that is still in the focus path sees + // its own release callback rather than a second one via the key-up below. + liftSuppression(keyIdentities(keyup)); + + propagateKeyPress( + keyup, + keyMapEntries[keyup.key] || keyMapEntries[keyup.keyCode], + true, + ); } }; -export const useFocusManager = ( - userKeyMap?: Partial, - keyHoldOptions?: KeyHoldOptions, -) => { +export const useFocusManager = (userKeyMap?: Partial) => { if (userKeyMap) { flattenKeyMap(userKeyMap, keyMapEntries); } - if (keyHoldOptions?.userKeyHoldMap) { - flattenKeyMap(keyHoldOptions.userKeyHoldMap, keyHoldMapEntries); - } // Capture the calling owner so signal updates and key-event reactions // can run inside it — needed for programmatic .setFocus(), post-mutation @@ -512,13 +575,10 @@ export const useFocusManager = ( Config.setActiveElement = (elm) => ownerContext(() => setActiveElementSignal(elm)); - const delay = keyHoldOptions?.holdThreshold || DEFAULT_KEY_HOLD_THRESHOLD; - const runKeyEvent = handleKeyEvents.bind(null, delay); - const keyPressHandler = (event: KeyboardEvent) => - ownerContext(() => runKeyEvent(event, undefined)); + ownerContext(() => handleKeyEvents(event, undefined)); const keyUpHandler = (event: KeyboardEvent) => - ownerContext(() => runKeyEvent(undefined, event)); + ownerContext(() => handleKeyEvents(undefined, event)); document.addEventListener('keydown', keyPressHandler); document.addEventListener('keyup', keyUpHandler); @@ -526,8 +586,6 @@ export const useFocusManager = ( onCleanup(() => { document.removeEventListener('keydown', keyPressHandler); document.removeEventListener('keyup', keyUpHandler); - for (const timeout of Object.values(keyHoldTimeouts)) { - if (timeout && timeout !== true) clearTimeout(timeout); - } + suppressedKeys.clear(); }); }; diff --git a/src/core/intrinsicTypes.ts b/src/core/intrinsicTypes.ts index b4e31eb..4170ff0 100644 --- a/src/core/intrinsicTypes.ts +++ b/src/core/intrinsicTypes.ts @@ -10,12 +10,7 @@ import { ShaderRoundedProps, ShaderShadowProps, } from './shaders.js'; -import { - EventHandlers, - DefaultKeyMap, - KeyHoldMap, - FocusNode, -} from './focusKeyTypes.js'; +import { EventHandlers, DefaultKeyMap, FocusNode } from './focusKeyTypes.js'; import type { JSXElement } from 'solid-js'; export type AnimationSettings = Partial; @@ -123,7 +118,6 @@ export interface NodeProps extends RendererNode, EventHandlers, - EventHandlers, FocusNode, Partial< NewOmit< diff --git a/src/primitives/useFocusManager.ts b/src/primitives/useFocusManager.ts index 6e10221..10e192c 100644 --- a/src/primitives/useFocusManager.ts +++ b/src/primitives/useFocusManager.ts @@ -2,7 +2,11 @@ export { focusPath, useFocusManager, setActiveElementCore, + suppressKeyUntilRelease, + releaseKeySuppression, + printFocusHistory, + getFocusHistory, + type FocusHistoryEntry, type KeyMap, - type KeyHoldOptions, } from '../core/focusManager.js'; export { activeElement, setActiveElement } from '../core/activeElement.js'; diff --git a/src/primitives/useHold.ts b/src/primitives/useHold.ts index 947ddce..82b87e6 100644 --- a/src/primitives/useHold.ts +++ b/src/primitives/useHold.ts @@ -1,11 +1,45 @@ import { createMemo } from 'solid-js'; +import { suppressKeyUntilRelease } from '../core/focusManager.js'; +import type { ElementNode } from '../core/elementNode.js'; + +/** + * A hold callback. Receives the `KeyboardEvent` that began the press, the + * element whose handler was invoked, and the focused (leaf) element at that + * moment — the same arguments a `KeyHandler` gets. All three are `undefined` + * when `startHold` was called without them. + */ +export type HoldCallback = ( + e?: KeyboardEvent, + target?: ElementNode, + handlerElm?: ElementNode, +) => void; + +export type HoldHandler = ( + e?: KeyboardEvent, + target?: ElementNode, + handlerElm?: ElementNode, +) => boolean; export type UseHoldProps = { - onHold: () => void; - onEnter: () => void; - onRelease?: () => void; + onHold: HoldCallback; + onEnter: HoldCallback; + onRelease?: HoldCallback; holdThreshold?: number; performOnEnterImmediately?: boolean; + /** + * Whether a hold must be confirmed by an auto-repeat key-down. + * + * `true` (default): a press with neither key-up nor auto-repeat by the + * threshold resolves as a tap. `false`: it resolves as a hold, matching the + * behavior of the removed `keyHoldOptions`. + * + * Set this to `false` for a key that emits no auto-repeat but does emit + * key-up on real release: the timer alone can then resolve the hold. It does + * *not* rescue a key that emits key-up immediately at press time (LG's Back + * button does this) — that key-up cancels the timer before it fires, and no + * setting can distinguish such a tap from a hold. + */ + holdRequiresRepeat?: boolean; }; /** @@ -23,10 +57,23 @@ export type UseHoldProps = { * - if neither key-up nor auto-repeat arrives, the timer resolves to a tap → * `onEnter` after `holdThreshold` ms. This is the key-up-independent path that * keeps taps working on webOS, at the cost of ~`holdThreshold` ms latency. + * Set `holdRequiresRepeat: false` to resolve this ambiguous case as a hold + * instead, on platforms that deliver no auto-repeat at all. + * + * Once `onHold` fires the key is still physically down, so the focus manager + * drops its remaining auto-repeats until it is released. Without that, a hold + * that moves focus — the canonical case, opening a context menu — would have + * its trailing repeats fire whatever it just focused. The same latch delivers + * `onRelease` even though the key-up now propagates somewhere else entirely. * * `performOnEnterImmediately` keeps the legacy behavior of firing `onEnter` on * key-down; a long-press then fires both `onEnter` and `onHold`. * + * Note that `startHold` returns `true`, which ends the bubble phase: ancestor + * handlers for that key will not run, and whether the press was a tap isn't + * known until after propagation is over. If an ancestor performs navigation, + * invoke it from `onEnter` yourself. See the caveat in the docs. + * * @example * const [holdRight, releaseRight] = useHold({ * onHold: handleHoldRight, @@ -40,21 +87,31 @@ export type UseHoldProps = { * onRightRelease={releaseRight} * /> * + * `startHold` reads `e.repeat` to detect a hold. Used directly as a `KeyHandler` + * (above) it gets the event; if you wrap it, forward all of the arguments, or + * hold detection silently never triggers. + * * @param {UseHoldProps} props - The properties for configuring the hold behavior. - * @returns {[(e?: KeyboardEvent) => boolean, () => boolean]} A tuple containing `startHold` and `releaseHold` functions. + * @returns {[HoldHandler, HoldHandler]} A tuple of `startHold` and `releaseHold`. */ -export function useHold(props: UseHoldProps) { +export function useHold(props: UseHoldProps): [HoldHandler, HoldHandler] { const holdThreshold = createMemo(() => props.holdThreshold ?? 500); const performOnEnterImmediately = createMemo( () => props.performOnEnterImmediately ?? false, ); + const holdRequiresRepeat = createMemo(() => props.holdRequiresRepeat ?? true); let holdTimeout = -1; let enterFired = false; // onEnter already fired for this press let holdFired = false; // onHold already fired for this press let repeated = false; // an auto-repeat key-down was seen (key still held) + // Context from the key-down that began the press, replayed to whichever + // callback resolves it — the timer path has no event of its own, and after a + // focus-moving hold neither does the release. + let press: Parameters = []; + const reset = () => { if (holdTimeout !== -1) { clearTimeout(holdTimeout); @@ -63,9 +120,17 @@ export function useHold(props: UseHoldProps) { enterFired = false; holdFired = false; repeated = false; + press = []; }; - const startHold = (e?: KeyboardEvent) => { + const fireRelease = () => { + if (!holdFired) return; + const args = press; + reset(); // before the callback, so a re-entrant press isn't clobbered + props.onRelease?.(...args); + }; + + const startHold: HoldHandler = (e, target, handlerElm) => { // Auto-repeat key-down: the key is still held. Record it so the timer // resolves to a hold even if the key-up event never arrives (webOS). if (e?.repeat) { @@ -76,30 +141,35 @@ export function useHold(props: UseHoldProps) { // Fresh key-down begins a new press. Reset first so a previous press whose // key-up was never delivered doesn't leave us wedged for this one. reset(); + press = [e, target, handlerElm]; if (performOnEnterImmediately()) { enterFired = true; - props.onEnter(); + props.onEnter(...press); } holdTimeout = setTimeout(() => { holdTimeout = -1; - if (repeated) { + if (repeated || !holdRequiresRepeat()) { // Held past the threshold → hold gesture. holdFired = true; - props.onHold(); + // The key is still down and will keep repeating. Drop those repeats so + // they can't reach whatever onHold focuses, and route the key-up back + // here even if this element is no longer in the focus path. + if (e) suppressKeyUntilRelease(e, fireRelease); + props.onHold(...press); } else if (!enterFired) { // No key-up and no auto-repeat arrived: resolve as a tap so the // primary action still fires on remotes that swallow key-up. enterFired = true; - props.onEnter(); + props.onEnter(...press); } }, holdThreshold()) as unknown as number; return true; }; - const releaseHold = () => { + const releaseHold: HoldHandler = (e, target, handlerElm) => { if (holdTimeout !== -1) { // Released before the threshold → tap. Fires immediately where key-up is // delivered, avoiding the timer latency. @@ -107,12 +177,19 @@ export function useHold(props: UseHoldProps) { holdTimeout = -1; if (!enterFired) { enterFired = true; - props.onEnter(); + props.onEnter( + e ?? press[0], + target ?? press[1], + handlerElm ?? press[2], + ); } - } else if (holdFired) { - props.onRelease?.(); + reset(); + } else { + // After a hold, the suppression latch has usually already fired onRelease + // and reset — `holdFired` is false and this is a no-op. + fireRelease(); + reset(); } - reset(); return true; }; diff --git a/tests/keySuppression.test.tsx b/tests/keySuppression.test.tsx new file mode 100644 index 0000000..9acbd0d --- /dev/null +++ b/tests/keySuppression.test.tsx @@ -0,0 +1,239 @@ +import * as v from 'vitest'; +import * as lng from '@solidtv/solid'; +import { + useFocusManager, + suppressKeyUntilRelease, + releaseKeySuppression, + useHold, +} from '@solidtv/solid/primitives'; +import { renderer, waitForUpdate } from './setup.js'; + +const keydown = (repeat = false) => + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', repeat })); +const keyup = () => + document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + +async function setup(props: Record = {}) { + const onEnter = v.vi.fn(); + let dispose!: () => void; + dispose = renderer.render(() => { + useFocusManager(); + return ; + }) as unknown as () => void; + await waitForUpdate(); + return { onEnter, dispose }; +} + +v.describe('key suppression', () => { + v.afterEach(() => releaseKeySuppression('Enter')); + + v.test('drops auto-repeat key-downs while a key is suppressed', async () => { + const { onEnter, dispose } = await setup(); + + keydown(); + v.assert.equal(onEnter.mock.calls.length, 1); + + // A hold fired and latched the key — the repeats that follow must not + // propagate, even though this element is still focused. + suppressKeyUntilRelease('Enter'); + keydown(true); + keydown(true); + v.assert.equal(onEnter.mock.calls.length, 1); + + dispose(); + }); + + v.test('key-up lifts suppression and runs the release callback', async () => { + const { onEnter, dispose } = await setup(); + const onRelease = v.vi.fn(); + + keydown(); + suppressKeyUntilRelease('Enter', onRelease); + keydown(true); + v.assert.equal(onRelease.mock.calls.length, 0); + + keyup(); + v.assert.equal(onRelease.mock.calls.length, 1); + + // Suppression is gone, so repeats propagate again. + keydown(true); + v.assert.equal(onEnter.mock.calls.length, 2); + + dispose(); + }); + + v.test('a fresh key-down lifts suppression when key-up never arrives', async () => { + const { onEnter, dispose } = await setup(); + const onRelease = v.vi.fn(); + + keydown(); + suppressKeyUntilRelease('Enter', onRelease); + keydown(true); + v.assert.equal(onEnter.mock.calls.length, 1); + + // webOS: no key-up is ever delivered. The next real press must not be + // swallowed, and must settle the abandoned one. + keydown(); + v.assert.equal(onRelease.mock.calls.length, 1); + v.assert.equal(onEnter.mock.calls.length, 2); + + dispose(); + }); + + v.test('never suppresses non-repeat key-downs', async () => { + const { onEnter, dispose } = await setup(); + + suppressKeyUntilRelease('Enter'); + keydown(); + v.assert.equal(onEnter.mock.calls.length, 1); + + dispose(); + }); +}); + +// webOS reports Back's key-down and key-up under different `key` names, sharing +// only the keyCode. Verbatim from a device log: +// captureBack { key: 'GoBack', keyCode: 461, repeat: false } +// captureBackRelease { key: 'Unidentified', keyCode: 461 } +const backDown = (repeat = false) => + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'GoBack', keyCode: 461, repeat }), + ); +const backUp = () => + document.dispatchEvent( + new KeyboardEvent('keyup', { key: 'Unidentified', keyCode: 461 }), + ); + +v.describe('key identity across key-down and key-up', () => { + v.test('a key-up naming the key differently still lifts suppression', async () => { + const { dispose } = await setup(); + const onRelease = v.vi.fn(); + + backDown(); + suppressKeyUntilRelease( + new KeyboardEvent('keydown', { key: 'GoBack', keyCode: 461 }), + onRelease, + ); + + // Matched on keyCode 461, since 'Unidentified' names no key. + backUp(); + v.assert.equal(onRelease.mock.calls.length, 1); + + dispose(); + }); + + v.test('suppression is not left latched after such a key-up', async () => { + const seen: string[] = []; + const { dispose } = await setup({ + onCaptureKey: (e: KeyboardEvent) => { + seen.push(`${String(e.key)}${e.repeat ? ':repeat' : ''}`); + return false; + }, + }); + + backDown(); + suppressKeyUntilRelease( + new KeyboardEvent('keydown', { key: 'GoBack', keyCode: 461 }), + ); + backDown(true); // repeat → dropped, never reaches the capture phase + backUp(); // lifts, despite the different `key` + + // The next press must get through rather than staying wedged. + backDown(); + backDown(true); + + v.assert.deepEqual(seen, ['GoBack', 'GoBack', 'GoBack:repeat']); + + dispose(); + }); + + v.test('useHold drives a Back hold end to end on webOS', async () => { + const onHold = v.vi.fn(); + const onEnter = v.vi.fn(); + const onRelease = v.vi.fn(); + const reached: string[] = []; + + const dispose = renderer.render(() => { + // Back reaches the app as GoBack/461 on key-down and Unidentified/461 on + // key-up, so it has to be mapped by keyCode. + useFocusManager({ Back: [461, 'GoBack'] }); + const [startHold, releaseHold] = useHold({ + onHold, + onEnter, + onRelease, + holdThreshold: 1000, + holdRequiresRepeat: false, // webOS emits no auto-repeat for Back + }); + return ( + { + reached.push(e.repeat ? 'repeat' : 'press'); + return false; + }} + onBack={startHold} + onCaptureBackRelease={releaseHold} + /> + ); + }) as unknown as () => void; + await waitForUpdate(); + // Let autofocus settle on real timers — switching to fake ones with focus + // still pending would strand it and leave the focus path empty. + await new Promise((resolve) => setTimeout(resolve, 10)); + + v.vi.useFakeTimers(); + try { + // Tap: released well before the threshold. + backDown(); + backUp(); + v.assert.equal(onEnter.mock.calls.length, 1); + v.vi.advanceTimersByTime(1000); + v.assert.equal(onHold.mock.calls.length, 0); + + // Hold: no key-up, no auto-repeat — only the timer can resolve it. + backDown(); + v.vi.advanceTimersByTime(1000); + v.assert.equal(onHold.mock.calls.length, 1); + v.assert.equal(onEnter.mock.calls.length, 1); + + // The key is still down. Anything it emits now must be swallowed, so a + // hold that moved focus could not fire whatever it just focused. + reached.length = 0; + backDown(true); + v.assert.deepEqual(reached, []); + + // Release. The key-up names the key differently, and must still land. + v.assert.equal(onRelease.mock.calls.length, 0); + backUp(); + v.assert.equal(onRelease.mock.calls.length, 1); + + // ...and the key must not be left latched. + backDown(true); + v.assert.deepEqual(reached, ['repeat']); + } finally { + v.vi.useRealTimers(); + dispose(); + } + }); + + v.test('does not conflate two keys that both report Unidentified', async () => { + const { dispose } = await setup(); + const backRelease = v.vi.fn(); + + suppressKeyUntilRelease( + new KeyboardEvent('keydown', { key: 'GoBack', keyCode: 461 }), + backRelease, + ); + + // A different physical key, also anonymised on key-up. + document.dispatchEvent( + new KeyboardEvent('keyup', { key: 'Unidentified', keyCode: 462 }), + ); + v.assert.equal(backRelease.mock.calls.length, 0); + + backUp(); + v.assert.equal(backRelease.mock.calls.length, 1); + + dispose(); + }); +}); diff --git a/tests/useHold.spec.ts b/tests/useHold.spec.ts index 7c11625..67d224c 100644 --- a/tests/useHold.spec.ts +++ b/tests/useHold.spec.ts @@ -1,8 +1,20 @@ import { createRoot } from 'solid-js'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { useHold } from '../src/primitives/useHold.ts'; +import type { ElementNode } from '../src/core/elementNode.ts'; -const downRepeat = { repeat: true } as KeyboardEvent; +const suppressKeyUntilRelease = + vi.fn<(e: KeyboardEvent | string | number, onRelease?: () => void) => void>(); +vi.mock('../src/core/focusManager.ts', () => ({ + suppressKeyUntilRelease: (...args: unknown[]) => + (suppressKeyUntilRelease as (...a: unknown[]) => void)(...args), +})); + +const { useHold } = await import('../src/primitives/useHold.ts'); + +const down = { key: 'Enter', repeat: false } as KeyboardEvent; +const downRepeat = { key: 'Enter', repeat: true } as KeyboardEvent; +const target = { id: 'target' } as unknown as ElementNode; +const handlerElm = { id: 'handlerElm' } as unknown as ElementNode; function setup(props: Partial[0]> = {}) { const onEnter = vi.fn(); @@ -18,7 +30,10 @@ function setup(props: Partial[0]> = {}) { } describe('useHold', () => { - beforeEach(() => vi.useFakeTimers()); + beforeEach(() => { + vi.useFakeTimers(); + suppressKeyUntilRelease.mockClear(); + }); afterEach(() => vi.useRealTimers()); it('fires onEnter immediately on key-up before the threshold (tap)', () => { @@ -90,4 +105,107 @@ describe('useHold', () => { expect(onEnter).toHaveBeenCalledTimes(1); // not double-fired dispose(); }); + + describe('holdRequiresRepeat', () => { + it('resolves a repeat-less press as a tap by default', () => { + const { startHold, onEnter, onHold, dispose } = setup(); + startHold(down); // no repeat, no key-up + vi.advanceTimersByTime(200); + expect(onEnter).toHaveBeenCalledTimes(1); + expect(onHold).not.toHaveBeenCalled(); + dispose(); + }); + + it('resolves a repeat-less press as a hold when false', () => { + const { startHold, onEnter, onHold, dispose } = setup({ + holdRequiresRepeat: false, + }); + startHold(down); // platform delivers neither key-up nor auto-repeat + vi.advanceTimersByTime(200); + expect(onHold).toHaveBeenCalledTimes(1); + expect(onEnter).not.toHaveBeenCalled(); + dispose(); + }); + + it('still resolves an early key-up as a tap when false', () => { + const { startHold, releaseHold, onEnter, onHold, dispose } = setup({ + holdRequiresRepeat: false, + }); + startHold(down); + releaseHold(); + expect(onEnter).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(200); + expect(onHold).not.toHaveBeenCalled(); + dispose(); + }); + }); + + describe('key suppression after a hold', () => { + it('suppresses the held key and fires onRelease via the latch', () => { + const { startHold, onHold, onRelease, dispose } = setup(); + startHold(down); + startHold(downRepeat); + vi.advanceTimersByTime(200); + expect(onHold).toHaveBeenCalledTimes(1); + expect(suppressKeyUntilRelease).toHaveBeenCalledTimes(1); + expect(suppressKeyUntilRelease.mock.calls[0]![0]).toBe(down); + + // The element that owns the hold is no longer in the focus path, so its + // key-up never arrives. The latch delivers the release instead. + expect(onRelease).not.toHaveBeenCalled(); + suppressKeyUntilRelease.mock.calls[0]![1]!(); + expect(onRelease).toHaveBeenCalledTimes(1); + dispose(); + }); + + it('does not fire onRelease twice when key-up also reaches the element', () => { + const { startHold, releaseHold, onRelease, dispose } = setup(); + startHold(down); + startHold(downRepeat); + vi.advanceTimersByTime(200); + // focusManager lifts suppression first, then propagates the key-up. + suppressKeyUntilRelease.mock.calls[0]![1]!(); + releaseHold(); + expect(onRelease).toHaveBeenCalledTimes(1); + dispose(); + }); + + it('does not suppress when the press resolves as a tap', () => { + const { startHold, releaseHold, dispose } = setup(); + startHold(down); + releaseHold(); + vi.advanceTimersByTime(200); + expect(suppressKeyUntilRelease).not.toHaveBeenCalled(); + dispose(); + }); + }); + + describe('callback context', () => { + it('passes the key-down event and elements through to onEnter', () => { + const { startHold, releaseHold, onEnter, dispose } = setup(); + startHold(down, target, handlerElm); + releaseHold(); + expect(onEnter).toHaveBeenCalledWith(down, target, handlerElm); + dispose(); + }); + + it('replays the key-down context to a timer-resolved onEnter', () => { + const { startHold, onEnter, dispose } = setup(); + startHold(down, target, handlerElm); // no key-up + vi.advanceTimersByTime(200); + expect(onEnter).toHaveBeenCalledWith(down, target, handlerElm); + dispose(); + }); + + it('replays the key-down context to onHold and onRelease', () => { + const { startHold, onHold, onRelease, dispose } = setup(); + startHold(down, target, handlerElm); + startHold(downRepeat); + vi.advanceTimersByTime(200); + expect(onHold).toHaveBeenCalledWith(down, target, handlerElm); + suppressKeyUntilRelease.mock.calls[0]![1]!(); + expect(onRelease).toHaveBeenCalledWith(down, target, handlerElm); + dispose(); + }); + }); });