@enigmax/primitives 0.18.0 → 0.19.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.
@@ -0,0 +1,243 @@
1
+ /*
2
+ * The palette's look.
3
+ *
4
+ * `SearchPalette` renders the structure, the state and every key it answers to; every
5
+ * colour, distance and curve is in this file. Nothing below is something the package decided
6
+ * for you, so editing it is the supported way to make it yours.
7
+ *
8
+ * The custom properties at the top are the whole API. Override them on `:root` and you have
9
+ * a different palette without touching a selector.
10
+ */
11
+
12
+ :root {
13
+ --enigma-palette-width: 34rem;
14
+ --enigma-palette-radius: 0.85rem;
15
+ --enigma-palette-top: 12vh;
16
+
17
+ --enigma-palette-bg: #101010;
18
+ --enigma-palette-border: #2a2a2a;
19
+ --enigma-palette-text: #f5f5f5;
20
+ --enigma-palette-muted: #a3a3a3;
21
+ --enigma-palette-active: #1c1c1c;
22
+ --enigma-palette-accent: #e0a458;
23
+ --enigma-palette-shadow: 0 20px 60px rgb(0 0 0 / 55%);
24
+ --enigma-palette-scrim: rgb(0 0 0 / 55%);
25
+ --enigma-palette-enter: 160ms;
26
+ --enigma-palette-exit: 140ms;
27
+ }
28
+
29
+ @media (prefers-color-scheme: light) {
30
+ :root {
31
+ --enigma-palette-bg: #ffffff;
32
+ --enigma-palette-border: #e5e5e5;
33
+ --enigma-palette-text: #171717;
34
+ --enigma-palette-muted: #737373;
35
+ --enigma-palette-active: #f5f5f5;
36
+ --enigma-palette-shadow: 0 20px 60px rgb(0 0 0 / 18%);
37
+ --enigma-palette-scrim: rgb(0 0 0 / 25%);
38
+ }
39
+ }
40
+
41
+ /* -------- the trigger -------- */
42
+
43
+ [data-enigma-palette-trigger] {
44
+ display: inline-flex;
45
+ align-items: center;
46
+ gap: 0.5rem;
47
+ min-width: 12rem;
48
+ padding: 0.45rem 0.6rem;
49
+ font: inherit;
50
+ font-size: 0.8125rem;
51
+ /* Set explicitly: this may be rendered as a button or, through asChild, as something
52
+ else entirely, and only a button gets `line-height: normal` from the user agent. */
53
+ line-height: 1.25;
54
+ color: var(--enigma-palette-muted);
55
+ background: transparent;
56
+ border: 1px solid var(--enigma-palette-border);
57
+ border-radius: 0.6rem;
58
+ cursor: pointer;
59
+ }
60
+
61
+ [data-enigma-palette-trigger]:hover { color: var(--enigma-palette-text); }
62
+ [data-enigma-palette-trigger-label] { flex: 1; text-align: left; }
63
+
64
+ [data-enigma-palette-trigger-key] {
65
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
66
+ font-size: 0.6875rem;
67
+ padding: 0.15rem 0.35rem;
68
+ border: 1px solid var(--enigma-palette-border);
69
+ border-radius: 0.3rem;
70
+ }
71
+
72
+ /* -------- the panel -------- */
73
+
74
+ [data-enigma-palette-overlay] {
75
+ position: fixed;
76
+ inset: 0;
77
+ z-index: 9998;
78
+ background: var(--enigma-palette-scrim);
79
+ /* Behind the panel, and the only thing a click outside can land on. */
80
+ backdrop-filter: blur(2px);
81
+ animation: enigma-palette-fade var(--enigma-palette-enter) ease-out;
82
+ }
83
+
84
+ /* The panel stays in the DOM through its exit (`closeDuration`), which is the only way a
85
+ component that unmounts on close can animate out at all. */
86
+ [data-enigma-palette-overlay][data-state="closed"] {
87
+ animation: enigma-palette-fade var(--enigma-palette-exit) ease-in reverse forwards;
88
+ }
89
+
90
+ [data-enigma-palette-content] {
91
+ position: fixed;
92
+ z-index: 9999;
93
+ top: var(--enigma-palette-top);
94
+ left: 50%;
95
+ transform: translateX(-50%);
96
+ display: flex;
97
+ flex-direction: column;
98
+ width: min(var(--enigma-palette-width), calc(100vw - 2rem));
99
+ /* Never taller than the room it has: the list scrolls, the field and the footer stay. */
100
+ max-height: min(70vh, 32rem);
101
+ overflow: hidden;
102
+ color: var(--enigma-palette-text);
103
+ background: var(--enigma-palette-bg);
104
+ border: 1px solid var(--enigma-palette-border);
105
+ border-radius: var(--enigma-palette-radius);
106
+ box-shadow: var(--enigma-palette-shadow);
107
+ animation: enigma-palette-in var(--enigma-palette-enter) cubic-bezier(0.16, 1, 0.3, 1);
108
+ }
109
+
110
+ [data-enigma-palette-content][data-state="closed"] {
111
+ animation: enigma-palette-in var(--enigma-palette-exit) ease-in reverse forwards;
112
+ }
113
+
114
+ @keyframes enigma-palette-fade {
115
+ from { opacity: 0; }
116
+ to { opacity: 1; }
117
+ }
118
+
119
+ /* Down and in: the panel arrives rather than appearing whole. */
120
+ @keyframes enigma-palette-in {
121
+ from { opacity: 0; transform: translateX(-50%) translateY(-8px) scale(0.98); }
122
+ to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
123
+ }
124
+
125
+ /* Reduced motion keeps the fade and drops the movement: the panel still has to announce
126
+ itself as new, and a hard cut in the middle of the screen is its own kind of jolt. */
127
+ @media (prefers-reduced-motion: reduce) {
128
+ [data-enigma-palette-content],
129
+ [data-enigma-palette-content][data-state="closed"] {
130
+ animation-name: enigma-palette-fade;
131
+ animation-duration: 90ms;
132
+ }
133
+ }
134
+
135
+ /* The dialog needs a name; nobody needs to see it. */
136
+ [data-enigma-palette-title] {
137
+ position: absolute;
138
+ width: 1px;
139
+ height: 1px;
140
+ margin: -1px;
141
+ padding: 0;
142
+ overflow: hidden;
143
+ clip-path: inset(50%);
144
+ white-space: nowrap;
145
+ }
146
+
147
+ [data-enigma-palette-field] {
148
+ display: block;
149
+ width: 100%;
150
+ padding: 0.95rem 1.1rem;
151
+ font: inherit;
152
+ font-size: 0.95rem;
153
+ /* Both set explicitly. A field inherits nothing useful from a dialog - a page that
154
+ styles `input` at all (a reset, a framework, the site's own form CSS) otherwise
155
+ reaches in and leaves the palette with a field that does not match its own panel. */
156
+ color: var(--enigma-palette-text);
157
+ background: var(--enigma-palette-bg);
158
+ border: 0;
159
+ border-bottom: 1px solid var(--enigma-palette-border);
160
+ border-radius: 0;
161
+ outline: none;
162
+ box-shadow: none;
163
+ }
164
+
165
+ [data-enigma-palette-field]::placeholder { color: var(--enigma-palette-muted); }
166
+ /* The platform's own clear button would sit next to a list that already answers to Escape. */
167
+ [data-enigma-palette-field]::-webkit-search-cancel-button { display: none; }
168
+
169
+ [data-enigma-palette-list] {
170
+ flex: 1;
171
+ overflow-y: auto;
172
+ /* A scroll that reaches the end stays in the panel instead of moving the page under it. */
173
+ overscroll-behavior: contain;
174
+ padding: 0.4rem;
175
+ }
176
+
177
+ [data-enigma-palette-group-label] {
178
+ margin: 0.4rem 0.6rem 0.25rem;
179
+ font-size: 0.6875rem;
180
+ font-weight: 600;
181
+ letter-spacing: 0.04em;
182
+ text-transform: uppercase;
183
+ color: var(--enigma-palette-muted);
184
+ }
185
+
186
+ [data-enigma-palette-item] {
187
+ display: flex;
188
+ align-items: center;
189
+ gap: 0.6rem;
190
+ padding: 0.55rem 0.6rem;
191
+ border-radius: 0.5rem;
192
+ cursor: pointer;
193
+ }
194
+
195
+ /* One highlight, whether it was the keyboard or the pointer that moved it. */
196
+ [data-enigma-palette-item][data-active="true"] { background: var(--enigma-palette-active); }
197
+ [data-enigma-palette-item][data-kind="recent"] [data-enigma-palette-item-label]::before {
198
+ content: "";
199
+ display: inline-block;
200
+ width: 0.35rem;
201
+ height: 0.35rem;
202
+ margin-right: 0.5rem;
203
+ vertical-align: 0.1rem;
204
+ border-radius: 999px;
205
+ background: var(--enigma-palette-accent);
206
+ }
207
+
208
+ [data-enigma-palette-item-label] { flex: 1; min-width: 0; }
209
+ [data-enigma-palette-item-description] { color: var(--enigma-palette-muted); font-size: 0.8125rem; }
210
+
211
+ [data-enigma-palette-empty] {
212
+ margin: 0;
213
+ padding: 2.5rem 1rem;
214
+ text-align: center;
215
+ color: var(--enigma-palette-muted);
216
+ font-size: 0.875rem;
217
+ }
218
+
219
+ [data-enigma-palette-footer] {
220
+ display: flex;
221
+ gap: 1rem;
222
+ padding: 0.55rem 0.9rem;
223
+ font-size: 0.75rem;
224
+ color: var(--enigma-palette-muted);
225
+ border-top: 1px solid var(--enigma-palette-border);
226
+ }
227
+
228
+ [data-enigma-palette-hint] kbd {
229
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
230
+ font-size: 0.6875rem;
231
+ padding: 0.1rem 0.3rem;
232
+ margin-right: 0.2rem;
233
+ border: 1px solid var(--enigma-palette-border);
234
+ border-radius: 0.25rem;
235
+ }
236
+
237
+ /* A phone has no Escape key and less room: the panel takes the top of the screen. */
238
+ @media (max-width: 640px) {
239
+ [data-enigma-palette-content] {
240
+ top: 0.75rem;
241
+ max-height: calc(100vh - 1.5rem);
242
+ }
243
+ }
package/registry.json CHANGED
@@ -515,6 +515,14 @@
515
515
  "react"
516
516
  ]
517
517
  },
518
+ {
519
+ "path": "recipes/palette/styles.css",
520
+ "dest": "palette/styles.css",
521
+ "targets": [
522
+ "react"
523
+ ],
524
+ "style": "css"
525
+ },
518
526
  {
519
527
  "path": "src/react/palette/context.ts",
520
528
  "dest": "palette/context.ts",
@@ -572,7 +580,10 @@
572
580
  "[data-enigma-palette-footer]",
573
581
  "[data-enigma-palette-hint]"
574
582
  ],
575
- "docs": "docs/notes/primitives.md#palette"
583
+ "docs": "docs/notes/primitives.md#palette",
584
+ "recipes": [
585
+ "css"
586
+ ]
576
587
  },
577
588
  {
578
589
  "name": "flags",
@@ -106,6 +106,61 @@ function substringMatcher<T>(query: string, items: readonly T[], keys: string[])
106
106
  return results.sort((left, right) => left.score - right.score);
107
107
  }
108
108
 
109
+ /**
110
+ * Subsequence ranking: every letter of the query, in order, anywhere in the text.
111
+ *
112
+ * This is what a COMMAND palette wants and a substring filter cannot do: "qgate" finds
113
+ * "Quality gate" and "plyg" finds "Playground", because the letters only have to appear in
114
+ * order. The score rewards consecutive runs and word starts and penalises gaps, so the
115
+ * closest thing to what was typed comes first rather than the first thing that happened to
116
+ * contain the letters.
117
+ *
118
+ * Pass it as `matcher`, or take it as the default from the palette. The substring matcher
119
+ * stays the default for a plain search FIELD, where a typo should fail rather than quietly
120
+ * match something four words away.
121
+ */
122
+ export function subsequenceMatcher<T>(keys: string[] = []): NonNullable<SearchOptions<T>["matcher"]> {
123
+ return (query, items) => {
124
+ const needle = fold(query);
125
+ const results: SearchMatch<T>[] = [];
126
+
127
+ for (const item of items) {
128
+ const fields = keys.length ? keys : [""];
129
+ let best: SearchMatch<T> | null = null;
130
+
131
+ for (const key of fields) {
132
+ const haystack = fold(key ? readPath(item, key) : String(item));
133
+ const score = subsequenceScore(needle, haystack);
134
+ if (score < 0) continue;
135
+ // The engine orders by ASCENDING score (Fuse's convention, 0 is exact), and
136
+ // this one counts upwards, so it is negated rather than compared backwards -
137
+ // getting that wrong ranks the worst match first and looks like a broken
138
+ // search rather than a sign error.
139
+ const ranked = -score;
140
+ if (!best || ranked < best.score) best = { item, score: ranked, key: key || undefined };
141
+ }
142
+ if (best) results.push(best);
143
+ }
144
+
145
+ return results.sort((left, right) => left.score - right.score);
146
+ };
147
+ }
148
+
149
+ /** -1 when the query is not a subsequence of the text; otherwise higher is better. */
150
+ function subsequenceScore(query: string, text: string): number {
151
+ if (!query) return 0;
152
+ let from = 0, score = 0, run = 0;
153
+ for (let i = 0; i < query.length; i++) {
154
+ const at = text.indexOf(query[i], from);
155
+ if (at === -1) return -1;
156
+ run = i > 0 && at === from ? run + 1 : 0;
157
+ const wordStart = at === 0 || /[^a-z0-9]/.test(text[at - 1]);
158
+ score += 10 + run * 6 + (wordStart ? 8 : 0) - Math.min(at - from, 6);
159
+ from = at + 1;
160
+ }
161
+ return score;
162
+ }
163
+
109
164
  export function createSearch<T>(options: SearchOptions<T> = {}): SearchInstance<T> {
110
165
  let opts: SearchOptions<T> = { ...options };
111
166
  let items: readonly T[] = opts.items ?? [];
@@ -205,6 +260,11 @@ export function createSearch<T>(options: SearchOptions<T> = {}): SearchInstance<
205
260
  input.addEventListener("input", onInput);
206
261
  input.addEventListener("keydown", onKeyDown);
207
262
  input.dataset.enigmaSearch = "";
263
+ // A field can already hold a value when the engine reaches it: a query restored
264
+ // from the URL, a browser refill, or simply someone typing before the module
265
+ // that searches finished loading. Binding without reading it leaves the field
266
+ // full and the list empty until the NEXT keystroke, which reads as broken.
267
+ if (input.value) this.searchNow(input.value);
208
268
  return () => {
209
269
  input.removeEventListener("input", onInput);
210
270
  input.removeEventListener("keydown", onKeyDown);
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { createMarquee, type MarqueeOptions, type MarqueeInstance, type MarqueeHover } from "@/core/marquee";
2
2
  export { createInput, type InputOptions, type InputInstance, type InputAction, type InputIcon, type InputActionState } from "@/core/input";
3
- export { createSearch, type SearchOptions, type SearchInstance, type SearchMatch, type FuseConstructor, type FuseLike } from "@/core/search";
3
+ export { createSearch, subsequenceMatcher, type SearchOptions, type SearchInstance, type SearchMatch, type FuseConstructor, type FuseLike } from "@/core/search";
4
4
  export { createButton, type ButtonOptions, type ButtonInstance, type ButtonState, type ButtonElement, type ButtonCooldown } from "@/core/button";
5
5
  export { INPUT_ICON_PATHS, iconMarkup } from "@/core/input";
6
6
  export {
@@ -45,3 +45,19 @@ export {
45
45
  type FlagFormat,
46
46
  type FlagSource
47
47
  } from "@/core/flags";
48
+ // The palette's non-rendering half: no framework anywhere near it, so a vanilla or Astro
49
+ // page can run the same keyboard arithmetic, grouping and history the React palette does.
50
+ export {
51
+ createRecentStore,
52
+ recentKey,
53
+ groupRows,
54
+ moveActive,
55
+ shortcutLabel,
56
+ isPaletteShortcut,
57
+ type RecentEntry,
58
+ type RecentStore,
59
+ type RecentStoreOptions,
60
+ type RowGroup,
61
+ type PositionedRow,
62
+ type PaletteKey
63
+ } from "@/core/palette";
@@ -40,6 +40,16 @@ export { Toaster, type ToasterProps, type ToastPosition, type ToastControls } fr
40
40
  export { useNetworkState } from "@/react/use-network-state";
41
41
  export { type NetworkState } from "@/core/network";
42
42
  export { RelativeTime, type RelativeTimeProps } from "@/react/relative-time";
43
+ // The option types too: a wrapper around any of these has to be able to name its own props.
44
+ export type {
45
+ RelativeTimeOptions,
46
+ RelativeTimeFormat,
47
+ RelativeTimeTense,
48
+ RelativeTimePrecision,
49
+ RelativeTimeStyle
50
+ } from "@/core/relative-time";
51
+ export type { NotificationTone, NotificationAction, NotificationInput, Notification, NotificationsOptions } from "@/core/notifications";
52
+ export type { SearchInstance, FuseLike } from "@/core/search";
43
53
  export { Flag, type FlagProps } from "@/react/flag";
44
54
  export {
45
55
  flagSrc,
@@ -3,7 +3,7 @@
3
3
  import { Slot } from "@/react/slot";
4
4
  import { createPortal } from "react-dom";
5
5
  import { PaletteContext, usePaletteContext, type PaletteRow } from "@/react/palette/context";
6
- import { createSearch, type SearchInstance, type SearchMatch, type SearchOptions } from "@/core/search";
6
+ import { createSearch, subsequenceMatcher, type SearchInstance, type SearchMatch, type SearchOptions } from "@/core/search";
7
7
  import { createRecentStore, groupRows, moveActive, shortcutLabel, isPaletteShortcut, type RecentEntry, type PaletteKey } from "@/core/palette";
8
8
  import {
9
9
  useCallback, useEffect, useId, useMemo, useRef, useState,
@@ -146,12 +146,28 @@ export function PaletteRoot<Item>({
146
146
 
147
147
  /* -------- the engine -------- */
148
148
 
149
+ /**
150
+ * A palette ranks by SUBSEQUENCE unless told otherwise: "qgate" has to find "Quality
151
+ * gate" and "plyg" has to find "Playground", which a substring filter cannot do. A plain
152
+ * search field keeps the substring matcher, where a typo should fail rather than quietly
153
+ * match something four words away.
154
+ *
155
+ * Computed ONCE and used by both the constructor and the update below. Passing the raw
156
+ * prop to `update` instead is how the first version lost it: the effect overwrote the
157
+ * default with `undefined` on the very next render, and the palette silently went back
158
+ * to substring matching.
159
+ */
160
+ const ranking = useMemo(
161
+ () => matcher ?? (fuse ? undefined : subsequenceMatcher<Item>(keys ?? [])),
162
+ [matcher, fuse, keys]
163
+ );
164
+
149
165
  const engine = useMemo<SearchInstance<Item>>(() => createSearch<Item>({
150
166
  items,
151
167
  keys,
152
168
  fuse,
153
169
  fuseOptions,
154
- matcher,
170
+ matcher: ranking,
155
171
  debounce: delay,
156
172
  limit,
157
173
  onResults: (next) => {
@@ -165,7 +181,7 @@ export function PaletteRoot<Item>({
165
181
 
166
182
  useEffect(() => () => engine.destroy(), [engine]);
167
183
  useEffect(() => { engine.setItems(items ?? []); }, [engine, items]);
168
- useEffect(() => { engine.update({ keys, fuse, fuseOptions, matcher, debounce: delay, limit }); }, [engine, keys, fuse, fuseOptions, matcher, delay, limit]);
184
+ useEffect(() => { engine.update({ keys, fuse, fuseOptions, matcher: ranking, debounce: delay, limit }); }, [engine, keys, fuse, fuseOptions, ranking, delay, limit]);
169
185
 
170
186
  /* -------- opening and closing -------- */
171
187
 
@@ -349,6 +365,15 @@ export interface PaletteContentProps extends ComponentPropsWithoutRef<"div"> {
349
365
  portal?: boolean;
350
366
  /** Rendered behind the panel. Pass null for no overlay of ours. */
351
367
  overlayProps?: ComponentPropsWithoutRef<"div"> | null;
368
+ /**
369
+ * How long the closing animation is given before the panel leaves the DOM, in ms.
370
+ *
371
+ * It exists because a component that unmounts on close can only ever animate IN: the
372
+ * element is gone before a leaving animation has a frame to run. `data-state="closed"`
373
+ * is set first, the stylesheet animates it, and only then does it unmount. 0 removes it
374
+ * immediately, which is also what a reader with reduced motion gets.
375
+ */
376
+ closeDuration?: number;
352
377
  }
353
378
 
354
379
  /**
@@ -357,15 +382,28 @@ export interface PaletteContentProps extends ComponentPropsWithoutRef<"div"> {
357
382
  * Mounted only while open, so nothing of the palette is in the document (or in the tab
358
383
  * order) the rest of the time.
359
384
  */
360
- export function PaletteContent({ title = "Search", portal = true, overlayProps, children, ...props }: PaletteContentProps): ReactNode {
385
+ export function PaletteContent({ title = "Search", portal = true, overlayProps, closeDuration = 160, children, ...props }: PaletteContentProps): ReactNode {
361
386
  const palette = usePaletteContext("SearchPalette.Content");
362
387
  const panelRef = useRef<HTMLDivElement | null>(null);
363
388
  const [mounted, setMounted] = useState(false);
389
+ /** Stays true through the closing animation, so the panel has frames to leave in. */
390
+ const [present, setPresent] = useState(palette.open);
364
391
 
365
392
  // A portal has no server render: `document` does not exist there, and rendering the
366
393
  // panel into the tree instead would put it in the wrong place for one frame.
367
394
  useEffect(() => setMounted(true), []);
368
395
 
396
+ useEffect(() => {
397
+ if (palette.open) {
398
+ setPresent(true);
399
+ return;
400
+ }
401
+ if (!present) return;
402
+ const reduced = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
403
+ const timer = setTimeout(() => setPresent(false), reduced ? 0 : closeDuration);
404
+ return () => clearTimeout(timer);
405
+ }, [palette.open, present, closeDuration]);
406
+
369
407
  useEffect(() => {
370
408
  if (!palette.open || typeof document === "undefined") return;
371
409
 
@@ -414,14 +452,15 @@ export function PaletteContent({ title = "Search", portal = true, overlayProps,
414
452
  };
415
453
  }, [palette.open, palette.setOpen, palette.triggerRef]);
416
454
 
417
- if (!palette.open) return null;
455
+ if (!present) return null;
418
456
 
419
457
  const panel = (
420
- <div data-enigma-palette-portal="">
458
+ <div data-enigma-palette-portal="" data-state={palette.open ? "open" : "closed"}>
421
459
  {overlayProps !== null && (
422
460
  <div
423
461
  {...overlayProps}
424
462
  data-enigma-palette-overlay=""
463
+ data-state={palette.open ? "open" : "closed"}
425
464
  // A click outside is a dismiss, and it is not a keyboard event, so it
426
465
  // never reaches the Escape handler.
427
466
  onClick={(event) => {
@@ -437,6 +476,7 @@ export function PaletteContent({ title = "Search", portal = true, overlayProps,
437
476
  aria-modal="true"
438
477
  aria-labelledby={palette.ids.title}
439
478
  data-enigma-palette-content=""
479
+ data-state={palette.open ? "open" : "closed"}
440
480
  >
441
481
  <h2 id={palette.ids.title} data-enigma-palette-title="">{title}</h2>
442
482
  {children}