Skip to content
Open
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.14",
"solid-js": "^2.0.0-beta.14"
},
"typesVersions": {},
"devDependencies": {
"solid-js": "^1.9.7"
"@babel/core": "^7.27.0",
"@solidjs/web": "2.0.0-beta.14",
"babel-preset-solid": "2.0.0-beta.14",
"solid-js": "2.0.0-beta.14"
}
}
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];
};
216 changes: 116 additions & 100 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,125 +19,141 @@ 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 => {
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);
it("reads selection from input", () => {
createRoot(dispose => {
let unmount: (() => void) | undefined;
try {
const [selection] = createSelection();
expect(selection()).toEqual([null, NaN, NaN]);
const rendered = renderTest(() => <input type="text" value="testing" />);
unmount = rendered.unmount;
const input = rendered.container.querySelector("input") as HTMLInputElement;
expect(input).toBeInstanceOf(HTMLInputElement);
input.focus();
input.setSelectionRange(1, 3);
input.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: false }));
flush();
expect(selection()).toEqual([input, 1, 3]);
unmount();
} finally {
unmount?.();
dispose();
});
}));
}
});
});
Comment thread
davedbase marked this conversation as resolved.

it("writes selection to an input", () =>
createRoot(async dispose => {
const [_selection, 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);
it("writes selection to an input", () => {
createRoot(dispose => {
let unmount: (() => void) | undefined;
try {
const [, setSelection] = createSelection();
const rendered = renderTest(() => <input type="text" value="testing" />);
unmount = rendered.unmount;
const input = rendered.container.querySelector("input") as HTMLInputElement;
expect(input).toBeInstanceOf(HTMLInputElement);
setSelection([input, 2, 5]);
flush();
expect(input.selectionStart).toBe(2);
expect(input.selectionEnd).toBe(5);
unmount();
} finally {
unmount?.();
dispose();
});
}));
}
});
});

it("reads selection from textarea", () =>
createRoot(async 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);
it("reads selection from textarea", () => {
createRoot(dispose => {
let unmount: (() => void) | undefined;
try {
const [selection] = createSelection();
expect(selection()).toEqual([null, NaN, NaN]);
const rendered = renderTest(() => <textarea>testing</textarea>);
unmount = rendered.unmount;
const textarea = rendered.container.querySelector("textarea") as HTMLTextAreaElement;
expect(textarea).toBeInstanceOf(HTMLTextAreaElement);
textarea.focus();
textarea.setSelectionRange(2, 5);
textarea.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: false }));
flush();
expect(selection()).toEqual([textarea, 2, 5]);
unmount();
} finally {
unmount?.();
dispose();
});
}));
}
});
});

it("writes selection to a textarea", () =>
createRoot(async dispose => {
const [_selection, 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);
it("writes selection to a textarea", () => {
createRoot(dispose => {
let unmount: (() => void) | undefined;
try {
const [, setSelection] = createSelection();
const rendered = renderTest(() => <textarea>testing</textarea>);
unmount = rendered.unmount;
const textarea = rendered.container.querySelector("textarea") as HTMLTextAreaElement;
expect(textarea).toBeInstanceOf(HTMLTextAreaElement);
setSelection([textarea, 2, 5]);
flush();
expect(textarea.selectionStart).toBe(2);
expect(textarea.selectionEnd).toBe(5);
unmount();
} finally {
unmount?.();
dispose();
});
}));
}
});
});

it("reads selection from contentEditable div", () =>
createRoot(async dispose => {
const [selection] = createSelection();
expect(selection()).toEqual([null, NaN, NaN]);
const { container, unmount } = renderTest(() => <div contenteditable>testing</div>);
const div = container.querySelector("div") as HTMLDivElement;
expect(div).toBeInstanceOf(HTMLDivElement);
div.focus();
window
.getSelection()
?.addRange(
(range => (range.setStart(div.firstChild!, 0), range.setEnd(div.firstChild!, 6), range))(
document.createRange(),
),
);
// only wrapped in createEffect, we will subscribe to changes
await createEffect(async () => {
await dispatchKeyupEvent(div);
// might be delayed because of JSDOM
it("reads selection from contentEditable div", () => {
createRoot(dispose => {
let unmount: (() => void) | undefined;
try {
const [selection] = createSelection();
expect(selection()).toEqual([null, NaN, NaN]);
const rendered = renderTest(() => <div contenteditable>testing</div>);
unmount = rendered.unmount;
const div = rendered.container.querySelector("div") as HTMLDivElement;
expect(div).toBeInstanceOf(HTMLDivElement);
div.focus();
window
.getSelection()
?.addRange(
(range => (range.setStart(div.firstChild!, 0), range.setEnd(div.firstChild!, 6), range))(
document.createRange(),
),
);
div.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, cancelable: false }));
flush();
if (selection()[0] !== null) {
expect(selection()).toEqual([div, 0, 6]);
unmount();
dispose();
}
});
}));
} finally {
unmount?.();
dispose();
}
});
});

it("writes selection to a contentEditable div", () =>
createRoot(async 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
it("writes selection to a contentEditable div", () => {
createRoot(dispose => {
let unmount: (() => void) | undefined;
try {
const [selection, setSelection] = createSelection();
const rendered = renderTest(() => <div contenteditable>testing</div>);
unmount = rendered.unmount;
const div = rendered.container.querySelector("div") as HTMLDivElement;
expect(div).toBeInstanceOf(HTMLDivElement);
setSelection([div, 2, 5]);
flush();
if (selection()[0] !== null) {
const range = window.getSelection()?.getRangeAt(0);
expect(range?.startContainer).toBe(div);
expect(range?.startContainer).toBe(div.firstChild);
expect(range?.startOffset).toBe(2);
expect(range?.endOffset).toBe(5);
unmount();
dispose();
}
});
}));
} finally {
unmount?.();
dispose();
}
});
});
});
Loading