@eifi1/ui-kit 0.9.0 → 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 (176) hide show
  1. package/README.md +21 -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 +4 -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 +4 -0
  13. package/dist/components/chip.d.ts +31 -5
  14. package/dist/components/chip.js +85 -5
  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/date-picker.d.ts +22 -2
  20. package/dist/components/date-picker.js +11 -3
  21. package/dist/components/date-picker.js.map +1 -1
  22. package/dist/components/description-list.d.ts +43 -6
  23. package/dist/components/description-list.js +91 -11
  24. package/dist/components/description-list.js.map +1 -1
  25. package/dist/components/dialog-frame.d.ts +7 -0
  26. package/dist/components/dialog-frame.js.map +1 -1
  27. package/dist/components/disclosure.d.ts +57 -2
  28. package/dist/components/disclosure.js +24 -12
  29. package/dist/components/disclosure.js.map +1 -1
  30. package/dist/components/file-dropzone.d.ts +3 -4
  31. package/dist/components/file-dropzone.js +2 -1
  32. package/dist/components/file-dropzone.js.map +1 -1
  33. package/dist/components/full-bleed-dialog.d.ts +23 -4
  34. package/dist/components/full-bleed-dialog.js +11 -2
  35. package/dist/components/full-bleed-dialog.js.map +1 -1
  36. package/dist/components/list.d.ts +174 -0
  37. package/dist/components/list.js +215 -0
  38. package/dist/components/list.js.map +1 -0
  39. package/dist/components/menu-item.d.ts +99 -0
  40. package/dist/components/menu-item.js +83 -0
  41. package/dist/components/menu-item.js.map +1 -0
  42. package/dist/components/modal.d.ts +23 -1
  43. package/dist/components/modal.js +36 -10
  44. package/dist/components/modal.js.map +1 -1
  45. package/dist/components/number-field.d.ts +4 -0
  46. package/dist/components/number-input.d.ts +4 -0
  47. package/dist/components/numpad-sheet.d.ts +4 -0
  48. package/dist/components/page-contents.js +1 -2
  49. package/dist/components/page-contents.js.map +1 -1
  50. package/dist/components/page-header.d.ts +37 -0
  51. package/dist/components/page-header.js +36 -0
  52. package/dist/components/page-header.js.map +1 -0
  53. package/dist/components/series-chart-labels.d.ts +3 -0
  54. package/dist/components/series-chart-labels.js +2 -1
  55. package/dist/components/series-chart-labels.js.map +1 -1
  56. package/dist/components/series-chart.d.ts +16 -0
  57. package/dist/components/series-chart.js +137 -3
  58. package/dist/components/series-chart.js.map +1 -1
  59. package/dist/components/status-dot.d.ts +49 -0
  60. package/dist/components/status-dot.js +52 -0
  61. package/dist/components/status-dot.js.map +1 -0
  62. package/dist/components/table.d.ts +39 -6
  63. package/dist/components/table.js +43 -4
  64. package/dist/components/table.js.map +1 -1
  65. package/dist/components/text.d.ts +63 -0
  66. package/dist/components/text.js +23 -0
  67. package/dist/components/text.js.map +1 -0
  68. package/dist/components/toast.d.ts +167 -0
  69. package/dist/components/toast.js +226 -0
  70. package/dist/components/toast.js.map +1 -0
  71. package/dist/components/toggle-group.d.ts +29 -1
  72. package/dist/components/toggle-group.js +52 -3
  73. package/dist/components/toggle-group.js.map +1 -1
  74. package/dist/components/tooltip.d.ts +38 -15
  75. package/dist/components/tooltip.js +85 -47
  76. package/dist/components/tooltip.js.map +1 -1
  77. package/dist/components/ui.d.ts +104 -4
  78. package/dist/components/ui.js +112 -8
  79. package/dist/components/ui.js.map +1 -1
  80. package/dist/hooks/use-close-transition.d.ts +7 -1
  81. package/dist/hooks/use-close-transition.js +5 -2
  82. package/dist/hooks/use-close-transition.js.map +1 -1
  83. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  84. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  85. package/dist/i18n/defaults.d.ts +4 -0
  86. package/dist/i18n/defaults.js +9 -1
  87. package/dist/i18n/defaults.js.map +1 -1
  88. package/dist/i18n/kit-labels.d.ts +8 -0
  89. package/dist/i18n/kit-labels.js.map +1 -1
  90. package/dist/i18n/locales/de-CH-informal.d.ts +4 -0
  91. package/dist/i18n/locales/de-CH.d.ts +4 -0
  92. package/dist/i18n/locales/de-informal.d.ts +4 -0
  93. package/dist/i18n/locales/de.d.ts +4 -0
  94. package/dist/i18n/locales/de.js +21 -1
  95. package/dist/i18n/locales/de.js.map +1 -1
  96. package/dist/i18n/locales/es.d.ts +4 -0
  97. package/dist/i18n/locales/es.js +21 -1
  98. package/dist/i18n/locales/es.js.map +1 -1
  99. package/dist/i18n/locales/fr.d.ts +4 -0
  100. package/dist/i18n/locales/fr.js +21 -1
  101. package/dist/i18n/locales/fr.js.map +1 -1
  102. package/dist/i18n/locales/hu.d.ts +4 -0
  103. package/dist/i18n/locales/hu.js +21 -1
  104. package/dist/i18n/locales/hu.js.map +1 -1
  105. package/dist/i18n/locales/it.d.ts +4 -0
  106. package/dist/i18n/locales/it.js +21 -1
  107. package/dist/i18n/locales/it.js.map +1 -1
  108. package/dist/i18n/locales/zh.d.ts +4 -0
  109. package/dist/i18n/locales/zh.js +21 -1
  110. package/dist/i18n/locales/zh.js.map +1 -1
  111. package/dist/index.d.ts +15 -7
  112. package/dist/index.js +12 -0
  113. package/dist/index.js.map +1 -1
  114. package/dist/lib/clipping.d.ts +9 -0
  115. package/dist/lib/clipping.js +14 -0
  116. package/dist/lib/clipping.js.map +1 -0
  117. package/dist/search/command-palette.d.ts +19 -2
  118. package/dist/search/command-palette.js +16 -5
  119. package/dist/search/command-palette.js.map +1 -1
  120. package/dist/search/global-search.d.ts +26 -1
  121. package/dist/search/global-search.js +29 -13
  122. package/dist/search/global-search.js.map +1 -1
  123. package/dist/search.d.ts +1 -1
  124. package/dist/wizard/stepper-nav.js +1 -1
  125. package/dist/wizard/stepper-nav.js.map +1 -1
  126. package/dist/wizard/types.d.ts +2 -2
  127. package/dist/wizard/types.js.map +1 -1
  128. package/dist/wizard/use-wizard.js +1 -1
  129. package/dist/wizard/use-wizard.js.map +1 -1
  130. package/dist/wizard/wizard-summary.js +3 -3
  131. package/dist/wizard/wizard-summary.js.map +1 -1
  132. package/package.json +16 -12
  133. package/src/components/alert-banner.tsx +117 -18
  134. package/src/components/breadcrumbs.tsx +168 -0
  135. package/src/components/bulk-action-bar.tsx +256 -0
  136. package/src/components/chip.tsx +128 -7
  137. package/src/components/copy-button.tsx +29 -7
  138. package/src/components/date-picker.tsx +49 -1
  139. package/src/components/description-list.tsx +168 -15
  140. package/src/components/dialog-frame.tsx +7 -0
  141. package/src/components/disclosure.tsx +93 -21
  142. package/src/components/file-dropzone.tsx +9 -10
  143. package/src/components/full-bleed-dialog.tsx +42 -5
  144. package/src/components/list.tsx +417 -0
  145. package/src/components/menu-item.tsx +214 -0
  146. package/src/components/modal.tsx +80 -17
  147. package/src/components/page-contents.tsx +4 -4
  148. package/src/components/page-header.tsx +68 -0
  149. package/src/components/series-chart-labels.ts +4 -0
  150. package/src/components/series-chart.tsx +253 -3
  151. package/src/components/status-dot.tsx +99 -0
  152. package/src/components/table.tsx +91 -6
  153. package/src/components/text.tsx +89 -0
  154. package/src/components/toast.tsx +441 -0
  155. package/src/components/toggle-group.tsx +82 -3
  156. package/src/components/tooltip.tsx +186 -100
  157. package/src/components/ui.tsx +237 -8
  158. package/src/hooks/use-close-transition.ts +14 -5
  159. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  160. package/src/i18n/defaults.ts +8 -0
  161. package/src/i18n/kit-labels.tsx +8 -0
  162. package/src/i18n/locales/de.ts +20 -0
  163. package/src/i18n/locales/es.ts +20 -0
  164. package/src/i18n/locales/fr.ts +20 -0
  165. package/src/i18n/locales/hu.ts +20 -0
  166. package/src/i18n/locales/it.ts +20 -0
  167. package/src/i18n/locales/zh.ts +20 -0
  168. package/src/index.ts +26 -0
  169. package/src/lib/clipping.ts +16 -0
  170. package/src/search/command-palette.tsx +42 -5
  171. package/src/search/global-search.tsx +59 -18
  172. package/src/wizard/stepper-nav.tsx +2 -1
  173. package/src/wizard/types.ts +2 -2
  174. package/src/wizard/use-wizard.ts +3 -3
  175. package/src/wizard/wizard-summary.tsx +14 -10
  176. package/tokens.css +97 -0
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";
@@ -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
+ }
@@ -100,6 +100,18 @@ export const DEFAULT_COMMAND_PALETTE_LABELS: CommandPaletteLabels = {
100
100
  close: "Close",
101
101
  };
102
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
+
103
115
  /** The defaults minus `dialog`, so a resolved `dialog` means someone SUPPLIED one —
104
116
  * see the dialog's `aria-label` for why that distinction matters. */
105
117
  const { dialog: DEFAULT_DIALOG_NAME, ...DEFAULTS_WITHOUT_DIALOG } = DEFAULT_COMMAND_PALETTE_LABELS;
@@ -112,6 +124,14 @@ interface CommandPaletteProps {
112
124
  * query is allowed — return defaults like pages/recent). Sync or async; stale
113
125
  * async responses are ignored.
114
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
+ *
115
135
  * Its IDENTITY is not a re-run signal — it is read through a ref, so the palette
116
136
  * never re-searches merely because the caller rebuilt the callback. That is
117
137
  * deliberate: a provider closing over a list rendered from a query allocates a new
@@ -175,6 +195,8 @@ interface CommandPaletteProps {
175
195
  * up. `false` keeps the card on every viewport.
176
196
  */
177
197
  fullScreenOnPhone?: boolean;
198
+ /** Row height — see {@link CommandPaletteDensity}. Default `"compact"`. */
199
+ density?: CommandPaletteDensity;
178
200
  labels?: Partial<CommandPaletteLabels>;
179
201
  }
180
202
 
@@ -208,6 +230,7 @@ export function CommandPalette({
208
230
  searchOn = "input",
209
231
  redactLabels = false,
210
232
  fullScreenOnPhone = true,
233
+ density = "compact",
211
234
  labels,
212
235
  }: CommandPaletteProps) {
213
236
  const l = useKitLabels("commandPalette", DEFAULTS_WITHOUT_DIALOG, labels);
@@ -259,6 +282,10 @@ export function CommandPalette({
259
282
  const searchRef = useRef(search);
260
283
  searchRef.current = search;
261
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);
262
289
  /** The query the results on screen answer, and the row highlighted in them — so a
263
290
  * re-run for the SAME query (a `revision` change: a source streaming in) keeps the
264
291
  * highlight on its row instead of throwing it back to the top mid-arrow. */
@@ -295,10 +322,13 @@ export function CommandPalette({
295
322
  useEffect(() => {
296
323
  if (!open) return;
297
324
  const id = ++reqId.current;
298
- setLoading(true);
325
+ if (syncRef.current === false) setLoading(true);
299
326
  const run = async () => {
300
327
  try {
301
- 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;
302
332
  if (reqId.current === id) {
303
333
  const keep = shownQuery.current === query ? activeIdRef.current : undefined;
304
334
  const at = keep === undefined ? -1 : groupItems(r).flat.findIndex((item) => item.id === keep);
@@ -366,11 +396,14 @@ export function CommandPalette({
366
396
  // Keep the active row scrolled into view.
367
397
  useEffect(() => {
368
398
  const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`);
369
- 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" });
370
402
  }, [active]);
371
403
 
372
404
  if (!open) return null;
373
405
 
406
+ const comfortable = density === "comfortable";
374
407
  let flatIndex = -1;
375
408
  return createPortal(
376
409
  <div
@@ -503,7 +536,10 @@ export function CommandPalette({
503
536
  <li
504
537
  key={item.id}
505
538
  role="presentation"
506
- className="flex items-center gap-2.5 px-3 py-2 text-xs text-[var(--text-placeholder)]"
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
+ )}
507
543
  >
508
544
  {item.icon && <span className="flex size-4 shrink-0 items-center justify-center">{item.icon}</span>}
509
545
  <span className="min-w-0 flex-1 truncate">{item.label}</span>
@@ -525,7 +561,8 @@ export function CommandPalette({
525
561
  "data-index": idx,
526
562
  onMouseMove: () => setActive(idx),
527
563
  className: cn(
528
- "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",
529
566
  isActive
530
567
  ? "bg-[var(--bg-active)] text-[var(--text-primary)]"
531
568
  : "text-[var(--text-secondary)]",
@@ -7,7 +7,7 @@ import { TOPBAR_TRIGGER_CLASS } from "../shell/topbar-controls";
7
7
  import { cn } from "../lib/cn";
8
8
  import { useKitLabels } from "../i18n/kit-labels";
9
9
  import { CommandPalette, DEFAULT_COMMAND_PALETTE_LABELS, useCommandKey } from "./command-palette";
10
- import type { CommandItem, CommandPaletteLabels } from "./command-palette";
10
+ import type { CommandItem, CommandPaletteDensity, CommandPaletteLabels } from "./command-palette";
11
11
  import { createSearchIndex } from "./search-index";
12
12
  import type { SearchEntry, SearchIndexOptions } from "./search-index";
13
13
 
@@ -94,6 +94,14 @@ export interface GlobalSearchProps {
94
94
  sources?: readonly GlobalSearchSource[];
95
95
  /** What the empty query shows, under the `suggestions` heading. */
96
96
  suggestions?: readonly GlobalSearchSuggestion[];
97
+ /**
98
+ * Show each suggested ENTRY under its own `group` instead of all of them under the
99
+ * one `suggestions` heading — keksdose's empty palette listed "Pages" and "Actions"
100
+ * apart, and one "Try" heading over both lost the difference. An entry with no
101
+ * `group` and a query suggestion (`{ query }`) stay under `suggestions`. `groupOrder`
102
+ * orders these groups as it orders the results'. Off by default.
103
+ */
104
+ suggestionsKeepGroups?: boolean;
97
105
  /** Groups listed here come first, in this order; the rest follow in ranking order. */
98
106
  groupOrder?: readonly string[];
99
107
  /** Ranking and capping — `groupLimit` (default 8) applies to every group. */
@@ -130,6 +138,23 @@ export interface GlobalSearchProps {
130
138
  hideTriggerOnPhone?: boolean;
131
139
  /** Class for the default trigger button, merged over `TOPBAR_TRIGGER_CLASS`. */
132
140
  triggerClassName?: string;
141
+ /**
142
+ * The default trigger's magnifier, in px. Default 16 (`size-4`), as it always was;
143
+ * 20 matches the kit's other top-bar triggers (`TopbarIconButton` draws `size-5`) and
144
+ * keksdose's top bar, which reached into the button with `[&_svg]:size-5` for it.
145
+ */
146
+ triggerIconSize?: number;
147
+ /**
148
+ * Whether the trigger's accessible name includes the shortcut: `"plain"` (default)
149
+ * names it "Search"; `"withShortcut"` names it by `labels.shortcut(keys)` —
150
+ * "Search (⌘K)" on Apple platforms, "Search (Ctrl K)" elsewhere — the same text its
151
+ * tooltip shows, so a speech-control user ("click Search") and a sighted keyboard user
152
+ * meet the shortcut in the name itself (keksdose C25). `aria-keyshortcuts` is set
153
+ * either way. Also reaches a custom trigger as `triggerProps.label`.
154
+ */
155
+ triggerName?: "plain" | "withShortcut";
156
+ /** The palette's row height — `CommandPalette`'s `density`. Default `"compact"`. */
157
+ density?: CommandPaletteDensity;
133
158
  labels?: Partial<GlobalSearchLabels>;
134
159
  /** Passed through to the palette (`empty`, `loading`, `error`, `hint`, …). */
135
160
  paletteLabels?: Partial<CommandPaletteLabels>;
@@ -211,6 +236,7 @@ function GlobalSearchImpl({
211
236
  entries,
212
237
  sources,
213
238
  suggestions,
239
+ suggestionsKeepGroups = false,
214
240
  groupOrder,
215
241
  indexOptions,
216
242
  navigate,
@@ -226,6 +252,9 @@ function GlobalSearchImpl({
226
252
  trigger = "button",
227
253
  hideTriggerOnPhone = false,
228
254
  triggerClassName,
255
+ triggerIconSize,
256
+ triggerName = "plain",
257
+ density,
229
258
  labels,
230
259
  paletteLabels,
231
260
  }: GlobalSearchProps & { navigate: (href: string) => void; hrefFor: (href: string) => string }) {
@@ -360,12 +389,27 @@ function GlobalSearchImpl({
360
389
  // which the compiler lint cannot tell from a read during render.
361
390
  /* eslint-disable react-hooks/refs */
362
391
  const items = useMemo((): CommandItem[] => {
392
+ const ordered = (out: CommandItem[]) => {
393
+ if (!order.length) return out;
394
+ const rank = (group: string) => {
395
+ const i = order.indexOf(group);
396
+ return i === -1 ? order.length : i;
397
+ };
398
+ // Stable: within a rank, groups keep the order their best hit gave them.
399
+ return out
400
+ .map((item, i) => ({ item, i }))
401
+ .sort((a, b) => rank(a.item.group) - rank(b.item.group) || a.i - b.i)
402
+ .map(({ item }) => item);
403
+ };
404
+
363
405
  if (!q) {
364
406
  const out: CommandItem[] = [];
407
+ const groupOf = (entry: SearchEntry) =>
408
+ suggestionsKeepGroups ? (entry.group ?? l.suggestions) : l.suggestions;
365
409
  suggestionList.forEach((s, i) => {
366
410
  if (typeof s === "string") {
367
411
  const entry = byId.get(s);
368
- if (entry) out.push(toItem("try:", entry, l.suggestions));
412
+ if (entry) out.push(toItem("try:", entry, groupOf(entry)));
369
413
  } else if ("query" in s) {
370
414
  out.push({
371
415
  id: `try:q${i}`,
@@ -380,9 +424,10 @@ function GlobalSearchImpl({
380
424
  refocusField();
381
425
  },
382
426
  });
383
- } else out.push(toItem("try:", s, l.suggestions));
427
+ } else out.push(toItem("try:", s, groupOf(s)));
384
428
  });
385
- return out;
429
+ // One heading has nothing to order; kept groups are ordered like results.
430
+ return suggestionsKeepGroups ? ordered(out) : out;
386
431
  }
387
432
 
388
433
  const out = index.search(q).map((hit) => toItem("s:", hit.entry, hit.entry.group ?? l.results));
@@ -410,17 +455,8 @@ function GlobalSearchImpl({
410
455
  }
411
456
  }
412
457
 
413
- if (!order.length) return out;
414
- const rank = (group: string) => {
415
- const i = order.indexOf(group);
416
- return i === -1 ? order.length : i;
417
- };
418
- // Stable: within a rank, groups keep the order their best hit gave them.
419
- return out
420
- .map((item, i) => ({ item, i }))
421
- .sort((a, b) => rank(a.item.group) - rank(b.item.group) || a.i - b.i)
422
- .map(({ item }) => item);
423
- }, [q, suggestionList, byId, index, sourceList, sourceResults, order, groupLimit, toItem, l.suggestions, l.results, pl.loading, pl.error, setOpen, setQuery, noop, redactLabels]);
458
+ return ordered(out);
459
+ }, [q, suggestionList, suggestionsKeepGroups, byId, index, sourceList, sourceResults, order, groupLimit, toItem, l.suggestions, l.results, pl.loading, pl.error, setOpen, setQuery, noop, redactLabels]);
424
460
  /* eslint-enable react-hooks/refs */
425
461
 
426
462
  // The palette re-runs its provider on a `revision` change, never on the provider's
@@ -434,9 +470,10 @@ function GlobalSearchImpl({
434
470
 
435
471
  const apple = useMemo(() => isApplePlatform(), []);
436
472
  const keys = apple ? "⌘K" : "Ctrl K";
473
+ const triggerLabel = triggerName === "withShortcut" ? l.shortcut(keys) : l.trigger;
437
474
  const triggerProps: GlobalSearchTriggerProps = {
438
475
  open: openSearch,
439
- label: l.trigger,
476
+ label: triggerLabel,
440
477
  tooltip: l.shortcut(keys),
441
478
  keys,
442
479
  ariaKeyShortcuts: "Meta+K Control+K",
@@ -456,12 +493,15 @@ function GlobalSearchImpl({
456
493
  <button
457
494
  type="button"
458
495
  onClick={openSearch}
459
- aria-label={l.trigger}
496
+ aria-label={triggerLabel}
460
497
  aria-keyshortcuts={triggerProps.ariaKeyShortcuts}
461
498
  aria-haspopup="dialog"
462
499
  className={cn(TOPBAR_TRIGGER_CLASS, triggerClassName)}
463
500
  >
464
- <Search className="size-4" />
501
+ <Search
502
+ className={triggerIconSize === undefined ? "size-4" : undefined}
503
+ style={triggerIconSize === undefined ? undefined : { width: triggerIconSize, height: triggerIconSize }}
504
+ />
465
505
  </button>
466
506
  </Tooltip>
467
507
  )}
@@ -475,6 +515,7 @@ function GlobalSearchImpl({
475
515
  searchOn={searchOn}
476
516
  redactLabels={redactLabels}
477
517
  fullScreenOnPhone={fullScreenOnPhone}
518
+ density={density}
478
519
  labels={resolvedPaletteLabels}
479
520
  />
480
521
  </>
@@ -107,7 +107,8 @@ export function StepperNav<TData extends Record<string, unknown>>({
107
107
  const pending = wizard.isValidating;
108
108
  const stepNextLabel = wizard.currentStep.nextLabel;
109
109
 
110
- let forwardButton: ReactNode = null;
110
+ // Every branch below assigns it; no initial value to overwrite.
111
+ let forwardButton: ReactNode;
111
112
  if (wizard.isCommitStep) {
112
113
  const finishButton = (
113
114
  <Button
@@ -109,8 +109,8 @@ export interface UseWizardOptions<TData extends Record<string, unknown>> {
109
109
  */
110
110
  missingRequiredMessage?: string;
111
111
  /**
112
- * How to surface {@link missingRequiredMessage}. Defaults to a `sonner` toast
113
- * (an optional peer, imported only when this actually fires). Pass your own to
112
+ * How to surface {@link missingRequiredMessage}. Defaults to the kit's `toast.error`
113
+ * (sonner, an optional peer, is loaded only when a toast is shown). Pass your own to
114
114
  * route it somewhere else, or to a no-op to silence it.
115
115
  */
116
116
  onValidationFailed?: (message: string) => void;
@@ -18,6 +18,7 @@ import { useCallback, useEffect, useReducer, useRef, useState } from "react";
18
18
  import { useSearchParams } from "react-router";
19
19
  import { useKitLabels } from "../i18n/kit-labels";
20
20
  import { DEFAULT_WIZARD_LABELS } from "./types";
21
+ import { toast } from "../components/toast";
21
22
  import type {
22
23
  FieldErrors,
23
24
  StepStatus,
@@ -312,9 +313,8 @@ export function useWizard<TData extends Record<string, unknown>>(
312
313
  if (onValidationFailed) {
313
314
  onValidationFailed(message);
314
315
  } else {
315
- // sonner is an optional peer: imported here, on the failure path only,
316
- // so an app that never trips this never has to install it.
317
- const { toast } = await import("sonner");
316
+ // The kit's `toast` loads sonner (an optional peer) lazily, so an app
317
+ // that never trips this never has to install it.
318
318
  toast.error(message);
319
319
  }
320
320
  }
@@ -1,5 +1,6 @@
1
1
  import { Pencil } from "lucide-react";
2
2
  import { Card, CardAction, CardContent, CardHeader, CardTitle, IconButton } from "../components/ui";
3
+ import { Tooltip } from "../components/tooltip";
3
4
  import type { SummarySection, WizardLabels } from "./types";
4
5
  import { DEFAULT_WIZARD_LABELS } from "./types";
5
6
  import { useKitLabels } from "../i18n/kit-labels";
@@ -44,16 +45,19 @@ export function WizardSummary({
44
45
  <CardTitle className="text-base">{section.label}</CardTitle>
45
46
  {section.stepIndex !== undefined && onEditStep && (
46
47
  <CardAction>
47
- <IconButton
48
- size="sm"
49
- variant="ghost"
50
- aria-label={l.edit}
51
- title={l.edit}
52
- disabled={disabled}
53
- onClick={() => onEditStep(section.stepIndex as number)}
54
- >
55
- <Pencil />
56
- </IconButton>
48
+ {/* The kit Tooltip, not a native `title`: it shows on keyboard focus too,
49
+ and an app with a one-tooltip rule (keksdose dev#523) sees one. */}
50
+ <Tooltip label={l.edit}>
51
+ <IconButton
52
+ size="sm"
53
+ variant="ghost"
54
+ aria-label={l.edit}
55
+ disabled={disabled}
56
+ onClick={() => onEditStep(section.stepIndex as number)}
57
+ >
58
+ <Pencil />
59
+ </IconButton>
60
+ </Tooltip>
57
61
  </CardAction>
58
62
  )}
59
63
  </CardHeader>
package/tokens.css CHANGED
@@ -233,6 +233,103 @@
233
233
  --success-bg: rgba(6, 78, 59, 0.25);
234
234
  }
235
235
 
236
+ /* ── Toasts ─────────────────────────────────────────────────────────────────
237
+ The kit `<Toaster>` (components/toast.tsx) paints sonner with the AlertBanner
238
+ tones. These are ALIASES, not new colours, and they exist for one reason: sonner
239
+ declares its own `--success-bg` / `--success-border` (and info, warning) on the
240
+ toaster element, so that element cannot read the kit's value under the same name
241
+ — `--success-bg: var(--success-bg)` is a cycle. Declared here, they are computed
242
+ on `<html>` and inherited down under a name sonner does not touch.
243
+
244
+ The background is the tone's wash layered on the surface, as AlertBanner's
245
+ `elevated` frame does: the dark washes are translucent, and a toast floats over
246
+ content. `--z-toast` is above every kit overlay (modal and sheets z-50, tour
247
+ z-60/61, command palette z-70) and above whatever an app stacks on top of those —
248
+ a toast confirming a dialog's action must not land under the dialog. */
249
+ :root,
250
+ .dark {
251
+ --z-toast: 2147483647;
252
+ --toast-success-bg: linear-gradient(var(--success-bg), var(--success-bg)) var(--bg-surface);
253
+ --toast-success-border: var(--success-border);
254
+ --toast-info-bg: linear-gradient(var(--info-bg), var(--info-bg)) var(--bg-surface);
255
+ --toast-info-border: var(--info-border);
256
+ --toast-warning-bg: linear-gradient(var(--warning-bg), var(--warning-bg)) var(--bg-surface);
257
+ --toast-warning-border: var(--warning-border);
258
+ --toast-danger-bg: linear-gradient(var(--danger-bg), var(--danger-bg)) var(--bg-surface);
259
+ --toast-danger-border: var(--danger-border);
260
+ }
261
+
262
+ /* The description of an untoned toast in the secondary text colour (sonner hard-codes
263
+ #3f3f3f, unreadable in the dark theme); a toned toast's inherits its tone. */
264
+ .kit-toaster [data-sonner-toast][data-styled="true"]:not([data-type="success"], [data-type="info"], [data-type="warning"], [data-type="error"]) [data-description] {
265
+ color: var(--text-secondary);
266
+ }
267
+ /* The action button in the tone's own colour rather than sonner's black pill. */
268
+ .kit-toaster [data-sonner-toast][data-styled="true"] [data-button]:not([data-cancel]) {
269
+ background: var(--toast-action-bg, var(--text-primary));
270
+ color: var(--bg-surface);
271
+ }
272
+ .kit-toaster [data-sonner-toast][data-type="success"] { --toast-action-bg: var(--success); }
273
+ .kit-toaster [data-sonner-toast][data-type="info"] { --toast-action-bg: var(--info); }
274
+ .kit-toaster [data-sonner-toast][data-type="warning"] { --toast-action-bg: var(--warning); }
275
+ .kit-toaster [data-sonner-toast][data-type="error"] { --toast-action-bg: var(--danger); }
276
+ /* The kit's focus ring on the toast's buttons — sonner's is a black shadow that
277
+ vanishes on the dark theme. */
278
+ .kit-toaster [data-sonner-toast][data-styled="true"] :is([data-button], [data-close-button]):focus-visible {
279
+ outline: 2px solid var(--brand);
280
+ outline-offset: 2px;
281
+ }
282
+
283
+ /* ── Categorical hues ───────────────────────────────────────────────────────
284
+ Colours that MEAN nothing but "this is a different value": kastlan's status
285
+ enums (status-badge.tsx over enum-meta.ts), where ~15 enums map to 9 Tailwind
286
+ hues. Four of those are semantic and already have tokens — green = --success,
287
+ yellow = --warning, red = --danger, gray = the neutral chip — so only the five
288
+ below are new: blue (open / sent / rented), purple (assigned / accepted),
289
+ orange (terminated / high urgency), teal (invoiced) and indigo (a meeting
290
+ stage). The smallest set that covers every enum; deliberately not a ramp.
291
+
292
+ Same triple and same shades as the status family (700 text / 300 border /
293
+ 100 surface in light, 300 text / translucent 700 border / 900 wash in dark),
294
+ so a categorical chip sits beside a semantic one at the same weight. Not in
295
+ `TokenSet`: like `--danger`, a hue is a specific colour, not a tint of the
296
+ palette preset, so it lives here and nowhere else. */
297
+ :root {
298
+ --hue-blue: #1d4ed8; /* blue-700 */
299
+ --hue-blue-border: #93c5fd; /* blue-300 */
300
+ --hue-blue-bg: #dbeafe; /* blue-100 */
301
+ --hue-indigo: #4338ca; /* indigo-700 */
302
+ --hue-indigo-border: #a5b4fc; /* indigo-300 */
303
+ --hue-indigo-bg: #e0e7ff; /* indigo-100 */
304
+ --hue-purple: #7e22ce; /* purple-700 */
305
+ --hue-purple-border: #d8b4fe; /* purple-300 */
306
+ --hue-purple-bg: #f3e8ff; /* purple-100 */
307
+ --hue-teal: #0f766e; /* teal-700 */
308
+ --hue-teal-border: #5eead4; /* teal-300 */
309
+ --hue-teal-bg: #ccfbf1; /* teal-100 */
310
+ --hue-orange: #c2410c; /* orange-700 */
311
+ --hue-orange-border: #fdba74; /* orange-300 */
312
+ --hue-orange-bg: #ffedd5; /* orange-100 */
313
+ }
314
+
315
+ .dark {
316
+ --hue-blue: #93c5fd; /* blue-300 */
317
+ --hue-blue-border: rgba(29, 78, 216, 0.6);
318
+ --hue-blue-bg: rgba(30, 58, 138, 0.25);
319
+ --hue-indigo: #a5b4fc; /* indigo-300 */
320
+ --hue-indigo-border: rgba(67, 56, 202, 0.6);
321
+ --hue-indigo-bg: rgba(49, 46, 129, 0.25);
322
+ --hue-purple: #d8b4fe; /* purple-300 */
323
+ --hue-purple-border: rgba(126, 34, 206, 0.6);
324
+ --hue-purple-bg: rgba(88, 28, 135, 0.25);
325
+ --hue-teal: #5eead4; /* teal-300 */
326
+ --hue-teal-border: rgba(15, 118, 110, 0.6);
327
+ --hue-teal-bg: rgba(19, 78, 74, 0.25);
328
+ --hue-orange: #fdba74; /* orange-300 */
329
+ --hue-orange-border: rgba(194, 65, 12, 0.6);
330
+ --hue-orange-bg: rgba(124, 45, 18, 0.25);
331
+ }
332
+
236
333
  /* Semantic colour utilities backed by the tokens above. Unlayered so they win
237
334
  over Tailwind's layered colour utilities, and theme-aware for free (the var
238
335
  flips under .dark) — no dark: variant needed at the call site. */