diff --git a/.changeset/clipboard-defer-initial-fix.md b/.changeset/clipboard-defer-initial-fix.md new file mode 100644 index 000000000..388f82558 --- /dev/null +++ b/.changeset/clipboard-defer-initial-fix.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/clipboard": patch +--- + +Fix `createClipboard`'s `deferInitial` parameter being completely non-functional — `deferInitial || true` always evaluated to `true` regardless of the argument passed, so explicitly passing `deferInitial={false}` had no effect and the clipboard was never written from the initial signal value (resolves #790). Also corrected the JSDoc, which incorrectly claimed the default was `false` when the intended (and actual, once fixed) default is `true` — skip the initial write unless `deferInitial` is explicitly `false`. diff --git a/.changeset/keyboard-meta-shortcut-repeat.md b/.changeset/keyboard-meta-shortcut-repeat.md new file mode 100644 index 000000000..6637104d5 --- /dev/null +++ b/.changeset/keyboard-meta-shortcut-repeat.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/keyboard": patch +--- + +Fix `useKeyDownList` (and everything built on it: `useCurrentlyHeldKey`, `useKeyDownSequence`, `createKeyHold`, `createShortcut`) failing to re-trigger on repeated presses of shortcuts involving the `Meta` key, e.g. `["Meta", "P"]` on macOS. macOS never fires `keyup` for other keys held down together with Meta, only for Meta itself, so `useKeyDownList` now treats Meta's `keyup` as a signal to clear all tracked keys, preventing stale key state from corrupting the next press. diff --git a/packages/clipboard/src/index.ts b/packages/clipboard/src/index.ts index 00c86804f..085355106 100644 --- a/packages/clipboard/src/index.ts +++ b/packages/clipboard/src/index.ts @@ -77,7 +77,7 @@ export const makeClipboard = (): [ * Creates a new reactive primitive for managing the clipboard. * * @param data - Data signal to write to the clipboard. - * @param deferInitial - Sets the value of the clipboard from the signal. defaults to false. + * @param deferInitial - When false, also writes the initial signal value on mount. Defaults to true (skip first write). * @return Returns a resource representing the clipboard elements and children. * * @example @@ -130,7 +130,7 @@ export const createClipboard = ( navigator.clipboard.addEventListener("clipboardchange", refetch); onCleanup(() => navigator.clipboard.removeEventListener("clipboardchange", refetch)); if (data) { - createEffect(on(data, () => writeClipboard(data()), { defer: deferInitial || true })); + createEffect(on(data, () => writeClipboard(data()), { defer: deferInitial !== false })); } return [clipboard, refetch, writeClipboard]; diff --git a/packages/clipboard/test/index.test.ts b/packages/clipboard/test/index.test.ts index 90057d397..b5b8617ed 100644 --- a/packages/clipboard/test/index.test.ts +++ b/packages/clipboard/test/index.test.ts @@ -1,8 +1,10 @@ -import "./setup"; -import { createComputed, createRoot } from "solid-js"; +import { getLastClipboardEntry } from "./setup.js"; +import { createComputed, createRoot, createSignal } from "solid-js"; import { createClipboard } from "../src/index.js"; import { describe, expect, it } from "vitest"; +const tick = () => new Promise(resolve => queueMicrotask(resolve)); + describe("createClipboard", () => { it("test initial read values", () => createRoot(async () => { @@ -24,4 +26,43 @@ describe("createClipboard", () => { }); }); })); + + it("does not write the initial signal value by default (deferInitial)", () => + createRoot(async dispose => { + const [data] = createSignal("Skip me"); + createClipboard(data); + await tick(); + expect(getLastClipboardEntry().value).toBe("InitialValue"); + dispose(); + })); + + it("does not write the initial signal value when deferInitial is true", () => + createRoot(async dispose => { + const [data] = createSignal("Skip me too"); + createClipboard(data, true); + await tick(); + expect(getLastClipboardEntry().value).toBe("InitialValue"); + dispose(); + })); + + it("writes the initial signal value when deferInitial is false", () => + createRoot(async dispose => { + const [data] = createSignal("Write me"); + createClipboard(data, false); + await tick(); + expect(getLastClipboardEntry().value).toBe("Write me"); + dispose(); + })); + + it("still writes on subsequent changes when deferInitial is false", () => + createRoot(async dispose => { + const [data, setData] = createSignal("Write me"); + createClipboard(data, false); + await tick(); + expect(getLastClipboardEntry().value).toBe("Write me"); + setData("Then me"); + await tick(); + expect(getLastClipboardEntry().value).toBe("Then me"); + dispose(); + })); }); diff --git a/packages/keyboard/src/index.ts b/packages/keyboard/src/index.ts index ce41f6717..572e7b6aa 100644 --- a/packages/keyboard/src/index.ts +++ b/packages/keyboard/src/index.ts @@ -134,7 +134,15 @@ export const useKeyDownList = /*#__PURE__*/ createSingletonRoot { if (typeof e.key !== "string") return; const key = e.key.toUpperCase(); - setPressedKeys(prev => prev.filter(_key => _key !== key)); + // macOS never fires keyup for other keys held down together with Meta — + // only Meta's own keyup arrives — so its release must clear everything, + // or the other keys' stale state corrupts the next press. + // See https://github.com/solidjs-community/solid-primitives/issues/269 + if (key === "META") { + reset(); + } else { + setPressedKeys(prev => prev.filter(_key => _key !== key)); + } }); makeEventListener(window, "blur", reset); diff --git a/packages/keyboard/test/index.test.ts b/packages/keyboard/test/index.test.ts index 7c8a6d645..bd535f81c 100644 --- a/packages/keyboard/test/index.test.ts +++ b/packages/keyboard/test/index.test.ts @@ -7,9 +7,14 @@ import { useKeyDownSequence, } from "../src/index.js"; -const dispatchKeyEvent = (key: string, type: "keydown" | "keyup") => { +const dispatchKeyEvent = ( + key: string, + type: "keydown" | "keyup", + extra: Partial = {}, +) => { let ev = new Event(type) as any; ev.key = key; + Object.assign(ev, extra); window.dispatchEvent(ev); }; @@ -38,6 +43,27 @@ describe("useKeyDownList", () => { dispose(); })); + // https://github.com/solidjs-community/solid-primitives/issues/269 + // macOS never fires `keyup` for other keys held down together with Meta — + // only Meta's own keyup arrives — so releasing Meta must clear the whole + // list, or the other key's stale state corrupts the next press. + test("clears all keys when Meta is released (macOS suppresses keyup for keys held with Meta)", () => + createRoot(dispose => { + let captured: any; + const [keys] = useKeyDownList(); + createComputed(() => (captured = keys())); + + dispatchKeyEvent("Meta", "keydown", { metaKey: true }); + dispatchKeyEvent("k", "keydown", { metaKey: true }); + expect(captured).toEqual(["META", "K"]); + + // macOS quirk: only Meta's keyup fires; "k" never gets its own keyup + dispatchKeyEvent("Meta", "keyup"); + expect(captured).toEqual([]); + + dispose(); + })); + test("returns a last keydown event", () => createRoot(dispose => { let captured: any; diff --git a/packages/presence/README.md b/packages/presence/README.md index 1e59eee15..061798a97 100644 --- a/packages/presence/README.md +++ b/packages/presence/README.md @@ -124,6 +124,42 @@ const SecondExample = () => { }; ``` +### `createPresence` with a store + +`createPresence`'s first argument must be an `Accessor` — a function. Reading a store property directly (`store.panelOpen`) passes its current value once and won't update reactively; wrap the access in a function (`() => store.panelOpen`) so `createPresence` can subscribe to changes: + +```tsx +const ThirdExample = () => { + const [store, setStore] = createStore({ panelOpen: true }); + + // ✅ wrapped in an accessor + const { isVisible, isMounted } = createPresence(() => store.panelOpen, { + transitionDuration: 500, + }); + + // ❌ createPresence(store.panelOpen, { transitionDuration: 500 }) + // — this is a boolean, not an Accessor, and won't type-check + + return ( +
+ + +
+ I am the panel! +
+
+
+ ); +}; +``` + ### `createPresence` options API ```ts