@enigmax/primitives 0.21.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 (83) 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-WPTBURIC.js +1627 -0
  12. package/dist/chunk-WSQC3PCC.js +466 -0
  13. package/dist/context-menu-D3FtTn7v.d.ts +174 -0
  14. package/dist/{index-dTdAbOWl.d.ts → index-DQNnohoo.d.ts} +1 -1
  15. package/dist/index.d.ts +5 -1
  16. package/dist/index.js +8 -4
  17. package/dist/keys-D2zJs1uB.d.ts +100 -0
  18. package/dist/next/index.d.ts +12 -5
  19. package/dist/next/index.js +21 -14
  20. package/dist/react/button.d.ts +2 -2
  21. package/dist/react/context-menu.d.ts +202 -0
  22. package/dist/react/context-menu.js +6 -0
  23. package/dist/react/index.d.ts +11 -4
  24. package/dist/react/index.js +20 -13
  25. package/dist/react/input.d.ts +2 -2
  26. package/dist/react/input.js +1 -1
  27. package/dist/react/palette.d.ts +1 -1
  28. package/dist/react/palette.js +3 -3
  29. package/dist/react/search.d.ts +1 -1
  30. package/dist/react/search.js +2 -2
  31. package/dist/react/select.d.ts +217 -0
  32. package/dist/react/select.js +7 -0
  33. package/dist/react/selection.d.ts +104 -0
  34. package/dist/react/selection.js +4 -0
  35. package/dist/react/slot.d.ts +2 -2
  36. package/dist/react/toast.d.ts +165 -32
  37. package/dist/react/toast.js +1 -1
  38. package/dist/react-router/index.d.ts +12 -5
  39. package/dist/react-router/index.js +21 -14
  40. package/dist/search/index.d.ts +2 -2
  41. package/dist/search/index.js +1 -1
  42. package/dist/{search-DXxY8SEH.d.ts → search-DYgqRp37.d.ts} +20 -3
  43. package/dist/{search-UQEXAPQB.js → search-PBZORZ7P.js} +1 -1
  44. package/dist/select-ClSy-J1f.d.ts +100 -0
  45. package/dist/selection-B_pmzHpy.d.ts +150 -0
  46. package/package.json +21 -2
  47. package/recipes/context-menu/styles.css +177 -0
  48. package/recipes/input/styles.css +9 -0
  49. package/recipes/palette/styles.css +3 -0
  50. package/recipes/search/tailwind.tsx +3 -2
  51. package/recipes/select/styles.css +230 -0
  52. package/recipes/toast/styles.css +688 -192
  53. package/registry.json +434 -29
  54. package/src/core/context-menu.ts +694 -0
  55. package/src/core/keys.ts +264 -0
  56. package/src/core/search.ts +25 -1
  57. package/src/core/select.ts +404 -0
  58. package/src/core/selection.ts +648 -0
  59. package/src/index.ts +60 -1
  60. package/src/react/context-menu/context.ts +57 -0
  61. package/src/react/context-menu/index.tsx +94 -0
  62. package/src/react/context-menu/root.tsx +846 -0
  63. package/src/react/context-menu/styles.ts +186 -0
  64. package/src/react/index.ts +70 -2
  65. package/src/react/palette/root.tsx +2 -2
  66. package/src/react/select/context.ts +56 -0
  67. package/src/react/select/index.tsx +96 -0
  68. package/src/react/select/root.tsx +839 -0
  69. package/src/react/select/styles.ts +238 -0
  70. package/src/react/selection/index.tsx +115 -0
  71. package/src/react/selection/use-selection.ts +260 -0
  72. package/src/react/toast/NOTICE +20 -0
  73. package/src/react/toast/assets.tsx +85 -0
  74. package/src/react/toast/cn.ts +10 -0
  75. package/src/react/toast/hooks.ts +13 -0
  76. package/src/react/toast/index.tsx +736 -0
  77. package/src/react/toast/state.ts +207 -0
  78. package/src/react/toast/styles.ts +738 -0
  79. package/src/react/toast/types.ts +193 -0
  80. package/src/react/toaster.tsx +76 -341
  81. package/dist/chunk-JCCL7XKC.js +0 -465
  82. package/src/react/toast-styles.ts +0 -247
  83. /package/dist/{chunk-U3V4EHOB.js → chunk-3HDEZ2E7.js} +0 -0
@@ -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
+ }