Skip to content

Commit bf0765f

Browse files
author
Alex Lohr
committed
selection wip
1 parent 0bedc28 commit bf0765f

File tree

8 files changed

+3891
-0
lines changed

8 files changed

+3891
-0
lines changed

packages/selection/.babelrc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"presets": [
3+
"@babel/preset-env",
4+
"babel-preset-solid",
5+
"@babel/preset-typescript"
6+
]
7+
}

packages/selection/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# @solid-primitives/permission
2+
3+
[![lerna](https://img.shields.io/badge/maintained%20with-lerna-cc00ff.svg)](https://lerna.js.org/)
4+
5+
Creates a primitive to handle selection in editable elements.
6+
7+
## How to use it
8+
9+
```ts
10+
type Selection = {
11+
start: number;
12+
end: number;
13+
text?: string;
14+
}
15+
const [selection: Accessor<Selection>, setSelection: Setter<Selection>] =
16+
createInputSelection(ref?: HTMLInputElement | HTMLTextAreaElement);
17+
```
18+
19+
## Demo
20+
21+
TODO
22+
23+
## Changelog
24+
25+
<details>
26+
<summary><b>Expand Changelog</b></summary>
27+
28+
0.0.100
29+
30+
Initial release.
31+
32+
</details>

packages/selection/package.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"name": "@solid-primitives/selection",
3+
"version": "0.0.100",
4+
"description": "Primitive that wraps permission queries",
5+
"author": "Alex Lohr <[email protected]>",
6+
"license": "MIT",
7+
"homepage": "https://github.com/davedbase/solid-primitives/tree/main/packages/selection",
8+
"private": false,
9+
"main": "dist/index.js",
10+
"module": "dist/index.js",
11+
"types": "dist/index.d.ts",
12+
"files": [
13+
"dist"
14+
],
15+
"sideEffects": "false",
16+
"scripts": {
17+
"prebuild": "npm run clean",
18+
"clean": "rimraf dist/",
19+
"build": "tsc",
20+
"test": "jest"
21+
},
22+
"keywords": [
23+
"permission",
24+
"query",
25+
"solid",
26+
"primitives"
27+
],
28+
"devDependencies": {
29+
"@babel/core": "7.14.8",
30+
"@babel/preset-env": "7.14.8",
31+
"@babel/preset-typescript": "7.14.5",
32+
"@types/jest": "27.0.1",
33+
"babel-preset-solid": "1.1.3",
34+
"jest": "27.0.1",
35+
"prettier": "^2.0.5",
36+
"solid-testing-library": "^0.2.0",
37+
"solid-jest": "0.2.0",
38+
"tslib": "^2.3.1",
39+
"typescript": "^4.4.3"
40+
},
41+
"dependencies": {
42+
"solid-js": "^1.1.4"
43+
},
44+
"jest": {
45+
"preset": "solid-jest/preset/browser",
46+
"setupFiles": [
47+
"./test/setup.ts"
48+
]
49+
}
50+
}

packages/selection/src/index.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { isArrayExpression } from "@babel/types";
2+
import { Accessor, createEffect, createSignal, onCleanup, onMount, Setter } from "solid-js";
3+
4+
export type InputSelection = {
5+
start: number;
6+
end: number;
7+
};
8+
9+
export const createInputSelection = (
10+
ref?: HTMLInputElement | HTMLTextAreaElement
11+
): [selection: Accessor<InputSelection>, setSelection: Setter<InputSelection>] => {
12+
const [selection, setSelection] = createSignal({
13+
start: ref?.selectionStart ?? -1,
14+
end: ref?.selectionEnd ?? -1
15+
});
16+
17+
onMount(() => {
18+
const listener = () => {
19+
setSelection((last) => {
20+
const start = ref?.selectionStart ?? -1,
21+
end = ref?.selectionEnd ?? -1;
22+
return (last.start === start && last.end === end)
23+
? last
24+
: { start, end };
25+
});
26+
}
27+
ref?.addEventListener('selectionchange', listener);
28+
onCleanup(() => ref?.removeEventListener('selectionChange', listener));
29+
});
30+
31+
return [selection, (sel) => {
32+
const next = setSelection(sel)
33+
ref?.setSelectionRange(next.start, next.end);
34+
return next;
35+
}];
36+
};
37+
38+
export type InputMaskFunc = ((value: string, sel: InputSelection) =>
39+
[maskedValue: string, sel: InputSelection]);
40+
export type InputMask = (string | RegExp)[] | InputMaskFunc;
41+
42+
const alignSelectionToRemovedPart = (selection: InputSelection, start: number, length: number): InputSelection => {
43+
if (selection.start > start) {
44+
if (selection.start > start + length) {
45+
selection.start -= length
46+
} else {
47+
selection.start = start;
48+
}
49+
}
50+
if (selection.end > start) {
51+
if (selection.end > start + length) {
52+
selection.end -= length
53+
} else {
54+
selection.end = start;
55+
}
56+
}
57+
return selection;
58+
}
59+
60+
const inputMaskArrayToFunc = (def: InputMask): InputMaskFunc => {
61+
if (Array.isArray(def)) {
62+
return (value, selection) => {
63+
if (value) {
64+
let index = 0;
65+
def.forEach((part, id) => {
66+
// fixed part; add if not already in string and jump to next part
67+
if (typeof part === 'string') {
68+
if (value.substr(index, part.length) !== part) {
69+
value = `${value.slice(0, index)}${part}${value.slice(index)}`;
70+
}
71+
index += part.length;
72+
// regex part; delete before match and jump after match
73+
} else {
74+
const match = value.slice(index).match(part);
75+
const matchIndex = match?.index ?? -1;
76+
if (match && (matchIndex >= 0)) {
77+
// remove characters padding the match
78+
if (matchIndex > 0) {
79+
value = `${value.slice(0, index)}${value.slice(matchIndex)}}`;
80+
alignSelectionToRemovedPart(selection, index, matchIndex);
81+
}
82+
index += match[0].length;
83+
// cut additional characters after the last part
84+
const isLastPart = id === def.length - 1;
85+
if (isLastPart && value.length > index) {
86+
value = value.slice(0, index)
87+
}
88+
}
89+
}
90+
});
91+
}
92+
return [value, selection];
93+
}
94+
}
95+
return def
96+
}
97+
98+
export const createInputMask = (ref?: HTMLInputElement, mask?: InputMask) => {
99+
if (!mask) {
100+
return;
101+
}
102+
const [selection, setSelection] = createInputSelection(ref);
103+
const inputMask = inputMaskArrayToFunc(mask);
104+
105+
onMount(() => {
106+
const handler = () => {
107+
if (ref) {
108+
const [newValue, newSelection] = inputMask(ref.value, selection());
109+
ref.value = newValue;
110+
setSelection(newSelection);
111+
}
112+
};
113+
ref?.addEventListener('input', handler);
114+
onCleanup(() => ref?.removeEventListener('input', handler));
115+
});
116+
}
117+
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createEffect, createRoot } from "solid-js";
2+
import { createInputSelection } from "../src";
3+
4+
describe("createInputSelection", () => {
5+
const selections = (window as any)._selections;
6+
const getInput = document.createElement('input');
7+
getInput.type = 'text';
8+
getInput.value = 'longer value to allow selection';
9+
selections.set(getInput, { start: 5, end: 5 });
10+
document.body.appendChild(getInput);
11+
12+
test("reads selection range", () =>
13+
createRoot(dispose => {
14+
const [selection] = createInputSelection(getInput);
15+
expect(selection()).toEqual({ start: 5, end: 5 });
16+
dispose();
17+
}));
18+
19+
const setInput = document.createElement('input');
20+
setInput.type = 'text';
21+
setInput.value = 'another longer value to allow selection';
22+
document.body.appendChild(setInput);
23+
24+
test("sets selection range", () =>
25+
createRoot(dispose => {
26+
const [_, setSelection] = createInputSelection(setInput);
27+
setSelection({ start: 7, end: 7 });
28+
expect(selections.get(setInput)).toEqual({ start: 7, end: 7 });
29+
dispose();
30+
}));
31+
32+
const evInput = document.createElement('input');
33+
evInput.type = 'text';
34+
evInput.value = 'and another even longer value to allow selection';
35+
selections.set(evInput, { start: 0, end: 0 });
36+
document.body.appendChild(evInput);
37+
38+
test("updates the selection on selectionupdate event", () => new Promise<void>(resolve => createRoot(dispose => {
39+
const [selection] = createInputSelection(evInput);
40+
const expectedSelections = [{ start: 0, end: 0 }, { start: 1, end: 1 }];
41+
createEffect(() => {
42+
expect(selection()).toEqual(expectedSelections.shift());
43+
if (expectedSelections.length === 1) {
44+
selections.set(evInput, { start: 1, end: 1 });
45+
evInput.dispatchEvent(new Event('selectionchange'));
46+
} else if (expectedSelections.length === 0) {
47+
dispose();
48+
resolve();
49+
}
50+
})
51+
})));
52+
});

packages/selection/test/setup.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const selections = new Map<HTMLInputElement, { start: number; end: number; }>();
2+
3+
(window as any)._selections = selections;
4+
5+
Object.defineProperties(HTMLInputElement.prototype, {
6+
selectionStart: { get: function() { return selections.get(this)?.start; } },
7+
selectionEnd: { get: function() { return selections.get(this)?.end; } },
8+
setSelectionRange: { get: function() {
9+
const field = this; return (start: number, end: number) => selections.set(field, { start, end });
10+
} }
11+
});

packages/selection/tsconfig.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "./dist",
5+
"emitDeclarationOnly": false
6+
},
7+
"include": ["./src"]
8+
}

0 commit comments

Comments
 (0)