@enigmax/primitives 0.22.0 → 0.24.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-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-DZMHY3SU.js +777 -0
- package/dist/{chunk-FWVWX67R.js → chunk-FGBIZDV2.js} +3 -3
- package/dist/chunk-N6PDHMAX.js +213 -0
- package/dist/chunk-NONGREXC.js +248 -0
- package/dist/{chunk-R4ZAEE7V.js → chunk-QJJ34E4G.js} +34 -10
- package/dist/chunk-S2FPN5ID.js +115 -0
- package/dist/chunk-S7EE57YB.js +9 -0
- package/dist/chunk-VKL3DEIQ.js +804 -0
- package/dist/chunk-WSQC3PCC.js +466 -0
- package/dist/chunk-Z3VDE7OA.js +532 -0
- package/dist/clipboard-menu-B_ouitfS.d.ts +264 -0
- package/dist/color-D_rZ83Oc.d.ts +152 -0
- package/dist/color-OPV3BJV6.js +452 -0
- package/dist/{index-BHpOZncw.d.ts → index-ZPvlf9vs.d.ts} +53 -5
- package/dist/index.d.ts +6 -2
- package/dist/index.js +9 -4
- package/dist/keys-D2zJs1uB.d.ts +100 -0
- package/dist/next/index.d.ts +10 -3
- package/dist/next/index.js +22 -13
- package/dist/react/context-menu.d.ts +219 -0
- package/dist/react/context-menu.js +6 -0
- package/dist/react/index.d.ts +12 -5
- package/dist/react/index.js +21 -12
- package/dist/react/input.d.ts +3 -3
- package/dist/react/input.js +2 -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 +10 -3
- package/dist/react-router/index.js +22 -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 +23 -2
- package/recipes/color/styles.css +143 -0
- package/recipes/context-menu/styles.css +185 -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 +432 -5
- package/src/core/clipboard-menu.ts +270 -0
- package/src/core/color.ts +248 -0
- 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 +92 -1
- package/src/react/context-menu/context.ts +64 -0
- package/src/react/context-menu/index.tsx +108 -0
- package/src/react/context-menu/root.tsx +970 -0
- package/src/react/context-menu/styles.ts +194 -0
- package/src/react/index.ts +100 -1
- package/src/react/input/color-styles.ts +156 -0
- package/src/react/input/color.tsx +466 -0
- package/src/react/input/index.tsx +54 -6
- package/src/react/input/types.ts +59 -3
- 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/password-C8lG4Zm9.d.ts +0 -71
- /package/dist/{chunk-U3V4EHOB.js → chunk-MSOCCQGH.js} +0 -0
package/src/core/keys.ts
ADDED
|
@@ -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
|
+
}
|
package/src/core/search.ts
CHANGED
|
@@ -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
|
+
}
|