@weasel-js/select 0.0.0-stage → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +42 -2
- package/dist/index.d.ts +47 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 orochi235
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @weasel-js/select
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Selection as a value: which ids a press selects, under toggle, range and anchor rules a surface
|
|
4
|
+
configures. No React, no DOM.
|
|
5
|
+
|
|
6
|
+
Part of [weasel](https://github.com/orochi235/weasel), a domain-agnostic 2D
|
|
7
|
+
scene-graph canvas kit for React. See the
|
|
8
|
+
[API reference](https://orochi235.github.io/weasel/api/).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @weasel-js/select
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { intentOf, select } from '@weasel-js/select';
|
|
20
|
+
|
|
21
|
+
// A list's convention: Cmd/Ctrl toggles a row, shift ranges from the anchor.
|
|
22
|
+
const policy = { mode: 'multi', toggle: ['meta', 'ctrl'], range: 'shift' } as const;
|
|
23
|
+
|
|
24
|
+
let state = { ids: ['b'], anchor: 'b' };
|
|
25
|
+
state = select(state, 'd', intentOf({ shift: true, meta: false, ctrl: false }, policy), {
|
|
26
|
+
order: ['a', 'b', 'c', 'd'], // what a range walks
|
|
27
|
+
eligible: (id) => id !== 'c', // locked rows never join a multi-selection
|
|
28
|
+
});
|
|
29
|
+
// → { ids: ['b', 'd'], anchor: 'b' }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| intent | ids | anchor |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `replace` | `[id]` | `id` |
|
|
35
|
+
| `toggle` | eligible ids, with `id` added or removed; `[id]` if `id` is ineligible | `id` |
|
|
36
|
+
| `range` | eligible ids from anchor to `id` in `order`; unchanged if none is eligible; `[id]` if there is no anchor or either end is outside `order` | unchanged |
|
|
37
|
+
|
|
38
|
+
`createSelectionStore` keeps ids outside a component, behind the `SelectionStore` contract that
|
|
39
|
+
`@weasel-js/core`'s `Scene` also satisfies.
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** `single` always replaces; `multi` lets a policy's keys toggle and range. */
|
|
2
|
+
type SelectionMode = 'single' | 'multi';
|
|
3
|
+
/** A modifier key a policy can assign to toggling or ranging. */
|
|
4
|
+
type SelectionExtendKey = 'shift' | 'meta' | 'ctrl';
|
|
5
|
+
/** The modifiers held during a press. */
|
|
6
|
+
type SelectModifiers = Readonly<Record<SelectionExtendKey, boolean>>;
|
|
7
|
+
/** What a press does to the selection. */
|
|
8
|
+
type SelectIntent = 'replace' | 'toggle' | 'range';
|
|
9
|
+
/** Which keys toggle and which range, so a surface states its convention: a list usually
|
|
10
|
+
* toggles on Cmd/Ctrl and ranges on shift, a canvas toggles on shift. */
|
|
11
|
+
interface SelectPolicy {
|
|
12
|
+
readonly mode: SelectionMode;
|
|
13
|
+
readonly toggle?: SelectionExtendKey | readonly SelectionExtendKey[];
|
|
14
|
+
readonly range?: SelectionExtendKey;
|
|
15
|
+
}
|
|
16
|
+
/** What is selected, and the end a range extends from. */
|
|
17
|
+
interface Selection<K> {
|
|
18
|
+
readonly ids: readonly K[];
|
|
19
|
+
readonly anchor: K | null;
|
|
20
|
+
}
|
|
21
|
+
interface SelectContext<K> {
|
|
22
|
+
/** The sequence a range walks. A range without one replaces. */
|
|
23
|
+
readonly order?: readonly K[];
|
|
24
|
+
/** Whether an id may join a multi-selection. Ineligible ids are skipped by a range, dropped
|
|
25
|
+
* when the selection grows, and selected alone when pressed. Default: every id. */
|
|
26
|
+
readonly eligible?: (id: K) => boolean;
|
|
27
|
+
}
|
|
28
|
+
/** The intent a press carries under `policy`. A range key beats a toggle key. */
|
|
29
|
+
declare function intentOf(modifiers: SelectModifiers, policy: SelectPolicy): SelectIntent;
|
|
30
|
+
/** The eligible ids from `from` to `to` in `order`, ends included, in order. Empty when either
|
|
31
|
+
* end is not in `order`. */
|
|
32
|
+
declare function rangeOf<K>(order: readonly K[], from: K, to: K, eligible?: (id: K) => boolean): K[];
|
|
33
|
+
/** The selection after pressing `id` with `intent`. Returns `state` itself when nothing changes
|
|
34
|
+
* hands, so a caller can compare by reference. */
|
|
35
|
+
declare function select<K>(state: Selection<K>, id: K, intent: SelectIntent, context?: SelectContext<K>): Selection<K>;
|
|
36
|
+
|
|
37
|
+
/** Somewhere a selection can live outside the component that edits it. core's `Scene` satisfies
|
|
38
|
+
* it, and so does any store with the same three methods. */
|
|
39
|
+
interface SelectionStore<K> {
|
|
40
|
+
getSelection(): readonly K[];
|
|
41
|
+
setSelection(ids: readonly K[]): void;
|
|
42
|
+
subscribe(listener: () => void): () => void;
|
|
43
|
+
}
|
|
44
|
+
/** A {@link SelectionStore} holding the ids itself. A listener reads the new value. */
|
|
45
|
+
declare function createSelectionStore<K>(initial?: readonly K[]): SelectionStore<K>;
|
|
46
|
+
|
|
47
|
+
export { type SelectContext, type SelectIntent, type SelectModifiers, type SelectPolicy, type Selection, type SelectionExtendKey, type SelectionMode, type SelectionStore, createSelectionStore, intentOf, rangeOf, select };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/select.ts
|
|
2
|
+
function intentOf(modifiers, policy) {
|
|
3
|
+
if (policy.mode === "single") return "replace";
|
|
4
|
+
if (policy.range && modifiers[policy.range]) return "range";
|
|
5
|
+
const toggle = typeof policy.toggle === "string" ? [policy.toggle] : policy.toggle ?? [];
|
|
6
|
+
return toggle.some((key) => modifiers[key]) ? "toggle" : "replace";
|
|
7
|
+
}
|
|
8
|
+
function rangeOf(order, from, to, eligible = always) {
|
|
9
|
+
const a = order.indexOf(from);
|
|
10
|
+
const b = order.indexOf(to);
|
|
11
|
+
if (a < 0 || b < 0) return [];
|
|
12
|
+
return order.slice(Math.min(a, b), Math.max(a, b) + 1).filter(eligible);
|
|
13
|
+
}
|
|
14
|
+
function select(state, id, intent, context = {}) {
|
|
15
|
+
const eligible = context.eligible ?? always;
|
|
16
|
+
if (intent === "toggle" && eligible(id)) {
|
|
17
|
+
const kept = state.ids.filter(eligible);
|
|
18
|
+
return { ids: kept.includes(id) ? kept.filter((x) => x !== id) : [...kept, id], anchor: id };
|
|
19
|
+
}
|
|
20
|
+
if (intent === "range" && state.anchor !== null && context.order) {
|
|
21
|
+
const { order } = context;
|
|
22
|
+
if (order.includes(state.anchor) && order.includes(id)) {
|
|
23
|
+
const ids = rangeOf(order, state.anchor, id, eligible);
|
|
24
|
+
return ids.length > 0 ? { ids, anchor: state.anchor } : state;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { ids: [id], anchor: id };
|
|
28
|
+
}
|
|
29
|
+
function always() {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/store.ts
|
|
34
|
+
function createSelectionStore(initial = []) {
|
|
35
|
+
let ids = initial;
|
|
36
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
37
|
+
return {
|
|
38
|
+
getSelection: () => ids,
|
|
39
|
+
setSelection(next) {
|
|
40
|
+
ids = next;
|
|
41
|
+
for (const listener of listeners) listener();
|
|
42
|
+
},
|
|
43
|
+
subscribe(listener) {
|
|
44
|
+
listeners.add(listener);
|
|
45
|
+
return () => {
|
|
46
|
+
listeners.delete(listener);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { createSelectionStore, intentOf, rangeOf, select };
|
|
53
|
+
//# sourceMappingURL=index.js.map
|
|
54
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/select.ts","../src/store.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/** `single` always replaces; `multi` lets a policy's keys toggle and range. */\nexport type SelectionMode = 'single' | 'multi';\n\n/** A modifier key a policy can assign to toggling or ranging. */\nexport type SelectionExtendKey = 'shift' | 'meta' | 'ctrl';\n\n/** The modifiers held during a press. */\nexport type SelectModifiers = Readonly<Record<SelectionExtendKey, boolean>>;\n\n/** What a press does to the selection. */\nexport type SelectIntent = 'replace' | 'toggle' | 'range';\n\n/** Which keys toggle and which range, so a surface states its convention: a list usually\n * toggles on Cmd/Ctrl and ranges on shift, a canvas toggles on shift. */\nexport interface SelectPolicy {\n readonly mode: SelectionMode;\n readonly toggle?: SelectionExtendKey | readonly SelectionExtendKey[];\n readonly range?: SelectionExtendKey;\n}\n\n/** What is selected, and the end a range extends from. */\nexport interface Selection<K> {\n readonly ids: readonly K[];\n readonly anchor: K | null;\n}\n\nexport interface SelectContext<K> {\n /** The sequence a range walks. A range without one replaces. */\n readonly order?: readonly K[];\n /** Whether an id may join a multi-selection. Ineligible ids are skipped by a range, dropped\n * when the selection grows, and selected alone when pressed. Default: every id. */\n readonly eligible?: (id: K) => boolean;\n}\n\n/** The intent a press carries under `policy`. A range key beats a toggle key. */\nexport function intentOf(modifiers: SelectModifiers, policy: SelectPolicy): SelectIntent {\n if (policy.mode === 'single') return 'replace';\n if (policy.range && modifiers[policy.range]) return 'range';\n const toggle = typeof policy.toggle === 'string' ? [policy.toggle] : (policy.toggle ?? []);\n return toggle.some((key) => modifiers[key]) ? 'toggle' : 'replace';\n}\n\n/** The eligible ids from `from` to `to` in `order`, ends included, in order. Empty when either\n * end is not in `order`. */\nexport function rangeOf<K>(order: readonly K[], from: K, to: K, eligible: (id: K) => boolean = always): K[] {\n const a = order.indexOf(from);\n const b = order.indexOf(to);\n if (a < 0 || b < 0) return [];\n return order.slice(Math.min(a, b), Math.max(a, b) + 1).filter(eligible);\n}\n\n/** The selection after pressing `id` with `intent`. Returns `state` itself when nothing changes\n * hands, so a caller can compare by reference. */\nexport function select<K>(state: Selection<K>, id: K, intent: SelectIntent, context: SelectContext<K> = {}): Selection<K> {\n const eligible = context.eligible ?? always;\n if (intent === 'toggle' && eligible(id)) {\n const kept = state.ids.filter(eligible);\n return { ids: kept.includes(id) ? kept.filter((x) => x !== id) : [...kept, id], anchor: id };\n }\n if (intent === 'range' && state.anchor !== null && context.order) {\n const { order } = context;\n if (order.includes(state.anchor) && order.includes(id)) {\n const ids = rangeOf(order, state.anchor, id, eligible);\n return ids.length > 0 ? { ids, anchor: state.anchor } : state;\n }\n }\n return { ids: [id], anchor: id };\n}\n\nfunction always(): boolean {\n return true;\n}\n","/** Somewhere a selection can live outside the component that edits it. core's `Scene` satisfies\n * it, and so does any store with the same three methods. */\nexport interface SelectionStore<K> {\n getSelection(): readonly K[];\n setSelection(ids: readonly K[]): void;\n subscribe(listener: () => void): () => void;\n}\n\n/** A {@link SelectionStore} holding the ids itself. A listener reads the new value. */\nexport function createSelectionStore<K>(initial: readonly K[] = []): SelectionStore<K> {\n let ids = initial;\n const listeners = new Set<() => void>();\n return {\n getSelection: () => ids,\n setSelection(next) {\n ids = next;\n for (const listener of listeners) listener();\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => { listeners.delete(listener); };\n },\n };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/select",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.8.1",
|
|
4
|
+
"description": "Selection as a value: which ids a press selects, with toggle, range and anchor rules a surface configures. No React, no DOM.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"author": "orochi235",
|
|
19
|
+
"homepage": "https://orochi235.github.io/weasel/",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/orochi235/weasel.git",
|
|
23
|
+
"directory": "packages/select"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/orochi235/weasel/issues"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"provenance": true
|
|
42
|
+
}
|
|
43
|
+
}
|