@eifi1/ui-kit 0.8.0 → 0.9.0

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