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
18 changes: 18 additions & 0 deletions .changeset/selection-solid2-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@solid-primitives/selection": major
---

Migrate to Solid.js v2.0 (beta.10)

## Breaking Changes

**Peer dependencies**: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10` are now required.

### `@solid-primitives/selection`

- `isServer` is now imported from `@solidjs/web` (was `solid-js/web`)
- `createEffect` for applying selection converted to the split compute/apply pattern required by Solid 2.0
- Event listeners are now registered directly with `onCleanup` rather than inside a `createEffect` with no reactive dependencies
- Internal signals now use `{ ownedWrite: true }` (via `INTERNAL_OPTIONS`) to allow `setSelection` to be called from within reactive scopes
- Added `test/server.test.ts` verifying SSR no-op behaviour for `createSelection`
- No changes to the public `createSelection` API or `HTMLSelection` type
2 changes: 2 additions & 0 deletions packages/selection/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ npm install @solid-primitives/selection
yarn add @solid-primitives/selection
```

**Requires**: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10`

## Usage

The format of the getter output and setter input is `HTMLSelection`, consisting of a tuple of the node in which the selection happens and a start and end offset within the text content. The offsets count from zero, so `1` would be the second character.
Expand Down
13 changes: 10 additions & 3 deletions packages/selection/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,26 @@
"scripts": {
"dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts",
"build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts",
"vitest": "vitest -c ../../configs/vitest.config.ts",
"vitest": "vitest -c vitest.config.ts",
"test": "pnpm run vitest",
"test:ssr": "pnpm run vitest --mode ssr"
},
"keywords": [
"solid",
"primitives"
],
"dependencies": {
"@solid-primitives/utils": "workspace:^"
},
"peerDependencies": {
"solid-js": "^1.6.12"
"@solidjs/web": "^2.0.0-beta.10",
"solid-js": "^2.0.0-beta.10"
},
"typesVersions": {},
"devDependencies": {
"solid-js": "^1.9.7"
"@babel/core": "^7.27.0",
"@solidjs/web": "2.0.0-beta.10",
"babel-preset-solid": "2.0.0-beta.10",
"solid-js": "2.0.0-beta.10"
}
}
65 changes: 33 additions & 32 deletions packages/selection/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type Accessor, createEffect, createSignal, onCleanup, type Setter } from "solid-js";
import { isServer } from "solid-js/web";
import { isServer } from "@solidjs/web";
import { INTERNAL_OPTIONS } from "@solid-primitives/utils";

export type HTMLSelection = [node: HTMLElement | null, start: number, end: number];

Expand Down Expand Up @@ -44,8 +45,8 @@ export const createSelection = (): [Accessor<HTMLSelection>, Setter<HTMLSelectio
sel => (typeof sel === "function" ? (sel as any)([null, NaN, NaN]) : sel),
];
}
const [getSelection, setSelection] = createSignal<HTMLSelection>([null, NaN, NaN]);
const [selected, setSelected] = createSignal<HTMLSelection>([null, NaN, NaN]);
const [getSelection, setSelection] = createSignal<HTMLSelection>([null, NaN, NaN], INTERNAL_OPTIONS);
const [selected, setSelected] = createSignal<HTMLSelection>([null, NaN, NaN], INTERNAL_OPTIONS);
const selectionHandler = () => {
const active = document.activeElement;
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
Expand All @@ -68,36 +69,36 @@ export const createSelection = (): [Accessor<HTMLSelection>, Setter<HTMLSelectio
setSelection([parent as HTMLElement, startPosition, endPosition]);
};
selectionHandler();
createEffect(() => {
document.addEventListener("selectionchange", selectionHandler);
document.addEventListener("click", selectionHandler);
document.addEventListener("keyup", selectionHandler);
onCleanup(() => {
document.removeEventListener("selectionchange", selectionHandler);
document.removeEventListener("click", selectionHandler);
document.removeEventListener("keyup", selectionHandler);
});
document.addEventListener("selectionchange", selectionHandler);
document.addEventListener("click", selectionHandler);
document.addEventListener("keyup", selectionHandler);
onCleanup(() => {
document.removeEventListener("selectionchange", selectionHandler);
document.removeEventListener("click", selectionHandler);
document.removeEventListener("keyup", selectionHandler);
});
createEffect(() => {
const [node, start, end] = selected();
const selection = window.getSelection();
if (node === null) {
selection?.rangeCount && selection.removeAllRanges();
} else if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
document.activeElement !== node && node.focus();
node.setSelectionRange(start, end);
} else {
selection?.removeAllRanges();
const range = document.createRange();
const texts = getTextNodes(node);
const [startNode, startPos] = getRangeArgs(start, texts);
const [endNode, endPos] = start === end ? [startNode, startPos] : getRangeArgs(end, texts);
if (startNode && endNode && startPos !== -1 && endPos !== -1) {
range.setStart(startNode, startPos);
range.setEnd(endNode, endPos);
selection?.addRange(range);
createEffect(
() => selected(),
([node, start, end]) => {
const selection = window.getSelection();
if (node === null) {
selection?.rangeCount && selection.removeAllRanges();
} else if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
document.activeElement !== node && node.focus();
node.setSelectionRange(start, end);
} else {
selection?.removeAllRanges();
const range = document.createRange();
const texts = getTextNodes(node);
const [startNode, startPos] = getRangeArgs(start, texts);
const [endNode, endPos] = start === end ? [startNode, startPos] : getRangeArgs(end, texts);
if (startNode && endNode && startPos !== -1 && endPos !== -1) {
range.setStart(startNode, startPos);
range.setEnd(endNode, endPos);
selection?.addRange(range);
}
}
}
});
},
);
return [getSelection, setSelected];
};
142 changes: 64 additions & 78 deletions packages/selection/test/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";

import { render } from "solid-js/web";
import { render } from "@solidjs/web";

import { createSelection } from "../src/index.js";
import { createEffect, createRoot, type JSX } from "solid-js";
import { createRoot, flush, type JSX } from "solid-js";

describe("createSelection", () => {
const renderTest = (component: () => JSX.Element) => {
Expand All @@ -19,82 +19,72 @@ describe("createSelection", () => {
};
};

const dispatchKeyupEvent = (node: HTMLElement | Document) =>
new Promise<void>(resolve => {
node.addEventListener("keyup", () => resolve(), { once: true });
node.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: false }));
});

it("reads selection from input", () =>
createRoot(async dispose => {
it("reads selection from input", () => {
createRoot(dispose => {
const [selection] = createSelection();
expect(selection()).toEqual([null, NaN, NaN]);
const { container, unmount } = renderTest(() => <input type="text" value="testing" />);
const input = container.querySelector("input") as HTMLInputElement;
expect(input).toBeInstanceOf(HTMLInputElement);
input.focus();
input.setSelectionRange(1, 3);
// only wrapped in createEffect, we will subscribe to changes
await createEffect(async () => {
await dispatchKeyupEvent(input);
expect(selection()).toEqual([input, 1, 3]);
unmount();
dispose();
});
}));
input.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: false }));
flush();
expect(selection()).toEqual([input, 1, 3]);
unmount();
dispose();
});
});

it("writes selection to an input", () =>
createRoot(async dispose => {
const [_selection, setSelection] = createSelection();
it("writes selection to an input", () => {
createRoot(dispose => {
const [, setSelection] = createSelection();
const { container, unmount } = renderTest(() => <input type="text" value="testing" />);
const input = container.querySelector("input") as HTMLInputElement;
expect(input).toBeInstanceOf(HTMLInputElement);
setSelection([input, 2, 5]);
await createEffect(async () => {
await dispatchKeyupEvent(input);
expect(input.selectionStart).toBe(2);
expect(input.selectionEnd).toBe(5);
unmount();
dispose();
});
}));
flush();
expect(input.selectionStart).toBe(2);
expect(input.selectionEnd).toBe(5);
unmount();
dispose();
});
});

it("reads selection from textarea", () =>
createRoot(async dispose => {
it("reads selection from textarea", () => {
createRoot(dispose => {
const [selection] = createSelection();
expect(selection()).toEqual([null, NaN, NaN]);
const { container, unmount } = renderTest(() => <textarea>testing</textarea>);
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
expect(textarea).toBeInstanceOf(HTMLTextAreaElement);
textarea.focus();
textarea.setSelectionRange(2, 5);
// only wrapped in createEffect, we will subscribe to changes
await createEffect(async () => {
await dispatchKeyupEvent(textarea);
expect(selection()).toEqual([textarea, 2, 5]);
unmount();
dispose();
});
}));
textarea.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: false }));
flush();
expect(selection()).toEqual([textarea, 2, 5]);
unmount();
dispose();
});
});

it("writes selection to a textarea", () =>
createRoot(async dispose => {
const [_selection, setSelection] = createSelection();
it("writes selection to a textarea", () => {
createRoot(dispose => {
const [, setSelection] = createSelection();
const { container, unmount } = renderTest(() => <textarea>testing</textarea>);
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
expect(textarea).toBeInstanceOf(HTMLTextAreaElement);
setSelection([textarea, 2, 5]);
await createEffect(async () => {
await dispatchKeyupEvent(textarea);
expect(textarea.selectionStart).toBe(2);
expect(textarea.selectionEnd).toBe(5);
unmount();
dispose();
});
}));
flush();
expect(textarea.selectionStart).toBe(2);
expect(textarea.selectionEnd).toBe(5);
unmount();
dispose();
});
});

it("reads selection from contentEditable div", () =>
createRoot(async dispose => {
it("reads selection from contentEditable div", () => {
createRoot(dispose => {
const [selection] = createSelection();
expect(selection()).toEqual([null, NaN, NaN]);
const { container, unmount } = renderTest(() => <div contenteditable>testing</div>);
Expand All @@ -108,36 +98,32 @@ describe("createSelection", () => {
document.createRange(),
),
);
// only wrapped in createEffect, we will subscribe to changes
await createEffect(async () => {
await dispatchKeyupEvent(div);
// might be delayed because of JSDOM
if (selection()[0] !== null) {
expect(selection()).toEqual([div, 0, 6]);
unmount();
dispose();
}
});
}));
div.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: false }));
flush();
if (selection()[0] !== null) {
expect(selection()).toEqual([div, 0, 6]);
}
unmount();
dispose();
});
});

it("writes selection to a contentEditable div", () =>
createRoot(async dispose => {
it("writes selection to a contentEditable div", () => {
createRoot(dispose => {
const [selection, setSelection] = createSelection();
const { container, unmount } = renderTest(() => <div contenteditable>testing</div>);
const div = container.querySelector("div") as HTMLDivElement;
expect(div).toBeInstanceOf(HTMLDivElement);
setSelection([div, 2, 5]);
await createEffect(async () => {
await dispatchKeyupEvent(div);
// might be delayed because of JSDOM
if (selection()[0] !== null) {
const range = window.getSelection()?.getRangeAt(0);
expect(range?.startContainer).toBe(div);
expect(range?.startOffset).toBe(2);
expect(range?.endOffset).toBe(5);
unmount();
dispose();
}
});
}));
flush();
if (selection()[0] !== null) {
const range = window.getSelection()?.getRangeAt(0);
expect(range?.startContainer).toBe(div.firstChild);
expect(range?.startOffset).toBe(2);
expect(range?.endOffset).toBe(5);
}
unmount();
dispose();
});
});
});
11 changes: 11 additions & 0 deletions packages/selection/test/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, it, expect } from "vitest";
import { createSelection } from "../src/index.js";

describe("createSelection (server)", () => {
it("returns no-op accessor and setter", () => {
const [selection, setSelection] = createSelection();
expect(selection()).toEqual([null, NaN, NaN]);
setSelection([null, NaN, NaN]);
expect(selection()).toEqual([null, NaN, NaN]);
});
});
6 changes: 5 additions & 1 deletion packages/selection/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
"outDir": "dist",
"rootDir": "src"
},
"references": [],
"references": [
{
"path": "../utils"
}
],
"include": [
"src"
]
Expand Down
Loading