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
26 changes: 26 additions & 0 deletions docs/primitives/useFocusManager.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,32 @@ Config.preventDefaultOnHandledKeys = true;
It is off by default, since in a browser it changes what handled keys do: a
handled arrow no longer scrolls the page, say.

### Inferred Repeats (`Config.inferKeyRepeat`)

Some devices never set `KeyboardEvent.repeat`. Holding a key sends a stream of
key-downs that all report `repeat: false`, followed by one key-up on release.
Anything that relies on `repeat` breaks: [useHold](./useHold.md) sees each
key-down as a new press, and a key latched by a hold is released by what looks
like a fresh press.

Set `Config.inferKeyRepeat` to a window in milliseconds and the focus manager
marks a key-down as a repeat when the same key sent a key-down less than that
long ago with no key-up in between. Handlers then read `e.repeat` as `true`.

```javascript
import { Config } from '@solidtv/solid';

Config.inferKeyRepeat = 700;
```

The window is measured from the key's previous key-down, so it only has to
cover the device's initial auto-repeat delay. A key-up that never arrives stops
mattering once the window passes, so a lost key-up cannot leave the key stuck.

It is off by default; enable it only on the devices that need it. On a key
that never sends a key-up (webOS OK), a second tap inside the window would be
read as a repeat.

### Focus History Logging

Focus history logging records each focus change — whether triggered by a key press or programmatically — into a ring buffer of up to 50 entries. This is a **dev-only** feature: recording and printing are both gated behind the `isDev` flag and do nothing in production builds.
Expand Down
8 changes: 8 additions & 0 deletions docs/primitives/useHold.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,14 @@ The three signals a key can offer, in the order the primitive prefers them:
| 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. |

Some devices auto-repeat a held key without ever setting `repeat`: every
key-down reports `repeat: false`, and a single key-up arrives on release. To
`useHold` each of those looks like a new press that restarts the timer, so a
hold never fires and the release resolves as a tap. Set
[`Config.inferKeyRepeat`](./useFocusManager.md#inferred-repeats-configinferkeyrepeat)
on such a device and the focus manager marks them as repeats before `useHold`
sees them.

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
Expand Down
11 changes: 11 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ export interface Config {
lockStyles?: boolean;
fontWeightAlias?: Record<string, number | string>;
throttleInput?: number;
/**
* For devices that never set `KeyboardEvent.repeat`: while a key is held
* they send a stream of `repeat=false` key-downs, and one key-up on release.
* With this set (in ms), a key-down of a key that has had no key-up since
* its previous key-down, arriving within this many ms of it, is marked as a
* repeat (`repeat` reads `true`) before anything handles it. It must cover
* the device's initial auto-repeat delay. A key-up that never arrives stops
* mattering once the window passes. Off by default: on a key that swallows
* key-up (webOS OK), a second tap inside the window would read as a repeat.
*/
inferKeyRepeat?: number;
/**
* Call `preventDefault()` on every key event the app consumed: a handler
* returned `true`, or the focus manager dropped the press itself (a
Expand Down
69 changes: 60 additions & 9 deletions src/core/focusManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,18 +488,62 @@ type Suppression = {
// key differently still finds it. Entries for one key share a Suppression.
const suppressedKeys = new Map<KeyNameOrKeyCode, Suppression>();

// The suppression of an event's key: looked up under its name, then its
// keyCode (keyNameId, keyCodeId), either of which may be undefined.
// An event's entry in a map keyed by key identity: looked up under its name,
// then its keyCode (keyNameId, keyCodeId), either of which may be undefined.
const findByIdentity = <T>(
map: Map<KeyNameOrKeyCode, T>,
name: KeyNameOrKeyCode | undefined,
code: KeyNameOrKeyCode | undefined,
): T | undefined => {
let found: T | undefined;
if (name !== undefined) found = map.get(name);
if (found === undefined && code !== undefined) found = map.get(code);
return found;
};

const findSuppression = (
name: KeyNameOrKeyCode | undefined,
code: KeyNameOrKeyCode | undefined,
): Suppression | undefined => {
let found: Suppression | undefined;
if (name !== undefined) found = suppressedKeys.get(name);
if (found === undefined && code !== undefined) {
found = suppressedKeys.get(code);
): Suppression | undefined => findByIdentity(suppressedKeys, name, code);

// Keys down with no key-up yet, for Config.inferKeyRepeat: when each last sent
// a key-down. Indexed under every identity like suppressedKeys, so a key-up
// naming the key differently (webOS Back) still ends the press.
type HeldKey = { ids: KeyNameOrKeyCode[]; time: number };
const heldKeys = new Map<KeyNameOrKeyCode, HeldKey>();

// Marks a key-down as a repeat when it follows a key-down of the same key,
// with no key-up between, by less than `windowMs`. KeyboardEvent.repeat is a
// read-only getter; an own property shadows it, so handlers, useHold and the
// suppression check below all read the same value.
const inferRepeat = (
e: KeyboardEvent,
name: KeyNameOrKeyCode | undefined,
code: KeyNameOrKeyCode | undefined,
windowMs: number,
): void => {
const now = performance.now();
const held = findByIdentity(heldKeys, name, code);
if (held === undefined) {
const ids = keyIdentities(e);
const entry: HeldKey = { ids, time: now };
for (let i = 0; i < ids.length; i++) heldKeys.set(ids[i]!, entry);
return;
}
return found;
if (!e.repeat && now - held.time < windowMs) {
Object.defineProperty(e, 'repeat', { value: true });
}
held.time = now;
};

const releaseHeldKey = (
name: KeyNameOrKeyCode | undefined,
code: KeyNameOrKeyCode | undefined,
): void => {
const held = findByIdentity(heldKeys, name, code);
if (!held) return;
const ids = held.ids;
for (let i = 0; i < ids.length; i++) heldKeys.delete(ids[i]!);
};

const liftSuppression = (found: Suppression | undefined): void => {
Expand Down Expand Up @@ -559,6 +603,9 @@ const handleKeyEvents = (
if (keydown) {
const name = keyNameId(keydown);
const code = keyCodeId(keydown);
if (Config.inferKeyRepeat) {
inferRepeat(keydown, name, code, Config.inferKeyRepeat);
}
if (keydown.repeat) {
if (findSuppression(name, code)) return true;
} else {
Expand All @@ -576,7 +623,10 @@ const handleKeyEvents = (
// 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(findSuppression(keyNameId(keyup), keyCodeId(keyup)));
const name = keyNameId(keyup);
const code = keyCodeId(keyup);
releaseHeldKey(name, code);
liftSuppression(findSuppression(name, code));

return propagateKeyPress(
keyup,
Expand Down Expand Up @@ -696,5 +746,6 @@ export const useFocusManager = (
eventTarget.removeEventListener('keydown', keyPressHandler);
eventTarget.removeEventListener('keyup', keyUpHandler);
suppressedKeys.clear();
heldKeys.clear();
});
};
Loading
Loading