diff --git a/src/primitives/useHold.ts b/src/primitives/useHold.ts
index d4c811e..947ddce 100644
--- a/src/primitives/useHold.ts
+++ b/src/primitives/useHold.ts
@@ -9,13 +9,30 @@ export type UseHoldProps = {
};
/**
+ * Distinguishes a tap from a press-and-hold for a single key, without depending
+ * on the key-up event. This matters on TV platforms (notably LG webOS) where the
+ * OK button does not reliably emit a key-up, so any tap logic gated on key-up
+ * (`onEnterRelease`) would never run and the card would never open.
+ *
+ * How a press resolves:
+ * - key-down starts the hold timer.
+ * - an auto-repeat key-down before the timer fires marks the key as still held;
+ * when the timer fires it resolves to a hold → `onHold`.
+ * - if key-up arrives before the timer, it's a tap → `onEnter` (fires
+ * immediately, no latency, on platforms that deliver key-up).
+ * - 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.
+ *
+ * `performOnEnterImmediately` keeps the legacy behavior of firing `onEnter` on
+ * key-down; a long-press then fires both `onEnter` and `onHold`.
+ *
* @example
* const [holdRight, releaseRight] = useHold({
* onHold: handleHoldRight,
* onEnter: handleOnRight,
* onRelease: handleReleaseHold,
* holdThreshold: 200,
- * performOnEnterImmediately: true
* });
*
*
*
* @param {UseHoldProps} props - The properties for configuring the hold behavior.
- * @returns {[() => boolean, () => boolean]} A tuple containing `startHold` and `releaseHold` functions.
+ * @returns {[(e?: KeyboardEvent) => boolean, () => boolean]} A tuple containing `startHold` and `releaseHold` functions.
*/
export function useHold(props: UseHoldProps) {
@@ -34,32 +51,68 @@ export function useHold(props: UseHoldProps) {
);
let holdTimeout = -1;
- let wasHeld = false;
+ 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)
+
+ const reset = () => {
+ if (holdTimeout !== -1) {
+ clearTimeout(holdTimeout);
+ holdTimeout = -1;
+ }
+ enterFired = false;
+ holdFired = false;
+ repeated = false;
+ };
+
+ const startHold = (e?: KeyboardEvent) => {
+ // 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) {
+ repeated = true;
+ return true;
+ }
+
+ // 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();
- const startHold = () => {
- if (holdTimeout === -1) {
- if (performOnEnterImmediately()) {
+ if (performOnEnterImmediately()) {
+ enterFired = true;
+ props.onEnter();
+ }
+
+ holdTimeout = setTimeout(() => {
+ holdTimeout = -1;
+ if (repeated) {
+ // Held past the threshold → hold gesture.
+ holdFired = true;
+ props.onHold();
+ } 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();
}
- holdTimeout = setTimeout(() => {
- wasHeld = true;
- props.onHold();
- }, holdThreshold()) as unknown as number;
- }
+ }, holdThreshold()) as unknown as number;
+
return true;
};
const releaseHold = () => {
if (holdTimeout !== -1) {
+ // Released before the threshold → tap. Fires immediately where key-up is
+ // delivered, avoiding the timer latency.
clearTimeout(holdTimeout);
holdTimeout = -1;
- if (!wasHeld) {
- if (!performOnEnterImmediately()) props.onEnter();
- return;
+ if (!enterFired) {
+ enterFired = true;
+ props.onEnter();
}
+ } else if (holdFired) {
props.onRelease?.();
- wasHeld = false;
}
+ reset();
return true;
};
diff --git a/tests/useHold.spec.ts b/tests/useHold.spec.ts
new file mode 100644
index 0000000..7c11625
--- /dev/null
+++ b/tests/useHold.spec.ts
@@ -0,0 +1,93 @@
+import { createRoot } from 'solid-js';
+import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { useHold } from '../src/primitives/useHold.ts';
+
+const downRepeat = { repeat: true } as KeyboardEvent;
+
+function setup(props: Partial[0]> = {}) {
+ const onEnter = vi.fn();
+ const onHold = vi.fn();
+ const onRelease = vi.fn();
+ let api!: ReturnType;
+ const dispose = createRoot((d) => {
+ api = useHold({ onEnter, onHold, onRelease, holdThreshold: 200, ...props });
+ return d;
+ });
+ const [startHold, releaseHold] = api;
+ return { startHold, releaseHold, onEnter, onHold, onRelease, dispose };
+}
+
+describe('useHold', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ it('fires onEnter immediately on key-up before the threshold (tap)', () => {
+ const { startHold, releaseHold, onEnter, onHold, dispose } = setup();
+ startHold();
+ releaseHold();
+ expect(onEnter).toHaveBeenCalledTimes(1);
+ vi.advanceTimersByTime(500);
+ expect(onHold).not.toHaveBeenCalled();
+ dispose();
+ });
+
+ it('fires onEnter via the timer when key-up never arrives (webOS tap)', () => {
+ const { startHold, onEnter, onHold, dispose } = setup();
+ startHold(); // no releaseHold — key-up swallowed
+ expect(onEnter).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(200);
+ expect(onEnter).toHaveBeenCalledTimes(1);
+ expect(onHold).not.toHaveBeenCalled();
+ dispose();
+ });
+
+ it('fires onHold (not onEnter) when held with auto-repeat', () => {
+ const { startHold, releaseHold, onEnter, onHold, onRelease, dispose } =
+ setup();
+ startHold();
+ startHold(downRepeat); // auto-repeat → key still held
+ vi.advanceTimersByTime(200);
+ expect(onHold).toHaveBeenCalledTimes(1);
+ expect(onEnter).not.toHaveBeenCalled();
+ releaseHold();
+ expect(onRelease).toHaveBeenCalledTimes(1);
+ dispose();
+ });
+
+ it('does not double-fire when key-up arrives after a hold', () => {
+ const { startHold, releaseHold, onEnter, onHold, dispose } = setup();
+ startHold();
+ startHold(downRepeat);
+ vi.advanceTimersByTime(200);
+ releaseHold();
+ expect(onHold).toHaveBeenCalledTimes(1);
+ expect(onEnter).not.toHaveBeenCalled();
+ dispose();
+ });
+
+ it('recovers on the next press after a key-up-less hold', () => {
+ const { startHold, releaseHold, onEnter, onHold, dispose } = setup();
+ // First press: held, no key-up ever delivered.
+ startHold();
+ startHold(downRepeat);
+ vi.advanceTimersByTime(200);
+ expect(onHold).toHaveBeenCalledTimes(1);
+ // Second press: a fresh tap with key-up must still work.
+ startHold();
+ releaseHold();
+ expect(onEnter).toHaveBeenCalledTimes(1);
+ expect(onHold).toHaveBeenCalledTimes(1);
+ dispose();
+ });
+
+ it('performOnEnterImmediately fires onEnter on key-down', () => {
+ const { startHold, releaseHold, onEnter, dispose } = setup({
+ performOnEnterImmediately: true,
+ });
+ startHold();
+ expect(onEnter).toHaveBeenCalledTimes(1);
+ releaseHold();
+ expect(onEnter).toHaveBeenCalledTimes(1); // not double-fired
+ dispose();
+ });
+});