diff --git a/.changeset/keyboard-solid2-migration.md b/.changeset/keyboard-solid2-migration.md new file mode 100644 index 000000000..196e6a9e9 --- /dev/null +++ b/.changeset/keyboard-solid2-migration.md @@ -0,0 +1,34 @@ +--- +"@solid-primitives/keyboard": major +--- + +Migrate to Solid.js v2.0 (beta.10) + +## Breaking Changes + +**Peer dependency**: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10` are now required. + +### Removed deprecated tuple API from `useKeyDownList` + +The old destructuring form is no longer supported: + +```ts +// ❌ removed +const [keys, { event }] = useKeyDownList(); + +// ✅ use the dedicated primitives instead +const keys = useKeyDownList(); +const event = useKeyDownEvent(); +``` + +### `isServer` import source + +`isServer` is now sourced from `@solidjs/web` instead of `solid-js/web` (handled internally — no consumer change needed). + +### `createShortcut` — synchronous `preventDefault` + +`createShortcut` now registers a direct `keydown` event listener instead of using a reactive effect. This fixes `preventDefault` calling correctly within the same event dispatch, which was not guaranteed with Solid 2.0's deferred effect scheduling. + +### `createKeyHold` — side-effect-free memo + +The `preventDefault` side effect has been moved out of the reactive `createMemo` into a dedicated `keydown` listener, aligning with Solid 2.0's guidance against side effects in memos. diff --git a/packages/keyboard/README.md b/packages/keyboard/README.md index e3d5e227e..ecd7a7072 100644 --- a/packages/keyboard/README.md +++ b/packages/keyboard/README.md @@ -9,14 +9,14 @@ [![version](https://img.shields.io/npm/v/@solid-primitives/keyboard?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/keyboard) [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-1.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -A library of reactive promitives helping handling user's keyboard input. +A library of reactive primitives for handling user keyboard input. - [`useKeyDownEvent`](#usekeydownevent) — Provides a signal with the last keydown event. -- [`useKeyDownList`](#usekeydownlist) — Provides a signal with the list of currently held keys +- [`useKeyDownList`](#usekeydownlist) — Provides a signal with the list of currently held keys. - [`useCurrentlyHeldKey`](#usecurrentlyheldkey) — Provides a signal with the currently held single key. - [`useKeyDownSequence`](#usekeydownsequence) — Provides a signal with a sequence of currently held keys, as they were pressed down and up. - [`createKeyHold`](#createkeyhold) — Provides a signal indicating if provided key is currently being held down. -- [`createShortcut`](#createshortcut) — Creates a keyboard shotcut observer. +- [`createShortcut`](#createshortcut) — Creates a keyboard shortcut observer. ## Installation @@ -62,7 +62,7 @@ This is a [singleton root](https://github.com/solidjs-community/solid-primitives ### How to use it -`useKeyDownList` takes no arguments, and returns a signal with the list of currently held keys +`useKeyDownList` takes no arguments and returns a signal with the list of currently held keys. ```tsx import { useKeyDownList } from "@solid-primitives/keyboard"; @@ -143,7 +143,7 @@ const pressing = createKeyHold("Alt", { preventDefault: false }); ## `createShortcut` -Creates a keyboard shotcut observer. The provided callback will be called when the specified keys are pressed. +Creates a keyboard shortcut observer. The provided callback will be called when the specified keys are pressed. ### How to use it diff --git a/packages/keyboard/package.json b/packages/keyboard/package.json index 788ad8ea1..5c10d8170 100644 --- a/packages/keyboard/package.json +++ b/packages/keyboard/package.json @@ -17,6 +17,7 @@ "name": "keyboard", "stage": 1, "list": [ + "useKeyDownEvent", "useKeyDownList", "useCurrentlyHeldKey", "useKeyDownSequence", @@ -61,10 +62,12 @@ "@solid-primitives/utils": "workspace:^" }, "peerDependencies": { - "solid-js": "^1.6.12" + "@solidjs/web": "^2.0.0-beta.10", + "solid-js": "^2.0.0-beta.10" }, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.10", + "solid-js": "2.0.0-beta.10" }, "typesVersions": {} } diff --git a/packages/keyboard/src/index.ts b/packages/keyboard/src/index.ts index ce41f6717..d3a93a4fd 100644 --- a/packages/keyboard/src/index.ts +++ b/packages/keyboard/src/index.ts @@ -1,8 +1,8 @@ import { makeEventListener } from "@solid-primitives/event-listener"; import { createSingletonRoot } from "@solid-primitives/rootless"; -import { arrayEquals } from "@solid-primitives/utils"; -import { type Accessor, createEffect, createMemo, createSignal, on, untrack } from "solid-js"; -import { isServer } from "solid-js/web"; +import { arrayEquals, INTERNAL_OPTIONS } from "@solid-primitives/utils"; +import { type Accessor, createMemo, createSignal, untrack } from "solid-js"; +import { isServer } from "@solidjs/web"; export type ModifierKey = "Alt" | "Control" | "Meta" | "Shift"; export type KbdKey = ModifierKey | (string & {}); @@ -37,8 +37,8 @@ function equalsKeyHoldSequence(sequence: string[][], model: string[]): boolean { * console.log(e) // => KeyboardEvent | null * * if (e) { - * console.log(e.key) // => "Q" | "ALT" | ... or null - * e.preventDefault(); // prevent default behavior or last keydown event + * console.log(e.key) // => "Q" | "ALT" | ... + * e.preventDefault(); * } * }) * ``` @@ -49,7 +49,7 @@ export const useKeyDownEvent = /*#__PURE__*/ createSingletonRoot null; } - const [event, setEvent] = createSignal(null); + const [event, setEvent] = createSignal(null, INTERNAL_OPTIONS); makeEventListener(window, "keydown", e => { setEvent(e); @@ -60,8 +60,6 @@ export const useKeyDownEvent = /*#__PURE__*/ createSingletonRoot, { event: Accessor }]; - /** * Provides a signal with the list of currently held keys, ordered from least recent to most recent. * @@ -85,21 +83,11 @@ type OldPressedKeys = [Accessor, { event: Accessor>(() => { if (isServer) { - const keys = () => []; - // this is for backwards compatibility - // TODO remove in the next major version - (keys as any as OldPressedKeys)[0] = keys; - (keys as any as OldPressedKeys)[1] = { event: () => null }; - (keys as any as OldPressedKeys)[Symbol.iterator] = function* () { - yield (keys as any as OldPressedKeys)[0]; - yield (keys as any as OldPressedKeys)[1]; - } as any; - return keys; + return () => []; } - const [pressedKeys, setPressedKeys] = createSignal([]), - reset = () => setPressedKeys([]), - event = useKeyDownEvent(); + const [pressedKeys, setPressedKeys] = createSignal([], INTERNAL_OPTIONS); + const reset = () => setPressedKeys([]); makeEventListener(window, "keydown", e => { // e.key may be undefined when used with el @@ -142,16 +130,6 @@ export const useKeyDownList = /*#__PURE__*/ createSingletonRoot { + // createMemo's second arg is options (not initialValue). The prev + // parameter starts as undefined; handle it with a fallback. + return createMemo((prev: string[][] | undefined) => { if (keys().length === 0) return []; - return [...prev, keys()]; - }, []); + return [...(prev ?? []), keys()]; + }); }); /** @@ -235,8 +215,8 @@ export const useKeyDownSequence = /*#__PURE__*/ createSingletonRoot @@ -259,22 +239,31 @@ export function createKeyHold( } key = key.toUpperCase(); - const { preventDefault = true } = options, - event = useKeyDownEvent(), - heldKey = useCurrentlyHeldKey(); + const { preventDefault = true } = options; + const heldKey = useCurrentlyHeldKey(); + + if (preventDefault) { + // Use a direct event listener for synchronous preventDefault — signal reads in event + // listeners return the pre-batch committed value, so we check e.key directly. + makeEventListener(window, "keydown", (e: KeyboardEvent) => { + if (e.key.toUpperCase() === key) { + e.preventDefault(); + } + }); + } - return createMemo(() => heldKey() === key && (preventDefault && event()?.preventDefault(), true)); + return createMemo(() => heldKey() === key); } /** - * Creates a keyboard shotcut observer. The provided {@link callback} will be called when the specified {@link keys} are pressed. + * Creates a keyboard shortcut observer. The provided {@link callback} will be called when the specified {@link keys} are pressed. * * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#createShortcut * * @param keys The sequence of keys to watch for. * @param callback The callback to call when the keys are pressed. - * @options The options for the shortcut. - * - `preventDefault` — Controlls in the keydown event should have it's default action prevented. Enabled by default. + * @param options The options for the shortcut. + * - `preventDefault` — Controls if the keydown event should have its default action prevented. Enabled by default. * - `requireReset` — If `true`, the shortcut will only be triggered once until all of the keys stop being pressed. Disabled by default. * * @example @@ -297,56 +286,97 @@ export function createShortcut( } keys = keys.map(key => key.toUpperCase()); - const { preventDefault = true } = options, - event = useKeyDownEvent(), - sequence = useKeyDownSequence(); - + const { preventDefault = true, requireReset = false } = options; + + // Track pressed keys and sequence locally with plain JS state rather than + // reactive signals. A signal reads from event listeners return + // the pre-batch committed value, so synchronous shortcut checking requires + // imperative state that's updated in the same event listener tick. + let pressedKeys: string[] = []; + let sequence: string[][] = []; let reset = false; - // allow to check the sequence only once the user has released all keys - const handleSequenceWithReset = (sequence: string[][]) => { - if (!sequence.length) return (reset = false); - if (reset) return; - const e = event(); - - if (sequence.length < keys.length) { - // optimistically preventDefault behavior if we yet don't have enough keys - if (equalsKeyHoldSequence(sequence, keys.slice(0, sequence.length))) { - preventDefault && e && e.preventDefault(); + + const resetAll = () => { + pressedKeys = []; + sequence = []; + reset = false; + }; + + makeEventListener(window, "keydown", (e: KeyboardEvent) => { + if (e.repeat || typeof e.key !== "string") return; + const key = e.key.toUpperCase(); + + if (!pressedKeys.includes(key)) { + const newKeys = [...pressedKeys]; + // Detect modifiers pressed before listener attached + if ( + pressedKeys.length === 0 && + key !== "ALT" && + key !== "CONTROL" && + key !== "META" && + key !== "SHIFT" + ) { + if (e.shiftKey && !newKeys.includes("SHIFT")) newKeys.unshift("SHIFT"); + if (e.altKey && !newKeys.includes("ALT")) newKeys.unshift("ALT"); + if (e.ctrlKey && !newKeys.includes("CONTROL")) newKeys.unshift("CONTROL"); + if (e.metaKey && !newKeys.includes("META")) newKeys.unshift("META"); + } + newKeys.push(key); + pressedKeys = newKeys; + sequence = [...sequence, [...pressedKeys]]; + } + + if (requireReset) { + if (reset) return; + if (sequence.length < keys.length) { + if (equalsKeyHoldSequence(sequence, keys.slice(0, sequence.length))) { + preventDefault && e.preventDefault(); + } else { + reset = true; + } } else { reset = true; + if (equalsKeyHoldSequence(sequence, keys)) { + preventDefault && e.preventDefault(); + callback(e); + } } } else { - reset = true; - if (equalsKeyHoldSequence(sequence, keys)) { - preventDefault && e && e.preventDefault(); - callback(e); + const last = sequence.at(-1); + if (!last) return; + + if (preventDefault && last.length < keys.length) { + if (arrayEquals(last, keys.slice(0, keys.length - 1))) { + e.preventDefault(); + } + return; } - } - }; - - // allow checking the sequence even if the user is still holding down keys - const handleSequenceWithoutReset = (sequence: string[][]) => { - const last = sequence.at(-1); - if (!last) return; - const e = event(); - - // optimistically preventDefault behavior if we yet don't have enough keys - if (preventDefault && last.length < keys.length) { - if (arrayEquals(last, keys.slice(0, keys.length - 1))) { - e && e.preventDefault(); + if (arrayEquals(last, keys)) { + const prev = sequence.at(-2); + if (!prev || arrayEquals(prev, keys.slice(0, keys.length - 1))) { + preventDefault && e.preventDefault(); + callback(e); + } } - return; } - if (arrayEquals(last, keys)) { - const prev = sequence.at(-2); - if (!prev || arrayEquals(prev, keys.slice(0, keys.length - 1))) { - preventDefault && e && e.preventDefault(); - callback(e); - } + }); + + makeEventListener(window, "keyup", (e: KeyboardEvent) => { + if (typeof e.key !== "string") return; + const key = e.key.toUpperCase(); + pressedKeys = pressedKeys.filter(k => k !== key); + if (pressedKeys.length === 0) { + sequence = []; + reset = false; + } else { + // Reset sequence to remaining held keys so repeated presses of the last + // key can re-trigger the shortcut while modifier keys stay held. + sequence = [[...pressedKeys]]; } - }; + }); - createEffect( - on(sequence, options.requireReset ? handleSequenceWithReset : handleSequenceWithoutReset), - ); + makeEventListener(window, "blur", resetAll); + makeEventListener(window, "contextmenu", (e: MouseEvent) => { + e.defaultPrevented || resetAll(); + }); } diff --git a/packages/keyboard/test/index.test.ts b/packages/keyboard/test/index.test.ts index 7c8a6d645..7191fc4ab 100644 --- a/packages/keyboard/test/index.test.ts +++ b/packages/keyboard/test/index.test.ts @@ -1,7 +1,9 @@ -import { createComputed, createRoot } from "solid-js"; +import { createRoot, flush } from "solid-js"; import { describe, test, expect } from "vitest"; import { createKeyHold, + createShortcut, + useKeyDownEvent, useKeyDownList, useCurrentlyHeldKey, useKeyDownSequence, @@ -16,44 +18,49 @@ const dispatchKeyEvent = (key: string, type: "keydown" | "keyup") => { describe("useKeyDownList", () => { test("returns a list of currently held keys", () => createRoot(dispose => { - let captured: any; - const [keys] = useKeyDownList(); - createComputed(() => (captured = keys())); - expect(captured).toEqual([]); + const keys = useKeyDownList(); + expect(keys()).toEqual([]); dispatchKeyEvent("a", "keydown"); - expect(captured).toEqual(["A"]); + flush(); + expect(keys()).toEqual(["A"]); dispatchKeyEvent("a", "keyup"); - expect(captured).toEqual([]); + flush(); + expect(keys()).toEqual([]); dispatchKeyEvent("Alt", "keydown"); + flush(); dispatchKeyEvent("q", "keydown"); - expect(captured).toEqual(["ALT", "Q"]); + flush(); + expect(keys()).toEqual(["ALT", "Q"]); dispatchKeyEvent("Alt", "keyup"); + flush(); dispatchKeyEvent("q", "keyup"); - expect(captured).toEqual([]); + flush(); + expect(keys()).toEqual([]); dispose(); })); - test("returns a last keydown event", () => + test("useKeyDownEvent tracks the last keydown event", () => createRoot(dispose => { - let captured: any; - const [, { event }] = useKeyDownList(); - createComputed(() => (captured = event())); + const event = useKeyDownEvent(); dispatchKeyEvent("a", "keydown"); - expect(captured).instanceOf(Event); - expect(captured.key).toBe("a"); + flush(); + expect(event()).instanceOf(Event); + expect(event()!.key).toBe("a"); dispatchKeyEvent("Alt", "keydown"); - expect(captured.key).toBe("Alt"); + flush(); + expect(event()!.key).toBe("Alt"); dispatchKeyEvent("Alt", "keyup"); dispatchKeyEvent("a", "keyup"); - expect(captured.key).toBe("Alt"); + flush(); + expect(event()!.key).toBe("Alt"); dispose(); })); @@ -62,26 +69,30 @@ describe("useKeyDownList", () => { describe("useCurrentlyHeldKey", () => { test("returns currently held key", () => createRoot(dispose => { - let captured: any; const key = useCurrentlyHeldKey(); - createComputed(() => (captured = key())); - expect(captured).toBe(null); + expect(key()).toBe(null); dispatchKeyEvent("a", "keydown"); - expect(captured).toBe("A"); + flush(); + expect(key()).toBe("A"); dispatchKeyEvent("a", "keyup"); - expect(captured).toBe(null); + flush(); + expect(key()).toBe(null); dispatchKeyEvent("Alt", "keydown"); - expect(captured).toBe("ALT"); + flush(); + expect(key()).toBe("ALT"); dispatchKeyEvent("q", "keydown"); - expect(captured).toBe(null); + flush(); + expect(key()).toBe(null); dispatchKeyEvent("Alt", "keyup"); - expect(captured).toBe(null); + flush(); + expect(key()).toBe(null); dispatchKeyEvent("q", "keyup"); - expect(captured).toBe(null); + flush(); + expect(key()).toBe(null); dispose(); })); @@ -90,26 +101,30 @@ describe("useCurrentlyHeldKey", () => { describe("useKeyDownSequence", () => { test("returns sequence of pressing currently held keys", () => createRoot(dispose => { - let captured: any; const sequence = useKeyDownSequence(); - createComputed(() => (captured = sequence())); - expect(captured).toEqual([]); + expect(sequence()).toEqual([]); dispatchKeyEvent("a", "keydown"); - expect(captured).toEqual([["A"]]); + flush(); + expect(sequence()).toEqual([["A"]]); dispatchKeyEvent("a", "keyup"); - expect(captured).toEqual([]); + flush(); + expect(sequence()).toEqual([]); dispatchKeyEvent("Alt", "keydown"); - expect(captured).toEqual([["ALT"]]); + flush(); + expect(sequence()).toEqual([["ALT"]]); dispatchKeyEvent("q", "keydown"); - expect(captured).toEqual([["ALT"], ["ALT", "Q"]]); + flush(); + expect(sequence()).toEqual([["ALT"], ["ALT", "Q"]]); dispatchKeyEvent("Alt", "keyup"); - expect(captured).toEqual([["ALT"], ["ALT", "Q"], ["Q"]]); + flush(); + expect(sequence()).toEqual([["ALT"], ["ALT", "Q"], ["Q"]]); dispatchKeyEvent("q", "keyup"); - expect(captured).toEqual([]); + flush(); + expect(sequence()).toEqual([]); dispose(); })); @@ -118,17 +133,67 @@ describe("useKeyDownSequence", () => { describe("createKeyHold", () => { test("returns a boolean of is the wanted key pressed", () => createRoot(dispose => { - let captured: any; const isHeld = createKeyHold("ALT"); - createComputed(() => (captured = isHeld())); - expect(captured).toBe(false); + expect(isHeld()).toBe(false); dispatchKeyEvent("ALT", "keydown"); + flush(); + expect(isHeld()).toBe(true); - expect(captured).toBe(true); + dispatchKeyEvent("a", "keyup"); + flush(); + expect(isHeld()).toBe(false); + + dispose(); + })); +}); + +describe("createShortcut", () => { + test("fires callback when shortcut keys are pressed", () => + createRoot(dispose => { + let fired = 0; + createShortcut(["Control", "Shift", "A"], () => fired++); + + dispatchKeyEvent("Control", "keydown"); + dispatchKeyEvent("Shift", "keydown"); + dispatchKeyEvent("a", "keydown"); + expect(fired).toBe(1); + + dispatchKeyEvent("a", "keyup"); + dispatchKeyEvent("Shift", "keyup"); + dispatchKeyEvent("Control", "keyup"); + + dispose(); + })); + + test("does not fire for partial key combinations", () => + createRoot(dispose => { + let fired = 0; + createShortcut(["Control", "A"], () => fired++); + + dispatchKeyEvent("Control", "keydown"); + expect(fired).toBe(0); + + dispatchKeyEvent("Control", "keyup"); + + dispose(); + })); + + test("requireReset — fires only once until keys are released", () => + createRoot(dispose => { + let fired = 0; + createShortcut(["Control", "A"], () => fired++, { requireReset: false }); + + dispatchKeyEvent("Control", "keydown"); + dispatchKeyEvent("a", "keydown"); + expect(fired).toBe(1); + + dispatchKeyEvent("a", "keyup"); + dispatchKeyEvent("a", "keydown"); + expect(fired).toBe(2); + dispatchKeyEvent("Control", "keyup"); dispatchKeyEvent("a", "keyup"); - expect(captured).toEqual(false); dispose(); })); diff --git a/packages/keyboard/test/server.test.ts b/packages/keyboard/test/server.test.ts new file mode 100644 index 000000000..3ceff5d0b --- /dev/null +++ b/packages/keyboard/test/server.test.ts @@ -0,0 +1,53 @@ +import { createRoot } from "solid-js"; +import { describe, it, expect } from "vitest"; +import { + useKeyDownEvent, + useKeyDownList, + useCurrentlyHeldKey, + useKeyDownSequence, + createKeyHold, + createShortcut, +} from "../src/index.js"; + +describe("SSR", () => { + it("useKeyDownEvent returns null on server", () => + createRoot(dispose => { + const event = useKeyDownEvent(); + expect(event()).toBe(null); + dispose(); + })); + + it("useKeyDownList returns empty array on server", () => + createRoot(dispose => { + const keys = useKeyDownList(); + expect(keys()).toEqual([]); + dispose(); + })); + + it("useCurrentlyHeldKey returns null on server", () => + createRoot(dispose => { + const key = useCurrentlyHeldKey(); + expect(key()).toBe(null); + dispose(); + })); + + it("useKeyDownSequence returns empty array on server", () => + createRoot(dispose => { + const sequence = useKeyDownSequence(); + expect(sequence()).toEqual([]); + dispose(); + })); + + it("createKeyHold returns false on server", () => + createRoot(dispose => { + const isHeld = createKeyHold("A"); + expect(isHeld()).toBe(false); + dispose(); + })); + + it("createShortcut does not throw on server", () => + createRoot(dispose => { + expect(() => createShortcut(["A"], () => {})).not.toThrow(); + dispose(); + })); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f59c63a9c..8235901e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -493,9 +493,12 @@ importers: specifier: workspace:^ version: link:../utils devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 packages/keyed: devDependencies: