@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.
- package/dist/{chunk-UZFEEFMF.js → chunk-D5A2ZMAG.js} +34 -1
- package/dist/chunk-DTWZDONY.js +99 -0
- package/dist/{chunk-LKA2UG4P.js → chunk-FWVWX67R.js} +26 -106
- package/dist/{chunk-WSB655JB.js → chunk-QQFNAKMY.js} +1 -1
- package/dist/{chunk-IIT6U7LM.js → chunk-R4ZAEE7V.js} +1 -1
- package/dist/{index-DNXPtY9X.d.ts → index-dTdAbOWl.d.ts} +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/next/index.d.ts +6 -5
- package/dist/next/index.js +5 -4
- package/dist/palette-D7iuQh_T.d.ts +86 -0
- package/dist/react/index.d.ts +7 -6
- package/dist/react/index.js +5 -4
- package/dist/react/input.d.ts +2 -2
- package/dist/react/input.js +1 -1
- package/dist/react/palette.d.ts +231 -3
- package/dist/react/palette.js +3 -2
- package/dist/react/search.d.ts +1 -1
- package/dist/react/search.js +2 -2
- package/dist/react-router/index.d.ts +6 -5
- package/dist/react-router/index.js +5 -4
- package/dist/search/index.d.ts +2 -2
- package/dist/search/index.js +1 -1
- package/dist/{search-CsO3L1Lw.d.ts → search-DXxY8SEH.d.ts} +15 -1
- package/dist/{search-P4OHCRXD.js → search-UQEXAPQB.js} +1 -1
- package/package.json +3 -2
- package/recipes/input/styles.css +60 -20
- package/recipes/palette/styles.css +243 -0
- package/registry.json +12 -1
- package/src/core/search.ts +60 -0
- package/src/index.ts +17 -1
- package/src/react/index.ts +10 -0
- package/src/react/palette/root.tsx +46 -6
- package/dist/index-D-ARvWpQ.d.ts +0 -306
|
@@ -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",
|
package/src/core/search.ts
CHANGED
|
@@ -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";
|
package/src/react/index.ts
CHANGED
|
@@ -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,
|
|
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 (!
|
|
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}
|