@eifi1/ui-kit 0.9.0 → 0.11.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 (243) hide show
  1. package/README.md +37 -23
  2. package/dist/components/alert-banner.d.ts +57 -5
  3. package/dist/components/alert-banner.js +63 -17
  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 +112 -0
  10. package/dist/components/bulk-action-bar.js +196 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/button-group.d.ts +58 -3
  13. package/dist/components/button-group.js +56 -5
  14. package/dist/components/button-group.js.map +1 -1
  15. package/dist/components/calculator.d.ts +6 -0
  16. package/dist/components/calendar-heatmap.d.ts +124 -0
  17. package/dist/components/calendar-heatmap.js +295 -0
  18. package/dist/components/calendar-heatmap.js.map +1 -0
  19. package/dist/components/chip.d.ts +31 -5
  20. package/dist/components/chip.js +85 -5
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +34 -2
  23. package/dist/components/choice-card.js +49 -0
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/copy-button.d.ts +14 -4
  26. package/dist/components/copy-button.js +17 -4
  27. package/dist/components/copy-button.js.map +1 -1
  28. package/dist/components/data-table.js +2 -0
  29. package/dist/components/data-table.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +22 -2
  31. package/dist/components/date-picker.js +11 -3
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/description-list.d.ts +43 -6
  34. package/dist/components/description-list.js +91 -11
  35. package/dist/components/description-list.js.map +1 -1
  36. package/dist/components/dialog-frame.d.ts +7 -0
  37. package/dist/components/dialog-frame.js.map +1 -1
  38. package/dist/components/disclosure.d.ts +57 -2
  39. package/dist/components/disclosure.js +24 -12
  40. package/dist/components/disclosure.js.map +1 -1
  41. package/dist/components/field.d.ts +43 -0
  42. package/dist/components/field.js +52 -0
  43. package/dist/components/field.js.map +1 -0
  44. package/dist/components/file-button.d.ts +1 -0
  45. package/dist/components/file-dropzone.d.ts +4 -4
  46. package/dist/components/file-dropzone.js +2 -1
  47. package/dist/components/file-dropzone.js.map +1 -1
  48. package/dist/components/floating-panel.d.ts +147 -4
  49. package/dist/components/floating-panel.js +188 -28
  50. package/dist/components/floating-panel.js.map +1 -1
  51. package/dist/components/full-bleed-dialog.d.ts +23 -4
  52. package/dist/components/full-bleed-dialog.js +11 -2
  53. package/dist/components/full-bleed-dialog.js.map +1 -1
  54. package/dist/components/list.d.ts +187 -0
  55. package/dist/components/list.js +221 -0
  56. package/dist/components/list.js.map +1 -0
  57. package/dist/components/menu-item.d.ts +109 -0
  58. package/dist/components/menu-item.js +88 -0
  59. package/dist/components/menu-item.js.map +1 -0
  60. package/dist/components/mini-calendar.d.ts +70 -5
  61. package/dist/components/mini-calendar.js +166 -62
  62. package/dist/components/mini-calendar.js.map +1 -1
  63. package/dist/components/modal.d.ts +23 -1
  64. package/dist/components/modal.js +36 -10
  65. package/dist/components/modal.js.map +1 -1
  66. package/dist/components/nav-pills.d.ts +66 -0
  67. package/dist/components/nav-pills.js +65 -0
  68. package/dist/components/nav-pills.js.map +1 -0
  69. package/dist/components/number-field.d.ts +6 -0
  70. package/dist/components/number-input.d.ts +6 -0
  71. package/dist/components/numpad-sheet.d.ts +6 -0
  72. package/dist/components/page-contents.js +1 -2
  73. package/dist/components/page-contents.js.map +1 -1
  74. package/dist/components/page-header.d.ts +37 -0
  75. package/dist/components/page-header.js +36 -0
  76. package/dist/components/page-header.js.map +1 -0
  77. package/dist/components/progress-bar.d.ts +31 -2
  78. package/dist/components/progress-bar.js +73 -7
  79. package/dist/components/progress-bar.js.map +1 -1
  80. package/dist/components/series-chart-labels.d.ts +3 -0
  81. package/dist/components/series-chart-labels.js +2 -1
  82. package/dist/components/series-chart-labels.js.map +1 -1
  83. package/dist/components/series-chart.d.ts +32 -0
  84. package/dist/components/series-chart.js +141 -4
  85. package/dist/components/series-chart.js.map +1 -1
  86. package/dist/components/settings-fields.d.ts +1 -0
  87. package/dist/components/status-dot.d.ts +53 -0
  88. package/dist/components/status-dot.js +57 -0
  89. package/dist/components/status-dot.js.map +1 -0
  90. package/dist/components/table.d.ts +78 -13
  91. package/dist/components/table.js +84 -11
  92. package/dist/components/table.js.map +1 -1
  93. package/dist/components/text.d.ts +70 -0
  94. package/dist/components/text.js +24 -0
  95. package/dist/components/text.js.map +1 -0
  96. package/dist/components/time-input.d.ts +1 -0
  97. package/dist/components/toast.d.ts +167 -0
  98. package/dist/components/toast.js +226 -0
  99. package/dist/components/toast.js.map +1 -0
  100. package/dist/components/toggle-group.d.ts +44 -1
  101. package/dist/components/toggle-group.js +73 -4
  102. package/dist/components/toggle-group.js.map +1 -1
  103. package/dist/components/tooltip.d.ts +46 -15
  104. package/dist/components/tooltip.js +87 -47
  105. package/dist/components/tooltip.js.map +1 -1
  106. package/dist/components/ui.d.ts +158 -7
  107. package/dist/components/ui.js +191 -10
  108. package/dist/components/ui.js.map +1 -1
  109. package/dist/components/user-avatar.d.ts +25 -3
  110. package/dist/components/user-avatar.js +32 -3
  111. package/dist/components/user-avatar.js.map +1 -1
  112. package/dist/hooks/use-close-transition.d.ts +7 -1
  113. package/dist/hooks/use-close-transition.js +5 -2
  114. package/dist/hooks/use-close-transition.js.map +1 -1
  115. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  116. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  117. package/dist/hooks/use-file-drop.d.ts +1 -0
  118. package/dist/i18n/defaults.d.ts +6 -0
  119. package/dist/i18n/defaults.js +11 -1
  120. package/dist/i18n/defaults.js.map +1 -1
  121. package/dist/i18n/kit-labels.d.ts +11 -0
  122. package/dist/i18n/kit-labels.js.map +1 -1
  123. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  124. package/dist/i18n/locales/de-CH.d.ts +6 -0
  125. package/dist/i18n/locales/de-informal.d.ts +6 -0
  126. package/dist/i18n/locales/de.d.ts +6 -0
  127. package/dist/i18n/locales/de.js +30 -2
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +6 -0
  130. package/dist/i18n/locales/es.js +30 -2
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +6 -0
  133. package/dist/i18n/locales/fr.js +30 -2
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +6 -0
  136. package/dist/i18n/locales/hu.js +30 -2
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +6 -0
  139. package/dist/i18n/locales/it.js +30 -2
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +6 -0
  142. package/dist/i18n/locales/zh.js +30 -2
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/index.d.ts +27 -15
  145. package/dist/index.js +15 -0
  146. package/dist/index.js.map +1 -1
  147. package/dist/lib/clipping.d.ts +25 -0
  148. package/dist/lib/clipping.js +17 -0
  149. package/dist/lib/clipping.js.map +1 -0
  150. package/dist/lib/dates.d.ts +17 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/rhf/form.d.ts +1 -0
  154. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  155. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  156. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  157. package/dist/rhf.d.ts +2 -0
  158. package/dist/rhf.js +1 -0
  159. package/dist/rhf.js.map +1 -1
  160. package/dist/search/command-palette.d.ts +19 -2
  161. package/dist/search/command-palette.js +16 -5
  162. package/dist/search/command-palette.js.map +1 -1
  163. package/dist/search/global-search.d.ts +26 -1
  164. package/dist/search/global-search.js +29 -13
  165. package/dist/search/global-search.js.map +1 -1
  166. package/dist/search.d.ts +1 -1
  167. package/dist/shell/topbar-action-menu.d.ts +73 -3
  168. package/dist/shell/topbar-action-menu.js +100 -27
  169. package/dist/shell/topbar-action-menu.js.map +1 -1
  170. package/dist/shell.d.ts +2 -1
  171. package/dist/wizard/stepper-nav.d.ts +1 -0
  172. package/dist/wizard/stepper-nav.js +1 -1
  173. package/dist/wizard/stepper-nav.js.map +1 -1
  174. package/dist/wizard/types.d.ts +2 -2
  175. package/dist/wizard/types.js.map +1 -1
  176. package/dist/wizard/use-wizard.js +1 -1
  177. package/dist/wizard/use-wizard.js.map +1 -1
  178. package/dist/wizard/wizard-context.d.ts +7 -1
  179. package/dist/wizard/wizard-context.js +4 -0
  180. package/dist/wizard/wizard-context.js.map +1 -1
  181. package/dist/wizard/wizard-summary.js +3 -3
  182. package/dist/wizard/wizard-summary.js.map +1 -1
  183. package/dist/wizard.d.ts +2 -1
  184. package/package.json +16 -12
  185. package/src/components/alert-banner.tsx +149 -21
  186. package/src/components/breadcrumbs.tsx +168 -0
  187. package/src/components/bulk-action-bar.tsx +362 -0
  188. package/src/components/button-group.tsx +124 -4
  189. package/src/components/calendar-heatmap.tsx +504 -0
  190. package/src/components/chip.tsx +128 -7
  191. package/src/components/choice-card.tsx +97 -1
  192. package/src/components/copy-button.tsx +29 -7
  193. package/src/components/data-table.tsx +7 -0
  194. package/src/components/date-picker.tsx +49 -1
  195. package/src/components/description-list.tsx +168 -15
  196. package/src/components/dialog-frame.tsx +7 -0
  197. package/src/components/disclosure.tsx +93 -21
  198. package/src/components/field.tsx +137 -0
  199. package/src/components/file-dropzone.tsx +9 -10
  200. package/src/components/floating-panel.tsx +366 -15
  201. package/src/components/full-bleed-dialog.tsx +42 -5
  202. package/src/components/list.tsx +443 -0
  203. package/src/components/menu-item.tsx +235 -0
  204. package/src/components/mini-calendar.tsx +245 -54
  205. package/src/components/modal.tsx +80 -17
  206. package/src/components/nav-pills.tsx +145 -0
  207. package/src/components/page-contents.tsx +4 -4
  208. package/src/components/page-header.tsx +68 -0
  209. package/src/components/progress-bar.tsx +129 -11
  210. package/src/components/series-chart-labels.ts +4 -0
  211. package/src/components/series-chart.tsx +274 -3
  212. package/src/components/status-dot.tsx +107 -0
  213. package/src/components/table.tsx +176 -17
  214. package/src/components/text.tsx +97 -0
  215. package/src/components/toast.tsx +441 -0
  216. package/src/components/toggle-group.tsx +128 -4
  217. package/src/components/tooltip.tsx +200 -100
  218. package/src/components/ui.tsx +381 -14
  219. package/src/components/user-avatar.tsx +57 -3
  220. package/src/hooks/use-close-transition.ts +14 -5
  221. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  222. package/src/i18n/defaults.ts +10 -0
  223. package/src/i18n/kit-labels.tsx +10 -0
  224. package/src/i18n/locales/de.ts +29 -0
  225. package/src/i18n/locales/es.ts +29 -0
  226. package/src/i18n/locales/fr.ts +29 -0
  227. package/src/i18n/locales/hu.ts +29 -0
  228. package/src/i18n/locales/it.ts +29 -0
  229. package/src/i18n/locales/zh.ts +29 -0
  230. package/src/index.ts +31 -0
  231. package/src/lib/clipping.ts +34 -0
  232. package/src/lib/dates.ts +37 -0
  233. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  234. package/src/rhf.ts +2 -0
  235. package/src/search/command-palette.tsx +42 -5
  236. package/src/search/global-search.tsx +59 -18
  237. package/src/shell/topbar-action-menu.tsx +239 -49
  238. package/src/wizard/stepper-nav.tsx +2 -1
  239. package/src/wizard/types.ts +2 -2
  240. package/src/wizard/use-wizard.ts +3 -3
  241. package/src/wizard/wizard-context.tsx +9 -0
  242. package/src/wizard/wizard-summary.tsx +14 -10
  243. package/tokens.css +97 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The bridge between a react-hook-form step and the wizard's Next gate.
3
+ * `@eifi1/ui-kit/rhf`.
4
+ *
5
+ * ```tsx
6
+ * function AddressStep({ onUpdate }: { onUpdate: (v: Address) => void }) {
7
+ * const form = useForm<Address>({ defaultValues });
8
+ * useRhfWizardStep(form, { onValid: onUpdate });
9
+ * return <Form {...form}>…</Form>;
10
+ * }
11
+ * ```
12
+ *
13
+ * Inside a `<StepperNav>` the hook registers the step's validator with the wizard, so
14
+ * Next (and Finish on a form-bearing last step) runs it: on success the values go to
15
+ * `onValid` and the wizard advances; on failure react-hook-form shows its own inline
16
+ * `<FormMessage>`s, focuses the first field in error, and the wizard stays put — and,
17
+ * since the result carries no per-field errors, says its "fill in the required
18
+ * fields" line so the button did not seem to do nothing. It also RETURNS the
19
+ * validator, for a nav of your own outside `<StepperNav>` (where there is no wizard
20
+ * context to register with, so nothing is registered).
21
+ *
22
+ * Two modes:
23
+ *
24
+ * - **The whole form** (no `fields`): `form.handleSubmit`, exactly as kastlan's
25
+ * `use-rhf-wizard-step.ts` — every rule and the resolver run, `onValid` receives
26
+ * the TRANSFORMED values (a zod `.transform()` included), and focus follows the
27
+ * form's own `shouldFocusError` (on by default).
28
+ * - **Some fields** (`fields: ["iban", "bic"]`): `form.trigger(fields)`, for a wizard
29
+ * that keeps one form across several steps, each of which validates only what it
30
+ * shows. The first of those fields in error is focused (`shouldFocus`; the control
31
+ * has to take `field.ref`), errors on fields of later steps are not raised, and
32
+ * `onValid` receives `form.getValues()` — the untransformed values, because a
33
+ * resolver's output is only produced for the whole form.
34
+ *
35
+ * Both callbacks are read through refs, so the validator is registered once per form
36
+ * however the caller writes them (an inline `(v) => onUpdate(v)` is the usual case).
37
+ *
38
+ * The positional `useRhfWizardStep(form, onValid)` of the hook kastlan kept is the
39
+ * whole-form mode, and still accepted.
40
+ *
41
+ * Why it lives here and not in `@eifi1/ui-kit/wizard`: it imports react-hook-form,
42
+ * which only this entry may (see packaging-contract).
43
+ */
44
+ import { useCallback, useEffect, useRef } from "react";
45
+ import type { FieldPath, FieldValues, UseFormReturn } from "react-hook-form";
46
+ import { useOptionalWizardContext } from "../wizard/wizard-context";
47
+
48
+ export type RhfWizardStepOptions<
49
+ TFieldValues extends FieldValues,
50
+ TTransformed = TFieldValues,
51
+ > =
52
+ | {
53
+ /** Validate only these fields — the step's share of a form that spans steps. */
54
+ fields: readonly FieldPath<TFieldValues>[];
55
+ /** Called with `form.getValues()` when the fields pass, before the wizard
56
+ * advances. */
57
+ onValid?: (values: TFieldValues) => void;
58
+ }
59
+ | {
60
+ /** Omitted: validate the whole form through `handleSubmit`. */
61
+ fields?: undefined;
62
+ /** Called with the form's (transformed) values when it passes, before the
63
+ * wizard advances. */
64
+ onValid?: (values: TTransformed) => void;
65
+ };
66
+
67
+ /**
68
+ * Connects `form` to the wizard's step validation and returns the step's validator.
69
+ * See the module note for the two modes.
70
+ */
71
+ export function useRhfWizardStep<
72
+ TFieldValues extends FieldValues,
73
+ TContext = unknown,
74
+ TTransformed = TFieldValues,
75
+ >(
76
+ form: UseFormReturn<TFieldValues, TContext, TTransformed>,
77
+ options?:
78
+ | RhfWizardStepOptions<TFieldValues, TTransformed>
79
+ | ((values: TTransformed) => void),
80
+ ): () => Promise<boolean> {
81
+ const opts = typeof options === "function" ? { onValid: options } : (options ?? {});
82
+ const optsRef = useRef(opts);
83
+ useEffect(() => {
84
+ optsRef.current = opts;
85
+ });
86
+
87
+ const validate = useCallback(async (): Promise<boolean> => {
88
+ const current = optsRef.current;
89
+ if (current.fields) {
90
+ const ok = await form.trigger([...current.fields], { shouldFocus: true });
91
+ if (ok) current.onValid?.(form.getValues());
92
+ return ok;
93
+ }
94
+ const onValid = current.onValid as ((values: TTransformed) => void) | undefined;
95
+ let ok = false;
96
+ await form.handleSubmit(
97
+ (values) => {
98
+ ok = true;
99
+ onValid?.(values);
100
+ },
101
+ () => {
102
+ ok = false;
103
+ },
104
+ )();
105
+ return ok;
106
+ }, [form]);
107
+
108
+ const wizard = useOptionalWizardContext();
109
+ const register = wizard?.registerStepValidate;
110
+ useEffect(() => register?.(validate), [register, validate]);
111
+
112
+ return validate;
113
+ }
package/src/rhf.ts CHANGED
@@ -7,3 +7,5 @@
7
7
  // the barrel dropped its last react-hook-form import (see the wizard section of
8
8
  // src/index.ts). The packaging contract test holds that line.
9
9
  export * from "./rhf/form";
10
+ // The react-hook-form bridge to the wizard's Next gate (useWizard lives in /wizard).
11
+ export * from "./rhf/use-rhf-wizard-step";
@@ -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
  </>