@eifi1/ui-kit 0.8.1 → 0.10.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 (204) hide show
  1. package/README.md +25 -11
  2. package/dist/components/alert-banner.d.ts +32 -5
  3. package/dist/components/alert-banner.js +57 -14
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +6 -0
  6. package/dist/components/breadcrumbs.d.ts +60 -0
  7. package/dist/components/breadcrumbs.js +88 -0
  8. package/dist/components/breadcrumbs.js.map +1 -0
  9. package/dist/components/bulk-action-bar.d.ts +66 -0
  10. package/dist/components/bulk-action-bar.js +149 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/calculator.d.ts +6 -0
  13. package/dist/components/chip.d.ts +58 -10
  14. package/dist/components/chip.js +91 -7
  15. package/dist/components/chip.js.map +1 -1
  16. package/dist/components/copy-button.d.ts +13 -4
  17. package/dist/components/copy-button.js +17 -4
  18. package/dist/components/copy-button.js.map +1 -1
  19. package/dist/components/data-table-filter-popover.d.ts +1 -1
  20. package/dist/components/data-table-filters.d.ts +1 -1
  21. package/dist/components/data-table.d.ts +1 -1
  22. package/dist/components/data-table.js +122 -91
  23. package/dist/components/data-table.js.map +1 -1
  24. package/dist/components/date-picker.d.ts +89 -3
  25. package/dist/components/date-picker.js +163 -68
  26. package/dist/components/date-picker.js.map +1 -1
  27. package/dist/components/description-list.d.ts +43 -6
  28. package/dist/components/description-list.js +91 -11
  29. package/dist/components/description-list.js.map +1 -1
  30. package/dist/components/dialog-frame.d.ts +7 -0
  31. package/dist/components/dialog-frame.js.map +1 -1
  32. package/dist/components/disclosure.d.ts +68 -4
  33. package/dist/components/disclosure.js +33 -15
  34. package/dist/components/disclosure.js.map +1 -1
  35. package/dist/components/file-dropzone.d.ts +3 -4
  36. package/dist/components/file-dropzone.js +2 -1
  37. package/dist/components/file-dropzone.js.map +1 -1
  38. package/dist/components/floating-panel.d.ts +29 -2
  39. package/dist/components/floating-panel.js +16 -2
  40. package/dist/components/floating-panel.js.map +1 -1
  41. package/dist/components/full-bleed-dialog.d.ts +23 -4
  42. package/dist/components/full-bleed-dialog.js +11 -2
  43. package/dist/components/full-bleed-dialog.js.map +1 -1
  44. package/dist/components/list.d.ts +174 -0
  45. package/dist/components/list.js +215 -0
  46. package/dist/components/list.js.map +1 -0
  47. package/dist/components/menu-item.d.ts +99 -0
  48. package/dist/components/menu-item.js +83 -0
  49. package/dist/components/menu-item.js.map +1 -0
  50. package/dist/components/modal.d.ts +23 -1
  51. package/dist/components/modal.js +36 -10
  52. package/dist/components/modal.js.map +1 -1
  53. package/dist/components/number-field.d.ts +6 -0
  54. package/dist/components/number-input.d.ts +6 -0
  55. package/dist/components/numpad-sheet.d.ts +6 -0
  56. package/dist/components/page-contents.js +1 -2
  57. package/dist/components/page-contents.js.map +1 -1
  58. package/dist/components/page-header.d.ts +37 -0
  59. package/dist/components/page-header.js +36 -0
  60. package/dist/components/page-header.js.map +1 -0
  61. package/dist/components/progress-bar.d.ts +16 -2
  62. package/dist/components/progress-bar.js +4 -2
  63. package/dist/components/progress-bar.js.map +1 -1
  64. package/dist/components/series-chart-labels.d.ts +3 -0
  65. package/dist/components/series-chart-labels.js +2 -1
  66. package/dist/components/series-chart-labels.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +16 -0
  68. package/dist/components/series-chart.js +137 -3
  69. package/dist/components/series-chart.js.map +1 -1
  70. package/dist/components/status-dot.d.ts +49 -0
  71. package/dist/components/status-dot.js +52 -0
  72. package/dist/components/status-dot.js.map +1 -0
  73. package/dist/components/table.d.ts +39 -6
  74. package/dist/components/table.js +43 -4
  75. package/dist/components/table.js.map +1 -1
  76. package/dist/components/text.d.ts +63 -0
  77. package/dist/components/text.js +23 -0
  78. package/dist/components/text.js.map +1 -0
  79. package/dist/components/toast.d.ts +167 -0
  80. package/dist/components/toast.js +226 -0
  81. package/dist/components/toast.js.map +1 -0
  82. package/dist/components/toggle-group.d.ts +29 -1
  83. package/dist/components/toggle-group.js +52 -3
  84. package/dist/components/toggle-group.js.map +1 -1
  85. package/dist/components/tooltip.d.ts +38 -15
  86. package/dist/components/tooltip.js +85 -47
  87. package/dist/components/tooltip.js.map +1 -1
  88. package/dist/components/ui.d.ts +139 -16
  89. package/dist/components/ui.js +136 -26
  90. package/dist/components/ui.js.map +1 -1
  91. package/dist/components/use-table-state.d.ts +1 -1
  92. package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
  93. package/dist/data-table.d.ts +1 -1
  94. package/dist/hooks/use-close-transition.d.ts +7 -1
  95. package/dist/hooks/use-close-transition.js +5 -2
  96. package/dist/hooks/use-close-transition.js.map +1 -1
  97. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  98. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  99. package/dist/hooks/use-overlay-history.js +84 -4
  100. package/dist/hooks/use-overlay-history.js.map +1 -1
  101. package/dist/i18n/defaults.d.ts +6 -0
  102. package/dist/i18n/defaults.js +11 -1
  103. package/dist/i18n/defaults.js.map +1 -1
  104. package/dist/i18n/kit-labels.d.ts +11 -0
  105. package/dist/i18n/kit-labels.js.map +1 -1
  106. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  107. package/dist/i18n/locales/de-CH.d.ts +6 -0
  108. package/dist/i18n/locales/de-informal.d.ts +6 -0
  109. package/dist/i18n/locales/de.d.ts +6 -0
  110. package/dist/i18n/locales/de.js +31 -1
  111. package/dist/i18n/locales/de.js.map +1 -1
  112. package/dist/i18n/locales/es.d.ts +6 -0
  113. package/dist/i18n/locales/es.js +31 -1
  114. package/dist/i18n/locales/es.js.map +1 -1
  115. package/dist/i18n/locales/fr.d.ts +6 -0
  116. package/dist/i18n/locales/fr.js +31 -1
  117. package/dist/i18n/locales/fr.js.map +1 -1
  118. package/dist/i18n/locales/hu.d.ts +6 -0
  119. package/dist/i18n/locales/hu.js +31 -1
  120. package/dist/i18n/locales/hu.js.map +1 -1
  121. package/dist/i18n/locales/it.d.ts +6 -0
  122. package/dist/i18n/locales/it.js +31 -1
  123. package/dist/i18n/locales/it.js.map +1 -1
  124. package/dist/i18n/locales/zh.d.ts +6 -0
  125. package/dist/i18n/locales/zh.js +31 -1
  126. package/dist/i18n/locales/zh.js.map +1 -1
  127. package/dist/index.d.ts +18 -8
  128. package/dist/index.js +14 -0
  129. package/dist/index.js.map +1 -1
  130. package/dist/lib/clipping.d.ts +9 -0
  131. package/dist/lib/clipping.js +14 -0
  132. package/dist/lib/clipping.js.map +1 -0
  133. package/dist/search/command-palette.d.ts +67 -2
  134. package/dist/search/command-palette.js +147 -33
  135. package/dist/search/command-palette.js.map +1 -1
  136. package/dist/search/global-search.d.ts +166 -0
  137. package/dist/search/global-search.js +293 -0
  138. package/dist/search/global-search.js.map +1 -0
  139. package/dist/search/search-index.d.ts +105 -0
  140. package/dist/search/search-index.js +142 -0
  141. package/dist/search/search-index.js.map +1 -0
  142. package/dist/search.d.ts +3 -1
  143. package/dist/search.js +2 -0
  144. package/dist/search.js.map +1 -1
  145. package/dist/wizard/stepper-nav.d.ts +10 -1
  146. package/dist/wizard/stepper-nav.js +3 -2
  147. package/dist/wizard/stepper-nav.js.map +1 -1
  148. package/dist/wizard/types.d.ts +2 -2
  149. package/dist/wizard/types.js.map +1 -1
  150. package/dist/wizard/use-wizard.js +1 -1
  151. package/dist/wizard/use-wizard.js.map +1 -1
  152. package/dist/wizard/wizard-summary.js +3 -3
  153. package/dist/wizard/wizard-summary.js.map +1 -1
  154. package/package.json +25 -12
  155. package/src/components/alert-banner.tsx +117 -18
  156. package/src/components/breadcrumbs.tsx +168 -0
  157. package/src/components/bulk-action-bar.tsx +256 -0
  158. package/src/components/chip.tsx +164 -13
  159. package/src/components/copy-button.tsx +29 -7
  160. package/src/components/data-table.tsx +81 -5
  161. package/src/components/date-picker.tsx +377 -118
  162. package/src/components/description-list.tsx +168 -15
  163. package/src/components/dialog-frame.tsx +7 -0
  164. package/src/components/disclosure.tsx +116 -28
  165. package/src/components/file-dropzone.tsx +9 -10
  166. package/src/components/floating-panel.tsx +46 -2
  167. package/src/components/full-bleed-dialog.tsx +42 -5
  168. package/src/components/list.tsx +417 -0
  169. package/src/components/menu-item.tsx +214 -0
  170. package/src/components/modal.tsx +80 -17
  171. package/src/components/page-contents.tsx +4 -4
  172. package/src/components/page-header.tsx +68 -0
  173. package/src/components/progress-bar.tsx +27 -3
  174. package/src/components/series-chart-labels.ts +4 -0
  175. package/src/components/series-chart.tsx +253 -3
  176. package/src/components/status-dot.tsx +99 -0
  177. package/src/components/table.tsx +91 -6
  178. package/src/components/text.tsx +89 -0
  179. package/src/components/toast.tsx +441 -0
  180. package/src/components/toggle-group.tsx +82 -3
  181. package/src/components/tooltip.tsx +186 -100
  182. package/src/components/ui.tsx +321 -41
  183. package/src/hooks/use-close-transition.ts +14 -5
  184. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  185. package/src/hooks/use-overlay-history.ts +181 -4
  186. package/src/i18n/defaults.ts +10 -0
  187. package/src/i18n/kit-labels.tsx +10 -0
  188. package/src/i18n/locales/de.ts +30 -0
  189. package/src/i18n/locales/es.ts +30 -0
  190. package/src/i18n/locales/fr.ts +30 -0
  191. package/src/i18n/locales/hu.ts +30 -0
  192. package/src/i18n/locales/it.ts +30 -0
  193. package/src/i18n/locales/zh.ts +30 -0
  194. package/src/index.ts +30 -0
  195. package/src/lib/clipping.ts +16 -0
  196. package/src/search/command-palette.tsx +241 -31
  197. package/src/search/global-search.tsx +525 -0
  198. package/src/search/search-index.ts +295 -0
  199. package/src/search.ts +2 -0
  200. package/src/wizard/stepper-nav.tsx +13 -2
  201. package/src/wizard/types.ts +2 -2
  202. package/src/wizard/use-wizard.ts +3 -3
  203. package/src/wizard/wizard-summary.tsx +14 -10
  204. package/tokens.css +97 -0
@@ -231,6 +231,11 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
231
231
  dialogFrame: {
232
232
  close: "关闭",
233
233
  },
234
+ bulkActionBar: {
235
+ selected: (count) => `已选择 ${n(count)} 项`,
236
+ clear: "清除选择",
237
+ cleared: "已清除选择",
238
+ },
234
239
  swipeableRow: {
235
240
  actions: "行操作",
236
241
  },
@@ -287,12 +292,22 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
287
292
  step: (current, total) => `${n(current)} / ${n(total)}`,
288
293
  },
289
294
  commandPalette: {
295
+ clear: "清除搜索",
296
+ submit: "搜索",
297
+ close: "关闭",
290
298
  placeholder: "搜索…",
291
299
  empty: "无结果",
292
300
  loading: "搜索中…",
293
301
  dialog: "搜索",
294
302
  error: "搜索失败,请重试。",
295
303
  },
304
+ globalSearch: {
305
+ trigger: "搜索",
306
+ placeholder: "搜索或跳转到…",
307
+ shortcut: (keys) => `搜索(${keys})`,
308
+ suggestions: "试试",
309
+ results: "结果",
310
+ },
296
311
  sparkline: {
297
312
  rising: (first, last) => `从 ${first} 上升到 ${last}`,
298
313
  falling: (first, last) => `从 ${first} 下降到 ${last}`,
@@ -352,6 +367,7 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
352
367
  "拖动以缩放:近似正方形的选区同时缩放两条坐标轴,细长的选区只缩放其所沿的那条轴。双击可重置。",
353
368
  empty: "无数据",
354
369
  legend: "数据系列",
370
+ points: "图表数值",
355
371
  },
356
372
  confirmDialog: {
357
373
  confirm: "确认",
@@ -367,6 +383,20 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
367
383
  copiedAnnouncement: "已复制到剪贴板",
368
384
  failedAnnouncement: "无法复制到剪贴板",
369
385
  },
386
+ list: {
387
+ unread: "未读",
388
+ opensInNewTab: "在新标签页中打开",
389
+ },
390
+ breadcrumbs: {
391
+ label: "面包屑导航",
392
+ showAll: "显示完整路径",
393
+ },
394
+ toast: {
395
+ close: "关闭通知",
396
+ notifications: "通知",
397
+ undo: "撤销",
398
+ redo: "重做",
399
+ },
370
400
  };
371
401
  }
372
402
 
package/src/index.ts CHANGED
@@ -96,6 +96,8 @@ export * from "./components/danger-confirm";
96
96
  export * from "./components/confirm-dialog";
97
97
  // A non-modal corner panel and its round trigger (keksdose's assistant launcher).
98
98
  export * from "./components/floating-panel";
99
+ // The count / clear / actions bar a selection brings up (keksdose's three copies).
100
+ export * from "./components/bulk-action-bar";
99
101
  export * from "./components/swatch-picker";
100
102
  export * from "./components/icon-picker";
101
103
  export * from "./components/choice-card";
@@ -151,6 +153,30 @@ export * from "./components/separator";
151
153
  // Named: scroll-area.tsx also holds the overflow hook and scrollbar class Table shares.
152
154
  export { ScrollArea } from "./components/scroll-area";
153
155
  export type { ScrollAreaProps } from "./components/scroll-area";
156
+ // 0.10.0 rows, menu rows, text primitives and page chrome the apps drew by hand.
157
+ export * from "./components/list";
158
+ export * from "./components/menu-item";
159
+ export * from "./components/text";
160
+ export * from "./components/status-dot";
161
+ export * from "./components/page-header";
162
+ export * from "./components/breadcrumbs";
163
+ // The toast layer over sonner (an OPTIONAL peer, loaded lazily): `toast` mirrors sonner's
164
+ // API so apps migrate by swapping the import; `<Toaster>` carries the placement, theme,
165
+ // tones and z-index both apps had wired by hand.
166
+ export { toast, Toaster, DEFAULT_TOAST_LABELS, TOAST_ACTION_DURATION } from "./components/toast";
167
+ export type {
168
+ ToastId,
169
+ ToastAction,
170
+ ToastOptions,
171
+ ToastPromiseOptions,
172
+ ToastUndoOptions,
173
+ ToastRedoOptions,
174
+ ToastLabels,
175
+ ToastPosition,
176
+ ToastSwipeDirection,
177
+ ToasterOffset,
178
+ ToasterProps,
179
+ } from "./components/toast";
154
180
 
155
181
  // ── data-table suite ─────────────────────────────────────────────────────────
156
182
  export * from "./components/data-table-labels";
@@ -163,6 +189,8 @@ export { FilterPopover } from "./components/data-table-filter-popover";
163
189
  export { DataTable } from "./components/data-table";
164
190
  export type {
165
191
  DataTableColumn,
192
+ DataTableCellProps,
193
+ DataTableHeadProps,
166
194
  DataTableProps,
167
195
  ServerPagination,
168
196
  FilterState,
@@ -217,6 +245,8 @@ export * from "./tour/tour";
217
245
 
218
246
  // ── command palette / global search ──────────────────────────────────────────
219
247
  export * from "./search/command-palette";
248
+ export * from "./search/search-index";
249
+ export * from "./search/global-search";
220
250
 
221
251
  // ── i18n: one label tree, one optional provider ──────────────────────────────
222
252
  // `UiKitLabels` names every string the kit renders; `<UiKitProvider labels locale>`
@@ -0,0 +1,16 @@
1
+ /** Internal (not exported from the package): the Tooltip's auto-portal check. */
2
+
3
+ /** Whether anything between `el` and `<body>` clips or scrolls its content — the
4
+ * containers an absolutely positioned bubble would be cut off by, or would widen.
5
+ * `<body>` and `<html>` are not counted: their `overflow` belongs to the viewport (a
6
+ * dialog's scroll lock sets it), and the viewport clips a portalled bubble just the
7
+ * same. */
8
+ export function hasClippingAncestor(el: Element | null): boolean {
9
+ if (typeof window === "undefined" || !el) return false;
10
+ for (let node = el.parentElement; node; node = node.parentElement) {
11
+ if (node === document.body || node === document.documentElement) return false;
12
+ const style = window.getComputedStyle(node);
13
+ if (style.overflowX !== "visible" || style.overflowY !== "visible") return true;
14
+ }
15
+ return false;
16
+ }
@@ -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,8 +95,23 @@ 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
 
103
+ /**
104
+ * How tall the palette's rows are. `"compact"` (the default) is the desktop list the
105
+ * palette always drew, 36px a row; `"comfortable"` gives every row the kit's 44px touch
106
+ * target (`min-h-11`) and body-size text — keksdose's phone search, where a 36px row
107
+ * under a thumb picks its neighbour.
108
+ */
109
+ export type CommandPaletteDensity = "compact" | "comfortable";
110
+
111
+ /** A provider's answer is a promise — the only case with anything to wait for. */
112
+ const isThenable = (value: unknown): value is PromiseLike<unknown> =>
113
+ typeof (value as PromiseLike<unknown> | null)?.then === "function";
114
+
62
115
  /** The defaults minus `dialog`, so a resolved `dialog` means someone SUPPLIED one —
63
116
  * see the dialog's `aria-label` for why that distinction matters. */
64
117
  const { dialog: DEFAULT_DIALOG_NAME, ...DEFAULTS_WITHOUT_DIALOG } = DEFAULT_COMMAND_PALETTE_LABELS;
@@ -71,6 +124,14 @@ interface CommandPaletteProps {
71
124
  * query is allowed — return defaults like pages/recent). Sync or async; stale
72
125
  * async responses are ignored.
73
126
  *
127
+ * A SYNCHRONOUS provider (one returning an array, not a promise) never shows the
128
+ * "Searching…" hint: its answer is there the moment it is asked, and the hint only
129
+ * flashed through the debounce before results that were never late — keksdose's
130
+ * transaction search filters a list already in memory. The palette tells the two
131
+ * apart by what the provider RETURNS, call by call, so there is no prop to keep in
132
+ * step with the implementation: an async provider shows the hint from the first
133
+ * promise on (and during the debounce of every later query), a sync one never.
134
+ *
74
135
  * Its IDENTITY is not a re-run signal — it is read through a ref, so the palette
75
136
  * never re-searches merely because the caller rebuilt the callback. That is
76
137
  * deliberate: a provider closing over a list rendered from a query allocates a new
@@ -105,6 +166,37 @@ interface CommandPaletteProps {
105
166
  /** Every edit of the field's text. Fires in both modes — uncontrolled, it only
106
167
  * observes. */
107
168
  onQueryChange?: (query: string) => void;
169
+ /**
170
+ * When the typed text becomes THE query — the one `search` runs on and
171
+ * `onQueryChange` reports.
172
+ *
173
+ * - `"input"` (default): every keystroke, debounced for the search, as before.
174
+ * - `"submit"`: typing edits a draft; the query is committed by ↵ or the submit
175
+ * button beside the field. Until then the results stay on the last committed query
176
+ * and `onQueryChange` stays quiet — which is what an owner binding `query` to the
177
+ * URL's `q` needs, or every keystroke re-filters the page behind and pushes history.
178
+ * ↵ with a draft that differs from the committed query COMMITS; ↵ with nothing new
179
+ * typed chooses the highlighted row, as in `"input"` mode. The draft follows any
180
+ * outside change of the committed query (Back through the URL), and an uncommitted
181
+ * draft is dropped when the palette is closed.
182
+ *
183
+ * The clear "×" commits the empty query in both modes: an emptied box over results
184
+ * (or a page) still filtered by the old text is the stuck state Keksdose #423 was.
185
+ */
186
+ searchOn?: "input" | "submit";
187
+ /** Mark every row's label and hint `data-private` (see {@link CommandItem.redact},
188
+ * which overrides it per row). Off by default. */
189
+ redactLabels?: boolean;
190
+ /**
191
+ * Below the phone breakpoint ({@link PHONE_QUERY}) the palette fills the screen:
192
+ * no inset, no rounded panel, the field pinned at the top inside the safe areas and
193
+ * the list scrolling under it, with a close button since there is no backdrop to tap.
194
+ * On by default — a top-centred card at 70vh leaves little room once the keyboard is
195
+ * up. `false` keeps the card on every viewport.
196
+ */
197
+ fullScreenOnPhone?: boolean;
198
+ /** Row height — see {@link CommandPaletteDensity}. Default `"compact"`. */
199
+ density?: CommandPaletteDensity;
108
200
  labels?: Partial<CommandPaletteLabels>;
109
201
  }
110
202
 
@@ -135,6 +227,10 @@ export function CommandPalette({
135
227
  revision,
136
228
  query: queryProp,
137
229
  onQueryChange,
230
+ searchOn = "input",
231
+ redactLabels = false,
232
+ fullScreenOnPhone = true,
233
+ density = "compact",
138
234
  labels,
139
235
  }: CommandPaletteProps) {
140
236
  const l = useKitLabels("commandPalette", DEFAULTS_WITHOUT_DIALOG, labels);
@@ -152,6 +248,23 @@ export function CommandPalette({
152
248
  if (!controlled) setOwnQuery(next);
153
249
  onQueryChange?.(next);
154
250
  };
251
+ const submitMode = searchOn === "submit";
252
+ // The field's text in `"submit"` mode. It follows the committed query whenever that
253
+ // changes — adjusted during render, React's pattern for state derived from a prop,
254
+ // rather than in an effect that would paint the stale draft first.
255
+ const [draft, setDraft] = useState(query);
256
+ const [draftBase, setDraftBase] = useState(query);
257
+ if (draftBase !== query) {
258
+ setDraftBase(query);
259
+ setDraft(query);
260
+ }
261
+ const fieldText = submitMode ? draft : query;
262
+ const commit = (next: string) => {
263
+ setDraft(next);
264
+ setQuery(next);
265
+ };
266
+ const dirty = submitMode && draft !== query;
267
+ const isPhone = useMediaQuery(PHONE_QUERY, false) && fullScreenOnPhone;
155
268
  const [results, setResults] = useState<CommandItem[]>([]);
156
269
  const [loading, setLoading] = useState(false);
157
270
  const [failed, setFailed] = useState(false);
@@ -169,6 +282,15 @@ export function CommandPalette({
169
282
  const searchRef = useRef(search);
170
283
  searchRef.current = search;
171
284
  const reqId = useRef(0);
285
+ /** What the provider's last answer was: a promise (`false`), an array (`true`), or
286
+ * not known yet. Only a provider known to be async shows "Searching…" while the
287
+ * debounce runs — see `search`. */
288
+ const syncRef = useRef<boolean | undefined>(undefined);
289
+ /** The query the results on screen answer, and the row highlighted in them — so a
290
+ * re-run for the SAME query (a `revision` change: a source streaming in) keeps the
291
+ * highlight on its row instead of throwing it back to the top mid-arrow. */
292
+ const shownQuery = useRef<string | null>(null);
293
+ const activeIdRef = useRef<string | undefined>(undefined);
172
294
 
173
295
  // Back dismisses the palette (Keksdose feedback #172). Declared with the other
174
296
  // hooks, above the `if (!open) return null` below — a hook past a conditional
@@ -187,7 +309,10 @@ export function CommandPalette({
187
309
  useEffect(() => {
188
310
  if (!open) return;
189
311
  if (!controlled) setOwnQuery("");
312
+ // An uncommitted draft does not outlive the open it was typed in.
313
+ setDraft(controlled ? query : "");
190
314
  setActive(0);
315
+ shownQuery.current = null;
191
316
  // `controlled` is read at the open, not watched: a caller switching modes while
192
317
  // the palette is open is not a reason to wipe what is typed.
193
318
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -197,19 +322,26 @@ export function CommandPalette({
197
322
  useEffect(() => {
198
323
  if (!open) return;
199
324
  const id = ++reqId.current;
200
- setLoading(true);
325
+ if (syncRef.current === false) setLoading(true);
201
326
  const run = async () => {
202
327
  try {
203
- const r = await Promise.resolve(searchRef.current(query));
328
+ const answer = searchRef.current(query);
329
+ syncRef.current = !isThenable(answer);
330
+ if (!syncRef.current) setLoading(true);
331
+ const r = await answer;
204
332
  if (reqId.current === id) {
333
+ const keep = shownQuery.current === query ? activeIdRef.current : undefined;
334
+ const at = keep === undefined ? -1 : groupItems(r).flat.findIndex((item) => item.id === keep);
335
+ shownQuery.current = query;
205
336
  setResults(r);
206
337
  setFailed(false);
207
- setActive(0);
338
+ setActive(Math.max(at, 0));
208
339
  }
209
340
  } catch (err) {
210
341
  // There was no catch: the previous query's results stayed on screen as if they
211
342
  // answered this one, and the rejection went unhandled. Clear them and say so.
212
343
  if (reqId.current === id) {
344
+ shownQuery.current = null;
213
345
  setResults([]);
214
346
  setFailed(true);
215
347
  setActive(0);
@@ -227,21 +359,10 @@ export function CommandPalette({
227
359
  }, [query, open, revision]);
228
360
 
229
361
  // 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]);
362
+ const { groups, flat } = useMemo(() => groupItems(results), [results]);
363
+ useEffect(() => {
364
+ activeIdRef.current = flat[active]?.id;
365
+ });
245
366
 
246
367
  const choose = (item: CommandItem | undefined) => {
247
368
  if (!item) return;
@@ -258,7 +379,8 @@ export function CommandPalette({
258
379
  setActive((i) => Math.max(i - 1, 0));
259
380
  } else if (e.key === "Enter") {
260
381
  e.preventDefault();
261
- choose(flat[active]);
382
+ if (dirty) commit(draft);
383
+ else choose(flat[active]);
262
384
  }
263
385
  };
264
386
 
@@ -274,15 +396,21 @@ export function CommandPalette({
274
396
  // Keep the active row scrolled into view.
275
397
  useEffect(() => {
276
398
  const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`);
277
- el?.scrollIntoView({ block: "nearest" });
399
+ // Optional-called, as in combobox-core: jsdom has no scrollIntoView, and every
400
+ // consumer's tests crashed on the first highlighted row (kastlan stubbed it).
401
+ el?.scrollIntoView?.({ block: "nearest" });
278
402
  }, [active]);
279
403
 
280
404
  if (!open) return null;
281
405
 
406
+ const comfortable = density === "comfortable";
282
407
  let flatIndex = -1;
283
408
  return createPortal(
284
409
  <div
285
- className="fixed inset-0 z-[70] flex items-start justify-center bg-black/40 p-4 pt-[10vh]"
410
+ className={cn(
411
+ "fixed inset-0 z-[70] flex items-start justify-center bg-black/40",
412
+ isPhone ? "p-0" : "p-4 pt-[10vh]",
413
+ )}
286
414
  onMouseDown={(e) => {
287
415
  if (e.target === e.currentTarget) onClose();
288
416
  }}
@@ -297,9 +425,27 @@ export function CommandPalette({
297
425
  tabIndex={-1}
298
426
  // Escape bubbling up from anything inside — a dialog is where it belongs.
299
427
  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"
428
+ data-fullscreen={isPhone || undefined}
429
+ className={cn(
430
+ "flex w-full flex-col overflow-hidden bg-[var(--bg-surface)] outline-none",
431
+ isPhone
432
+ ? "h-full"
433
+ : "max-h-[70vh] max-w-xl rounded-xl border border-[var(--border)] shadow-2xl",
434
+ )}
435
+ // Full screen means edge to edge, so the notch, the home indicator and the
436
+ // rounded corners are the panel's to keep clear of.
437
+ style={
438
+ isPhone
439
+ ? {
440
+ paddingTop: "max(0px, env(safe-area-inset-top))",
441
+ paddingBottom: "max(0px, env(safe-area-inset-bottom))",
442
+ paddingLeft: "max(0px, env(safe-area-inset-left))",
443
+ paddingRight: "max(0px, env(safe-area-inset-right))",
444
+ }
445
+ : undefined
446
+ }
301
447
  >
302
- <div className="flex items-center gap-2 border-b border-[var(--border)] px-3">
448
+ <div className="flex shrink-0 items-center gap-2 border-b border-[var(--border)] px-3">
303
449
  <Search className="size-4 shrink-0 text-[var(--text-placeholder)]" />
304
450
  <input
305
451
  ref={inputRef}
@@ -307,13 +453,54 @@ export function CommandPalette({
307
453
  aria-expanded
308
454
  aria-controls={listId}
309
455
  aria-activedescendant={flat[active] ? itemId(flat[active].id) : undefined}
310
- value={query}
311
- onChange={(e) => setQuery(e.target.value)}
456
+ value={fieldText}
457
+ onChange={(e) => (submitMode ? setDraft(e.target.value) : setQuery(e.target.value))}
312
458
  onKeyDown={onKeyDown}
313
459
  placeholder={l.placeholder}
314
- className="w-full bg-transparent py-3 text-sm outline-none placeholder:text-[var(--text-placeholder)] text-[var(--text-primary)]"
460
+ enterKeyHint={submitMode ? "search" : undefined}
461
+ className={cn(
462
+ "w-full bg-transparent py-3 outline-none placeholder:text-[var(--text-placeholder)] text-[var(--text-primary)]",
463
+ // 16px on a phone, or iOS zooms the page in on focus.
464
+ isPhone ? "text-base" : "text-sm",
465
+ )}
315
466
  />
316
467
  {loading && <span className="shrink-0 text-[11px] text-[var(--text-placeholder)]">{l.loading}</span>}
468
+ {fieldText !== "" && (
469
+ <button
470
+ type="button"
471
+ aria-label={l.clear ?? DEFAULT_COMMAND_PALETTE_LABELS.clear}
472
+ onClick={() => {
473
+ commit("");
474
+ // Clearing starts the next query far more often than it ends this one.
475
+ inputRef.current?.focus();
476
+ }}
477
+ className="shrink-0 rounded p-1 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
478
+ >
479
+ <X className="size-4" />
480
+ </button>
481
+ )}
482
+ {dirty && (
483
+ <button
484
+ type="button"
485
+ aria-label={l.submit ?? DEFAULT_COMMAND_PALETTE_LABELS.submit}
486
+ onClick={() => {
487
+ commit(draft);
488
+ inputRef.current?.focus();
489
+ }}
490
+ className="shrink-0 rounded p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
491
+ >
492
+ <CornerDownLeft className="size-4" />
493
+ </button>
494
+ )}
495
+ {isPhone && (
496
+ <button
497
+ type="button"
498
+ onClick={onClose}
499
+ className="-me-1 shrink-0 rounded px-1 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
500
+ >
501
+ {l.close ?? DEFAULT_COMMAND_PALETTE_LABELS.close}
502
+ </button>
503
+ )}
317
504
  </div>
318
505
 
319
506
  {/* The status lines sit outside the listbox: a listbox owns options and groups,
@@ -323,7 +510,9 @@ export function CommandPalette({
323
510
  {l.error ?? DEFAULT_COMMAND_PALETTE_LABELS.error}
324
511
  </div>
325
512
  )}
326
- {!failed && flat.length === 0 && !loading && (
513
+ {/* `results`, not `flat`: a group whose only row is its "Searching…" line is not
514
+ an empty result. */}
515
+ {!failed && results.length === 0 && !loading && (
327
516
  <div className="px-3 py-6 text-center text-sm text-[var(--text-muted)]">{l.empty}</div>
328
517
  )}
329
518
  {/* Listbox grouping: each group is a `role="group"` named by its heading, and
@@ -342,9 +531,25 @@ export function CommandPalette({
342
531
  </div>
343
532
  <ul role="group" aria-labelledby={groupId(groupIndex)}>
344
533
  {items.map((item) => {
534
+ if (item.kind === "status") {
535
+ return (
536
+ <li
537
+ key={item.id}
538
+ role="presentation"
539
+ className={cn(
540
+ "flex items-center gap-2.5 px-3 text-xs text-[var(--text-placeholder)]",
541
+ comfortable ? "min-h-11 py-2.5" : "py-2",
542
+ )}
543
+ >
544
+ {item.icon && <span className="flex size-4 shrink-0 items-center justify-center">{item.icon}</span>}
545
+ <span className="min-w-0 flex-1 truncate">{item.label}</span>
546
+ </li>
547
+ );
548
+ }
345
549
  flatIndex += 1;
346
550
  const idx = flatIndex;
347
551
  const isActive = idx === active;
552
+ const redact = item.redact ?? redactLabels;
348
553
  // Written once and worn by either tag below, so the anchor and the
349
554
  // button can never drift apart in looks or in listbox semantics.
350
555
  // `role="option"` on an <a href> is fine: the browser's middle-click
@@ -356,7 +561,8 @@ export function CommandPalette({
356
561
  "data-index": idx,
357
562
  onMouseMove: () => setActive(idx),
358
563
  className: cn(
359
- "flex w-full items-center gap-2.5 px-3 py-2 text-start text-sm",
564
+ "flex w-full items-center gap-2.5 px-3 text-start",
565
+ comfortable ? "min-h-11 py-2.5 text-base" : "py-2 text-sm",
360
566
  isActive
361
567
  ? "bg-[var(--bg-active)] text-[var(--text-primary)]"
362
568
  : "text-[var(--text-secondary)]",
@@ -369,9 +575,13 @@ export function CommandPalette({
369
575
  {item.icon}
370
576
  </span>
371
577
  )}
372
- <span className="min-w-0 flex-1 truncate">{item.label}</span>
578
+ <span data-private={redact || undefined} className="min-w-0 flex-1 truncate">
579
+ {item.label}
580
+ </span>
373
581
  {item.hint && (
374
- <span className="shrink-0 text-xs text-[var(--text-placeholder)]">{item.hint}</span>
582
+ <span data-private={redact || undefined} className="shrink-0 text-xs text-[var(--text-placeholder)]">
583
+ {item.hint}
584
+ </span>
375
585
  )}
376
586
  </>
377
587
  );