@eifi1/ui-kit 0.8.1 → 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.
Files changed (101) hide show
  1. package/README.md +4 -0
  2. package/dist/components/amount-input.d.ts +2 -0
  3. package/dist/components/calculator.d.ts +2 -0
  4. package/dist/components/chip.d.ts +27 -5
  5. package/dist/components/chip.js +6 -2
  6. package/dist/components/chip.js.map +1 -1
  7. package/dist/components/data-table-filter-popover.d.ts +1 -1
  8. package/dist/components/data-table-filters.d.ts +1 -1
  9. package/dist/components/data-table.d.ts +1 -1
  10. package/dist/components/data-table.js +122 -91
  11. package/dist/components/data-table.js.map +1 -1
  12. package/dist/components/date-picker.d.ts +69 -3
  13. package/dist/components/date-picker.js +152 -65
  14. package/dist/components/date-picker.js.map +1 -1
  15. package/dist/components/disclosure.d.ts +11 -2
  16. package/dist/components/disclosure.js +10 -4
  17. package/dist/components/disclosure.js.map +1 -1
  18. package/dist/components/floating-panel.d.ts +29 -2
  19. package/dist/components/floating-panel.js +16 -2
  20. package/dist/components/floating-panel.js.map +1 -1
  21. package/dist/components/number-field.d.ts +2 -0
  22. package/dist/components/number-input.d.ts +2 -0
  23. package/dist/components/numpad-sheet.d.ts +2 -0
  24. package/dist/components/progress-bar.d.ts +16 -2
  25. package/dist/components/progress-bar.js +4 -2
  26. package/dist/components/progress-bar.js.map +1 -1
  27. package/dist/components/ui.d.ts +37 -14
  28. package/dist/components/ui.js +28 -22
  29. package/dist/components/ui.js.map +1 -1
  30. package/dist/components/use-table-state.d.ts +1 -1
  31. package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
  32. package/dist/data-table.d.ts +1 -1
  33. package/dist/hooks/use-overlay-history.js +84 -4
  34. package/dist/hooks/use-overlay-history.js.map +1 -1
  35. package/dist/i18n/defaults.d.ts +2 -0
  36. package/dist/i18n/defaults.js +2 -0
  37. package/dist/i18n/defaults.js.map +1 -1
  38. package/dist/i18n/kit-labels.d.ts +3 -0
  39. package/dist/i18n/kit-labels.js.map +1 -1
  40. package/dist/i18n/locales/de-CH-informal.d.ts +2 -0
  41. package/dist/i18n/locales/de-CH.d.ts +2 -0
  42. package/dist/i18n/locales/de-informal.d.ts +2 -0
  43. package/dist/i18n/locales/de.d.ts +2 -0
  44. package/dist/i18n/locales/de.js +10 -0
  45. package/dist/i18n/locales/de.js.map +1 -1
  46. package/dist/i18n/locales/es.d.ts +2 -0
  47. package/dist/i18n/locales/es.js +10 -0
  48. package/dist/i18n/locales/es.js.map +1 -1
  49. package/dist/i18n/locales/fr.d.ts +2 -0
  50. package/dist/i18n/locales/fr.js +10 -0
  51. package/dist/i18n/locales/fr.js.map +1 -1
  52. package/dist/i18n/locales/hu.d.ts +2 -0
  53. package/dist/i18n/locales/hu.js +10 -0
  54. package/dist/i18n/locales/hu.js.map +1 -1
  55. package/dist/i18n/locales/it.d.ts +2 -0
  56. package/dist/i18n/locales/it.js +10 -0
  57. package/dist/i18n/locales/it.js.map +1 -1
  58. package/dist/i18n/locales/zh.d.ts +2 -0
  59. package/dist/i18n/locales/zh.js +10 -0
  60. package/dist/i18n/locales/zh.js.map +1 -1
  61. package/dist/index.d.ts +5 -3
  62. package/dist/index.js +2 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/search/command-palette.d.ts +49 -1
  65. package/dist/search/command-palette.js +132 -29
  66. package/dist/search/command-palette.js.map +1 -1
  67. package/dist/search/global-search.d.ts +141 -0
  68. package/dist/search/global-search.js +277 -0
  69. package/dist/search/global-search.js.map +1 -0
  70. package/dist/search/search-index.d.ts +105 -0
  71. package/dist/search/search-index.js +142 -0
  72. package/dist/search/search-index.js.map +1 -0
  73. package/dist/search.d.ts +2 -0
  74. package/dist/search.js +2 -0
  75. package/dist/search.js.map +1 -1
  76. package/dist/wizard/stepper-nav.d.ts +10 -1
  77. package/dist/wizard/stepper-nav.js +2 -1
  78. package/dist/wizard/stepper-nav.js.map +1 -1
  79. package/package.json +12 -3
  80. package/src/components/chip.tsx +36 -6
  81. package/src/components/data-table.tsx +81 -5
  82. package/src/components/date-picker.tsx +328 -117
  83. package/src/components/disclosure.tsx +24 -8
  84. package/src/components/floating-panel.tsx +46 -2
  85. package/src/components/progress-bar.tsx +27 -3
  86. package/src/components/ui.tsx +88 -37
  87. package/src/hooks/use-overlay-history.ts +181 -4
  88. package/src/i18n/defaults.ts +2 -0
  89. package/src/i18n/kit-labels.tsx +2 -0
  90. package/src/i18n/locales/de.ts +10 -0
  91. package/src/i18n/locales/es.ts +10 -0
  92. package/src/i18n/locales/fr.ts +10 -0
  93. package/src/i18n/locales/hu.ts +10 -0
  94. package/src/i18n/locales/it.ts +10 -0
  95. package/src/i18n/locales/zh.ts +10 -0
  96. package/src/index.ts +4 -0
  97. package/src/search/command-palette.tsx +200 -27
  98. package/src/search/global-search.tsx +484 -0
  99. package/src/search/search-index.ts +295 -0
  100. package/src/search.ts +2 -0
  101. package/src/wizard/stepper-nav.tsx +11 -1
@@ -9,6 +9,7 @@ import { DEFAULT_PASSWORD_REVEAL_LABELS, DEFAULT_TABS_LABELS } from "../componen
9
9
  import { DEFAULT_WIZARD_LABELS } from "../wizard/types";
10
10
  import { DEFAULT_TOUR_LABELS } from "../tour/tour";
11
11
  import { DEFAULT_COMMAND_PALETTE_LABELS } from "../search/command-palette";
12
+ import { DEFAULT_GLOBAL_SEARCH_LABELS } from "../search/global-search";
12
13
  import { DEFAULT_SERIES_CHART_LABELS } from "../components/series-chart-labels";
13
14
  import { DEFAULT_SPARKLINE_LABELS } from "../components/sparkline";
14
15
  import { DEFAULT_STAT_TILE_LABELS } from "../components/stat-tile";
@@ -71,6 +72,7 @@ export const DEFAULT_UI_KIT_LABELS: UiKitLabels = {
71
72
  wizard: DEFAULT_WIZARD_LABELS,
72
73
  tour: DEFAULT_TOUR_LABELS,
73
74
  commandPalette: DEFAULT_COMMAND_PALETTE_LABELS,
75
+ globalSearch: DEFAULT_GLOBAL_SEARCH_LABELS,
74
76
  seriesChart: DEFAULT_SERIES_CHART_LABELS,
75
77
  sparkline: DEFAULT_SPARKLINE_LABELS,
76
78
  statTile: DEFAULT_STAT_TILE_LABELS,
@@ -9,6 +9,7 @@ import type { PasswordRevealLabels, TabsLabels } from "../components/ui";
9
9
  import type { WizardLabels } from "../wizard/types";
10
10
  import type { TourLabels } from "../tour/tour";
11
11
  import type { CommandPaletteLabels } from "../search/command-palette";
12
+ import type { GlobalSearchLabels } from "../search/global-search";
12
13
  import type { MonthPickerLabels } from "../components/month-picker";
13
14
  import type { PageContentsLabels } from "../components/page-contents";
14
15
  import type { SeriesChartLabels } from "../components/series-chart-labels";
@@ -212,6 +213,7 @@ export interface UiKitLabels {
212
213
  wizard: WizardLabels;
213
214
  tour: TourLabels;
214
215
  commandPalette: CommandPaletteLabels;
216
+ globalSearch: GlobalSearchLabels;
215
217
  seriesChart: SeriesChartLabels;
216
218
  sparkline: SparklineLabels;
217
219
  statTile: StatTileLabels;
@@ -302,12 +302,22 @@ export function uiKitLabelsDe(numberLocale = "de-DE"): UiKitLabels {
302
302
  step: (current, total) => `${n(current)} / ${n(total)}`,
303
303
  },
304
304
  commandPalette: {
305
+ clear: "Suche löschen",
306
+ submit: "Suchen",
307
+ close: "Schließen",
305
308
  placeholder: "Suchen…",
306
309
  empty: "Keine Ergebnisse",
307
310
  loading: "Wird gesucht…",
308
311
  dialog: "Suche",
309
312
  error: "Die Suche ist fehlgeschlagen. Bitte erneut versuchen.",
310
313
  },
314
+ globalSearch: {
315
+ trigger: "Suche",
316
+ placeholder: "Suchen oder springen zu…",
317
+ shortcut: (keys) => `Suche (${keys})`,
318
+ suggestions: "Vorschläge",
319
+ results: "Ergebnisse",
320
+ },
311
321
  sparkline: {
312
322
  rising: (first, last) => `Steigend von ${first} auf ${last}`,
313
323
  falling: (first, last) => `Fallend von ${first} auf ${last}`,
@@ -295,12 +295,22 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
295
295
  step: (current, total) => `${n(current)} / ${n(total)}`,
296
296
  },
297
297
  commandPalette: {
298
+ clear: "Borrar búsqueda",
299
+ submit: "Buscar",
300
+ close: "Cerrar",
298
301
  placeholder: "Buscar…",
299
302
  empty: "Sin resultados",
300
303
  loading: "Buscando…",
301
304
  dialog: "Búsqueda",
302
305
  error: "La búsqueda ha fallado. Inténtelo de nuevo.",
303
306
  },
307
+ globalSearch: {
308
+ trigger: "Buscar",
309
+ placeholder: "Buscar o ir a…",
310
+ shortcut: (keys) => `Buscar (${keys})`,
311
+ suggestions: "Pruebe",
312
+ results: "Resultados",
313
+ },
304
314
  sparkline: {
305
315
  rising: (first, last) => `Sube de ${first} a ${last}`,
306
316
  falling: (first, last) => `Baja de ${first} a ${last}`,
@@ -307,12 +307,22 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
307
307
  step: (current, total) => `${n(current)} / ${n(total)}`,
308
308
  },
309
309
  commandPalette: {
310
+ clear: "Effacer la recherche",
311
+ submit: "Rechercher",
312
+ close: "Fermer",
310
313
  placeholder: "Rechercher…",
311
314
  empty: "Aucun résultat",
312
315
  loading: "Recherche…",
313
316
  dialog: "Recherche",
314
317
  error: "La recherche a échoué. Réessayez.",
315
318
  },
319
+ globalSearch: {
320
+ trigger: "Rechercher",
321
+ placeholder: "Rechercher ou aller à…",
322
+ shortcut: (keys) => `Rechercher (${keys})`,
323
+ suggestions: "Essayez",
324
+ results: "Résultats",
325
+ },
316
326
  sparkline: {
317
327
  // "En hausse de 12 à 40" would read as "up BY 12", hence the colon.
318
328
  rising: (first, last) => `En hausse\u00a0: de ${first} à ${last}`,
@@ -297,12 +297,22 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
297
297
  step: (current, total) => `${n(current)} / ${n(total)}`,
298
298
  },
299
299
  commandPalette: {
300
+ clear: "Keresés törlése",
301
+ submit: "Keresés",
302
+ close: "Bezárás",
300
303
  placeholder: "Keresés…",
301
304
  empty: "Nincs találat",
302
305
  loading: "Keresés folyamatban…",
303
306
  dialog: "Keresés",
304
307
  error: "A keresés nem sikerült. Próbálja újra.",
305
308
  },
309
+ globalSearch: {
310
+ trigger: "Keresés",
311
+ placeholder: "Keresés vagy ugrás…",
312
+ shortcut: (keys) => `Keresés (${keys})`,
313
+ suggestions: "Próbálja ki",
314
+ results: "Találatok",
315
+ },
306
316
  sparkline: {
307
317
  // Numerals stay bare (see 3. above): "12-ről 40-re" would need vowel harmony.
308
318
  rising: (first, last) => `Emelkedik (első érték: ${first}, utolsó: ${last})`,
@@ -297,12 +297,22 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
297
297
  step: (current, total) => `${n(current)} / ${n(total)}`,
298
298
  },
299
299
  commandPalette: {
300
+ clear: "Cancella ricerca",
301
+ submit: "Cerca",
302
+ close: "Chiudi",
300
303
  placeholder: "Cerca…",
301
304
  empty: "Nessun risultato",
302
305
  loading: "Ricerca in corso…",
303
306
  dialog: "Ricerca",
304
307
  error: "Ricerca non riuscita. Riprova.",
305
308
  },
309
+ globalSearch: {
310
+ trigger: "Cerca",
311
+ placeholder: "Cerca o vai a…",
312
+ shortcut: (keys) => `Cerca (${keys})`,
313
+ suggestions: "Prova",
314
+ results: "Risultati",
315
+ },
306
316
  sparkline: {
307
317
  rising: (first, last) => `In aumento da ${first} a ${last}`,
308
318
  falling: (first, last) => `In calo da ${first} a ${last}`,
@@ -287,12 +287,22 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
287
287
  step: (current, total) => `${n(current)} / ${n(total)}`,
288
288
  },
289
289
  commandPalette: {
290
+ clear: "清除搜索",
291
+ submit: "搜索",
292
+ close: "关闭",
290
293
  placeholder: "搜索…",
291
294
  empty: "无结果",
292
295
  loading: "搜索中…",
293
296
  dialog: "搜索",
294
297
  error: "搜索失败,请重试。",
295
298
  },
299
+ globalSearch: {
300
+ trigger: "搜索",
301
+ placeholder: "搜索或跳转到…",
302
+ shortcut: (keys) => `搜索(${keys})`,
303
+ suggestions: "试试",
304
+ results: "结果",
305
+ },
296
306
  sparkline: {
297
307
  rising: (first, last) => `从 ${first} 上升到 ${last}`,
298
308
  falling: (first, last) => `从 ${first} 下降到 ${last}`,
package/src/index.ts CHANGED
@@ -163,6 +163,8 @@ export { FilterPopover } from "./components/data-table-filter-popover";
163
163
  export { DataTable } from "./components/data-table";
164
164
  export type {
165
165
  DataTableColumn,
166
+ DataTableCellProps,
167
+ DataTableHeadProps,
166
168
  DataTableProps,
167
169
  ServerPagination,
168
170
  FilterState,
@@ -217,6 +219,8 @@ export * from "./tour/tour";
217
219
 
218
220
  // ── command palette / global search ──────────────────────────────────────────
219
221
  export * from "./search/command-palette";
222
+ export * from "./search/search-index";
223
+ export * from "./search/global-search";
220
224
 
221
225
  // ── i18n: one label tree, one optional provider ──────────────────────────────
222
226
  // `UiKitLabels` names every string the kit renders; `<UiKitProvider labels locale>`
@@ -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
- const order: string[] = [];
232
- const byGroup = new Map<string, CommandItem[]>();
233
- for (const item of results) {
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
- choose(flat[active]);
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="fixed inset-0 z-[70] flex items-start justify-center bg-black/40 p-4 pt-[10vh]"
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
- className="flex max-h-[70vh] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-2xl outline-none"
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={query}
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
- className="w-full bg-transparent py-3 text-sm outline-none placeholder:text-[var(--text-placeholder)] text-[var(--text-primary)]"
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
- {!failed && flat.length === 0 && !loading && (
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">{item.label}</span>
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)]">{item.hint}</span>
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
  );