@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.
Files changed (67) hide show
  1. package/dist/chunk-3BQVOOAM.js +388 -0
  2. package/dist/{chunk-QQFNAKMY.js → chunk-45UHLZYT.js} +1 -1
  3. package/dist/chunk-4VUHQFAT.js +130 -0
  4. package/dist/{chunk-D5A2ZMAG.js → chunk-4ZPMP47J.js} +6 -1
  5. package/dist/chunk-DSBYVA7V.js +713 -0
  6. package/dist/{chunk-FWVWX67R.js → chunk-FGBIZDV2.js} +3 -3
  7. package/dist/{chunk-R4ZAEE7V.js → chunk-KEVZ5XQV.js} +1 -1
  8. package/dist/chunk-N6PDHMAX.js +213 -0
  9. package/dist/chunk-NONGREXC.js +248 -0
  10. package/dist/chunk-VKL3DEIQ.js +804 -0
  11. package/dist/chunk-WSQC3PCC.js +466 -0
  12. package/dist/context-menu-D3FtTn7v.d.ts +174 -0
  13. package/dist/{index-BHpOZncw.d.ts → index-DQNnohoo.d.ts} +1 -1
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.js +8 -4
  16. package/dist/keys-D2zJs1uB.d.ts +100 -0
  17. package/dist/next/index.d.ts +9 -2
  18. package/dist/next/index.js +20 -13
  19. package/dist/react/context-menu.d.ts +202 -0
  20. package/dist/react/context-menu.js +6 -0
  21. package/dist/react/index.d.ts +10 -3
  22. package/dist/react/index.js +19 -12
  23. package/dist/react/input.d.ts +2 -2
  24. package/dist/react/input.js +1 -1
  25. package/dist/react/palette.d.ts +1 -1
  26. package/dist/react/palette.js +3 -3
  27. package/dist/react/search.d.ts +1 -1
  28. package/dist/react/search.js +2 -2
  29. package/dist/react/select.d.ts +217 -0
  30. package/dist/react/select.js +7 -0
  31. package/dist/react/selection.d.ts +104 -0
  32. package/dist/react/selection.js +4 -0
  33. package/dist/react-router/index.d.ts +9 -2
  34. package/dist/react-router/index.js +20 -13
  35. package/dist/search/index.d.ts +2 -2
  36. package/dist/search/index.js +1 -1
  37. package/dist/{search-BD9-5O5U.d.ts → search-DYgqRp37.d.ts} +13 -1
  38. package/dist/{search-UQEXAPQB.js → search-PBZORZ7P.js} +1 -1
  39. package/dist/select-ClSy-J1f.d.ts +100 -0
  40. package/dist/selection-B_pmzHpy.d.ts +150 -0
  41. package/package.json +21 -2
  42. package/recipes/context-menu/styles.css +177 -0
  43. package/recipes/input/styles.css +9 -0
  44. package/recipes/palette/styles.css +3 -0
  45. package/recipes/search/tailwind.tsx +3 -2
  46. package/recipes/select/styles.css +230 -0
  47. package/recipes/toast/styles.css +1 -1
  48. package/registry.json +358 -1
  49. package/src/core/context-menu.ts +694 -0
  50. package/src/core/keys.ts +264 -0
  51. package/src/core/search.ts +19 -0
  52. package/src/core/select.ts +404 -0
  53. package/src/core/selection.ts +648 -0
  54. package/src/index.ts +60 -1
  55. package/src/react/context-menu/context.ts +57 -0
  56. package/src/react/context-menu/index.tsx +94 -0
  57. package/src/react/context-menu/root.tsx +846 -0
  58. package/src/react/context-menu/styles.ts +186 -0
  59. package/src/react/index.ts +68 -1
  60. package/src/react/palette/root.tsx +2 -2
  61. package/src/react/select/context.ts +56 -0
  62. package/src/react/select/index.tsx +96 -0
  63. package/src/react/select/root.tsx +839 -0
  64. package/src/react/select/styles.ts +238 -0
  65. package/src/react/selection/index.tsx +115 -0
  66. package/src/react/selection/use-selection.ts +260 -0
  67. /package/dist/{chunk-U3V4EHOB.js → chunk-3HDEZ2E7.js} +0 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Keyboard shortcuts as data: parsed from a string, matched against an event, and written
3
+ * back out the way the platform writes them.
4
+ *
5
+ * Two components need the same three things and would otherwise each get their own half of
6
+ * them - the context menu prints a shortcut beside an action, and the selection list matches
7
+ * one against a key press. A menu whose label says `Ctrl+A` while the list listens for
8
+ * `Meta+A` is the defect that shape produces, so both read this file.
9
+ *
10
+ * `Mod` is the whole point of the spec being a string. It means Command on an Apple keyboard
11
+ * and Control everywhere else, which is what every one of these shortcuts actually means -
12
+ * hardcoding either one is wrong on half the machines.
13
+ */
14
+
15
+ /** One shortcut, normalized. `mod` and `ctrl`/`meta` are exclusive: `Mod` sets only `mod`. */
16
+ export interface Shortcut {
17
+ /** The `KeyboardEvent.key` to match, lowercased for letters (`a`, `f2`, `delete`, ` `). */
18
+ key: string;
19
+ /** Command on an Apple platform, Control elsewhere. */
20
+ mod?: boolean;
21
+ ctrl?: boolean;
22
+ meta?: boolean;
23
+ shift?: boolean;
24
+ alt?: boolean;
25
+ }
26
+
27
+ /** The parts of a key press a shortcut reads. A real KeyboardEvent satisfies it. */
28
+ export interface ShortcutEvent {
29
+ key: string;
30
+ ctrlKey?: boolean;
31
+ metaKey?: boolean;
32
+ shiftKey?: boolean;
33
+ altKey?: boolean;
34
+ }
35
+
36
+ /**
37
+ * A shortcut as written: `"Mod+A"`, `"Shift+F10"`, `"Delete"`, `"Ctrl+Shift+N"`. A list means
38
+ * several presses do the same thing, and `false` means the command has no binding at all.
39
+ */
40
+ export type ShortcutSpec = string | Shortcut | readonly (string | Shortcut)[] | false;
41
+
42
+ const MODIFIERS = new Set(["mod", "ctrl", "control", "meta", "cmd", "command", "super", "win", "shift", "alt", "option", "opt"]);
43
+
44
+ /** Names that are not one character but are one key. Written the way `KeyboardEvent.key` spells them. */
45
+ const NAMED: Record<string, string> = {
46
+ esc: "escape",
47
+ escape: "escape",
48
+ del: "delete",
49
+ delete: "delete",
50
+ back: "backspace",
51
+ backspace: "backspace",
52
+ enter: "enter",
53
+ return: "enter",
54
+ space: " ",
55
+ spacebar: " ",
56
+ tab: "tab",
57
+ up: "arrowup",
58
+ down: "arrowdown",
59
+ left: "arrowleft",
60
+ right: "arrowright",
61
+ arrowup: "arrowup",
62
+ arrowdown: "arrowdown",
63
+ arrowleft: "arrowleft",
64
+ arrowright: "arrowright",
65
+ home: "home",
66
+ end: "end",
67
+ pageup: "pageup",
68
+ pagedown: "pagedown",
69
+ plus: "+"
70
+ };
71
+
72
+ /**
73
+ * Whether this is an Apple keyboard, and so whether `Mod` is Command.
74
+ *
75
+ * `navigator.platform` is deprecated and still the only reliable answer where it exists, so
76
+ * it is tried first and the user agent is the fallback. A server has neither and gets `false`,
77
+ * which is the right guess: a label rendered on the server is corrected on hydration, and a
78
+ * key press cannot happen there at all.
79
+ */
80
+ export function isApplePlatform(): boolean {
81
+ if (typeof navigator === "undefined") return false;
82
+ const source = navigator.platform || navigator.userAgent || "";
83
+ return /mac|iphone|ipad|ipod/i.test(source);
84
+ }
85
+
86
+ /** `"Mod+Shift+A"` -> the shortcut it stands for. Unknown words are treated as the key. */
87
+ export function parseShortcut(spec: string | Shortcut): Shortcut {
88
+ if (typeof spec !== "string") return { ...spec, key: normalizeKey(spec.key) };
89
+
90
+ const shortcut: Shortcut = { key: "" };
91
+ // Split on + and - so `Ctrl-A` reads the same as `Ctrl+A`, but never on a LONE separator:
92
+ // `Ctrl++` and `Ctrl+-` are real shortcuts whose key is the separator itself.
93
+ const parts = spec.split(/[+-](?!$)/).map((part) => part.trim()).filter(Boolean);
94
+ if (parts.length === 0) return { key: normalizeKey(spec.trim()) };
95
+
96
+ parts.forEach((part, index) => {
97
+ const word = part.toLowerCase();
98
+ const last = index === parts.length - 1;
99
+ // The last word is the KEY even when it names a modifier: `Shift` alone is a
100
+ // shortcut, and so is the `Alt` in `Ctrl+Alt`.
101
+ if (!last && MODIFIERS.has(word)) {
102
+ applyModifier(shortcut, word);
103
+ return;
104
+ }
105
+ if (last && MODIFIERS.has(word) && parts.length > 1) {
106
+ applyModifier(shortcut, word);
107
+ return;
108
+ }
109
+ shortcut.key = normalizeKey(part);
110
+ });
111
+
112
+ // `Ctrl+Alt` with no key left: the trailing modifier IS the key.
113
+ if (!shortcut.key) shortcut.key = normalizeKey(parts[parts.length - 1]);
114
+ return shortcut;
115
+ }
116
+
117
+ function applyModifier(shortcut: Shortcut, word: string): void {
118
+ if (word === "mod") shortcut.mod = true;
119
+ else if (word === "ctrl" || word === "control") shortcut.ctrl = true;
120
+ else if (word === "meta" || word === "cmd" || word === "command" || word === "super" || word === "win") shortcut.meta = true;
121
+ else if (word === "shift") shortcut.shift = true;
122
+ else shortcut.alt = true;
123
+ }
124
+
125
+ /**
126
+ * `KeyboardEvent.key` as this file compares it: lowercase, with the aliases resolved.
127
+ *
128
+ * NOT trimmed, and that is the whole comment: the space bar reports its key as `" "`, so
129
+ * trimming here turns Ctrl+Space into Ctrl+nothing and the binding silently never matches.
130
+ * The spec's own words are trimmed where they are split instead.
131
+ */
132
+ function normalizeKey(key: string): string {
133
+ const lower = key.toLowerCase();
134
+ return NAMED[lower] ?? lower;
135
+ }
136
+
137
+ /** A spec as a list, so one binding and several read the same downstream. */
138
+ export function shortcutList(spec: ShortcutSpec): Shortcut[] {
139
+ if (spec === false || spec == null) return [];
140
+ const entries = Array.isArray(spec) ? spec : [spec as string | Shortcut];
141
+ return entries.map(parseShortcut);
142
+ }
143
+
144
+ /**
145
+ * Whether a key press is this shortcut.
146
+ *
147
+ * Every modifier is checked, including the ones the shortcut does NOT ask for: `Delete` must
148
+ * not fire on `Ctrl+Delete`, which means something else in every file manager there is. Shift
149
+ * is the one exception the caller can waive, because a shifted letter arrives as a different
150
+ * `key` on some layouts.
151
+ */
152
+ export function matchesShortcut(event: ShortcutEvent, spec: ShortcutSpec, apple = isApplePlatform()): boolean {
153
+ return shortcutList(spec).some((shortcut) => matchesOne(event, shortcut, apple));
154
+ }
155
+
156
+ function matchesOne(event: ShortcutEvent, shortcut: Shortcut, apple: boolean): boolean {
157
+ const key = normalizeKey(event.key);
158
+ // `Mod` resolves to exactly one physical modifier, so Ctrl+A on a Mac is NOT Cmd+A: it
159
+ // is the terminal's start-of-line, and a list that stole it would be the thing at fault.
160
+ const wantCtrl = Boolean(shortcut.ctrl || (shortcut.mod && !apple));
161
+ const wantMeta = Boolean(shortcut.meta || (shortcut.mod && apple));
162
+ return key === shortcut.key
163
+ && Boolean(event.ctrlKey) === wantCtrl
164
+ && Boolean(event.metaKey) === wantMeta
165
+ && Boolean(event.shiftKey) === Boolean(shortcut.shift)
166
+ && Boolean(event.altKey) === Boolean(shortcut.alt);
167
+ }
168
+
169
+ /**
170
+ * The shortcut written the way this platform writes it, as tokens.
171
+ *
172
+ * Tokens rather than a string because that is what a menu renders: one `<kbd>` per key, so
173
+ * `Ctrl` and `A` can be spaced and styled apart. `shortcutText` joins them for anything that
174
+ * only has room for a string.
175
+ */
176
+ export function shortcutTokens(spec: string | Shortcut, apple = isApplePlatform()): string[] {
177
+ const shortcut = parseShortcut(spec);
178
+ const tokens: string[] = [];
179
+ // Apple's own order, which is also the order Windows uses for the modifiers it has:
180
+ // Control, Option/Alt, Shift, Command.
181
+ if (shortcut.ctrl || (shortcut.mod && !apple)) tokens.push(apple ? "⌃" : "Ctrl");
182
+ if (shortcut.alt) tokens.push(apple ? "⌥" : "Alt");
183
+ if (shortcut.shift) tokens.push(apple ? "⇧" : "Shift");
184
+ if (shortcut.meta || (shortcut.mod && apple)) tokens.push(apple ? "⌘" : "Win");
185
+ tokens.push(keyLabel(shortcut.key, apple));
186
+ return tokens;
187
+ }
188
+
189
+ /** One string, for a `title`, an `aria-keyshortcuts` neighbour, or a menu with no room. */
190
+ export function shortcutText(spec: string | Shortcut, apple = isApplePlatform()): string {
191
+ return shortcutTokens(spec, apple).join(apple ? "" : "+");
192
+ }
193
+
194
+ const KEY_LABELS: Record<string, string> = {
195
+ " ": "Space",
196
+ escape: "Esc",
197
+ enter: "Enter",
198
+ backspace: "Backspace",
199
+ delete: "Del",
200
+ arrowup: "↑",
201
+ arrowdown: "↓",
202
+ arrowleft: "←",
203
+ arrowright: "→",
204
+ pageup: "PgUp",
205
+ pagedown: "PgDn"
206
+ };
207
+
208
+ /** The glyphs an Apple keyboard prints on its own keys, which is what its menus show. */
209
+ const APPLE_KEY_LABELS: Record<string, string> = {
210
+ delete: "⌦",
211
+ backspace: "⌫",
212
+ enter: "↩",
213
+ escape: "esc"
214
+ };
215
+
216
+ function keyLabel(key: string, apple: boolean): string {
217
+ if (apple && APPLE_KEY_LABELS[key]) return APPLE_KEY_LABELS[key];
218
+ const named = KEY_LABELS[key];
219
+ if (named) return named;
220
+ // A function key is upper case whole (`F2`); a letter is one capital.
221
+ return key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1);
222
+ }
223
+
224
+ /**
225
+ * How long a typeahead buffer survives. Long enough to type a word, short enough to reset
226
+ * before the next thing you meant.
227
+ */
228
+ export const TYPEAHEAD_MS = 600;
229
+
230
+ /** The buffer between two presses: what has been typed, and when. */
231
+ export interface TypeaheadState {
232
+ typed: string;
233
+ at: number;
234
+ }
235
+
236
+ export interface TypeaheadStep extends TypeaheadState {
237
+ /** What to look for. The buffer, or its one repeated character - see below. */
238
+ needle: string;
239
+ /**
240
+ * Whether this press CYCLES through the rows starting with the letter rather than refining
241
+ * what the last one found. True for a single letter and for the same letter pressed again,
242
+ * which is the rule every desktop list follows: "rrr" is not a word anybody is typing, it
243
+ * is someone walking through the Rs.
244
+ */
245
+ cycle: boolean;
246
+ }
247
+
248
+ /**
249
+ * Advance a typeahead buffer.
250
+ *
251
+ * Shared because two lists in this package do the same thing with it - a select with no filter
252
+ * and a menu - and a buffer that timed out differently in the two would be felt as one of them
253
+ * being broken.
254
+ */
255
+ export function typeaheadStep(state: TypeaheadState, character: string, now = Date.now(), windowMs = TYPEAHEAD_MS): TypeaheadStep {
256
+ const typed = now - state.at > windowMs ? character : state.typed + character;
257
+ const repeated = typed.length > 1 && [...typed].every((letter) => letter === typed[0]);
258
+ return {
259
+ typed,
260
+ at: now,
261
+ needle: repeated ? typed[0] : typed,
262
+ cycle: typed.length === 1 || repeated
263
+ };
264
+ }
@@ -166,6 +166,25 @@ function subsequenceScore(query: string, text: string): number {
166
166
  return score;
167
167
  }
168
168
 
169
+ /**
170
+ * A query, short enough to put back on the screen.
171
+ *
172
+ * Every empty state quotes what was typed - "Nothing matches ..." - and what was typed is
173
+ * arbitrary: paste sixty characters with no spaces in them and there is no break opportunity
174
+ * in the whole string, so the panel grows to fit it and keeps growing. Cut in the TEXT and
175
+ * not only in CSS, because `text-overflow` needs a bounded box and the box is what the
176
+ * string is stretching.
177
+ *
178
+ * The tail is what identifies a typo, so the start is what survives.
179
+ */
180
+ export function shortenQuery(query: string, max = 32): string {
181
+ const trimmed = query.trim();
182
+ if (trimmed.length <= max) return trimmed;
183
+ // A real ellipsis, not three dots: one character, and a screen reader says "ellipsis"
184
+ // rather than reading three full stops.
185
+ return `${trimmed.slice(0, max - 1).trimEnd()}\u2026`;
186
+ }
187
+
169
188
  export function createSearch<T>(options: SearchOptions<T> = {}): SearchInstance<T> {
170
189
  let opts: SearchOptions<T> = { ...options };
171
190
  let items: readonly T[] = opts.items ?? [];
@@ -0,0 +1,404 @@
1
+ /**
2
+ * The parts of a select that are not rendering: what is chosen, what is visible after the
3
+ * filter, and where the highlight goes when a key is pressed.
4
+ *
5
+ * Framework-agnostic on purpose - the React layer over this file is thin, and a renderer
6
+ * that is not React only has to draw. The two hard parts of a select live here: a selection
7
+ * that behaves the same whether one value or many are allowed, and a highlight that never
8
+ * lands on a disabled row.
9
+ *
10
+ * Filtering reuses the search core, so a select filters exactly like the search field does:
11
+ * accent-insensitive substring by default, Fuse.js when you hand over its constructor, or
12
+ * your own matcher. Fuse is never imported here - it is a peer the caller passes or not.
13
+ */
14
+
15
+ import { typeaheadStep } from "@/core/keys";
16
+ import { createSearch, type FuseConstructor, type SearchOptions, type SearchInstance } from "@/core/search";
17
+
18
+ export interface SelectOption {
19
+ /** What ends up in `value`. Unique within the list. */
20
+ value: string;
21
+ /** What is read and what the filter searches. */
22
+ label: string;
23
+ /** A second line under the label. Searched as well. */
24
+ description?: string;
25
+ /**
26
+ * Anything the renderer can draw beside the label - a ReactNode, an URL, a name. Typed
27
+ * as `unknown` because a core shared by every adapter cannot know what a node is.
28
+ */
29
+ icon?: unknown;
30
+ /** Visible, listed, announced as unavailable, and never selectable or highlightable. */
31
+ disabled?: boolean;
32
+ /** Rows carrying the same group render together under its name. */
33
+ group?: string;
34
+ /** Extra words the filter should match - synonyms, an old name, a code. */
35
+ keywords?: string[];
36
+ }
37
+
38
+ /** The fields the filter reads when the caller does not say. */
39
+ export const SELECT_SEARCH_KEYS = ["label", "description", "group", "keywords"];
40
+
41
+ export interface SelectOptions {
42
+ options?: readonly SelectOption[];
43
+ /** A string, a list when `multiple`, or null for nothing chosen. */
44
+ value?: string | readonly string[] | null;
45
+ multiple?: boolean;
46
+ /** Filter the list from a field inside the panel. */
47
+ searchable?: boolean;
48
+ /** Fuse.js's constructor, for fuzzy filtering. Omit it for the built-in matcher. */
49
+ fuse?: FuseConstructor;
50
+ fuseOptions?: Record<string, unknown>;
51
+ matcher?: SearchOptions<SelectOption>["matcher"];
52
+ searchKeys?: string[];
53
+ /** Close after a choice. Default: true when one value is allowed, false when many are. */
54
+ closeOnSelect?: boolean;
55
+ /** Every state change. */
56
+ onChange?: (state: SelectState) => void;
57
+ /**
58
+ * Only the value, in the shape the caller asked for: a string, or a list when
59
+ * `multiple`. The React layer narrows this per mode, so a call site never has to
60
+ * widen it back by hand.
61
+ */
62
+ onValueChange?: (value: string | string[], options: SelectOption[]) => void;
63
+ }
64
+
65
+ export interface SelectState {
66
+ open: boolean;
67
+ query: string;
68
+ /** Index into `visible`. -1 when there is nothing to highlight. */
69
+ active: number;
70
+ /** What the panel shows: every option, or what survived the filter. */
71
+ visible: SelectOption[];
72
+ /** The chosen options, in the order they were chosen. */
73
+ selected: SelectOption[];
74
+ /** The chosen values, always as a list - `multiple` decides what is reported outwards. */
75
+ value: string[];
76
+ multiple: boolean;
77
+ /** True when a filter is running and matched nothing. */
78
+ empty: boolean;
79
+ }
80
+
81
+ export interface SelectInstance {
82
+ readonly state: SelectState;
83
+ setOpen(open: boolean): void;
84
+ toggleOpen(): void;
85
+ setQuery(query: string): void;
86
+ /** Move the highlight, skipping disabled rows. */
87
+ move(key: SelectMoveKey): void;
88
+ setActive(index: number): void;
89
+ /** Choose a row: replaces the value, or toggles it when many are allowed. */
90
+ select(value: string): void;
91
+ /** Choose whatever is highlighted. */
92
+ selectActive(): void;
93
+ /** Drop one value. The × on a tag. */
94
+ remove(value: string): void;
95
+ clear(): void;
96
+ /** Jump to the row starting with what was typed, the way a native select does. */
97
+ typeahead(character: string): void;
98
+ update(options: Partial<SelectOptions>): void;
99
+ subscribe(listener: (state: SelectState) => void): () => void;
100
+ destroy(): void;
101
+ }
102
+
103
+ export type SelectMoveKey = "ArrowDown" | "ArrowUp" | "Home" | "End" | "PageDown" | "PageUp";
104
+
105
+ const PAGE = 5;
106
+
107
+ /** "Perú" must be reachable by typing "peru" - here as in the search core. */
108
+ function fold(value: string): string {
109
+ return value.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
110
+ }
111
+
112
+ function toList(value: SelectOptions["value"]): string[] {
113
+ if (value == null) return [];
114
+ const list = Array.isArray(value) ? [...value] : [value as string];
115
+ // The empty string is NOTHING CHOSEN, not a value. `value=""` is how React writes an
116
+ // empty controlled field and how HTML writes a placeholder option, so keeping it would
117
+ // leave a select that shows its placeholder while holding a value, offering a clear
118
+ // button for it and posting it with the form.
119
+ return list.filter((entry) => entry !== "");
120
+ }
121
+
122
+ function sameValues(left: readonly string[], right: readonly string[]): boolean {
123
+ return left.length === right.length && left.every((entry, index) => entry === right[index]);
124
+ }
125
+
126
+ /** By identity: the option objects are the caller's, and a rebuilt list holds the same ones. */
127
+ function sameRows(left: readonly SelectOption[], right: readonly SelectOption[]): boolean {
128
+ return left.length === right.length && left.every((option, index) => option === right[index]);
129
+ }
130
+
131
+ function sameState(left: SelectState, right: SelectState): boolean {
132
+ return left.open === right.open
133
+ && left.query === right.query
134
+ && left.active === right.active
135
+ && left.multiple === right.multiple
136
+ && left.empty === right.empty
137
+ && sameValues(left.value, right.value)
138
+ && sameRows(left.visible, right.visible)
139
+ && sameRows(left.selected, right.selected);
140
+ }
141
+
142
+ export function createSelect(options: SelectOptions = {}): SelectInstance {
143
+ let opts: SelectOptions = { ...options };
144
+ let all: readonly SelectOption[] = opts.options ?? [];
145
+ let value = toList(opts.value);
146
+ let open = false;
147
+ let query = "";
148
+ let active = -1;
149
+ let visible: SelectOption[] = [...all];
150
+ let typed = "";
151
+ let typedAt = 0;
152
+ let destroyed = false;
153
+ const listeners = new Set<(state: SelectState) => void>();
154
+
155
+ // One engine, built once and updated: it indexes on construction, so rebuilding it per
156
+ // keystroke would re-index the whole list on every letter.
157
+ const engine: SearchInstance<SelectOption> = createSearch<SelectOption>({
158
+ items: all,
159
+ keys: opts.searchKeys ?? SELECT_SEARCH_KEYS,
160
+ fuse: opts.fuse,
161
+ fuseOptions: opts.fuseOptions,
162
+ matcher: opts.matcher,
163
+ // A select filters as you type. There is nothing to debounce: the data is already
164
+ // in memory, and a delay here is felt as the list lagging behind the field.
165
+ debounce: 0,
166
+ // An empty query in a select means "everything", not "nothing" - the opposite of a
167
+ // search field, where an empty query has nothing to show.
168
+ empty: "all"
169
+ });
170
+
171
+ function optionOf(candidate: string): SelectOption | undefined {
172
+ return all.find((option) => option.value === candidate);
173
+ }
174
+
175
+ function selectedOptions(): SelectOption[] {
176
+ // Mapped through the option list so a value with no option left (data reloaded,
177
+ // an id that no longer exists) simply disappears instead of rendering a blank tag.
178
+ return value.map(optionOf).filter((option): option is SelectOption => Boolean(option));
179
+ }
180
+
181
+ function snapshot(): SelectState {
182
+ return {
183
+ open,
184
+ query,
185
+ active,
186
+ visible,
187
+ selected: selectedOptions(),
188
+ value: [...value],
189
+ multiple: Boolean(opts.multiple),
190
+ empty: visible.length === 0
191
+ };
192
+ }
193
+
194
+ function emit(): void {
195
+ const state = snapshot();
196
+ opts.onChange?.(state);
197
+ for (const listener of listeners) listener(state);
198
+ }
199
+
200
+ /** Positive modulo: the walk goes backwards as often as forwards. */
201
+ function wrap(index: number): number {
202
+ const length = visible.length;
203
+ return ((index % length) + length) % length;
204
+ }
205
+
206
+ function enabledIndex(from: number, step: number): number {
207
+ if (visible.length === 0) return -1;
208
+ // Walks at most once around the list: a list whose rows are all disabled is a real
209
+ // list, and it has to leave the highlight nowhere rather than spin looking for one.
210
+ for (let attempt = 0; attempt < visible.length; attempt++) {
211
+ const index = wrap(from + step * attempt);
212
+ if (!visible[index]?.disabled) return index;
213
+ }
214
+ return -1;
215
+ }
216
+
217
+ /** The option the highlight is on, so a filter can put it back where it was. */
218
+ let lastActive: SelectOption | undefined;
219
+
220
+ function refilter(): void {
221
+ visible = opts.searchable && query.trim()
222
+ ? engine.searchNow(query).map((match) => match.item)
223
+ : [...all];
224
+
225
+ // The highlight follows the row it was on when that row survived the filter, so
226
+ // deleting a letter does not throw the selection back to the top of the list.
227
+ const current = active >= 0 ? visible.findIndex((option) => option === lastActive) : -1;
228
+ active = current >= 0 && !visible[current]?.disabled ? current : enabledIndex(0, 1);
229
+ lastActive = visible[active];
230
+ }
231
+
232
+ function reportValue(): void {
233
+ const chosen = selectedOptions();
234
+ opts.onValueChange?.((opts.multiple ? [...value] : value[0] ?? "") as never, chosen);
235
+ }
236
+
237
+ function closesOnSelect(): boolean {
238
+ return opts.closeOnSelect ?? !opts.multiple;
239
+ }
240
+
241
+ refilter();
242
+
243
+ return {
244
+ get state() { return snapshot(); },
245
+
246
+ setOpen(next: boolean) {
247
+ if (destroyed || open === next) return;
248
+ open = next;
249
+ if (open) {
250
+ // Opening starts clean and lands on what is already chosen: a select that
251
+ // reopens on the first row makes you find your own value again.
252
+ query = "";
253
+ refilter();
254
+ const chosen = value[0] ? visible.findIndex((option) => option.value === value[0]) : -1;
255
+ active = chosen >= 0 && !visible[chosen]?.disabled ? chosen : enabledIndex(0, 1);
256
+ lastActive = visible[active];
257
+ } else {
258
+ query = "";
259
+ typed = "";
260
+ refilter();
261
+ }
262
+ emit();
263
+ },
264
+
265
+ toggleOpen() { this.setOpen(!open); },
266
+
267
+ setQuery(next: string) {
268
+ if (destroyed) return;
269
+ query = next;
270
+ refilter();
271
+ emit();
272
+ },
273
+
274
+ move(key: SelectMoveKey) {
275
+ if (destroyed || visible.length === 0) return;
276
+ const from = active < 0 ? -1 : active;
277
+ switch (key) {
278
+ // Wrapping, because a select is a short list and the row after the last one
279
+ // is the first: clamping leaves the arrow key doing nothing, which reads as
280
+ // the panel having frozen.
281
+ case "ArrowDown": active = enabledIndex(from + 1, 1); break;
282
+ case "ArrowUp": active = enabledIndex(from - 1 + visible.length, -1); break;
283
+ case "Home": active = enabledIndex(0, 1); break;
284
+ case "End": active = enabledIndex(visible.length - 1, -1); break;
285
+ case "PageDown": active = enabledIndex(Math.min(visible.length - 1, from + PAGE), 1); break;
286
+ case "PageUp": active = enabledIndex(Math.max(0, from - PAGE), -1); break;
287
+ }
288
+ lastActive = visible[active];
289
+ emit();
290
+ },
291
+
292
+ setActive(index: number) {
293
+ // The row already highlighted is not a change: this arrives from `pointermove`,
294
+ // which fires on every pixel, and emitting there re-renders the whole panel
295
+ // sixty times a second - and the scroll it triggers moves the row under a
296
+ // stationary cursor, which fires it again.
297
+ if (destroyed || index === active || index < 0 || index >= visible.length || visible[index]?.disabled) return;
298
+ active = index;
299
+ lastActive = visible[active];
300
+ emit();
301
+ },
302
+
303
+ select(next: string) {
304
+ if (destroyed) return;
305
+ const option = optionOf(next);
306
+ // A disabled row is listed and announced, never chosen - including by a click
307
+ // that got through, which is the one path a renderer tends to forget.
308
+ if (!option || option.disabled) return;
309
+
310
+ if (opts.multiple) {
311
+ value = value.includes(next) ? value.filter((entry) => entry !== next) : [...value, next];
312
+ } else {
313
+ value = [next];
314
+ }
315
+
316
+ reportValue();
317
+ if (closesOnSelect()) {
318
+ open = false;
319
+ query = "";
320
+ refilter();
321
+ }
322
+ emit();
323
+ },
324
+
325
+ selectActive() {
326
+ const option = visible[active];
327
+ if (option) this.select(option.value);
328
+ },
329
+
330
+ remove(next: string) {
331
+ if (destroyed || !value.includes(next)) return;
332
+ value = value.filter((entry) => entry !== next);
333
+ reportValue();
334
+ emit();
335
+ },
336
+
337
+ clear() {
338
+ if (destroyed || value.length === 0) return;
339
+ value = [];
340
+ reportValue();
341
+ emit();
342
+ },
343
+
344
+ typeahead(character: string) {
345
+ if (destroyed || character.length !== 1) return;
346
+ const step = typeaheadStep({ typed, at: typedAt }, character);
347
+ typed = step.typed;
348
+ typedAt = step.at;
349
+
350
+ const needle = fold(step.needle);
351
+ // A cycling press searches from the row AFTER this one, so pressing the letter
352
+ // again walks through everything starting with it instead of sticking on the
353
+ // first match; a word searches from the current row, so refining it does not
354
+ // jump off the option it already found.
355
+ const from = step.cycle ? 1 : 0;
356
+ for (let step = from; step < visible.length + from; step++) {
357
+ const index = wrap(Math.max(active, 0) + step);
358
+ const option = visible[index];
359
+ if (!option || option.disabled) continue;
360
+ if (fold(option.label).startsWith(needle)) {
361
+ active = index;
362
+ lastActive = option;
363
+ emit();
364
+ return;
365
+ }
366
+ }
367
+ },
368
+
369
+ update(next: Partial<SelectOptions>) {
370
+ if (destroyed) return;
371
+ const before = snapshot();
372
+ const hadOptions = next.options !== undefined;
373
+ opts = { ...opts, ...next };
374
+ if (hadOptions) all = next.options ?? [];
375
+ if (next.value !== undefined) value = toList(next.value);
376
+ if (hadOptions || next.searchKeys || next.fuse || next.fuseOptions || next.matcher) {
377
+ engine.update({
378
+ items: all,
379
+ keys: opts.searchKeys ?? SELECT_SEARCH_KEYS,
380
+ fuse: opts.fuse,
381
+ fuseOptions: opts.fuseOptions,
382
+ matcher: opts.matcher
383
+ });
384
+ }
385
+ refilter();
386
+ // An update that changed nothing must not emit. A renderer subscribes to this
387
+ // and pushes its props back in whenever it renders, so emitting regardless
388
+ // redraws the whole panel for a list, a filter and a value that are the ones it
389
+ // already had - and hands any renderer that rebuilds those props a render loop.
390
+ if (!sameState(before, snapshot())) emit();
391
+ },
392
+
393
+ subscribe(listener) {
394
+ listeners.add(listener);
395
+ return () => { listeners.delete(listener); };
396
+ },
397
+
398
+ destroy() {
399
+ destroyed = true;
400
+ listeners.clear();
401
+ engine.destroy();
402
+ }
403
+ };
404
+ }