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
83 changes: 68 additions & 15 deletions src/primitives/useHold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* });
*
* <view
Expand All @@ -24,7 +41,7 @@ export type UseHoldProps = {
* />
*
* @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) {
Expand All @@ -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;
};

Expand Down
93 changes: 93 additions & 0 deletions tests/useHold.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof useHold>[0]> = {}) {
const onEnter = vi.fn();
const onHold = vi.fn();
const onRelease = vi.fn();
let api!: ReturnType<typeof useHold>;
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();
});
});
Loading