Skip to content
Draft
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
34 changes: 34 additions & 0 deletions .changeset/keyboard-solid2-migration.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions packages/keyboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions packages/keyboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"name": "keyboard",
"stage": 1,
"list": [
"useKeyDownEvent",
"useKeyDownList",
"useCurrentlyHeldKey",
"useKeyDownSequence",
Expand Down Expand Up @@ -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": {}
}
200 changes: 115 additions & 85 deletions packages/keyboard/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 & {});
Expand Down Expand Up @@ -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();
* }
* })
* ```
Expand All @@ -49,7 +49,7 @@ export const useKeyDownEvent = /*#__PURE__*/ createSingletonRoot<Accessor<Keyboa
return () => null;
}

const [event, setEvent] = createSignal<KeyboardEvent | null>(null);
const [event, setEvent] = createSignal<KeyboardEvent | null>(null, INTERNAL_OPTIONS);

makeEventListener(window, "keydown", e => {
setEvent(e);
Expand All @@ -60,8 +60,6 @@ export const useKeyDownEvent = /*#__PURE__*/ createSingletonRoot<Accessor<Keyboa
},
);

type OldPressedKeys = [Accessor<string[]>, { event: Accessor<KeyboardEvent | null> }];

/**
* Provides a signal with the list of currently held keys, ordered from least recent to most recent.
*
Expand All @@ -85,21 +83,11 @@ type OldPressedKeys = [Accessor<string[]>, { event: Accessor<KeyboardEvent | nul
*/
export const useKeyDownList = /*#__PURE__*/ createSingletonRoot<Accessor<string[]>>(() => {
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<string[]>([]),
reset = () => setPressedKeys([]),
event = useKeyDownEvent();
const [pressedKeys, setPressedKeys] = createSignal<string[]>([], INTERNAL_OPTIONS);
const reset = () => setPressedKeys([]);

makeEventListener(window, "keydown", e => {
// e.key may be undefined when used with <datalist> el
Expand Down Expand Up @@ -142,16 +130,6 @@ export const useKeyDownList = /*#__PURE__*/ createSingletonRoot<Accessor<string[
e.defaultPrevented || reset();
});

// this is for backwards compatibility
// TODO remove in the next major version
(pressedKeys as any as OldPressedKeys)[0] = pressedKeys;
(pressedKeys as any as OldPressedKeys)[1] = { event };

(pressedKeys as any as OldPressedKeys)[Symbol.iterator] = function* () {
yield (pressedKeys as any as OldPressedKeys)[0];
yield (pressedKeys as any as OldPressedKeys)[1];
} as any;

return pressedKeys;
});

Expand Down Expand Up @@ -222,10 +200,12 @@ export const useKeyDownSequence = /*#__PURE__*/ createSingletonRoot<Accessor<str

const keys = useKeyDownList();

return createMemo(prev => {
// 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()];
});
});

/**
Expand All @@ -235,8 +215,8 @@ export const useKeyDownSequence = /*#__PURE__*/ createSingletonRoot<Accessor<str
* @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#createKeyHold
*
* @param key The key to check for.
* @options The options for the key hold.
* - `preventDefault` — Controlls in the keydown event should have it's default action prevented. Enabled by default.
* @param options The options for the key hold.
* - `preventDefault` — Controls if the keydown event should have its default action prevented. Enabled by default.
* @returns
* ```ts
* Accessor<boolean>
Expand All @@ -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
Expand All @@ -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();
});
}
Loading