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
5 changes: 5 additions & 0 deletions .changeset/clipboard-defer-initial-fix.md
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 5 additions & 0 deletions .changeset/keyboard-meta-shortcut-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/clipboard/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand Down
45 changes: 43 additions & 2 deletions packages/clipboard/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>(resolve => queueMicrotask(resolve));

describe("createClipboard", () => {
it("test initial read values", () =>
createRoot(async () => {
Expand All @@ -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();
}));
});
10 changes: 9 additions & 1 deletion packages/keyboard/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,15 @@ export const useKeyDownList = /*#__PURE__*/ createSingletonRoot<Accessor<string[
makeEventListener(window, "keyup", e => {
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);
Expand Down
28 changes: 27 additions & 1 deletion packages/keyboard/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeyboardEvent> = {},
) => {
let ev = new Event(type) as any;
ev.key = key;
Object.assign(ev, extra);
window.dispatchEvent(ev);
};

Expand Down Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions packages/presence/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>, and won't type-check

return (
<div>
<button onclick={() => setStore("panelOpen", open => !open)}>
{store.panelOpen ? "Hide" : "Show"} panel
</button>
<Show when={isMounted()}>
<div
style={{
transition: "all .5s ease",
opacity: isVisible() ? "1" : "0",
}}
>
I am the panel!
</div>
</Show>
</div>
);
};
```

### `createPresence` options API

```ts
Expand Down
Loading