@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
@@ -0,0 +1,256 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, CSSProperties, KeyboardEvent, ReactNode } from "react";
3
+ import { X } from "lucide-react";
4
+
5
+ import { cn } from "../lib/cn";
6
+ import { horizontalStep } from "../lib/direction";
7
+ import { useKitLabels } from "../i18n/kit-labels";
8
+ import { useAnnounce } from "../hooks/use-announce";
9
+ import { OVERLAY_EXIT_MS, prefersReducedMotion } from "../hooks/use-close-transition";
10
+ import { Button, IconButton } from "./ui";
11
+
12
+ export interface BulkActionBarLabels {
13
+ /** The count, shown in the bar, spoken by its live region and used as the toolbar's
14
+ * name: "3 selected". */
15
+ selected: (count: number) => string;
16
+ /** The clear/cancel action's name — the X of the floating bar, the text button of the
17
+ * others. */
18
+ clear: string;
19
+ /** Spoken when the selection empties and the bar goes. */
20
+ cleared: string;
21
+ }
22
+
23
+ export const DEFAULT_BULK_ACTION_BAR_LABELS: BulkActionBarLabels = {
24
+ selected: (count) => `${count} selected`,
25
+ clear: "Clear selection",
26
+ cleared: "Selection cleared",
27
+ };
28
+
29
+ /**
30
+ * `floating` — the phone's: fixed above `AppShell`'s bottom nav (`--app-nav-h`) and the
31
+ * home indicator, inset from both sides, a rounded card with a shadow. `sticky` — the
32
+ * desktop's: pinned to the top of its scroll container, over the rows it acts on.
33
+ * `inline` — in the flow, where the caller places it.
34
+ */
35
+ export type BulkActionBarVariant = "floating" | "sticky" | "inline";
36
+
37
+ export interface BulkActionBarProps extends Omit<ComponentPropsWithoutRef<"div">, "role" | "children"> {
38
+ /** How many items are selected. The bar shows only while this is above zero. */
39
+ count: number;
40
+ /** Clear the selection / leave selection mode. */
41
+ onClear: () => void;
42
+ /** The actions: buttons (`IconButton size="lg"` on a phone, `Button` from md up),
43
+ * in the caller's order. They are the toolbar's arrow-key stops, after the clear. */
44
+ children?: ReactNode;
45
+ /** Default `floating`. See {@link BulkActionBarVariant}. */
46
+ variant?: BulkActionBarVariant;
47
+ /** Default: `bulkActionBar` from the {@link UiKitProvider}, else English. */
48
+ labels?: Partial<BulkActionBarLabels>;
49
+ }
50
+
51
+ /** Where the floating bar sits: clear of the phone nav and of the home indicator,
52
+ * whichever reaches higher — `FloatingPanel`'s rule, plus the bar's own 0.5rem gap.
53
+ * Set as inline style, so reposition it through `style` (which is merged over this,
54
+ * e.g. `style={{ insetInline: "auto 2rem", width: "24rem" }}`), not `className`. */
55
+ const FLOATING_STYLE: CSSProperties = {
56
+ bottom: "calc(max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px)) + 0.5rem)",
57
+ // Both insets on both sides: a logical side has no one physical inset to name, and
58
+ // the bar is symmetric, so RTL needs nothing more.
59
+ insetInline: "max(0.5rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))",
60
+ };
61
+
62
+ /** What the arrow keys walk: the toolbar's enabled controls. Matched by element, not by
63
+ * `tabindex`, because the roving below sets every one but the current to `-1` — a
64
+ * custom control joins with `data-toolbar-item`. */
65
+ const ITEMS =
66
+ "button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[data-toolbar-item]";
67
+
68
+ /** Keys a text field needs for itself — the toolbar must not take its caret away. */
69
+ function ownsArrows(el: Element): boolean {
70
+ if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) return true;
71
+ if (el instanceof HTMLInputElement) return !["button", "checkbox", "radio", "submit", "reset"].includes(el.type);
72
+ return (el as HTMLElement).isContentEditable;
73
+ }
74
+
75
+ /**
76
+ * The bar that appears while rows are selected: the count, a way out, and what can be
77
+ * done to all of them at once.
78
+ *
79
+ * keksdose had written it three times — the transactions phone bar
80
+ * (mobile-bulk-bar.tsx), the invoice lines' phone and desktop bars
81
+ * (invoice-lines-bulk-bar.tsx) and the payees selection bar — and the copies had
82
+ * drifted where it matters: two parked themselves with a guessed `bottom-16` that the
83
+ * third had already found leaves a 24px gap (live #314: the nav is content-sized and
84
+ * `AppShell` publishes its height as `--app-nav-h`), none of them announced the count,
85
+ * and all three were `role="toolbar"` without the arrow keys that role promises.
86
+ *
87
+ * ## Keep it mounted
88
+ *
89
+ * Render it unconditionally and let `count` decide. At zero it renders only its live
90
+ * region — which must already be in the DOM for the first "3 selected" to be heard —
91
+ * and when the count drops to zero it plays its exit before it goes, which a
92
+ * `{count > 0 && <Bar/>}` gate would cut off.
93
+ *
94
+ * ## The toolbar
95
+ *
96
+ * One Tab stop: ←/→ (mirrored in RTL), Home and End move between the controls, and Tab
97
+ * leaves — the WAI-ARIA toolbar pattern, since that is what `role="toolbar"` tells a
98
+ * screen reader to expect. The stop remembers the control last used. Arrow keys inside
99
+ * a text field stay the field's.
100
+ *
101
+ * It is NOT a dialog: no focus trap, no scroll lock — the list behind stays live,
102
+ * because tapping more rows is how a selection grows.
103
+ */
104
+ export function BulkActionBar({
105
+ count,
106
+ onClear,
107
+ children,
108
+ variant = "floating",
109
+ labels: labelsProp,
110
+ className,
111
+ style,
112
+ onKeyDown,
113
+ onFocus,
114
+ ...rest
115
+ }: BulkActionBarProps) {
116
+ const labels = useKitLabels("bulkActionBar", DEFAULT_BULK_ACTION_BAR_LABELS, labelsProp);
117
+ const { announce, regionProps } = useAnnounce();
118
+ const barRef = useRef<HTMLDivElement>(null);
119
+ const shown = count > 0;
120
+
121
+ // The count the bar last SHOWED, so a bar lowering itself after "clear" still reads
122
+ // "3 selected" on its way out rather than "0 selected". Adjusted during render
123
+ // (`Collapse`'s pattern), as is the exit below.
124
+ const [shownCount, setShownCount] = useState(count);
125
+ const [leaving, setLeaving] = useState(false);
126
+ const [prevCount, setPrevCount] = useState(count);
127
+ if (count !== prevCount) {
128
+ setPrevCount(count);
129
+ if (shown) {
130
+ setShownCount(count);
131
+ setLeaving(false);
132
+ } else {
133
+ setLeaving(prevCount > 0 && !prefersReducedMotion());
134
+ }
135
+ }
136
+ useEffect(() => {
137
+ if (!leaving) return;
138
+ const timer = setTimeout(() => setLeaving(false), OVERLAY_EXIT_MS);
139
+ return () => clearTimeout(timer);
140
+ }, [leaving]);
141
+
142
+ // Spoken on every CHANGE, not on mount: a bar mounted with rows already selected
143
+ // was not the result of anything the user just did.
144
+ const spoken = useRef(count);
145
+ useEffect(() => {
146
+ if (spoken.current === count) return;
147
+ spoken.current = count;
148
+ announce(count > 0 ? labels.selected(count) : labels.cleared);
149
+ }, [count, labels, announce]);
150
+
151
+ // Roving tab stop. The controls are the caller's, so the stop is kept on the DOM
152
+ // rather than through props: after every render the remembered control (or the
153
+ // first) is the one with `tabindex=0`, every other one `-1`.
154
+ const active = useRef<HTMLElement | null>(null);
155
+ // Every render: the caller's controls come and go (a "remove added" that exists
156
+ // only while some are removable), and a stop on a control that left must move on.
157
+ useLayoutEffect(() => {
158
+ rove(barRef.current, active);
159
+ });
160
+
161
+ const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
162
+ onKeyDown?.(e);
163
+ if (e.defaultPrevented || ownsArrows(e.target as Element)) return;
164
+ const list = itemsOf(barRef.current);
165
+ const at = list.indexOf(document.activeElement as HTMLElement);
166
+ if (at === -1 || list.length === 0) return;
167
+ const step = horizontalStep(e.key, barRef.current);
168
+ let next = -1;
169
+ if (step !== 0) next = (at + step + list.length) % list.length;
170
+ else if (e.key === "Home") next = 0;
171
+ else if (e.key === "End") next = list.length - 1;
172
+ if (next === -1) return;
173
+ e.preventDefault();
174
+ active.current = list[next];
175
+ rove(barRef.current, active);
176
+ list[next].focus();
177
+ };
178
+
179
+ if (!shown && !leaving) {
180
+ return <span {...regionProps} />;
181
+ }
182
+
183
+ const floating = variant === "floating";
184
+ const clear = floating ? (
185
+ <IconButton size="lg" onClick={onClear} aria-label={labels.clear}>
186
+ <X />
187
+ </IconButton>
188
+ ) : (
189
+ <Button type="button" variant="secondary" size="sm" onClick={onClear}>
190
+ {labels.clear}
191
+ </Button>
192
+ );
193
+
194
+ return (
195
+ <>
196
+ <span {...regionProps} />
197
+ <div
198
+ {...rest}
199
+ ref={barRef}
200
+ role="toolbar"
201
+ aria-label={labels.selected(shownCount)}
202
+ aria-orientation="horizontal"
203
+ data-bulk-action-bar={variant}
204
+ // Leaving: out of the tab order and the accessibility tree at once, while the
205
+ // bar still animates. The selection it acted on is already gone.
206
+ inert={!shown || undefined}
207
+ onKeyDown={handleKeyDown}
208
+ onFocus={(e) => {
209
+ onFocus?.(e);
210
+ // Clicking a control is using it: the stop follows the pointer too.
211
+ const hit = itemsOf(barRef.current).find((el) => el === e.target);
212
+ if (hit) {
213
+ active.current = hit;
214
+ rove(barRef.current, active);
215
+ }
216
+ }}
217
+ style={floating ? { ...FLOATING_STYLE, ...style } : style}
218
+ className={cn(
219
+ "flex items-center",
220
+ floating
221
+ ? cn(
222
+ "fixed z-30 gap-1 rounded-2xl border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 shadow-lg",
223
+ shown ? "animate-sheet" : "animate-sheet-out",
224
+ )
225
+ : cn(
226
+ "gap-2 px-3 py-2",
227
+ variant === "sticky" &&
228
+ "sticky top-0 z-20 border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--brand)_8%,var(--bg-surface))] backdrop-blur",
229
+ shown ? "animate-overlay" : "animate-overlay-out",
230
+ ),
231
+ className,
232
+ )}
233
+ >
234
+ {floating && clear}
235
+ <span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--text-primary)]">
236
+ {labels.selected(shownCount)}
237
+ </span>
238
+ {/* `contents`, so the caller's controls are flex items of the bar itself. */}
239
+ <div className="contents">{children}</div>
240
+ {!floating && clear}
241
+ </div>
242
+ </>
243
+ );
244
+ }
245
+
246
+ function itemsOf(bar: HTMLElement | null): HTMLElement[] {
247
+ return bar ? Array.from(bar.querySelectorAll<HTMLElement>(ITEMS)) : [];
248
+ }
249
+
250
+ /** Put the one Tab stop on the remembered control — or the first, when that one has
251
+ * gone or was never set — and take it off every other. */
252
+ function rove(bar: HTMLElement | null, active: { current: HTMLElement | null }): void {
253
+ const list = itemsOf(bar);
254
+ if (!active.current || !list.includes(active.current)) active.current = list[0] ?? null;
255
+ for (const el of list) el.tabIndex = el === active.current ? 0 : -1;
256
+ }
@@ -1,6 +1,6 @@
1
1
  import { forwardRef, useId, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from "react";
3
- import { X } from "lucide-react";
3
+ import { Check, X } from "lucide-react";
4
4
  import type { LucideIcon } from "lucide-react";
5
5
  import { cn } from "../lib/cn";
6
6
  import { horizontalStep } from "../lib/direction";
@@ -30,9 +30,22 @@ export type ChipTone =
30
30
  | "success"
31
31
  | "info"
32
32
  | "income"
33
- | "expense";
34
- /** `lg` is the 44px touch target (`min-h-11`) a phone surface wants; `md` is ~28px. */
35
- export type ChipSize = "sm" | "md" | "lg";
33
+ | "expense"
34
+ | ChipHue;
35
+ /**
36
+ * Categorical hues (0.10.0): colour that says "a different value" and nothing more —
37
+ * kastlan's status-badge.tsx paints ~15 enums in 9 Tailwind hues by hand. Four of those
38
+ * nine are semantic and already tones (green = `success`, yellow = `warning`, red =
39
+ * `danger`, gray = `neutral`); these five are the rest, each a `--hue-*` token triple
40
+ * in tokens.css with a light and a dark value. Use a semantic tone when the value HAS a
41
+ * meaning (overdue is `danger`), a hue when it only needs telling apart (sent vs
42
+ * accepted vs invoiced).
43
+ */
44
+ export type ChipHue = "blue" | "indigo" | "purple" | "teal" | "orange";
45
+ /** `lg` is the 44px touch target (`min-h-11`) a phone surface wants; `md` is ~28px.
46
+ * `xs` (0.10.0) is 11px type at `px-1.5` — keksdose's goal markers and budget-cell
47
+ * badges, which sit inside a figure's line and must not make it taller. */
48
+ export type ChipSize = "xs" | "sm" | "md" | "lg";
36
49
  /**
37
50
  * How much of the tone the chip wears.
38
51
  *
@@ -43,8 +56,12 @@ export type ChipSize = "sm" | "md" | "lg";
43
56
  * - `solid`: the tone as a fill under contrasting text — the "count" pill (unread
44
57
  * messages, open tasks). It centres its content and sets figures tabular, so a
45
58
  * one-digit count is a round dot and "9" → "10" does not jiggle the row.
59
+ * - `dot` (0.10.0): no pill at all — a dot in the tone's colour before plain text, for
60
+ * a status in a dense list or a table column where eight tinted pills in a column
61
+ * would outshout the figures beside them (kastlan's unit picker already draws its
62
+ * UnitStatus this way, from a separate `dotColor` map).
46
63
  */
47
- export type ChipVariant = "soft" | "outline" | "solid";
64
+ export type ChipVariant = "soft" | "outline" | "solid" | "dot";
48
65
  /** `pill` (default) is fully rounded; `square` has the small radius of a field or a
49
66
  * button — for a chip that sits in a table cell or beside square controls. */
50
67
  export type ChipShape = "pill" | "square";
@@ -86,6 +103,28 @@ const TONE: Record<ChipTone, { idle: string; selected: string }> = {
86
103
  idle: "border-current/40 bg-[var(--bg-surface-2)] text-[var(--money-expense)]",
87
104
  selected: "border-current bg-[var(--bg-active)] text-[var(--money-expense)]",
88
105
  },
106
+ // The categorical hues: the `info` recipe with its own token triple, so a hue chip
107
+ // and a status chip in one row carry the same weight.
108
+ blue: {
109
+ idle: "border-[var(--hue-blue-border)] bg-[var(--hue-blue-bg)] text-[var(--hue-blue)]",
110
+ selected: "border-[var(--hue-blue)] bg-[var(--hue-blue-bg)] text-[var(--hue-blue)]",
111
+ },
112
+ indigo: {
113
+ idle: "border-[var(--hue-indigo-border)] bg-[var(--hue-indigo-bg)] text-[var(--hue-indigo)]",
114
+ selected: "border-[var(--hue-indigo)] bg-[var(--hue-indigo-bg)] text-[var(--hue-indigo)]",
115
+ },
116
+ purple: {
117
+ idle: "border-[var(--hue-purple-border)] bg-[var(--hue-purple-bg)] text-[var(--hue-purple)]",
118
+ selected: "border-[var(--hue-purple)] bg-[var(--hue-purple-bg)] text-[var(--hue-purple)]",
119
+ },
120
+ teal: {
121
+ idle: "border-[var(--hue-teal-border)] bg-[var(--hue-teal-bg)] text-[var(--hue-teal)]",
122
+ selected: "border-[var(--hue-teal)] bg-[var(--hue-teal-bg)] text-[var(--hue-teal)]",
123
+ },
124
+ orange: {
125
+ idle: "border-[var(--hue-orange-border)] bg-[var(--hue-orange-bg)] text-[var(--hue-orange)]",
126
+ selected: "border-[var(--hue-orange)] bg-[var(--hue-orange-bg)] text-[var(--hue-orange)]",
127
+ },
89
128
  };
90
129
 
91
130
  // `outline` idle looks: the tone's border and text, the surface left to whatever the
@@ -100,6 +139,11 @@ const OUTLINE: Record<ChipTone, string> = {
100
139
  info: "border-[var(--info-border)] bg-transparent text-[var(--info)]",
101
140
  income: "border-current/60 bg-transparent text-[var(--money-income)]",
102
141
  expense: "border-current/60 bg-transparent text-[var(--money-expense)]",
142
+ blue: "border-[var(--hue-blue-border)] bg-transparent text-[var(--hue-blue)]",
143
+ indigo: "border-[var(--hue-indigo-border)] bg-transparent text-[var(--hue-indigo)]",
144
+ purple: "border-[var(--hue-purple-border)] bg-transparent text-[var(--hue-purple)]",
145
+ teal: "border-[var(--hue-teal-border)] bg-transparent text-[var(--hue-teal)]",
146
+ orange: "border-[var(--hue-orange-border)] bg-transparent text-[var(--hue-orange)]",
103
147
  };
104
148
 
105
149
  // `solid`: the fill under its contrast pair. Only brand and danger have a declared
@@ -116,12 +160,40 @@ const SOLID: Record<ChipTone, string> = {
116
160
  info: "border-transparent bg-[var(--info)] text-[var(--text-inverse)]",
117
161
  income: "border-transparent bg-[var(--money-income)] text-[var(--text-inverse)]",
118
162
  expense: "border-transparent bg-[var(--money-expense)] text-[var(--text-inverse)]",
163
+ blue: "border-transparent bg-[var(--hue-blue)] text-[var(--text-inverse)]",
164
+ indigo: "border-transparent bg-[var(--hue-indigo)] text-[var(--text-inverse)]",
165
+ purple: "border-transparent bg-[var(--hue-purple)] text-[var(--text-inverse)]",
166
+ teal: "border-transparent bg-[var(--hue-teal)] text-[var(--text-inverse)]",
167
+ orange: "border-transparent bg-[var(--hue-orange)] text-[var(--text-inverse)]",
168
+ };
169
+
170
+ /** `dot`: the fill of the dot itself — the tone's strongest colour, which is the one
171
+ * chosen to read on the page surface in both themes. Neutral takes the muted ink: a
172
+ * grey dot is "no particular state", not a darker black. */
173
+ const DOT: Record<ChipTone, string> = {
174
+ neutral: "bg-[var(--text-muted)]",
175
+ brand: "bg-[var(--brand)]",
176
+ danger: "bg-[var(--danger)]",
177
+ warning: "bg-[var(--warning)]",
178
+ success: "bg-[var(--success)]",
179
+ info: "bg-[var(--info)]",
180
+ income: "bg-[var(--money-income)]",
181
+ expense: "bg-[var(--money-expense)]",
182
+ blue: "bg-[var(--hue-blue)]",
183
+ indigo: "bg-[var(--hue-indigo)]",
184
+ purple: "bg-[var(--hue-purple)]",
185
+ teal: "bg-[var(--hue-teal)]",
186
+ orange: "bg-[var(--hue-orange)]",
119
187
  };
120
188
 
189
+ /** Per size: the dot's diameter. */
190
+ const DOT_SIZE: Record<ChipSize, string> = { xs: "size-1.5", sm: "size-2", md: "size-2", lg: "size-2.5" };
191
+
121
192
  /** The status-badge type (`caps`): a size step down, heavier and tracked, because
122
193
  * capitals at body size shout and capitals untracked run together. Per size, so it
123
194
  * replaces the size's own `text-*` through tailwind-merge. */
124
195
  const CAPS: Record<ChipSize, string> = {
196
+ xs: "text-[10px] font-semibold uppercase tracking-wider",
125
197
  sm: "text-[10px] font-semibold uppercase tracking-wider",
126
198
  md: "text-[11px] font-semibold uppercase tracking-wider",
127
199
  lg: "text-xs font-semibold uppercase tracking-wider",
@@ -129,6 +201,13 @@ const CAPS: Record<ChipSize, string> = {
129
201
 
130
202
  function surfaceOf(tone: ChipTone, variant: ChipVariant, selected: boolean | undefined): string {
131
203
  const palette = TONE[tone];
204
+ if (variant === "dot") {
205
+ // No surface and no border colour: the dot carries the tone, the text stays ink.
206
+ // The border goes transparent rather than away, so a dot chip lines up with a pill.
207
+ return selected
208
+ ? "border-transparent bg-transparent font-medium text-[var(--text-primary)]"
209
+ : "border-transparent bg-transparent text-[var(--text-secondary)]";
210
+ }
132
211
  if (variant === "solid") {
133
212
  // A solid chip is already the strongest look there is, so "on" is a ring round it.
134
213
  return cn(SOLID[tone], selected && "ring-2 ring-[var(--border-strong)] ring-offset-1 ring-offset-[var(--bg-surface)]");
@@ -172,6 +251,13 @@ const SIZE: Record<
172
251
  ChipSize,
173
252
  { body: string; split: string; tail: string; icon: string; remove: string }
174
253
  > = {
254
+ xs: {
255
+ body: "gap-1 px-1.5 py-0 text-[11px] leading-4",
256
+ split: "gap-1 ps-1.5 pe-0.5 py-0 text-[11px] leading-4",
257
+ tail: "pe-1",
258
+ icon: "size-3",
259
+ remove: "size-2.5",
260
+ },
175
261
  sm: {
176
262
  body: "gap-1 px-2 py-0.5 text-xs",
177
263
  split: "gap-1 ps-2 pe-1 py-0.5 text-xs",
@@ -238,6 +324,16 @@ interface ChipBaseProps {
238
324
  * it — "+ Outflow" in green next to an inflow (keksdose #417).
239
325
  */
240
326
  selected?: boolean;
327
+ /**
328
+ * Checkbox mode (0.10.0), for a toggle chip (`onClick` + `selected`): a small box
329
+ * at the start that shows a tick when `selected`, and the button reports
330
+ * `role="checkbox"` + `aria-checked` instead of `aria-pressed`. For a set of
331
+ * include/exclude choices where "on" must read as TICKED, not merely tinted —
332
+ * keksdose's add-group-card (the "which categories come along" pills), which put a
333
+ * real `<Checkbox>` inside a `<label>` pill by hand. The box is drawn in both
334
+ * states so ticking one never shifts its label.
335
+ */
336
+ checkbox?: boolean;
241
337
  /** Renders a dismiss affordance. Works alongside `href`/`onClick` — see the note below. */
242
338
  onRemove?: () => void;
243
339
  /** Accessible name for the dismiss button. Default: `common.remove` from the
@@ -341,6 +437,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
341
437
  caps = false,
342
438
  icon: Icon,
343
439
  selected,
440
+ checkbox = false,
344
441
  href,
345
442
  renderLink,
346
443
  onClick,
@@ -362,7 +459,9 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
362
459
  const interactive = !!href || !!onClick;
363
460
  // One radius for every rounded piece — the pill, the body inside a split pill, the ×
364
461
  // — so a square chip has no round focus ring inside it.
365
- const radius = shape === "square" ? (size === "sm" ? "rounded" : "rounded-md") : "rounded-full";
462
+ const radius =
463
+ shape === "square" ? (size === "sm" || size === "xs" ? "rounded" : "rounded-md") : "rounded-full";
464
+ const dot = variant === "dot";
366
465
  const type = cn(caps && CAPS[size], variant === "solid" && "justify-center tabular-nums");
367
466
  const surface = cn(
368
467
  surfaceOf(tone, variant, selected),
@@ -375,11 +474,29 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
375
474
  radius,
376
475
  surface,
377
476
  interactive && !disabled && "cursor-pointer hover:brightness-[0.97] dark:hover:brightness-110",
477
+ // An inert dot chip is text in a column: no inset, so it aligns with the header.
478
+ dot && !interactive && "px-0",
378
479
  className,
379
480
  );
380
481
 
482
+ // The checkbox box, only on the toggle shape it has meaning on.
483
+ const isCheckbox = checkbox && !!onClick && !href;
381
484
  const body = (
382
485
  <>
486
+ {isCheckbox && (
487
+ <span
488
+ aria-hidden
489
+ data-chip-check=""
490
+ className={cn(
491
+ "flex shrink-0 items-center justify-center rounded-sm border",
492
+ s.icon,
493
+ selected ? "border-current bg-current/15" : "border-current/50",
494
+ )}
495
+ >
496
+ {selected && <Check className="size-full" strokeWidth={3} />}
497
+ </span>
498
+ )}
499
+ {dot && <span aria-hidden data-chip-dot="" className={cn("shrink-0 rounded-full", DOT_SIZE[size], DOT[tone])} />}
383
500
  {Icon && <Icon className={cn(s.icon, "shrink-0")} aria-hidden />}
384
501
  <span className="min-w-0 truncate">{children}</span>
385
502
  </>
@@ -485,7 +602,11 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
485
602
  // Only when `selected` is PASSED: a chip with `onClick` and no `selected` is an
486
603
  // action button (it does something), not a toggle (it is on or off), and
487
604
  // announcing it "not pressed" would claim a state it does not have.
488
- aria-pressed={selected}
605
+ // A checkbox chip says "checked", not "pressed" — the same state in the words
606
+ // of the control it looks like.
607
+ role={isCheckbox ? "checkbox" : undefined}
608
+ aria-pressed={isCheckbox ? undefined : selected}
609
+ aria-checked={isCheckbox ? !!selected : undefined}
489
610
  className={remove ? inner : look}
490
611
  {...rest}
491
612
  >
@@ -6,7 +6,7 @@ import { useAnnounce } from "../hooks/use-announce";
6
6
  import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
7
7
  import type { CopyState } from "../hooks/use-copy-to-clipboard";
8
8
  import { Button, IconButton } from "./ui";
9
- import type { ButtonVariant, IconButtonSize } from "./ui";
9
+ import type { ButtonSize, ButtonTone, ButtonVariant, IconButtonSize, IconButtonTone } from "./ui";
10
10
  import { Tooltip } from "./tooltip";
11
11
  import type { TooltipSide } from "./tooltip";
12
12
 
@@ -43,8 +43,17 @@ export interface CopyButtonProps
43
43
  label?: string;
44
44
  /** The button's own look. Default: `ghost` for the icon, `secondary` for the label. */
45
45
  buttonVariant?: ButtonVariant;
46
- /** Icon variant only. */
47
- size?: IconButtonSize;
46
+ /** The icon variant's IconButton size (default `sm`). The `label` variant takes a
47
+ * Button size, `sm` or `md` — passed only when given, so an existing label button
48
+ * keeps its look (keksdose budget-share-card and jobs-panel wanted the compact one). */
49
+ size?: IconButtonSize | ButtonSize;
50
+ /** The icon variant's IconButton tone (e.g. `muted`, so a row of copy icons is no
51
+ * heavier than the row's other actions); in the `label` variant a Button tone,
52
+ * which applies to `link` / `ghost`. The copied / failed colour still wins. */
53
+ tone?: IconButtonTone | ButtonTone;
54
+ /** Keep the click and Enter/Space from reaching a clickable row around the button,
55
+ * as IconButton's own `stopPropagation` does (keksdose feedback-page, users-panel). */
56
+ stopPropagation?: boolean;
48
57
  /** Icon variant only: where the result tooltip opens. */
49
58
  tooltipSide?: TooltipSide;
50
59
  /** Pass through to the tooltip — needed inside a scroll container (see Tooltip). */
@@ -80,7 +89,9 @@ export function CopyButton({
80
89
  variant = "icon",
81
90
  label,
82
91
  buttonVariant,
83
- size = "sm",
92
+ size,
93
+ tone,
94
+ stopPropagation,
84
95
  tooltipSide = "top",
85
96
  tooltipPortal = false,
86
97
  resetAfter = 2000,
@@ -123,8 +134,17 @@ export function CopyButton({
123
134
  {...rest}
124
135
  disabled={disabled}
125
136
  variant={buttonVariant ?? "secondary"}
137
+ size={size === "sm" || size === "md" ? size : undefined}
138
+ tone={tone === "default" || tone === "muted" || tone === "danger" ? tone : undefined}
126
139
  data-state={state}
127
- onClick={onClick}
140
+ onClick={(e) => {
141
+ if (stopPropagation) e.stopPropagation();
142
+ void onClick();
143
+ }}
144
+ onKeyDown={(e) => {
145
+ if (stopPropagation && (e.key === "Enter" || e.key === " ")) e.stopPropagation();
146
+ rest.onKeyDown?.(e);
147
+ }}
128
148
  className={className}
129
149
  >
130
150
  <Icon aria-hidden className={cn("size-4 shrink-0", iconTone)} />
@@ -144,9 +164,11 @@ export function CopyButton({
144
164
  disabled={disabled}
145
165
  aria-label={idleText}
146
166
  variant={buttonVariant ?? "ghost"}
147
- size={size}
167
+ size={(size ?? "sm") as IconButtonSize}
168
+ tone={tone as IconButtonTone | undefined}
169
+ stopPropagation={stopPropagation}
148
170
  data-state={state}
149
- onClick={onClick}
171
+ onClick={() => void onClick()}
150
172
  className={cn(iconTone, className)}
151
173
  >
152
174
  <Icon aria-hidden />
@@ -770,6 +770,27 @@ export interface DateRangePickerProps extends DatePickerBaseProps {
770
770
  * would overwrite the sheet's history marker.
771
771
  */
772
772
  sheetBackCloses?: boolean;
773
+ /**
774
+ * With `commit="apply"`: a line above the calendar saying what Apply would commit —
775
+ * keksdose's report range writes "Custom · 1 Mar 2026 – …" there
776
+ * (report-range-field.tsx), the one place a preset armed and then nudged by a day is
777
+ * seen to have become "Custom", and a half-made range to still lack its end. Called
778
+ * with the DRAFT on every change, in the popover and the phone sheet alike; `preset`
779
+ * is the preset the column marks for it (none once a day was picked by hand), `to` is
780
+ * `""` until the second click. The line is a polite live region, so a screen reader
781
+ * hears the draft change as the calendar is used.
782
+ *
783
+ * Ignored in `"immediate"` mode, where there is no draft: every click is the value.
784
+ */
785
+ renderDraftSummary?: (draft: DateRangeDraftSummary) => ReactNode;
786
+ }
787
+
788
+ /** What {@link DateRangePickerProps.renderDraftSummary} is called with. */
789
+ export interface DateRangeDraftSummary {
790
+ from: string;
791
+ to: string;
792
+ /** The preset the column marks for the draft, if any. */
793
+ preset: DateRangePickerPreset | undefined;
773
794
  }
774
795
 
775
796
  /** What {@link DateRangePickerProps.renderTrigger} is called with. */
@@ -889,6 +910,7 @@ function RangePanel({
889
910
  commitRange,
890
911
  calendarProps,
891
912
  labels,
913
+ renderDraftSummary,
892
914
  }: {
893
915
  from: string;
894
916
  to: string;
@@ -903,6 +925,7 @@ function RangePanel({
903
925
  commitRange: (from: string, to: string, presetId: string | undefined) => void;
904
926
  calendarProps: Pick<MiniCalendarProps, "locale" | "min" | "max" | "labels">;
905
927
  labels: DatePickerLabels;
928
+ renderDraftSummary?: DateRangePickerProps["renderDraftSummary"];
906
929
  }) {
907
930
  const drafting = commit === "apply";
908
931
  const [draft, setDraft] = useState<RangeDraft>({
@@ -917,7 +940,12 @@ function RangePanel({
917
940
  : -1;
918
941
  const complete = Boolean(draft.from && draft.to);
919
942
 
920
- const calendar = (
943
+ const summary =
944
+ drafting && renderDraftSummary
945
+ ? renderDraftSummary({ from: draft.from, to: draft.to, preset: presets?.[marked] })
946
+ : null;
947
+
948
+ const picker = (
921
949
  <MiniCalendar
922
950
  {...calendarProps}
923
951
  focusOnOpen
@@ -934,6 +962,24 @@ function RangePanel({
934
962
  }}
935
963
  />
936
964
  );
965
+ // The summary heads the CALENDAR's column, not the panel: beside a preset column it
966
+ // reads as the calendar's caption, and in the sheet it sits between the presets and
967
+ // the month it describes. `null` renders no line — a caller may opt out per draft —
968
+ // but keeps the wrapper, so the calendar is not remounted (and its month and focus
969
+ // lost) when a line comes or goes.
970
+ const calendar =
971
+ drafting && renderDraftSummary ? (
972
+ <div className="flex flex-col gap-2">
973
+ {summary != null && (
974
+ <div aria-live="polite" data-draft-summary="" className="text-xs text-[var(--text-muted)]">
975
+ {summary}
976
+ </div>
977
+ )}
978
+ {picker}
979
+ </div>
980
+ ) : (
981
+ picker
982
+ );
937
983
 
938
984
  const body =
939
985
  presets && presets.length > 0 ? (
@@ -1006,6 +1052,7 @@ export function DateRangePicker({
1006
1052
  commit = "immediate",
1007
1053
  renderTrigger,
1008
1054
  sheetBackCloses,
1055
+ renderDraftSummary,
1009
1056
  calendarLabels,
1010
1057
  label,
1011
1058
  clearable,
@@ -1082,6 +1129,7 @@ export function DateRangePicker({
1082
1129
  commitRange={commitRange}
1083
1130
  calendarProps={{ locale, min, max, labels: calendarLabels }}
1084
1131
  labels={text}
1132
+ renderDraftSummary={renderDraftSummary}
1085
1133
  />
1086
1134
  )}
1087
1135
  </DateField>