@enigmax/primitives 0.22.0 → 0.23.0
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/dist/chunk-3BQVOOAM.js +388 -0
- package/dist/{chunk-QQFNAKMY.js → chunk-45UHLZYT.js} +1 -1
- package/dist/chunk-4VUHQFAT.js +130 -0
- package/dist/{chunk-D5A2ZMAG.js → chunk-4ZPMP47J.js} +6 -1
- package/dist/chunk-DSBYVA7V.js +713 -0
- package/dist/{chunk-FWVWX67R.js → chunk-FGBIZDV2.js} +3 -3
- package/dist/{chunk-R4ZAEE7V.js → chunk-KEVZ5XQV.js} +1 -1
- package/dist/chunk-N6PDHMAX.js +213 -0
- package/dist/chunk-NONGREXC.js +248 -0
- package/dist/chunk-VKL3DEIQ.js +804 -0
- package/dist/chunk-WSQC3PCC.js +466 -0
- package/dist/context-menu-D3FtTn7v.d.ts +174 -0
- package/dist/{index-BHpOZncw.d.ts → index-DQNnohoo.d.ts} +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +8 -4
- package/dist/keys-D2zJs1uB.d.ts +100 -0
- package/dist/next/index.d.ts +9 -2
- package/dist/next/index.js +20 -13
- package/dist/react/context-menu.d.ts +202 -0
- package/dist/react/context-menu.js +6 -0
- package/dist/react/index.d.ts +10 -3
- package/dist/react/index.js +19 -12
- package/dist/react/input.d.ts +2 -2
- package/dist/react/input.js +1 -1
- package/dist/react/palette.d.ts +1 -1
- package/dist/react/palette.js +3 -3
- package/dist/react/search.d.ts +1 -1
- package/dist/react/search.js +2 -2
- package/dist/react/select.d.ts +217 -0
- package/dist/react/select.js +7 -0
- package/dist/react/selection.d.ts +104 -0
- package/dist/react/selection.js +4 -0
- package/dist/react-router/index.d.ts +9 -2
- package/dist/react-router/index.js +20 -13
- package/dist/search/index.d.ts +2 -2
- package/dist/search/index.js +1 -1
- package/dist/{search-BD9-5O5U.d.ts → search-DYgqRp37.d.ts} +13 -1
- package/dist/{search-UQEXAPQB.js → search-PBZORZ7P.js} +1 -1
- package/dist/select-ClSy-J1f.d.ts +100 -0
- package/dist/selection-B_pmzHpy.d.ts +150 -0
- package/package.json +21 -2
- package/recipes/context-menu/styles.css +177 -0
- package/recipes/input/styles.css +9 -0
- package/recipes/palette/styles.css +3 -0
- package/recipes/search/tailwind.tsx +3 -2
- package/recipes/select/styles.css +230 -0
- package/recipes/toast/styles.css +1 -1
- package/registry.json +358 -1
- package/src/core/context-menu.ts +694 -0
- package/src/core/keys.ts +264 -0
- package/src/core/search.ts +19 -0
- package/src/core/select.ts +404 -0
- package/src/core/selection.ts +648 -0
- package/src/index.ts +60 -1
- package/src/react/context-menu/context.ts +57 -0
- package/src/react/context-menu/index.tsx +94 -0
- package/src/react/context-menu/root.tsx +846 -0
- package/src/react/context-menu/styles.ts +186 -0
- package/src/react/index.ts +68 -1
- package/src/react/palette/root.tsx +2 -2
- package/src/react/select/context.ts +56 -0
- package/src/react/select/index.tsx +96 -0
- package/src/react/select/root.tsx +839 -0
- package/src/react/select/styles.ts +238 -0
- package/src/react/selection/index.tsx +115 -0
- package/src/react/selection/use-selection.ts +260 -0
- /package/dist/{chunk-U3V4EHOB.js → chunk-3HDEZ2E7.js} +0 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of a selectable list that are not rendering: what is selected, where the cursor
|
|
3
|
+
* is, which row the next Shift+click measures from, and which command a key press stands for.
|
|
4
|
+
*
|
|
5
|
+
* This is the file-manager selection model, on purpose and in full: a plain click replaces
|
|
6
|
+
* the selection, Ctrl toggles one row without losing the rest, Shift takes everything between
|
|
7
|
+
* the anchor and here, Ctrl+Shift adds that range to what was already there, Ctrl+A takes the
|
|
8
|
+
* lot and Escape drops it. Every one of those is a rule people already know from Explorer,
|
|
9
|
+
* Finder and every list built on them, and every one of them is a bug when a list re-invents
|
|
10
|
+
* it - a Shift+click that measures from the last CLICK instead of the anchor, a Ctrl+click
|
|
11
|
+
* that clears the selection, an Escape that does nothing.
|
|
12
|
+
*
|
|
13
|
+
* The commands are DATA. Each one has a default binding, any of them can be rebound, removed
|
|
14
|
+
* one at a time or turned off altogether, and a binding that is not a built-in command is
|
|
15
|
+
* still matched and reported - so a list can add "star" on Mod+D without this file knowing
|
|
16
|
+
* what starring is.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { matchesShortcut, shortcutList, type ShortcutEvent, type ShortcutSpec } from "@/core/keys";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What a key press means.
|
|
23
|
+
*
|
|
24
|
+
* The first group the list PERFORMS - it owns the selection, so it moves it. The second it
|
|
25
|
+
* only REPORTS, because deleting, renaming and opening are the caller's. Both are reported,
|
|
26
|
+
* and both can be stopped by the handler, so a list that wants to confirm a delete or animate
|
|
27
|
+
* its own select-all can.
|
|
28
|
+
*/
|
|
29
|
+
export type SelectionCommand =
|
|
30
|
+
| "selectAll"
|
|
31
|
+
| "clear"
|
|
32
|
+
| "invert"
|
|
33
|
+
| "moveUp" | "moveDown" | "moveLeft" | "moveRight"
|
|
34
|
+
| "moveHome" | "moveEnd" | "movePageUp" | "movePageDown"
|
|
35
|
+
| "extendUp" | "extendDown" | "extendLeft" | "extendRight"
|
|
36
|
+
| "extendHome" | "extendEnd" | "extendPageUp" | "extendPageDown"
|
|
37
|
+
| "cursorUp" | "cursorDown"
|
|
38
|
+
| "toggleCursor"
|
|
39
|
+
| "open"
|
|
40
|
+
| "rename"
|
|
41
|
+
| "delete"
|
|
42
|
+
| "deletePermanent"
|
|
43
|
+
| "copy"
|
|
44
|
+
| "cut"
|
|
45
|
+
| "paste";
|
|
46
|
+
|
|
47
|
+
/** A command name: one of the built-ins, or anything else the caller binds. */
|
|
48
|
+
export type SelectionCommandName = SelectionCommand | (string & {});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What each command is bound to out of the box.
|
|
52
|
+
*
|
|
53
|
+
* `Mod` is Command on an Apple keyboard and Control everywhere else, which is what these
|
|
54
|
+
* shortcuts mean on both. `invert` ships unbound deliberately: Explorer has the action and no
|
|
55
|
+
* key for it, and inventing one would take a combination the page may already use.
|
|
56
|
+
*/
|
|
57
|
+
export const DEFAULT_SELECTION_SHORTCUTS: Readonly<Record<SelectionCommand, ShortcutSpec>> = {
|
|
58
|
+
selectAll: "Mod+A",
|
|
59
|
+
clear: "Escape",
|
|
60
|
+
invert: false,
|
|
61
|
+
|
|
62
|
+
moveUp: "ArrowUp",
|
|
63
|
+
moveDown: "ArrowDown",
|
|
64
|
+
moveLeft: "ArrowLeft",
|
|
65
|
+
moveRight: "ArrowRight",
|
|
66
|
+
moveHome: "Home",
|
|
67
|
+
moveEnd: "End",
|
|
68
|
+
movePageUp: "PageUp",
|
|
69
|
+
movePageDown: "PageDown",
|
|
70
|
+
|
|
71
|
+
extendUp: "Shift+ArrowUp",
|
|
72
|
+
extendDown: "Shift+ArrowDown",
|
|
73
|
+
extendLeft: "Shift+ArrowLeft",
|
|
74
|
+
extendRight: "Shift+ArrowRight",
|
|
75
|
+
extendHome: "Shift+Home",
|
|
76
|
+
extendEnd: "Shift+End",
|
|
77
|
+
extendPageUp: "Shift+PageUp",
|
|
78
|
+
extendPageDown: "Shift+PageDown",
|
|
79
|
+
|
|
80
|
+
// The cursor moving WITHOUT the selection following, which is how a file manager lets you
|
|
81
|
+
// reach a distant row and add it with Ctrl+Space rather than starting again.
|
|
82
|
+
cursorUp: "Mod+ArrowUp",
|
|
83
|
+
cursorDown: "Mod+ArrowDown",
|
|
84
|
+
toggleCursor: "Mod+Space",
|
|
85
|
+
|
|
86
|
+
open: "Enter",
|
|
87
|
+
rename: "F2",
|
|
88
|
+
delete: "Delete",
|
|
89
|
+
deletePermanent: "Shift+Delete",
|
|
90
|
+
copy: "Mod+C",
|
|
91
|
+
cut: "Mod+X",
|
|
92
|
+
paste: "Mod+V"
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The bindings, as the caller writes them.
|
|
97
|
+
*
|
|
98
|
+
* A missing entry keeps the default, `false` removes that one command, and a string or a list
|
|
99
|
+
* replaces it. `shortcuts: false` on the options removes every one of them at once - for a
|
|
100
|
+
* list inside an editor, or one whose keyboard belongs to something else.
|
|
101
|
+
*/
|
|
102
|
+
export type SelectionShortcuts = Partial<Record<SelectionCommandName, ShortcutSpec>>;
|
|
103
|
+
|
|
104
|
+
/** The modifiers a click carried. A real MouseEvent or PointerEvent satisfies it. */
|
|
105
|
+
export interface SelectionClickModifiers {
|
|
106
|
+
ctrlKey?: boolean;
|
|
107
|
+
metaKey?: boolean;
|
|
108
|
+
shiftKey?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface SelectionCommandEvent<Item> {
|
|
112
|
+
command: SelectionCommandName;
|
|
113
|
+
/**
|
|
114
|
+
* What the command applies to: everything selected, or the row under the cursor when
|
|
115
|
+
* nothing is. That fallback is the file-manager rule - pressing Delete with one row
|
|
116
|
+
* focused and none selected deletes that row.
|
|
117
|
+
*/
|
|
118
|
+
items: Item[];
|
|
119
|
+
ids: string[];
|
|
120
|
+
/** The row the cursor is on, selected or not. */
|
|
121
|
+
cursor: Item | null;
|
|
122
|
+
cursorIndex: number;
|
|
123
|
+
/** The press that matched, for a handler that needs the modifiers back. */
|
|
124
|
+
event: ShortcutEvent;
|
|
125
|
+
/** Stop what the list would have done - the moves, select-all, clear, invert. */
|
|
126
|
+
preventDefault(): void;
|
|
127
|
+
defaultPrevented: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface SelectionOptions<Item> {
|
|
131
|
+
items?: readonly Item[];
|
|
132
|
+
/** How a row is identified across reorders and reloads. Default: its index, as a string. */
|
|
133
|
+
getId?: (item: Item, index: number) => string;
|
|
134
|
+
/** A row that cannot be selected or landed on. Still rendered, still counted. */
|
|
135
|
+
disabled?: (item: Item, index: number) => boolean;
|
|
136
|
+
/** One row at a time: Ctrl and Shift stop meaning anything. Default: many. */
|
|
137
|
+
multiple?: boolean;
|
|
138
|
+
/** Columns in a grid, so Left and Right move by one and Up and Down by a row. Default 1. */
|
|
139
|
+
columns?: number;
|
|
140
|
+
/** How far PageUp and PageDown jump. Default 10. */
|
|
141
|
+
page?: number;
|
|
142
|
+
/** Rebind, remove or extend the commands. `false` removes every binding. */
|
|
143
|
+
shortcuts?: SelectionShortcuts | false;
|
|
144
|
+
/** A command matched a press. Fired for built-ins too, before the list acts on them. */
|
|
145
|
+
onCommand?: (event: SelectionCommandEvent<Item>) => void;
|
|
146
|
+
/** Every state change. */
|
|
147
|
+
onChange?: (state: SelectionState<Item>) => void;
|
|
148
|
+
/** Only the selection, when it actually changed. */
|
|
149
|
+
onSelectionChange?: (ids: string[], items: Item[]) => void;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface SelectionState<Item> {
|
|
153
|
+
/** Selected ids, in the order the rows appear - not the order they were clicked. */
|
|
154
|
+
selected: string[];
|
|
155
|
+
/** The same ids, for a renderer that asks per row. */
|
|
156
|
+
selectedSet: ReadonlySet<string>;
|
|
157
|
+
/** The selected rows themselves. */
|
|
158
|
+
items: Item[];
|
|
159
|
+
/** Where the keyboard is. -1 when the list has not been touched. */
|
|
160
|
+
cursor: number;
|
|
161
|
+
/** Where the next Shift+click or Shift+arrow measures from. */
|
|
162
|
+
anchor: number;
|
|
163
|
+
count: number;
|
|
164
|
+
/** Every selectable row is selected. What a header checkbox reads. */
|
|
165
|
+
allSelected: boolean;
|
|
166
|
+
/** Some but not all - the third state of that same checkbox. */
|
|
167
|
+
partiallySelected: boolean;
|
|
168
|
+
/** A rubber band is being dragged. */
|
|
169
|
+
marquee: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface SelectionInstance<Item> {
|
|
173
|
+
readonly state: SelectionState<Item>;
|
|
174
|
+
/** A click on a row, with its modifiers. The whole Explorer rule, in one call. */
|
|
175
|
+
click(index: number, modifiers?: SelectionClickModifiers): void;
|
|
176
|
+
/** A key press. Returns whether a command matched, so the caller knows to prevent it. */
|
|
177
|
+
keyDown(event: ShortcutEvent): boolean;
|
|
178
|
+
select(ids: readonly string[]): void;
|
|
179
|
+
selectIndex(index: number, additive?: boolean): void;
|
|
180
|
+
toggle(index: number): void;
|
|
181
|
+
/** Everything from the anchor to here. `additive` keeps what was already selected. */
|
|
182
|
+
selectRange(index: number, additive?: boolean): void;
|
|
183
|
+
selectAll(): void;
|
|
184
|
+
clear(): void;
|
|
185
|
+
invert(): void;
|
|
186
|
+
/** Move the cursor. `extend` takes everything from the anchor, `carry` leaves the selection alone. */
|
|
187
|
+
moveCursor(delta: number, extend?: boolean, carry?: boolean): void;
|
|
188
|
+
setCursor(index: number): void;
|
|
189
|
+
/**
|
|
190
|
+
* What an action on this row applies to: the selection when the row is part of it, and the
|
|
191
|
+
* row alone when it is not. The rule every file manager's context menu follows - and the
|
|
192
|
+
* one that makes right-clicking outside a selection act on what was clicked.
|
|
193
|
+
*/
|
|
194
|
+
targets(index: number): Item[];
|
|
195
|
+
/** Start a rubber band. `additive` keeps the current selection as its base. */
|
|
196
|
+
beginMarquee(additive?: boolean): void;
|
|
197
|
+
/** The rows the band currently covers. */
|
|
198
|
+
marqueeTo(indices: readonly number[]): void;
|
|
199
|
+
endMarquee(): void;
|
|
200
|
+
/** The resolved bindings, so a menu can print the same shortcut the list listens for. */
|
|
201
|
+
bindings(): Record<string, ShortcutSpec>;
|
|
202
|
+
/** What one command is bound to, or false when it has no binding. */
|
|
203
|
+
binding(command: SelectionCommandName): ShortcutSpec;
|
|
204
|
+
update(options: Partial<SelectionOptions<Item>>): void;
|
|
205
|
+
subscribe(listener: (state: SelectionState<Item>) => void): () => void;
|
|
206
|
+
destroy(): void;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Which commands the list performs itself. Everything else is only reported. */
|
|
210
|
+
const OWNED = new Set<string>([
|
|
211
|
+
"selectAll", "clear", "invert",
|
|
212
|
+
"moveUp", "moveDown", "moveLeft", "moveRight", "moveHome", "moveEnd", "movePageUp", "movePageDown",
|
|
213
|
+
"extendUp", "extendDown", "extendLeft", "extendRight", "extendHome", "extendEnd", "extendPageUp", "extendPageDown",
|
|
214
|
+
"cursorUp", "cursorDown", "toggleCursor"
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
export function createSelection<Item>(options: SelectionOptions<Item> = {}): SelectionInstance<Item> {
|
|
218
|
+
let opts: SelectionOptions<Item> = { ...options };
|
|
219
|
+
let items: readonly Item[] = opts.items ?? [];
|
|
220
|
+
let selected = new Set<string>();
|
|
221
|
+
let cursor = -1;
|
|
222
|
+
let anchor = -1;
|
|
223
|
+
/** What a Shift+Ctrl range is added to, captured when that run starts. */
|
|
224
|
+
let rangeBase: Set<string> | null = null;
|
|
225
|
+
/** What a rubber band is added to, captured when the drag starts. */
|
|
226
|
+
let marqueeBase: Set<string> | null = null;
|
|
227
|
+
let destroyed = false;
|
|
228
|
+
const listeners = new Set<(state: SelectionState<Item>) => void>();
|
|
229
|
+
|
|
230
|
+
function idOf(index: number): string | null {
|
|
231
|
+
const item = items[index];
|
|
232
|
+
if (item === undefined) return null;
|
|
233
|
+
return opts.getId ? opts.getId(item, index) : String(index);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function selectable(index: number): boolean {
|
|
237
|
+
const item = items[index];
|
|
238
|
+
if (item === undefined) return false;
|
|
239
|
+
return !opts.disabled?.(item, index);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function multiple(): boolean {
|
|
243
|
+
return opts.multiple ?? true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function columns(): number {
|
|
247
|
+
return Math.max(1, opts.columns ?? 1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Ids in LIST order, so the selection reads the way the rows do rather than in click order. */
|
|
251
|
+
function orderedIds(): string[] {
|
|
252
|
+
const ids: string[] = [];
|
|
253
|
+
for (let index = 0; index < items.length; index++) {
|
|
254
|
+
const id = idOf(index);
|
|
255
|
+
if (id !== null && selected.has(id)) ids.push(id);
|
|
256
|
+
}
|
|
257
|
+
return ids;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function selectedItems(): Item[] {
|
|
261
|
+
const chosen: Item[] = [];
|
|
262
|
+
for (let index = 0; index < items.length; index++) {
|
|
263
|
+
const id = idOf(index);
|
|
264
|
+
if (id !== null && selected.has(id)) chosen.push(items[index] as Item);
|
|
265
|
+
}
|
|
266
|
+
return chosen;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function snapshot(): SelectionState<Item> {
|
|
270
|
+
const ids = orderedIds();
|
|
271
|
+
let count = 0;
|
|
272
|
+
for (let index = 0; index < items.length; index++) if (selectable(index)) count++;
|
|
273
|
+
return {
|
|
274
|
+
selected: ids,
|
|
275
|
+
selectedSet: new Set(ids),
|
|
276
|
+
items: selectedItems(),
|
|
277
|
+
cursor,
|
|
278
|
+
anchor,
|
|
279
|
+
count: ids.length,
|
|
280
|
+
allSelected: count > 0 && ids.length === count,
|
|
281
|
+
partiallySelected: ids.length > 0 && ids.length < count,
|
|
282
|
+
marquee: marqueeBase !== null
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Everything a subscriber can observe that does NOT depend on the identity of the rows.
|
|
288
|
+
*
|
|
289
|
+
* `update` emits on a change to this rather than on every call: the items come from a
|
|
290
|
+
* render, so `items={rows.map(...)}` hands over a new array of new objects each time and
|
|
291
|
+
* emitting on that alone would render, rebuild the array and emit again.
|
|
292
|
+
*/
|
|
293
|
+
function summary(): string {
|
|
294
|
+
let count = 0;
|
|
295
|
+
for (let index = 0; index < items.length; index++) if (selectable(index)) count++;
|
|
296
|
+
return `${cursor}:${anchor}:${count}:${orderedIds().join("\u0000")}`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The last reported selection, so a change that changed nothing reports nothing. */
|
|
300
|
+
let reported: string[] = [];
|
|
301
|
+
|
|
302
|
+
function emit(): void {
|
|
303
|
+
const state = snapshot();
|
|
304
|
+
opts.onChange?.(state);
|
|
305
|
+
for (const listener of listeners) listener(state);
|
|
306
|
+
const ids = state.selected;
|
|
307
|
+
if (ids.length !== reported.length || ids.some((id, index) => id !== reported[index])) {
|
|
308
|
+
reported = ids;
|
|
309
|
+
opts.onSelectionChange?.(ids, state.items);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function replace(ids: Iterable<string>): void {
|
|
314
|
+
selected = new Set(ids);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The next selectable row from `from`, walking `step` at a time. Clamped, never wrapped. */
|
|
318
|
+
function nextEnabled(from: number, step: number): number {
|
|
319
|
+
// Clamped and not wrapped, unlike a menu: a list is long, and an arrow key that jumps
|
|
320
|
+
// from the last row to the first loses the reader's place entirely.
|
|
321
|
+
for (let index = from; index >= 0 && index < items.length; index += step) {
|
|
322
|
+
if (selectable(index)) return index;
|
|
323
|
+
}
|
|
324
|
+
return -1;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function rangeIds(from: number, to: number): string[] {
|
|
328
|
+
const [low, high] = from < to ? [from, to] : [to, from];
|
|
329
|
+
const ids: string[] = [];
|
|
330
|
+
for (let index = low; index <= high; index++) {
|
|
331
|
+
if (!selectable(index)) continue;
|
|
332
|
+
const id = idOf(index);
|
|
333
|
+
if (id !== null) ids.push(id);
|
|
334
|
+
}
|
|
335
|
+
return ids;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function applyRange(index: number, additive: boolean): void {
|
|
339
|
+
const from = anchor >= 0 ? anchor : index;
|
|
340
|
+
const ids = rangeIds(from, index);
|
|
341
|
+
if (additive) {
|
|
342
|
+
// The base is captured once per Shift+Ctrl RUN, so dragging the shift-click
|
|
343
|
+
// around grows and shrinks one range instead of leaving a trail of them.
|
|
344
|
+
rangeBase = rangeBase ?? new Set(selected);
|
|
345
|
+
replace([...rangeBase, ...ids]);
|
|
346
|
+
} else {
|
|
347
|
+
rangeBase = null;
|
|
348
|
+
replace(ids);
|
|
349
|
+
}
|
|
350
|
+
cursor = index;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The bindings in force: the defaults, with the caller's entries applied over them. */
|
|
354
|
+
function table(): Record<string, ShortcutSpec> {
|
|
355
|
+
if (opts.shortcuts === false) return {};
|
|
356
|
+
return { ...DEFAULT_SELECTION_SHORTCUTS, ...(opts.shortcuts ?? {}) };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function commandFor(event: ShortcutEvent): SelectionCommandName | null {
|
|
360
|
+
const bindings = table();
|
|
361
|
+
let match: { command: string; length: number; } | null = null;
|
|
362
|
+
for (const [command, spec] of Object.entries(bindings)) {
|
|
363
|
+
if (spec === false || spec == null) continue;
|
|
364
|
+
// The most SPECIFIC binding wins, counting the modifiers of the alternative that
|
|
365
|
+
// actually matched - `delete: ["Delete", "Mod+Backspace"]` is a plain Delete when
|
|
366
|
+
// that is the one the press hit, whatever the other alternative asks for.
|
|
367
|
+
const hit = shortcutList(spec).find((shortcut) => matchesShortcut(event, shortcut));
|
|
368
|
+
if (!hit) continue;
|
|
369
|
+
const length = specificity(hit);
|
|
370
|
+
if (!match || length > match.length) match = { command, length };
|
|
371
|
+
}
|
|
372
|
+
return match?.command ?? null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function specificity(shortcut: { mod?: boolean; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean; }): number {
|
|
376
|
+
return Number(Boolean(shortcut.mod)) + Number(Boolean(shortcut.ctrl)) + Number(Boolean(shortcut.meta))
|
|
377
|
+
+ Number(Boolean(shortcut.shift)) + Number(Boolean(shortcut.alt));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function perform(command: SelectionCommandName): boolean {
|
|
381
|
+
const step = columns();
|
|
382
|
+
const page = Math.max(1, opts.page ?? 10);
|
|
383
|
+
switch (command) {
|
|
384
|
+
case "selectAll": instance.selectAll(); return true;
|
|
385
|
+
case "clear": instance.clear(); return true;
|
|
386
|
+
case "invert": instance.invert(); return true;
|
|
387
|
+
case "moveUp": instance.moveCursor(-step); return true;
|
|
388
|
+
case "moveDown": instance.moveCursor(step); return true;
|
|
389
|
+
case "moveLeft": instance.moveCursor(-1); return true;
|
|
390
|
+
case "moveRight": instance.moveCursor(1); return true;
|
|
391
|
+
case "moveHome": instance.moveCursor(-items.length); return true;
|
|
392
|
+
case "moveEnd": instance.moveCursor(items.length); return true;
|
|
393
|
+
case "movePageUp": instance.moveCursor(-page * step); return true;
|
|
394
|
+
case "movePageDown": instance.moveCursor(page * step); return true;
|
|
395
|
+
case "extendUp": instance.moveCursor(-step, true); return true;
|
|
396
|
+
case "extendDown": instance.moveCursor(step, true); return true;
|
|
397
|
+
case "extendLeft": instance.moveCursor(-1, true); return true;
|
|
398
|
+
case "extendRight": instance.moveCursor(1, true); return true;
|
|
399
|
+
case "extendHome": instance.moveCursor(-items.length, true); return true;
|
|
400
|
+
case "extendEnd": instance.moveCursor(items.length, true); return true;
|
|
401
|
+
case "extendPageUp": instance.moveCursor(-page * step, true); return true;
|
|
402
|
+
case "extendPageDown": instance.moveCursor(page * step, true); return true;
|
|
403
|
+
case "cursorUp": instance.moveCursor(-step, false, true); return true;
|
|
404
|
+
case "cursorDown": instance.moveCursor(step, false, true); return true;
|
|
405
|
+
case "toggleCursor": if (cursor >= 0) instance.toggle(cursor); return true;
|
|
406
|
+
default: return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const instance: SelectionInstance<Item> = {
|
|
411
|
+
get state() { return snapshot(); },
|
|
412
|
+
|
|
413
|
+
click(index, modifiers = {}) {
|
|
414
|
+
if (destroyed || !selectable(index)) return;
|
|
415
|
+
const mod = Boolean(modifiers.ctrlKey || modifiers.metaKey);
|
|
416
|
+
const shift = Boolean(modifiers.shiftKey) && multiple();
|
|
417
|
+
|
|
418
|
+
if (shift) {
|
|
419
|
+
applyRange(index, mod && multiple());
|
|
420
|
+
emit();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
rangeBase = null;
|
|
424
|
+
if (mod && multiple()) {
|
|
425
|
+
// Ctrl re-anchors: the next Shift+click measures from the row you just added,
|
|
426
|
+
// which is what makes "one here, then a run from there" work.
|
|
427
|
+
this.toggle(index);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const id = idOf(index);
|
|
431
|
+
if (id === null) return;
|
|
432
|
+
replace([id]);
|
|
433
|
+
cursor = index;
|
|
434
|
+
anchor = index;
|
|
435
|
+
emit();
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
keyDown(event) {
|
|
439
|
+
if (destroyed) return false;
|
|
440
|
+
const command = commandFor(event);
|
|
441
|
+
if (!command) return false;
|
|
442
|
+
|
|
443
|
+
const targets = selected.size > 0 ? selectedItems() : (cursor >= 0 && items[cursor] !== undefined ? [items[cursor] as Item] : []);
|
|
444
|
+
const ids = selected.size > 0 ? orderedIds() : (cursor >= 0 ? [idOf(cursor)].filter((id): id is string => id !== null) : []);
|
|
445
|
+
let prevented = false;
|
|
446
|
+
const report: SelectionCommandEvent<Item> = {
|
|
447
|
+
command,
|
|
448
|
+
items: targets,
|
|
449
|
+
ids,
|
|
450
|
+
cursor: cursor >= 0 ? (items[cursor] ?? null) : null,
|
|
451
|
+
cursorIndex: cursor,
|
|
452
|
+
event,
|
|
453
|
+
preventDefault() { prevented = true; report.defaultPrevented = true; },
|
|
454
|
+
defaultPrevented: false
|
|
455
|
+
};
|
|
456
|
+
opts.onCommand?.(report);
|
|
457
|
+
|
|
458
|
+
if (!prevented) perform(command);
|
|
459
|
+
// Handled when the list acted on it, or when there was a handler listening for it:
|
|
460
|
+
// the browser's own meaning has to go (Ctrl+A selects the page, F2 does nothing,
|
|
461
|
+
// Backspace navigates back) exactly where something took the press instead. A list
|
|
462
|
+
// with no `onCommand` reports `copy` and does nothing with it, and swallowing
|
|
463
|
+
// Ctrl+C there would leave the text inside its rows uncopyable.
|
|
464
|
+
return OWNED.has(command) || opts.onCommand !== undefined;
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
select(ids) {
|
|
468
|
+
if (destroyed) return;
|
|
469
|
+
replace(ids);
|
|
470
|
+
emit();
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
selectIndex(index, additive = false) {
|
|
474
|
+
if (destroyed || !selectable(index)) return;
|
|
475
|
+
const id = idOf(index);
|
|
476
|
+
if (id === null) return;
|
|
477
|
+
if (additive && multiple()) selected.add(id);
|
|
478
|
+
else replace([id]);
|
|
479
|
+
cursor = index;
|
|
480
|
+
anchor = index;
|
|
481
|
+
emit();
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
toggle(index) {
|
|
485
|
+
if (destroyed || !selectable(index)) return;
|
|
486
|
+
const id = idOf(index);
|
|
487
|
+
if (id === null) return;
|
|
488
|
+
if (!multiple()) { replace([id]); }
|
|
489
|
+
else if (selected.has(id)) selected.delete(id);
|
|
490
|
+
else selected.add(id);
|
|
491
|
+
cursor = index;
|
|
492
|
+
anchor = index;
|
|
493
|
+
emit();
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
selectRange(index, additive = false) {
|
|
497
|
+
if (destroyed || !selectable(index) || !multiple()) return;
|
|
498
|
+
applyRange(index, additive);
|
|
499
|
+
emit();
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
selectAll() {
|
|
503
|
+
if (destroyed || !multiple()) return;
|
|
504
|
+
const ids: string[] = [];
|
|
505
|
+
for (let index = 0; index < items.length; index++) {
|
|
506
|
+
if (!selectable(index)) continue;
|
|
507
|
+
const id = idOf(index);
|
|
508
|
+
if (id !== null) ids.push(id);
|
|
509
|
+
}
|
|
510
|
+
replace(ids);
|
|
511
|
+
emit();
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
clear() {
|
|
515
|
+
if (destroyed || selected.size === 0) return;
|
|
516
|
+
selected = new Set();
|
|
517
|
+
rangeBase = null;
|
|
518
|
+
emit();
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
invert() {
|
|
522
|
+
if (destroyed || !multiple()) return;
|
|
523
|
+
const next = new Set<string>();
|
|
524
|
+
for (let index = 0; index < items.length; index++) {
|
|
525
|
+
if (!selectable(index)) continue;
|
|
526
|
+
const id = idOf(index);
|
|
527
|
+
if (id !== null && !selected.has(id)) next.add(id);
|
|
528
|
+
}
|
|
529
|
+
selected = next;
|
|
530
|
+
emit();
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
moveCursor(delta, extend = false, carry = false) {
|
|
534
|
+
if (destroyed || items.length === 0) return;
|
|
535
|
+
const from = cursor >= 0 ? cursor : anchor >= 0 ? anchor : delta > 0 ? -1 : items.length;
|
|
536
|
+
const wanted = Math.max(0, Math.min(items.length - 1, from + delta));
|
|
537
|
+
// Past a disabled row rather than onto it, then back the other way when the end of
|
|
538
|
+
// the list is disabled - otherwise the cursor sticks one row short of the bottom.
|
|
539
|
+
const forward = delta >= 0 ? 1 : -1;
|
|
540
|
+
const ahead = nextEnabled(wanted, forward);
|
|
541
|
+
const next = ahead >= 0 ? ahead : nextEnabled(wanted, -forward);
|
|
542
|
+
if (next < 0) return;
|
|
543
|
+
|
|
544
|
+
if (extend && multiple()) {
|
|
545
|
+
applyRange(next, false);
|
|
546
|
+
emit();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
cursor = next;
|
|
550
|
+
if (carry) {
|
|
551
|
+
// The cursor alone: the selection stays where it was, which is what Ctrl+arrow
|
|
552
|
+
// is for - reaching a distant row to add it without starting again.
|
|
553
|
+
emit();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const id = idOf(next);
|
|
557
|
+
if (id === null) return;
|
|
558
|
+
replace([id]);
|
|
559
|
+
anchor = next;
|
|
560
|
+
rangeBase = null;
|
|
561
|
+
emit();
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
setCursor(index) {
|
|
565
|
+
if (destroyed || index === cursor) return;
|
|
566
|
+
if (index < -1 || index >= items.length) return;
|
|
567
|
+
cursor = index;
|
|
568
|
+
emit();
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
targets(index) {
|
|
572
|
+
const id = idOf(index);
|
|
573
|
+
const item = items[index];
|
|
574
|
+
if (item === undefined) return selectedItems();
|
|
575
|
+
return id !== null && selected.has(id) ? selectedItems() : [item];
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
beginMarquee(additive = false) {
|
|
579
|
+
if (destroyed || !multiple()) return;
|
|
580
|
+
marqueeBase = additive ? new Set(selected) : new Set();
|
|
581
|
+
if (!additive && selected.size > 0) selected = new Set();
|
|
582
|
+
emit();
|
|
583
|
+
},
|
|
584
|
+
|
|
585
|
+
marqueeTo(indices) {
|
|
586
|
+
if (destroyed || marqueeBase === null) return;
|
|
587
|
+
const next = new Set(marqueeBase);
|
|
588
|
+
for (const index of indices) {
|
|
589
|
+
if (!selectable(index)) continue;
|
|
590
|
+
const id = idOf(index);
|
|
591
|
+
if (id !== null) next.add(id);
|
|
592
|
+
}
|
|
593
|
+
// Compared before it is applied: a drag fires on every pixel, and emitting a set
|
|
594
|
+
// equal to the one on screen re-renders every row of the list sixty times a second.
|
|
595
|
+
if (next.size === selected.size && [...next].every((id) => selected.has(id))) return;
|
|
596
|
+
selected = next;
|
|
597
|
+
emit();
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
endMarquee() {
|
|
601
|
+
if (destroyed || marqueeBase === null) return;
|
|
602
|
+
marqueeBase = null;
|
|
603
|
+
emit();
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
bindings() { return table(); },
|
|
607
|
+
|
|
608
|
+
binding(command) { return table()[command] ?? false; },
|
|
609
|
+
|
|
610
|
+
update(next) {
|
|
611
|
+
if (destroyed) return;
|
|
612
|
+
const before = summary();
|
|
613
|
+
const hadItems = next.items !== undefined;
|
|
614
|
+
opts = { ...opts, ...next };
|
|
615
|
+
if (hadItems) {
|
|
616
|
+
items = next.items ?? [];
|
|
617
|
+
// Ids that are no longer in the list are dropped rather than kept: a selection
|
|
618
|
+
// holding rows that were deleted or filtered away reports a count nobody can
|
|
619
|
+
// see, and hands a delete the ids of things that are already gone.
|
|
620
|
+
const live = new Set<string>();
|
|
621
|
+
for (let index = 0; index < items.length; index++) {
|
|
622
|
+
const id = idOf(index);
|
|
623
|
+
if (id !== null) live.add(id);
|
|
624
|
+
}
|
|
625
|
+
for (const id of selected) if (!live.has(id)) selected.delete(id);
|
|
626
|
+
if (cursor >= items.length) cursor = items.length - 1;
|
|
627
|
+
if (anchor >= items.length) anchor = items.length - 1;
|
|
628
|
+
}
|
|
629
|
+
// Rows arriving or leaving is a state change even when the selection is untouched:
|
|
630
|
+
// `count`, `allSelected` and `partiallySelected` are all measured against the list,
|
|
631
|
+
// so a header checkbox left on the old snapshot keeps reading "all" over a list
|
|
632
|
+
// that just grew.
|
|
633
|
+
if (summary() !== before) emit();
|
|
634
|
+
},
|
|
635
|
+
|
|
636
|
+
subscribe(listener) {
|
|
637
|
+
listeners.add(listener);
|
|
638
|
+
return () => { listeners.delete(listener); };
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
destroy() {
|
|
642
|
+
destroyed = true;
|
|
643
|
+
listeners.clear();
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
return instance;
|
|
648
|
+
}
|