@eifi1/ui-kit 0.8.0 → 0.9.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/README.md +4 -0
- package/dist/components/amount-input.d.ts +2 -0
- package/dist/components/calculator.d.ts +2 -0
- package/dist/components/chip.d.ts +27 -5
- package/dist/components/chip.js +6 -2
- package/dist/components/chip.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +122 -91
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +69 -3
- package/dist/components/date-picker.js +152 -65
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/disclosure.d.ts +11 -2
- package/dist/components/disclosure.js +10 -4
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/floating-panel.d.ts +29 -2
- package/dist/components/floating-panel.js +16 -2
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/full-bleed-dialog.js +3 -1
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/number-field.d.ts +2 -0
- package/dist/components/number-input.d.ts +2 -0
- package/dist/components/numpad-sheet.d.ts +2 -0
- package/dist/components/progress-bar.d.ts +16 -2
- package/dist/components/progress-bar.js +4 -2
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/treemap.js +4 -1
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +44 -14
- package/dist/components/ui.js +28 -22
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-overlay-history.js +84 -4
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +2 -0
- package/dist/i18n/defaults.js +2 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +3 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +2 -0
- package/dist/i18n/locales/de-CH.d.ts +2 -0
- package/dist/i18n/locales/de-informal.d.ts +2 -0
- package/dist/i18n/locales/de.d.ts +2 -0
- package/dist/i18n/locales/de.js +10 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +2 -0
- package/dist/i18n/locales/es.js +10 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +2 -0
- package/dist/i18n/locales/fr.js +10 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +2 -0
- package/dist/i18n/locales/hu.js +10 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +2 -0
- package/dist/i18n/locales/it.js +10 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +2 -0
- package/dist/i18n/locales/zh.js +10 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/search/command-palette.d.ts +49 -1
- package/dist/search/command-palette.js +132 -29
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.d.ts +141 -0
- package/dist/search/global-search.js +277 -0
- package/dist/search/global-search.js.map +1 -0
- package/dist/search/search-index.d.ts +105 -0
- package/dist/search/search-index.js +142 -0
- package/dist/search/search-index.js.map +1 -0
- package/dist/search.d.ts +2 -0
- package/dist/search.js +2 -0
- package/dist/search.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +10 -1
- package/dist/wizard/stepper-nav.js +2 -1
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/package.json +12 -3
- package/src/components/chip.tsx +36 -6
- package/src/components/data-table.tsx +81 -5
- package/src/components/date-picker.tsx +328 -117
- package/src/components/disclosure.tsx +24 -8
- package/src/components/floating-panel.tsx +46 -2
- package/src/components/full-bleed-dialog.tsx +7 -2
- package/src/components/progress-bar.tsx +27 -3
- package/src/components/treemap.tsx +3 -0
- package/src/components/ui.tsx +95 -37
- package/src/hooks/use-overlay-history.ts +181 -4
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/kit-labels.tsx +2 -0
- package/src/i18n/locales/de.ts +10 -0
- package/src/i18n/locales/es.ts +10 -0
- package/src/i18n/locales/fr.ts +10 -0
- package/src/i18n/locales/hu.ts +10 -0
- package/src/i18n/locales/it.ts +10 -0
- package/src/i18n/locales/zh.ts +10 -0
- package/src/index.ts +4 -0
- package/src/search/command-palette.tsx +200 -27
- package/src/search/global-search.tsx +484 -0
- package/src/search/search-index.ts +295 -0
- package/src/search.ts +2 -0
- package/src/wizard/stepper-nav.tsx +11 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
|
-
import { Search } from "lucide-react";
|
|
4
|
+
import { CornerDownLeft, Search, X } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
|
+
import { PHONE_QUERY } from "../components/ui";
|
|
7
|
+
import { useMediaQuery } from "../hooks/use-media-query";
|
|
6
8
|
import { useOverlayHistory } from "../hooks/use-overlay-history";
|
|
7
9
|
import { useFocusTrap } from "../hooks/use-focus-trap";
|
|
8
10
|
import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
|
|
@@ -27,6 +29,35 @@ export interface CommandItem {
|
|
|
27
29
|
* with a link and JavaScript cannot fake — the rejected alternative, an
|
|
28
30
|
* `onAuxClick` calling `window.open`, gets the middle click and nothing else. */
|
|
29
31
|
href?: string;
|
|
32
|
+
/** Marks this row's label and hint `data-private`, so session replay and screenshot
|
|
33
|
+
* tooling masks them — a payee, an account name, an amount. Overrides the palette's
|
|
34
|
+
* {@link CommandPaletteProps.redactLabels} either way, so `false` un-masks one
|
|
35
|
+
* harmless row ("Settings") in a palette that masks by default. */
|
|
36
|
+
redact?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* `"status"` makes the row a line of text rather than a choice — a group's
|
|
39
|
+
* "Searching…" while its source is still out, or its error. It is skipped by the
|
|
40
|
+
* arrow keys and ↵, has no hover highlight, and its `onSelect` is never called.
|
|
41
|
+
* Default `"option"`.
|
|
42
|
+
*/
|
|
43
|
+
kind?: "option" | "status";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Group items preserving first-seen group order; `flat` is the navigable rows only. */
|
|
47
|
+
function groupItems(items: CommandItem[]) {
|
|
48
|
+
const order: string[] = [];
|
|
49
|
+
const byGroup = new Map<string, CommandItem[]>();
|
|
50
|
+
for (const item of items) {
|
|
51
|
+
if (!byGroup.has(item.group)) {
|
|
52
|
+
byGroup.set(item.group, []);
|
|
53
|
+
order.push(item.group);
|
|
54
|
+
}
|
|
55
|
+
byGroup.get(item.group)!.push(item);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
groups: order.map((g) => ({ group: g, items: byGroup.get(g)! })),
|
|
59
|
+
flat: order.flatMap((g) => byGroup.get(g)!).filter((item) => item.kind !== "status"),
|
|
60
|
+
};
|
|
30
61
|
}
|
|
31
62
|
|
|
32
63
|
export interface CommandPaletteLabels {
|
|
@@ -49,6 +80,13 @@ export interface CommandPaletteLabels {
|
|
|
49
80
|
* rejects. Optional for the same reason as `dialog`; the default always carries it.
|
|
50
81
|
*/
|
|
51
82
|
error?: string;
|
|
83
|
+
/** Names the "×" that empties the field. Optional like `dialog`. */
|
|
84
|
+
clear?: string;
|
|
85
|
+
/** Names the button that commits the typed text when `searchOn="submit"`. */
|
|
86
|
+
submit?: string;
|
|
87
|
+
/** Names the close button of the full-screen phone presentation, which has no
|
|
88
|
+
* backdrop to tap and, on a phone, no Escape key. */
|
|
89
|
+
close?: string;
|
|
52
90
|
}
|
|
53
91
|
|
|
54
92
|
export const DEFAULT_COMMAND_PALETTE_LABELS: CommandPaletteLabels = {
|
|
@@ -57,6 +95,9 @@ export const DEFAULT_COMMAND_PALETTE_LABELS: CommandPaletteLabels = {
|
|
|
57
95
|
loading: "Searching…",
|
|
58
96
|
dialog: "Search",
|
|
59
97
|
error: "Search failed. Try again.",
|
|
98
|
+
clear: "Clear search",
|
|
99
|
+
submit: "Search",
|
|
100
|
+
close: "Close",
|
|
60
101
|
};
|
|
61
102
|
|
|
62
103
|
/** The defaults minus `dialog`, so a resolved `dialog` means someone SUPPLIED one —
|
|
@@ -105,6 +146,35 @@ interface CommandPaletteProps {
|
|
|
105
146
|
/** Every edit of the field's text. Fires in both modes — uncontrolled, it only
|
|
106
147
|
* observes. */
|
|
107
148
|
onQueryChange?: (query: string) => void;
|
|
149
|
+
/**
|
|
150
|
+
* When the typed text becomes THE query — the one `search` runs on and
|
|
151
|
+
* `onQueryChange` reports.
|
|
152
|
+
*
|
|
153
|
+
* - `"input"` (default): every keystroke, debounced for the search, as before.
|
|
154
|
+
* - `"submit"`: typing edits a draft; the query is committed by ↵ or the submit
|
|
155
|
+
* button beside the field. Until then the results stay on the last committed query
|
|
156
|
+
* and `onQueryChange` stays quiet — which is what an owner binding `query` to the
|
|
157
|
+
* URL's `q` needs, or every keystroke re-filters the page behind and pushes history.
|
|
158
|
+
* ↵ with a draft that differs from the committed query COMMITS; ↵ with nothing new
|
|
159
|
+
* typed chooses the highlighted row, as in `"input"` mode. The draft follows any
|
|
160
|
+
* outside change of the committed query (Back through the URL), and an uncommitted
|
|
161
|
+
* draft is dropped when the palette is closed.
|
|
162
|
+
*
|
|
163
|
+
* The clear "×" commits the empty query in both modes: an emptied box over results
|
|
164
|
+
* (or a page) still filtered by the old text is the stuck state Keksdose #423 was.
|
|
165
|
+
*/
|
|
166
|
+
searchOn?: "input" | "submit";
|
|
167
|
+
/** Mark every row's label and hint `data-private` (see {@link CommandItem.redact},
|
|
168
|
+
* which overrides it per row). Off by default. */
|
|
169
|
+
redactLabels?: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* Below the phone breakpoint ({@link PHONE_QUERY}) the palette fills the screen:
|
|
172
|
+
* no inset, no rounded panel, the field pinned at the top inside the safe areas and
|
|
173
|
+
* the list scrolling under it, with a close button since there is no backdrop to tap.
|
|
174
|
+
* On by default — a top-centred card at 70vh leaves little room once the keyboard is
|
|
175
|
+
* up. `false` keeps the card on every viewport.
|
|
176
|
+
*/
|
|
177
|
+
fullScreenOnPhone?: boolean;
|
|
108
178
|
labels?: Partial<CommandPaletteLabels>;
|
|
109
179
|
}
|
|
110
180
|
|
|
@@ -135,6 +205,9 @@ export function CommandPalette({
|
|
|
135
205
|
revision,
|
|
136
206
|
query: queryProp,
|
|
137
207
|
onQueryChange,
|
|
208
|
+
searchOn = "input",
|
|
209
|
+
redactLabels = false,
|
|
210
|
+
fullScreenOnPhone = true,
|
|
138
211
|
labels,
|
|
139
212
|
}: CommandPaletteProps) {
|
|
140
213
|
const l = useKitLabels("commandPalette", DEFAULTS_WITHOUT_DIALOG, labels);
|
|
@@ -152,6 +225,23 @@ export function CommandPalette({
|
|
|
152
225
|
if (!controlled) setOwnQuery(next);
|
|
153
226
|
onQueryChange?.(next);
|
|
154
227
|
};
|
|
228
|
+
const submitMode = searchOn === "submit";
|
|
229
|
+
// The field's text in `"submit"` mode. It follows the committed query whenever that
|
|
230
|
+
// changes — adjusted during render, React's pattern for state derived from a prop,
|
|
231
|
+
// rather than in an effect that would paint the stale draft first.
|
|
232
|
+
const [draft, setDraft] = useState(query);
|
|
233
|
+
const [draftBase, setDraftBase] = useState(query);
|
|
234
|
+
if (draftBase !== query) {
|
|
235
|
+
setDraftBase(query);
|
|
236
|
+
setDraft(query);
|
|
237
|
+
}
|
|
238
|
+
const fieldText = submitMode ? draft : query;
|
|
239
|
+
const commit = (next: string) => {
|
|
240
|
+
setDraft(next);
|
|
241
|
+
setQuery(next);
|
|
242
|
+
};
|
|
243
|
+
const dirty = submitMode && draft !== query;
|
|
244
|
+
const isPhone = useMediaQuery(PHONE_QUERY, false) && fullScreenOnPhone;
|
|
155
245
|
const [results, setResults] = useState<CommandItem[]>([]);
|
|
156
246
|
const [loading, setLoading] = useState(false);
|
|
157
247
|
const [failed, setFailed] = useState(false);
|
|
@@ -169,6 +259,11 @@ export function CommandPalette({
|
|
|
169
259
|
const searchRef = useRef(search);
|
|
170
260
|
searchRef.current = search;
|
|
171
261
|
const reqId = useRef(0);
|
|
262
|
+
/** The query the results on screen answer, and the row highlighted in them — so a
|
|
263
|
+
* re-run for the SAME query (a `revision` change: a source streaming in) keeps the
|
|
264
|
+
* highlight on its row instead of throwing it back to the top mid-arrow. */
|
|
265
|
+
const shownQuery = useRef<string | null>(null);
|
|
266
|
+
const activeIdRef = useRef<string | undefined>(undefined);
|
|
172
267
|
|
|
173
268
|
// Back dismisses the palette (Keksdose feedback #172). Declared with the other
|
|
174
269
|
// hooks, above the `if (!open) return null` below — a hook past a conditional
|
|
@@ -187,7 +282,10 @@ export function CommandPalette({
|
|
|
187
282
|
useEffect(() => {
|
|
188
283
|
if (!open) return;
|
|
189
284
|
if (!controlled) setOwnQuery("");
|
|
285
|
+
// An uncommitted draft does not outlive the open it was typed in.
|
|
286
|
+
setDraft(controlled ? query : "");
|
|
190
287
|
setActive(0);
|
|
288
|
+
shownQuery.current = null;
|
|
191
289
|
// `controlled` is read at the open, not watched: a caller switching modes while
|
|
192
290
|
// the palette is open is not a reason to wipe what is typed.
|
|
193
291
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -202,14 +300,18 @@ export function CommandPalette({
|
|
|
202
300
|
try {
|
|
203
301
|
const r = await Promise.resolve(searchRef.current(query));
|
|
204
302
|
if (reqId.current === id) {
|
|
303
|
+
const keep = shownQuery.current === query ? activeIdRef.current : undefined;
|
|
304
|
+
const at = keep === undefined ? -1 : groupItems(r).flat.findIndex((item) => item.id === keep);
|
|
305
|
+
shownQuery.current = query;
|
|
205
306
|
setResults(r);
|
|
206
307
|
setFailed(false);
|
|
207
|
-
setActive(0);
|
|
308
|
+
setActive(Math.max(at, 0));
|
|
208
309
|
}
|
|
209
310
|
} catch (err) {
|
|
210
311
|
// There was no catch: the previous query's results stayed on screen as if they
|
|
211
312
|
// answered this one, and the rejection went unhandled. Clear them and say so.
|
|
212
313
|
if (reqId.current === id) {
|
|
314
|
+
shownQuery.current = null;
|
|
213
315
|
setResults([]);
|
|
214
316
|
setFailed(true);
|
|
215
317
|
setActive(0);
|
|
@@ -227,21 +329,10 @@ export function CommandPalette({
|
|
|
227
329
|
}, [query, open, revision]);
|
|
228
330
|
|
|
229
331
|
// Group results, preserving first-seen group order; keep a flat list for nav.
|
|
230
|
-
const { groups, flat } = useMemo(() =>
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (!byGroup.has(item.group)) {
|
|
235
|
-
byGroup.set(item.group, []);
|
|
236
|
-
order.push(item.group);
|
|
237
|
-
}
|
|
238
|
-
byGroup.get(item.group)!.push(item);
|
|
239
|
-
}
|
|
240
|
-
return {
|
|
241
|
-
groups: order.map((g) => ({ group: g, items: byGroup.get(g)! })),
|
|
242
|
-
flat: order.flatMap((g) => byGroup.get(g)!),
|
|
243
|
-
};
|
|
244
|
-
}, [results]);
|
|
332
|
+
const { groups, flat } = useMemo(() => groupItems(results), [results]);
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
activeIdRef.current = flat[active]?.id;
|
|
335
|
+
});
|
|
245
336
|
|
|
246
337
|
const choose = (item: CommandItem | undefined) => {
|
|
247
338
|
if (!item) return;
|
|
@@ -258,7 +349,8 @@ export function CommandPalette({
|
|
|
258
349
|
setActive((i) => Math.max(i - 1, 0));
|
|
259
350
|
} else if (e.key === "Enter") {
|
|
260
351
|
e.preventDefault();
|
|
261
|
-
|
|
352
|
+
if (dirty) commit(draft);
|
|
353
|
+
else choose(flat[active]);
|
|
262
354
|
}
|
|
263
355
|
};
|
|
264
356
|
|
|
@@ -282,7 +374,10 @@ export function CommandPalette({
|
|
|
282
374
|
let flatIndex = -1;
|
|
283
375
|
return createPortal(
|
|
284
376
|
<div
|
|
285
|
-
className=
|
|
377
|
+
className={cn(
|
|
378
|
+
"fixed inset-0 z-[70] flex items-start justify-center bg-black/40",
|
|
379
|
+
isPhone ? "p-0" : "p-4 pt-[10vh]",
|
|
380
|
+
)}
|
|
286
381
|
onMouseDown={(e) => {
|
|
287
382
|
if (e.target === e.currentTarget) onClose();
|
|
288
383
|
}}
|
|
@@ -297,9 +392,27 @@ export function CommandPalette({
|
|
|
297
392
|
tabIndex={-1}
|
|
298
393
|
// Escape bubbling up from anything inside — a dialog is where it belongs.
|
|
299
394
|
onKeyDown={onDialogKeyDown}
|
|
300
|
-
|
|
395
|
+
data-fullscreen={isPhone || undefined}
|
|
396
|
+
className={cn(
|
|
397
|
+
"flex w-full flex-col overflow-hidden bg-[var(--bg-surface)] outline-none",
|
|
398
|
+
isPhone
|
|
399
|
+
? "h-full"
|
|
400
|
+
: "max-h-[70vh] max-w-xl rounded-xl border border-[var(--border)] shadow-2xl",
|
|
401
|
+
)}
|
|
402
|
+
// Full screen means edge to edge, so the notch, the home indicator and the
|
|
403
|
+
// rounded corners are the panel's to keep clear of.
|
|
404
|
+
style={
|
|
405
|
+
isPhone
|
|
406
|
+
? {
|
|
407
|
+
paddingTop: "max(0px, env(safe-area-inset-top))",
|
|
408
|
+
paddingBottom: "max(0px, env(safe-area-inset-bottom))",
|
|
409
|
+
paddingLeft: "max(0px, env(safe-area-inset-left))",
|
|
410
|
+
paddingRight: "max(0px, env(safe-area-inset-right))",
|
|
411
|
+
}
|
|
412
|
+
: undefined
|
|
413
|
+
}
|
|
301
414
|
>
|
|
302
|
-
<div className="flex items-center gap-2 border-b border-[var(--border)] px-3">
|
|
415
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--border)] px-3">
|
|
303
416
|
<Search className="size-4 shrink-0 text-[var(--text-placeholder)]" />
|
|
304
417
|
<input
|
|
305
418
|
ref={inputRef}
|
|
@@ -307,13 +420,54 @@ export function CommandPalette({
|
|
|
307
420
|
aria-expanded
|
|
308
421
|
aria-controls={listId}
|
|
309
422
|
aria-activedescendant={flat[active] ? itemId(flat[active].id) : undefined}
|
|
310
|
-
value={
|
|
311
|
-
onChange={(e) => setQuery(e.target.value)}
|
|
423
|
+
value={fieldText}
|
|
424
|
+
onChange={(e) => (submitMode ? setDraft(e.target.value) : setQuery(e.target.value))}
|
|
312
425
|
onKeyDown={onKeyDown}
|
|
313
426
|
placeholder={l.placeholder}
|
|
314
|
-
|
|
427
|
+
enterKeyHint={submitMode ? "search" : undefined}
|
|
428
|
+
className={cn(
|
|
429
|
+
"w-full bg-transparent py-3 outline-none placeholder:text-[var(--text-placeholder)] text-[var(--text-primary)]",
|
|
430
|
+
// 16px on a phone, or iOS zooms the page in on focus.
|
|
431
|
+
isPhone ? "text-base" : "text-sm",
|
|
432
|
+
)}
|
|
315
433
|
/>
|
|
316
434
|
{loading && <span className="shrink-0 text-[11px] text-[var(--text-placeholder)]">{l.loading}</span>}
|
|
435
|
+
{fieldText !== "" && (
|
|
436
|
+
<button
|
|
437
|
+
type="button"
|
|
438
|
+
aria-label={l.clear ?? DEFAULT_COMMAND_PALETTE_LABELS.clear}
|
|
439
|
+
onClick={() => {
|
|
440
|
+
commit("");
|
|
441
|
+
// Clearing starts the next query far more often than it ends this one.
|
|
442
|
+
inputRef.current?.focus();
|
|
443
|
+
}}
|
|
444
|
+
className="shrink-0 rounded p-1 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
|
|
445
|
+
>
|
|
446
|
+
<X className="size-4" />
|
|
447
|
+
</button>
|
|
448
|
+
)}
|
|
449
|
+
{dirty && (
|
|
450
|
+
<button
|
|
451
|
+
type="button"
|
|
452
|
+
aria-label={l.submit ?? DEFAULT_COMMAND_PALETTE_LABELS.submit}
|
|
453
|
+
onClick={() => {
|
|
454
|
+
commit(draft);
|
|
455
|
+
inputRef.current?.focus();
|
|
456
|
+
}}
|
|
457
|
+
className="shrink-0 rounded p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
458
|
+
>
|
|
459
|
+
<CornerDownLeft className="size-4" />
|
|
460
|
+
</button>
|
|
461
|
+
)}
|
|
462
|
+
{isPhone && (
|
|
463
|
+
<button
|
|
464
|
+
type="button"
|
|
465
|
+
onClick={onClose}
|
|
466
|
+
className="-me-1 shrink-0 rounded px-1 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
467
|
+
>
|
|
468
|
+
{l.close ?? DEFAULT_COMMAND_PALETTE_LABELS.close}
|
|
469
|
+
</button>
|
|
470
|
+
)}
|
|
317
471
|
</div>
|
|
318
472
|
|
|
319
473
|
{/* The status lines sit outside the listbox: a listbox owns options and groups,
|
|
@@ -323,7 +477,9 @@ export function CommandPalette({
|
|
|
323
477
|
{l.error ?? DEFAULT_COMMAND_PALETTE_LABELS.error}
|
|
324
478
|
</div>
|
|
325
479
|
)}
|
|
326
|
-
{
|
|
480
|
+
{/* `results`, not `flat`: a group whose only row is its "Searching…" line is not
|
|
481
|
+
an empty result. */}
|
|
482
|
+
{!failed && results.length === 0 && !loading && (
|
|
327
483
|
<div className="px-3 py-6 text-center text-sm text-[var(--text-muted)]">{l.empty}</div>
|
|
328
484
|
)}
|
|
329
485
|
{/* Listbox grouping: each group is a `role="group"` named by its heading, and
|
|
@@ -342,9 +498,22 @@ export function CommandPalette({
|
|
|
342
498
|
</div>
|
|
343
499
|
<ul role="group" aria-labelledby={groupId(groupIndex)}>
|
|
344
500
|
{items.map((item) => {
|
|
501
|
+
if (item.kind === "status") {
|
|
502
|
+
return (
|
|
503
|
+
<li
|
|
504
|
+
key={item.id}
|
|
505
|
+
role="presentation"
|
|
506
|
+
className="flex items-center gap-2.5 px-3 py-2 text-xs text-[var(--text-placeholder)]"
|
|
507
|
+
>
|
|
508
|
+
{item.icon && <span className="flex size-4 shrink-0 items-center justify-center">{item.icon}</span>}
|
|
509
|
+
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
|
510
|
+
</li>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
345
513
|
flatIndex += 1;
|
|
346
514
|
const idx = flatIndex;
|
|
347
515
|
const isActive = idx === active;
|
|
516
|
+
const redact = item.redact ?? redactLabels;
|
|
348
517
|
// Written once and worn by either tag below, so the anchor and the
|
|
349
518
|
// button can never drift apart in looks or in listbox semantics.
|
|
350
519
|
// `role="option"` on an <a href> is fine: the browser's middle-click
|
|
@@ -369,9 +538,13 @@ export function CommandPalette({
|
|
|
369
538
|
{item.icon}
|
|
370
539
|
</span>
|
|
371
540
|
)}
|
|
372
|
-
<span className="min-w-0 flex-1 truncate">
|
|
541
|
+
<span data-private={redact || undefined} className="min-w-0 flex-1 truncate">
|
|
542
|
+
{item.label}
|
|
543
|
+
</span>
|
|
373
544
|
{item.hint && (
|
|
374
|
-
<span className="shrink-0 text-xs text-[var(--text-placeholder)]">
|
|
545
|
+
<span data-private={redact || undefined} className="shrink-0 text-xs text-[var(--text-placeholder)]">
|
|
546
|
+
{item.hint}
|
|
547
|
+
</span>
|
|
375
548
|
)}
|
|
376
549
|
</>
|
|
377
550
|
);
|