@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
@@ -144,8 +144,17 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
144
144
  * settings or analysis page. `bare` draws nothing: a leading chevron that turns
145
145
  * down, for an inline "Show 3 hidden accounts" inside something that already has
146
146
  * its own surface.
147
+ *
148
+ * `menu` is a row of a `HoverMenu`: the top bar's menu-item look
149
+ * (`TOPBAR_MENU_ITEM_CLASS` — full-width, `px-3 py-2`, regular weight, square, the
150
+ * hover wash with the text colour left alone), an INSET focus ring so the menu's
151
+ * clipping edge cannot cut it off, the chevron at the end (`chevronPosition`
152
+ * defaults to `end` here) and a body with no padding of its own, so the sub-list's
153
+ * rows sit flush like the rows around them. keksdose's account menu opens its
154
+ * language sub-list this way, and on `bare` it took four header overrides and a
155
+ * body one to get there (account-menu.tsx).
147
156
  */
148
- variant?: "card" | "bare";
157
+ variant?: "card" | "bare" | "menu";
149
158
  /**
150
159
  * Where a `bare` disclosure draws its chevron. `start` (default) is the leading
151
160
  * chevron that turns from the reading direction to down. `end` puts it at the far
@@ -153,7 +162,7 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
153
162
  * account menu, whose language sub-list reads "Language 🇩🇪 ⌄" with the chevron
154
163
  * after the flag, like every other menu row with a sub-list. With `trailing`, the
155
164
  * chevron follows it, as on a card. A `card` always has it at the end, so this is
156
- * ignored there.
165
+ * ignored there. A `menu` defaults to `end`, like the menu rows around it.
157
166
  */
158
167
  chevronPosition?: "start" | "end";
159
168
  /**
@@ -230,7 +239,7 @@ export function Disclosure({
230
239
  defaultOpen = false,
231
240
  onOpenChange,
232
241
  variant = "card",
233
- chevronPosition = "start",
242
+ chevronPosition,
234
243
  headingAs: Heading,
235
244
  keepMounted,
236
245
  disabled,
@@ -247,8 +256,10 @@ export function Disclosure({
247
256
  const open = controlled ?? own;
248
257
  const bodyId = useId();
249
258
  const card = variant === "card";
250
- // The card's chevron trails always; a bare one only when asked to.
251
- const chevronAtEnd = card || chevronPosition === "end";
259
+ const menu = variant === "menu";
260
+ // The card's chevron trails always; a menu row's unless asked otherwise; a bare
261
+ // one only when asked to.
262
+ const chevronAtEnd = card || (chevronPosition ?? (menu ? "end" : "start")) === "end";
252
263
  const triggerOnly = controls !== undefined;
253
264
  // The card's header squares its lower corners only when a body opens under it.
254
265
  const joined = open && !triggerOnly;
@@ -280,7 +291,7 @@ export function Disclosure({
280
291
  cn(
281
292
  "min-w-0 flex-1 after:absolute after:inset-0 after:content-['']",
282
293
  "focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-[var(--brand)]",
283
- card ? cn("after:rounded-lg", joined && "after:rounded-b-none") : "after:rounded-sm",
294
+ card ? cn("after:rounded-lg", joined && "after:rounded-b-none") : !menu && "after:rounded-sm",
284
295
  )
285
296
  : "focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
286
297
  card
@@ -289,7 +300,12 @@ export function Disclosure({
289
300
  hasTrailing ? "pe-0" : "hover:bg-[var(--bg-hover)] focus-visible:ring-inset",
290
301
  joined && "rounded-b-none",
291
302
  )
292
- : "items-center rounded-sm text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
303
+ : menu
304
+ ? // TOPBAR_MENU_ITEM_CLASS's row, spelled out rather than imported: a
305
+ // component does not reach up into the shell. A test holds the two to
306
+ // the same classes.
307
+ "items-center justify-between gap-3 px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] focus-visible:ring-inset"
308
+ : "items-center rounded-sm text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
293
309
  headerClassName,
294
310
  )}
295
311
  >
@@ -337,7 +353,7 @@ export function Disclosure({
337
353
  )}
338
354
  {!triggerOnly && (
339
355
  <Collapse id={bodyId} open={open} keepMounted={keepMounted}>
340
- <div className={cn(card ? "space-y-3 px-4 pb-4" : "space-y-2 pt-2", bodyClassName)}>{children}</div>
356
+ <div className={cn(card ? "space-y-3 px-4 pb-4" : !menu && "space-y-2 pt-2", bodyClassName)}>{children}</div>
341
357
  </Collapse>
342
358
  )}
343
359
  </div>
@@ -65,6 +65,30 @@ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTM
65
65
  * clears the transactions page's own action group this way (`calc(1rem + 4rem)`).
66
66
  */
67
67
  offset?: string;
68
+ /**
69
+ * Whether the button carries the browser's own tooltip (`title`, the `label` unless a
70
+ * `title` is passed). Default `true`, so an icon-only button keeps a hover hint for a
71
+ * mouse user with no other way to learn what it is. `false` renders no `title` at all:
72
+ * keksdose has ONE tooltip, the kit's `Tooltip` (dev#523), and its source scan fails a
73
+ * native `title=` anywhere — so a flag, not `title=""`, which the scan would still
74
+ * see. An empty `title` renders no attribute either, for the caller who has no scan.
75
+ *
76
+ * Not flipped to off by default: a FAB wrapped in nothing would lose the only hover
77
+ * hint it has, silently, in every app already on it. And the kit cannot wrap itself
78
+ * in its `Tooltip` — the bubble's anchor is a `relative inline-flex` span, and the
79
+ * button is `fixed` and portalled — so the one-tooltip caller wraps it.
80
+ */
81
+ nativeTitle?: boolean;
82
+ /**
83
+ * Make it a toggle. `true`/`false` set `aria-pressed` and swap the solid brand disc for
84
+ * a surface disc — the glyph in brand on the quiet brand fill when on, the secondary
85
+ * text colour when off — the "on" look of `IconButton`'s `pressed` and of a selected
86
+ * Chip. Left out, it is the ordinary action FAB. For keksdose's corner filter toggles
87
+ * (feedback-page's "awaiting only", the /transactions pending and upcoming toggles),
88
+ * which paint `text-[var(--brand)]` on a surface button by hand. Keep `label` the same
89
+ * in both states; `aria-pressed` already says which one it is in.
90
+ */
91
+ pressed?: boolean;
68
92
  ref?: Ref<HTMLButtonElement>;
69
93
  }
70
94
 
@@ -90,12 +114,18 @@ export function FloatingActionButton({
90
114
  icon,
91
115
  corner = "bottom-end",
92
116
  offset = "1rem",
117
+ nativeTitle = true,
118
+ pressed,
93
119
  className,
94
120
  style,
95
121
  type = "button",
122
+ title,
96
123
  ...rest
97
124
  }: FloatingActionButtonProps) {
98
125
  const [marker, dir] = usePortalDir();
126
+ const toggle = pressed !== undefined;
127
+ // An empty title is "no tooltip", not an attribute with nothing in it.
128
+ const nativeTip = nativeTitle && title !== "" ? (title ?? label) : undefined;
99
129
  return (
100
130
  <>
101
131
  <span ref={marker} hidden />
@@ -106,11 +136,20 @@ export function FloatingActionButton({
106
136
  type={type}
107
137
  dir={dir}
108
138
  aria-label={label}
109
- title={rest.title ?? label}
139
+ aria-pressed={pressed ?? rest["aria-pressed"]}
140
+ title={nativeTip}
110
141
  style={{ ...cornerStyle(corner, offset), ...style }}
111
142
  className={cn(
112
143
  "fixed z-40 inline-flex size-12 items-center justify-center rounded-full",
113
- "bg-[var(--brand)] text-[var(--brand-contrast)] shadow-lg transition-colors hover:bg-[var(--brand-hover)]",
144
+ "shadow-lg transition-colors",
145
+ !toggle && "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]",
146
+ // A toggle sits on the page's surface — a solid brand disc would read as
147
+ // "on" in both states — and says "on" in the brand family.
148
+ toggle && "border border-[var(--border)]",
149
+ pressed === false &&
150
+ "bg-[var(--bg-surface)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]",
151
+ pressed === true &&
152
+ "border-[var(--brand)] bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]",
114
153
  "outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2",
115
154
  "[&_svg]:size-6",
116
155
  className,
@@ -141,6 +180,9 @@ export interface FloatingPanelProps {
141
180
  /** The FAB's distance above the nav — see {@link FloatingActionButtonProps.offset}.
142
181
  * From `md` up the card sits above the FAB, so it moves with it. */
143
182
  offset?: string;
183
+ /** The FAB's native `title` — see {@link FloatingActionButtonProps.nativeTitle}.
184
+ * `false` for keksdose's assistant launcher, under its one-tooltip rule (dev#523). */
185
+ fabNativeTitle?: boolean;
144
186
  /** Default: `floatingPanel.close` from the {@link UiKitProvider}, else "Close". */
145
187
  closeLabel?: string;
146
188
  /**
@@ -196,6 +238,7 @@ export function FloatingPanel({
196
238
  onOpenChange,
197
239
  corner = "bottom-end",
198
240
  offset = "1rem",
241
+ fabNativeTitle,
199
242
  closeLabel,
200
243
  initialFocus,
201
244
  className,
@@ -264,6 +307,7 @@ export function FloatingPanel({
264
307
  icon={fabIcon}
265
308
  corner={corner}
266
309
  offset={offset}
310
+ nativeTitle={fabNativeTitle}
267
311
  aria-expanded={open}
268
312
  aria-controls={open ? panelId : undefined}
269
313
  onClick={() => {
@@ -123,9 +123,14 @@ export function FullBleedDialog({
123
123
 
124
124
  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
125
125
  // The caller's handler first (keksdose's transaction-search keeps its list keys on
126
- // the dialog); `preventDefault()` there claims the key, Escape included.
126
+ // the dialog); a `preventDefault()` IN THAT HANDLER claims the key, Escape included.
127
+ // Only there: a descendant's preventDefault does not count. A SearchField in the
128
+ // header prevents Escape to stop Chromium clearing a type=search input, and in
129
+ // 0.8.0 that silently stopped Escape from closing the dialog (keksdose).
130
+ const preventedBefore = e.defaultPrevented;
127
131
  onKeyDown?.(e);
128
- if (e.defaultPrevented || e.key !== "Escape") return;
132
+ const claimedByCaller = !preventedBefore && e.defaultPrevented;
133
+ if (claimedByCaller || e.key !== "Escape") return;
129
134
  // On the panel, the way `Modal` does it, and NOT through `useEscapeKey`: this
130
135
  // dialog is the thing a `PickerSheet` opens on top of, and a document-level
131
136
  // listener cannot tell which of the two the user meant. One press would dismiss
@@ -3,8 +3,30 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
5
5
 
6
- export type ProgressBarTone = "brand" | "neutral" | "success" | "warning" | "danger" | "info";
7
- export type ProgressBarSize = "sm" | "md" | "lg";
6
+ /**
7
+ * `income` / `expense` are the money pair (`--money-income` / `--money-expense`), the
8
+ * names `Chip`, `StatTile` and `Sparkline` already use for them: a share bar of what
9
+ * came in or went out is money, and painting it `success` / `warning` said "good" and
10
+ * "careful" about a figure that is neither. keksdose's report meters (food-group-card,
11
+ * drilldown-modal) sit beside amounts that are already in the money colours.
12
+ */
13
+ export type ProgressBarTone =
14
+ | "brand"
15
+ | "neutral"
16
+ | "success"
17
+ | "warning"
18
+ | "danger"
19
+ | "info"
20
+ | "income"
21
+ | "expense";
22
+ /**
23
+ * Track heights: `sm` 4px, `slim` 6px, `md` 8px, `lg` 12px. `slim` is a word and not a
24
+ * letter because the letters are taken in order — an `xs` at 6px would be THICKER than
25
+ * `sm` — and renaming `sm` would move every bar already on it. 6px is the Slider's
26
+ * track and keksdose's hand-drawn meters (`h-1.5`, landing-visuals), for a bar under a
27
+ * line of text where 8px outweighs the text.
28
+ */
29
+ export type ProgressBarSize = "sm" | "slim" | "md" | "lg";
8
30
 
9
31
  const FILL: Record<ProgressBarTone, string> = {
10
32
  brand: "bg-[var(--brand)]",
@@ -13,9 +35,11 @@ const FILL: Record<ProgressBarTone, string> = {
13
35
  warning: "bg-[var(--warning)]",
14
36
  danger: "bg-[var(--danger)]",
15
37
  info: "bg-[var(--info)]",
38
+ income: "bg-[var(--money-income)]",
39
+ expense: "bg-[var(--money-expense)]",
16
40
  };
17
41
 
18
- const TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: "h-1", md: "h-2", lg: "h-3" };
42
+ const TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: "h-1", slim: "h-1.5", md: "h-2", lg: "h-3" };
19
43
 
20
44
  export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
21
45
  /** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way
@@ -389,6 +389,9 @@ export function Treemap({
389
389
  name: d.name,
390
390
  size: d.value,
391
391
  fill: d.fill ?? ramp[idx % ramp.length],
392
+ // recharts hands each node's fields to its cell; a field left out of this map
393
+ // never reaches `TreemapCell` (0.8.0 shipped `labelColor` without it — keksdose).
394
+ labelColor: d.labelColor,
392
395
  }))
393
396
  .filter((n) => Number.isFinite(n.size) && n.size > 0)
394
397
  .slice(0, maxTiles ?? undefined);
@@ -84,6 +84,13 @@ export function buttonClasses(
84
84
  return cn(BUTTON_BASE, BUTTON_SIZES[size], buttonVariantClasses[variant], className);
85
85
  }
86
86
 
87
+ /**
88
+ * ⚠️ No default `type`: like a native `<button>`, a `Button` inside a `<form>` SUBMITS
89
+ * it unless you pass `type="button"`. Kept native on purpose — a form's own submit
90
+ * button relies on it, and changing the default would silently stop those forms from
91
+ * submitting. Pass `type="button"` for every other action in a form (keksdose had six
92
+ * link buttons submitting their form).
93
+ */
87
94
  export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
88
95
  variant?: ButtonVariant;
89
96
  /** `md` (default) is the page's action button. `sm` is the compact one — 12px text
@@ -147,27 +154,50 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
147
154
  };
148
155
 
149
156
  // A tone re-colours the glyph without changing what the variant draws around it.
150
- // `muted` and `danger` both sit quiet at rest — an action in every row of a list
151
- // must not shout from every row — and `danger` answers the pointer in the
152
- // destructive family, so the red arrives only on the one row you are about to
153
- // act on.
154
- const ICON_BUTTON_TONES = {
155
- default: "",
156
- muted:
157
- "text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]",
158
- danger:
159
- "text-[var(--text-placeholder)] hover:bg-[var(--danger-bg)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)]",
160
- // Amber AT REST, unlike `danger`: a warning icon button is the one on the row that
161
- // needs attention (keksdose's "needs review" flag on a transaction, the stale-rate
162
- // marker), not an action repeated down a list — quiet grey would hide the very
163
- // thing it is there to point out.
164
- warning:
165
- "text-[var(--warning)] hover:bg-[var(--warning-bg)] focus:ring-[var(--warning-border)]",
166
- // Sky at rest, for the same reason as `warning`: keksdose's reconcile action on an
167
- // account row (accounts-page:867) is the one on the row to notice, and it is
168
- // informational rather than a problem, so it takes the `--info` family.
169
- info: "text-[var(--info)] hover:bg-[var(--info-bg)] focus:ring-[var(--info-border)]",
170
- } as const;
157
+ // Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):
158
+ // QUIET is placeholder grey until the pointer or focus arrives, then the tone's
159
+ // family; TONED wears the tone's colour at rest. The defaults are the looks each tone
160
+ // had before `quiet` existed: `danger` is quiet — an action in every row of a list
161
+ // must not shout from every row, so the red arrives only on the one row you are about
162
+ // to act on — while `warning` and `info` are toned, because they mark the ONE thing
163
+ // on the row to notice (keksdose's "needs review" flag, its reconcile action at
164
+ // accounts-page:867), and quiet grey would hide the very thing they point out.
165
+ // `muted` is quiet by definition and has no toned look; `default` has no tone.
166
+ export type IconButtonTone = "default" | "muted" | "danger" | "warning" | "info";
167
+
168
+ type ColouredTone = "danger" | "warning" | "info";
169
+
170
+ const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {
171
+ danger: {
172
+ quiet:
173
+ "text-[var(--text-placeholder)] hover:bg-[var(--danger-bg)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)]",
174
+ toned: "text-[var(--danger)] hover:bg-[var(--danger-bg)] focus:ring-[var(--danger-border)]",
175
+ },
176
+ warning: {
177
+ quiet:
178
+ "text-[var(--text-placeholder)] hover:bg-[var(--warning-bg)] hover:text-[var(--warning)] focus:ring-[var(--warning-border)]",
179
+ toned: "text-[var(--warning)] hover:bg-[var(--warning-bg)] focus:ring-[var(--warning-border)]",
180
+ },
181
+ info: {
182
+ quiet:
183
+ "text-[var(--text-placeholder)] hover:bg-[var(--info-bg)] hover:text-[var(--info)] focus:ring-[var(--info-border)]",
184
+ toned: "text-[var(--info)] hover:bg-[var(--info-bg)] focus:ring-[var(--info-border)]",
185
+ },
186
+ };
187
+
188
+ const ICON_BUTTON_MUTED =
189
+ "text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]";
190
+
191
+ /** Which tones sit quiet at rest when `quiet` is left out. */
192
+ const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = { danger: true, warning: false, info: false };
193
+
194
+ function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {
195
+ if (tone === "default") return "";
196
+ if (tone === "muted") return cn(ICON_BUTTON_MUTED, ICON_BUTTON_QUIET_DISABLED_REST);
197
+ const isQuiet = quiet ?? QUIET_BY_DEFAULT[tone];
198
+ const look = ICON_BUTTON_TONES[tone];
199
+ return isQuiet ? cn(look.quiet, ICON_BUTTON_QUIET_DISABLED_REST) : look.toned;
200
+ }
171
201
 
172
202
  // A disabled button must not answer the pointer. The hover classes above are plain
173
203
  // `hover:` (so a caller's `className="hover:…"` still replaces them through
@@ -187,11 +217,9 @@ const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
187
217
  overlay: "disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]",
188
218
  };
189
219
 
190
- // The tones that change the glyph on hover pin their resting glyph the same way.
191
- const ICON_BUTTON_TONES_DISABLED_REST: Partial<Record<keyof typeof ICON_BUTTON_TONES, string>> = {
192
- muted: "disabled:hover:text-[var(--text-placeholder)]",
193
- danger: "disabled:hover:text-[var(--text-placeholder)]",
194
- };
220
+ // The quiet looks change the glyph on hover, so they pin their resting glyph the
221
+ // same way. A toned look keeps its glyph on hover and needs no pin.
222
+ const ICON_BUTTON_QUIET_DISABLED_REST = "disabled:hover:text-[var(--text-placeholder)]";
195
223
 
196
224
  // `pressed`: a toggle that is on. The brand glyph on the quiet brand fill — the
197
225
  // "selected" look of a Chip or a SegmentedControl option, so an on toggle reads as on
@@ -222,8 +250,26 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
222
250
  * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
223
251
  * remove/delete that repeats down a list. `warning`: amber at rest — a flag that
224
252
  * wants attention. `info`: sky at rest — a notice-worthy but harmless action
225
- * (keksdose's reconcile). Default: the variant's own colours. */
226
- tone?: keyof typeof ICON_BUTTON_TONES;
253
+ * (keksdose's reconcile). Default: the variant's own colours. See `quiet` for
254
+ * turning a coloured tone's resting look the other way. */
255
+ tone?: IconButtonTone;
256
+ /**
257
+ * Whether a coloured tone (`danger`, `warning`, `info`) waits for the pointer:
258
+ * `true` is placeholder grey at rest and the tone's colour on hover and focus;
259
+ * `false` wears the tone's colour at rest. Left out, each tone keeps its own
260
+ * default — `danger` quiet, `warning` and `info` not. Ignored for `muted` (quiet by
261
+ * definition) and `default` (no tone).
262
+ *
263
+ * `quiet={false}` on `danger` is for a destructive action that stands ALONE, where
264
+ * hover-to-reveal hides it: keksdose's phone bulk bar (mobile-bulk-bar.tsx) has one
265
+ * "delete all" and a touch screen that never hovers, so it painted rose by hand.
266
+ * `quiet` on `warning`/`info` is the same switch the other way, for a flag repeated
267
+ * down a list. A boolean over the tone rather than a new tone (`danger-solid`) or an
268
+ * `emphasis` scale: there are exactly two resting looks, every coloured tone has
269
+ * both, and which one fits is a question about the SITE (alone or repeated, touch or
270
+ * pointer), not about the tone — so it is one switch the family shares.
271
+ */
272
+ quiet?: boolean;
227
273
  /**
228
274
  * Make it a toggle button. `true` sets `aria-pressed="true"` and draws the "on" look
229
275
  * (brand glyph on the quiet brand fill); `false` sets `aria-pressed="false"` with the
@@ -262,7 +308,7 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
262
308
  }
263
309
 
264
310
  export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
265
- { variant = "ghost", size = "md", tone = "default", pressed, stopPropagation, className, onClick, onKeyDown, ...rest },
311
+ { variant = "ghost", size = "md", tone = "default", quiet, pressed, stopPropagation, className, onClick, onKeyDown, ...rest },
266
312
  ref,
267
313
  ) {
268
314
  return (
@@ -286,8 +332,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
286
332
  // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
287
333
  variant === "overlay" ? ICON_BUTTON_OVERLAY : buttonVariantClasses[variant],
288
334
  ICON_BUTTON_DISABLED_REST[variant],
289
- ICON_BUTTON_TONES[tone],
290
- ICON_BUTTON_TONES_DISABLED_REST[tone],
335
+ iconButtonToneClass(tone, quiet),
291
336
  pressed && ICON_BUTTON_PRESSED,
292
337
  className,
293
338
  )}
@@ -1327,13 +1372,24 @@ export function Spinner({ className, label, ...rest }: SpinnerProps) {
1327
1372
  );
1328
1373
  }
1329
1374
 
1330
- export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
1375
+ export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "title"> {
1331
1376
  /** The box renders `title` and `hint` in its own two-line rhythm, which is what makes
1332
1377
  * every empty state in three apps look like the same thing — so there is no
1333
1378
  * `children` slot to put arbitrary content in. The two slots below are the only
1334
1379
  * other things an empty state has turned out to need, and each has a fixed place. */
1335
- title: string;
1336
- hint?: string;
1380
+ /** Nodes rather than strings, so a title can carry a code, a link or an emphasised
1381
+ * word — keksdose's error boundary shows the error's name in `<code>` — while the
1382
+ * box still sets the type. */
1383
+ title: ReactNode;
1384
+ hint?: ReactNode;
1385
+ /**
1386
+ * Render the title as a heading of this level. Left out it is a `<div>`, as before:
1387
+ * most empty states are a message inside a section that already has its heading.
1388
+ * keksdose's error boundary IS the page when it trips, and its `<h2>` went missing
1389
+ * when it moved onto EmptyState — so the page's heading outline lost the one line
1390
+ * that says what happened. Only the element changes; the look is the box's.
1391
+ */
1392
+ headingAs?: "h2" | "h3" | "h4" | "h5" | "h6";
1337
1393
  /** A glyph ABOVE the title — kastlan's InboxEmptyState (an inbox), keksdose's
1338
1394
  * offline card (a cloud with a slash). Sized by the box (`[&_svg]:size-8`) and
1339
1395
  * muted, so five call sites cannot pick five sizes; hidden from assistive tech,
@@ -1346,7 +1402,8 @@ export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "
1346
1402
  action?: ReactNode;
1347
1403
  }
1348
1404
 
1349
- export function EmptyState({ title, hint, icon, action, className, ...rest }: EmptyStateProps) {
1405
+ export function EmptyState({ title, hint, icon, action, headingAs, className, ...rest }: EmptyStateProps) {
1406
+ const Title = headingAs ?? "div";
1350
1407
  return (
1351
1408
  <div
1352
1409
  {...rest}
@@ -1360,8 +1417,9 @@ export function EmptyState({ title, hint, icon, action, className, ...rest }: Em
1360
1417
  {icon}
1361
1418
  </div>
1362
1419
  )}
1363
- <div className="font-medium text-[var(--text-secondary)]">{title}</div>
1364
- {hint && <div className="mt-1 text-xs">{hint}</div>}
1420
+ {/* `text-sm`: a heading keeps the box's size, not whatever a stylesheet gives h2. */}
1421
+ <Title className="text-sm font-medium text-[var(--text-secondary)]">{title}</Title>
1422
+ {hint != null && hint !== false && hint !== "" && <div className="mt-1 text-xs">{hint}</div>}
1365
1423
  {action != null && (
1366
1424
  <div className="mt-4 flex flex-wrap items-center justify-center gap-2">{action}</div>
1367
1425
  )}