@eifi1/ui-kit 0.7.1 → 0.8.1

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 (222) hide show
  1. package/README.md +32 -17
  2. package/dist/chart.d.ts +3 -2
  3. package/dist/components/alert-banner.d.ts +59 -10
  4. package/dist/components/alert-banner.js +114 -8
  5. package/dist/components/alert-banner.js.map +1 -1
  6. package/dist/components/amount-input.d.ts +3 -0
  7. package/dist/components/button-group.d.ts +26 -0
  8. package/dist/components/button-group.js +49 -0
  9. package/dist/components/button-group.js.map +1 -0
  10. package/dist/components/calculator.d.ts +3 -0
  11. package/dist/components/chart-zoom.d.ts +63 -14
  12. package/dist/components/chart-zoom.js +82 -16
  13. package/dist/components/chart-zoom.js.map +1 -1
  14. package/dist/components/chip.d.ts +67 -2
  15. package/dist/components/chip.js +73 -8
  16. package/dist/components/chip.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +120 -0
  18. package/dist/components/confirm-dialog.js +96 -0
  19. package/dist/components/confirm-dialog.js.map +1 -0
  20. package/dist/components/copy-button.d.ts +62 -0
  21. package/dist/components/copy-button.js +95 -0
  22. package/dist/components/copy-button.js.map +1 -0
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table-pagination.d.ts +5 -1
  26. package/dist/components/data-table-pagination.js +71 -58
  27. package/dist/components/data-table-pagination.js.map +1 -1
  28. package/dist/components/data-table-sort.d.ts +28 -5
  29. package/dist/components/data-table-sort.js +11 -7
  30. package/dist/components/data-table-sort.js.map +1 -1
  31. package/dist/components/data-table.d.ts +1 -1
  32. package/dist/components/data-table.js +544 -423
  33. package/dist/components/data-table.js.map +1 -1
  34. package/dist/components/date-picker.d.ts +51 -5
  35. package/dist/components/date-picker.js +152 -40
  36. package/dist/components/date-picker.js.map +1 -1
  37. package/dist/components/description-list.d.ts +60 -0
  38. package/dist/components/description-list.js +112 -0
  39. package/dist/components/description-list.js.map +1 -0
  40. package/dist/components/disclosure.d.ts +37 -2
  41. package/dist/components/disclosure.js +14 -5
  42. package/dist/components/disclosure.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +2 -0
  44. package/dist/components/file-button.d.ts +17 -1
  45. package/dist/components/file-button.js +8 -1
  46. package/dist/components/file-button.js.map +1 -1
  47. package/dist/components/file-dropzone.d.ts +54 -9
  48. package/dist/components/file-dropzone.js +100 -70
  49. package/dist/components/file-dropzone.js.map +1 -1
  50. package/dist/components/floating-panel.d.ts +107 -0
  51. package/dist/components/floating-panel.js +206 -0
  52. package/dist/components/floating-panel.js.map +1 -0
  53. package/dist/components/full-bleed-dialog.d.ts +12 -1
  54. package/dist/components/full-bleed-dialog.js +28 -5
  55. package/dist/components/full-bleed-dialog.js.map +1 -1
  56. package/dist/components/modal.d.ts +6 -2
  57. package/dist/components/modal.js +2 -1
  58. package/dist/components/modal.js.map +1 -1
  59. package/dist/components/number-field.d.ts +3 -0
  60. package/dist/components/number-input.d.ts +3 -0
  61. package/dist/components/numpad-sheet.d.ts +3 -0
  62. package/dist/components/progress-bar.d.ts +54 -0
  63. package/dist/components/progress-bar.js +94 -0
  64. package/dist/components/progress-bar.js.map +1 -0
  65. package/dist/components/scroll-area.d.ts +53 -0
  66. package/dist/components/scroll-area.js +86 -0
  67. package/dist/components/scroll-area.js.map +1 -0
  68. package/dist/components/separator.d.ts +23 -0
  69. package/dist/components/separator.js +24 -0
  70. package/dist/components/separator.js.map +1 -0
  71. package/dist/components/series-chart-ticks.d.ts +34 -1
  72. package/dist/components/series-chart-ticks.js +83 -1
  73. package/dist/components/series-chart-ticks.js.map +1 -1
  74. package/dist/components/series-chart.d.ts +314 -19
  75. package/dist/components/series-chart.js +510 -120
  76. package/dist/components/series-chart.js.map +1 -1
  77. package/dist/components/skeleton.d.ts +36 -0
  78. package/dist/components/skeleton.js +35 -0
  79. package/dist/components/skeleton.js.map +1 -0
  80. package/dist/components/stat-tile.d.ts +60 -6
  81. package/dist/components/stat-tile.js +60 -26
  82. package/dist/components/stat-tile.js.map +1 -1
  83. package/dist/components/table.d.ts +83 -0
  84. package/dist/components/table.js +166 -0
  85. package/dist/components/table.js.map +1 -0
  86. package/dist/components/toggle-legend.d.ts +3 -2
  87. package/dist/components/toggle-legend.js +1 -1
  88. package/dist/components/toggle-legend.js.map +1 -1
  89. package/dist/components/tree-view.d.ts +129 -0
  90. package/dist/components/tree-view.js +376 -0
  91. package/dist/components/tree-view.js.map +1 -0
  92. package/dist/components/treemap.d.ts +9 -2
  93. package/dist/components/treemap.js +6 -2
  94. package/dist/components/treemap.js.map +1 -1
  95. package/dist/components/ui.d.ts +94 -13
  96. package/dist/components/ui.js +111 -29
  97. package/dist/components/ui.js.map +1 -1
  98. package/dist/components/use-table-state.d.ts +1 -1
  99. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  100. package/dist/data-table.d.ts +2 -2
  101. package/dist/data-table.js.map +1 -1
  102. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  103. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  104. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  105. package/dist/hooks/use-debounce.d.ts +43 -0
  106. package/dist/hooks/use-debounce.js +56 -0
  107. package/dist/hooks/use-debounce.js.map +1 -0
  108. package/dist/hooks/use-file-drop.d.ts +72 -0
  109. package/dist/hooks/use-file-drop.js +57 -0
  110. package/dist/hooks/use-file-drop.js.map +1 -0
  111. package/dist/i18n/defaults.d.ts +3 -0
  112. package/dist/i18n/defaults.js +7 -1
  113. package/dist/i18n/defaults.js.map +1 -1
  114. package/dist/i18n/kit-labels.d.ts +15 -0
  115. package/dist/i18n/kit-labels.js +6 -2
  116. package/dist/i18n/kit-labels.js.map +1 -1
  117. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  118. package/dist/i18n/locales/de-CH-informal.js +8 -0
  119. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  120. package/dist/i18n/locales/de-CH.d.ts +3 -0
  121. package/dist/i18n/locales/de-CH.js +1 -14
  122. package/dist/i18n/locales/de-CH.js.map +1 -1
  123. package/dist/i18n/locales/de-informal.d.ts +64 -0
  124. package/dist/i18n/locales/de-informal.js +37 -0
  125. package/dist/i18n/locales/de-informal.js.map +1 -0
  126. package/dist/i18n/locales/de.d.ts +3 -0
  127. package/dist/i18n/locales/de.js +25 -0
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +3 -0
  130. package/dist/i18n/locales/es.js +25 -0
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +3 -0
  133. package/dist/i18n/locales/fr.js +25 -0
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +3 -0
  136. package/dist/i18n/locales/hu.js +25 -0
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +3 -0
  139. package/dist/i18n/locales/it.js +25 -0
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +3 -0
  142. package/dist/i18n/locales/zh.js +25 -0
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/i18n/swiss.d.ts +13 -0
  145. package/dist/i18n/swiss.js +19 -0
  146. package/dist/i18n/swiss.js.map +1 -0
  147. package/dist/index.d.ts +25 -10
  148. package/dist/index.js +22 -0
  149. package/dist/index.js.map +1 -1
  150. package/dist/lib/dates.d.ts +37 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/search/command-palette.d.ts +15 -1
  154. package/dist/search/command-palette.js +10 -2
  155. package/dist/search/command-palette.js.map +1 -1
  156. package/dist/wizard/stepper-nav.d.ts +35 -2
  157. package/dist/wizard/stepper-nav.js +54 -22
  158. package/dist/wizard/stepper-nav.js.map +1 -1
  159. package/dist/wizard/types.d.ts +107 -5
  160. package/dist/wizard/types.js +2 -1
  161. package/dist/wizard/types.js.map +1 -1
  162. package/dist/wizard/use-wizard.js +141 -21
  163. package/dist/wizard/use-wizard.js.map +1 -1
  164. package/dist/wizard/wizard-summary.d.ts +10 -2
  165. package/dist/wizard/wizard-summary.js +3 -1
  166. package/dist/wizard/wizard-summary.js.map +1 -1
  167. package/dist/wizard.d.ts +4 -1
  168. package/dist/wizard.js.map +1 -1
  169. package/package.json +1 -1
  170. package/src/components/alert-banner.tsx +211 -20
  171. package/src/components/button-group.tsx +75 -0
  172. package/src/components/chart-zoom.tsx +158 -20
  173. package/src/components/chip.tsx +160 -9
  174. package/src/components/confirm-dialog.tsx +242 -0
  175. package/src/components/copy-button.tsx +158 -0
  176. package/src/components/data-table-pagination.tsx +19 -6
  177. package/src/components/data-table-sort.ts +49 -10
  178. package/src/components/data-table.tsx +293 -24
  179. package/src/components/date-picker.tsx +262 -57
  180. package/src/components/description-list.tsx +174 -0
  181. package/src/components/disclosure.tsx +58 -5
  182. package/src/components/file-button.tsx +22 -2
  183. package/src/components/file-dropzone.tsx +198 -100
  184. package/src/components/floating-panel.tsx +341 -0
  185. package/src/components/full-bleed-dialog.tsx +53 -5
  186. package/src/components/modal.tsx +9 -2
  187. package/src/components/progress-bar.tsx +163 -0
  188. package/src/components/scroll-area.tsx +129 -0
  189. package/src/components/separator.tsx +37 -0
  190. package/src/components/series-chart-ticks.ts +135 -0
  191. package/src/components/series-chart.tsx +934 -67
  192. package/src/components/skeleton.tsx +65 -0
  193. package/src/components/stat-tile.tsx +160 -27
  194. package/src/components/table.tsx +263 -0
  195. package/src/components/toggle-legend.tsx +4 -3
  196. package/src/components/tree-view.tsx +589 -0
  197. package/src/components/treemap.tsx +13 -2
  198. package/src/components/ui.tsx +270 -39
  199. package/src/data-table.ts +2 -0
  200. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  201. package/src/hooks/use-debounce.ts +105 -0
  202. package/src/hooks/use-file-drop.ts +123 -0
  203. package/src/i18n/defaults.ts +6 -0
  204. package/src/i18n/kit-labels.tsx +19 -0
  205. package/src/i18n/locales/de-CH-informal.ts +10 -0
  206. package/src/i18n/locales/de-CH.ts +1 -16
  207. package/src/i18n/locales/de-informal.ts +61 -0
  208. package/src/i18n/locales/de.ts +25 -0
  209. package/src/i18n/locales/es.ts +25 -0
  210. package/src/i18n/locales/fr.ts +25 -0
  211. package/src/i18n/locales/hu.ts +25 -0
  212. package/src/i18n/locales/it.ts +25 -0
  213. package/src/i18n/locales/zh.ts +25 -0
  214. package/src/i18n/swiss.ts +25 -0
  215. package/src/index.ts +29 -0
  216. package/src/lib/dates.ts +48 -0
  217. package/src/search/command-palette.tsx +29 -3
  218. package/src/wizard/stepper-nav.tsx +102 -28
  219. package/src/wizard/types.ts +106 -4
  220. package/src/wizard/use-wizard.ts +211 -31
  221. package/src/wizard/wizard-summary.tsx +24 -12
  222. package/src/wizard.ts +3 -2
@@ -7,13 +7,27 @@ import { useMediaQuery } from "../hooks/use-media-query";
7
7
  import { Tooltip, type TooltipSide } from "./tooltip";
8
8
  import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
9
9
 
10
- export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand";
10
+ export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
11
11
 
12
12
  // Shared base ring for every button-styled element. Kept as a named const so the
13
13
  // <Button> component and the {@link buttonClasses} helper draw from one source and
14
14
  // can never drift apart.
15
15
  const BUTTON_BASE =
16
- "inline-flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
16
+ "inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
17
+
18
+ export type ButtonSize = "sm" | "md";
19
+
20
+ // Box geometry per size, split out of the base so the two cannot be merged into one
21
+ // string that a size then has to fight. `md` is the pre-0.8.0 look, unchanged. `sm` is
22
+ // the compact secondary action keksdose repeats by hand as `px-2 py-1 text-xs` (and
23
+ // `px-2 py-0.5 text-xs`) on the buttons in a toolbar or a table header; one rung is
24
+ // enough, so the two spellings meet at `py-1`. There is no `lg`: no app has asked for
25
+ // a bigger text button (IconButton's `lg` is a touch target, not a text size). The
26
+ // variant map comes AFTER the size, so `link`'s `p-0` still wins at either size.
27
+ const BUTTON_SIZES: Record<ButtonSize, string> = {
28
+ md: "gap-2 px-3 py-2 text-sm",
29
+ sm: "gap-1.5 px-2 py-1 text-xs",
30
+ };
17
31
 
18
32
  // Warm, palette-token-driven so buttons blend with the fields + cards in every theme.
19
33
  // Actions default to a warm bordered look (primary = filled warm chip, secondary =
@@ -32,21 +46,58 @@ const buttonVariantClasses: Record<ButtonVariant, string> = {
32
46
  "bg-[var(--danger)] text-[var(--danger-contrast)] hover:bg-[var(--danger-hover)] focus:ring-[var(--danger-border)]",
33
47
  brand:
34
48
  "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)] focus:ring-[var(--brand)]",
49
+ // A text link that is still a `<button>` — keksdose's six hand-rolled
50
+ // `<button className="text-brand underline">` sites ("Resend code", "Show all",
51
+ // "Undo" in a toast…), which act rather than navigate and so must not be anchors.
52
+ // The box padding goes (`p-0`) so it sits in a sentence at the text's own size, but
53
+ // the base's `focus:ring-2` stays: those copies had `outline-none` and no ring, so
54
+ // a keyboard user tabbing onto them saw nothing at all. `rounded-sm` keeps that
55
+ // ring hugging the word instead of drawing a pill round it.
56
+ link:
57
+ "rounded-sm p-0 bg-transparent text-[var(--brand)] underline-offset-4 hover:underline focus:ring-[var(--brand)]",
35
58
  };
36
59
 
60
+ /** The second argument of {@link buttonClasses} in its options form. */
61
+ export interface ButtonClassesOptions {
62
+ /** See {@link ButtonProps.size}. */
63
+ size?: ButtonSize;
64
+ className?: string;
65
+ }
66
+
37
67
  /**
38
68
  * Button classes for the rare case where the styling must land on a non-`<button>`
39
69
  * element that {@link Button} can't render — e.g. a router `<Link>` or a Radix
40
70
  * AlertDialog Action/Cancel (which must stay the Radix element). Everywhere a real
41
- * button works, prefer `<Button>`. Draws from the same base + variant maps as
71
+ * button works, prefer `<Button>`. Draws from the same base, size and variant maps as
42
72
  * `<Button>`, so the two stay in lockstep.
73
+ *
74
+ * The second argument is either the extra classes (the pre-0.8.0 form) or
75
+ * `{ size, className }` — `buttonClasses("secondary", { size: "sm" })` for keksdose's
76
+ * compact toolbar links.
43
77
  */
44
- export function buttonClasses(variant: ButtonVariant = "primary", className?: string): string {
45
- return cn(BUTTON_BASE, buttonVariantClasses[variant], className);
78
+ export function buttonClasses(
79
+ variant: ButtonVariant = "primary",
80
+ classNameOrOptions?: string | ButtonClassesOptions,
81
+ ): string {
82
+ const { size = "md", className } =
83
+ typeof classNameOrOptions === "object" ? classNameOrOptions : { className: classNameOrOptions };
84
+ return cn(BUTTON_BASE, BUTTON_SIZES[size], buttonVariantClasses[variant], className);
46
85
  }
47
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
+ */
48
94
  export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
49
95
  variant?: ButtonVariant;
96
+ /** `md` (default) is the page's action button. `sm` is the compact one — 12px text
97
+ * and `px-2 py-1` — for the secondary actions in a toolbar, a card header or a
98
+ * table's header row (keksdose writes `px-2 py-1 text-xs` over `secondary` by hand
99
+ * there). Every variant takes either size. */
100
+ size?: ButtonSize;
50
101
  /** In a flex row next to a taller labelled field, fill the field's height so the
51
102
  * two line up. No effect outside a flex row. */
52
103
  stretch?: boolean;
@@ -56,12 +107,13 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
56
107
  ref?: Ref<HTMLButtonElement>;
57
108
  }
58
109
 
59
- export function Button({ variant = "primary", stretch, className, ...rest }: ButtonProps) {
110
+ export function Button({ variant = "primary", size = "md", stretch, className, ...rest }: ButtonProps) {
60
111
  return (
61
112
  <button
62
113
  {...rest}
63
114
  className={cn(
64
115
  BUTTON_BASE,
116
+ BUTTON_SIZES[size],
65
117
  // In a flex row next to a taller labelled field, `stretch` makes the button
66
118
  // fill the field's height so the two line up (self-stretch overrides the row's
67
119
  // align-items). No effect outside a flex row / when it's already the tallest.
@@ -86,12 +138,15 @@ export function Button({ variant = "primary", stretch, className, ...rest }: But
86
138
  const ICON_BUTTON_BASE =
87
139
  "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
88
140
 
89
- export type IconButtonSize = "md" | "sm" | "xs" | "2xs";
141
+ export type IconButtonSize = "lg" | "md" | "sm" | "xs" | "2xs";
90
142
 
91
143
  // Box and glyph together, so a 24px chip action cannot end up holding a 20px icon
92
144
  // that touches its edges. The two small steps are lenkbank's list-row (28px) and
93
145
  // chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.
94
146
  const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
147
+ // The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's
148
+ // bulk-action bars write `size-11` by hand over an `md` button to get it on phones.
149
+ lg: "size-11 [&_svg]:size-5",
95
150
  md: "size-9 [&_svg]:size-5",
96
151
  sm: "size-8 [&_svg]:size-5",
97
152
  xs: "size-7 rounded [&_svg]:size-4",
@@ -109,18 +164,83 @@ const ICON_BUTTON_TONES = {
109
164
  "text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]",
110
165
  danger:
111
166
  "text-[var(--text-placeholder)] hover:bg-[var(--danger-bg)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)]",
167
+ // Amber AT REST, unlike `danger`: a warning icon button is the one on the row that
168
+ // needs attention (keksdose's "needs review" flag on a transaction, the stale-rate
169
+ // marker), not an action repeated down a list — quiet grey would hide the very
170
+ // thing it is there to point out.
171
+ warning:
172
+ "text-[var(--warning)] hover:bg-[var(--warning-bg)] focus:ring-[var(--warning-border)]",
173
+ // Sky at rest, for the same reason as `warning`: keksdose's reconcile action on an
174
+ // account row (accounts-page:867) is the one on the row to notice, and it is
175
+ // informational rather than a problem, so it takes the `--info` family.
176
+ info: "text-[var(--info)] hover:bg-[var(--info-bg)] focus:ring-[var(--info-border)]",
112
177
  } as const;
113
178
 
179
+ // A disabled button must not answer the pointer. The hover classes above are plain
180
+ // `hover:` (so a caller's `className="hover:…"` still replaces them through
181
+ // tailwind-merge), which means they fire on a disabled button too — the old grey
182
+ // icon lit up on hover while refusing the click. Rather than rewrite every hover as
183
+ // `enabled:hover:` (which never matches the `<a>` that `buttonClasses` also styles,
184
+ // and would out-rank a caller's plain `hover:` override), each variant pins its
185
+ // RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity
186
+ // and only ever matches a disabled button.
187
+ const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
188
+ primary: "disabled:hover:bg-[var(--bg-surface-2)]",
189
+ secondary: "disabled:hover:bg-transparent",
190
+ ghost: "disabled:hover:bg-transparent",
191
+ danger: "disabled:hover:bg-[var(--danger)]",
192
+ brand: "disabled:hover:bg-[var(--brand)]",
193
+ link: "disabled:hover:bg-transparent disabled:hover:no-underline",
194
+ overlay: "disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]",
195
+ };
196
+
197
+ // The tones that change the glyph on hover pin their resting glyph the same way.
198
+ const ICON_BUTTON_TONES_DISABLED_REST: Partial<Record<keyof typeof ICON_BUTTON_TONES, string>> = {
199
+ muted: "disabled:hover:text-[var(--text-placeholder)]",
200
+ danger: "disabled:hover:text-[var(--text-placeholder)]",
201
+ };
202
+
203
+ // `pressed`: a toggle that is on. The brand glyph on the quiet brand fill — the
204
+ // "selected" look of a Chip or a SegmentedControl option, so an on toggle reads as on
205
+ // beside them. After the tone, so a pressed `muted` button is brand, not grey.
206
+ const ICON_BUTTON_PRESSED =
207
+ "bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)] hover:text-[var(--brand)] disabled:hover:bg-[var(--brand-bg)] disabled:hover:text-[var(--brand)]";
208
+
209
+ // `variant="overlay"`: a round, translucent disc for a control that sits ON a photo
210
+ // (keksdose's receipt-scan preview: close, rotate, retake over the camera image).
211
+ // The inverse pair, not the surface one: what has to hold is the contrast between the
212
+ // disc and its glyph, whatever the picture underneath is, and `--bg-inverse` /
213
+ // `--text-inverse` are the one token pair defined as each other's opposite in both
214
+ // themes. The disc is the inverse at 60% (`color-mix`, so it stays a token a palette
215
+ // can re-point); the blur keeps a busy background from breaking the glyph's edge.
216
+ const ICON_BUTTON_OVERLAY =
217
+ "rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)] text-[var(--text-inverse)] backdrop-blur-sm hover:bg-[color-mix(in_srgb,var(--bg-inverse)_75%,transparent)] focus:ring-[var(--text-inverse)]";
218
+
114
219
  export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
115
- variant?: ButtonVariant;
116
- /** Box size: md = 36px (matches the top bar), sm = 32px — both with a 20px icon;
220
+ /** Any {@link ButtonVariant}, or `overlay` — a round translucent disc for use over
221
+ * an image (see `ICON_BUTTON_OVERLAY`). */
222
+ variant?: ButtonVariant | "overlay";
223
+ /** Box size: lg = 44px (a phone's touch target), md = 36px (matches the top bar),
224
+ * sm = 32px — all three with a 20px icon;
117
225
  * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
118
226
  * icon (an action on a chip or a tab). */
119
227
  size?: IconButtonSize;
120
228
  /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
121
229
  * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
122
- * remove/delete that repeats down a list. Default: the variant's own colours. */
230
+ * remove/delete that repeats down a list. `warning`: amber at rest — a flag that
231
+ * wants attention. `info`: sky at rest — a notice-worthy but harmless action
232
+ * (keksdose's reconcile). Default: the variant's own colours. */
123
233
  tone?: keyof typeof ICON_BUTTON_TONES;
234
+ /**
235
+ * Make it a toggle button. `true` sets `aria-pressed="true"` and draws the "on" look
236
+ * (brand glyph on the quiet brand fill); `false` sets `aria-pressed="false"` with the
237
+ * normal look, so a screen reader still hears a toggle that is off. Left out, it is
238
+ * an ordinary button with no `aria-pressed` — or whatever `aria-pressed` the caller
239
+ * passes. For keksdose's budget share toggle (budgets-page:291), which paints its own
240
+ * brand colour over a ghost button today. Keep the `aria-label` the same in both
241
+ * states ("Share budget"): the pressed state already says whether it is on.
242
+ */
243
+ pressed?: boolean;
124
244
  /** Keep the click (and the Enter/Space that produces it) from reaching an
125
245
  * ancestor's handler — for an action inside a clickable table row or card.
126
246
  *
@@ -149,13 +269,14 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
149
269
  }
150
270
 
151
271
  export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
152
- { variant = "ghost", size = "md", tone = "default", stopPropagation, className, onClick, onKeyDown, ...rest },
272
+ { variant = "ghost", size = "md", tone = "default", pressed, stopPropagation, className, onClick, onKeyDown, ...rest },
153
273
  ref,
154
274
  ) {
155
275
  return (
156
276
  <button
157
277
  ref={ref}
158
278
  {...rest}
279
+ aria-pressed={pressed ?? rest["aria-pressed"]}
159
280
  onClick={(e) => {
160
281
  if (stopPropagation) e.stopPropagation();
161
282
  onClick?.(e);
@@ -169,8 +290,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
169
290
  className={cn(
170
291
  ICON_BUTTON_BASE,
171
292
  ICON_BUTTON_SIZES[size],
172
- buttonVariantClasses[variant],
293
+ // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
294
+ variant === "overlay" ? ICON_BUTTON_OVERLAY : buttonVariantClasses[variant],
295
+ ICON_BUTTON_DISABLED_REST[variant],
173
296
  ICON_BUTTON_TONES[tone],
297
+ ICON_BUTTON_TONES_DISABLED_REST[tone],
298
+ pressed && ICON_BUTTON_PRESSED,
174
299
  className,
175
300
  )}
176
301
  />
@@ -1044,20 +1169,34 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
1044
1169
  * content cards on data pages; leave off for centered dialog/panel cards.
1045
1170
  */
1046
1171
  flush?: boolean;
1172
+ /**
1173
+ * `inset`: a panel INSIDE a card rather than a card on the page — the raised
1174
+ * `--bg-surface-2`, a small radius, `p-3` of its own and no border or shadow, since
1175
+ * it is already sitting on the card that has them. Lenkbank's results blocks and
1176
+ * corner panels write this by hand (`rounded-md bg-surface-2 p-3`) under a Card of
1177
+ * their own. Unlike the default card it carries its padding, because every one of
1178
+ * those copies wanted the same one; a caller's `p-*` still wins. `flush` is ignored:
1179
+ * an inset panel never runs edge to edge.
1180
+ */
1181
+ variant?: "default" | "inset";
1047
1182
  }
1048
1183
 
1049
- export function Card({ className, children, flush, ...rest }: CardProps) {
1184
+ export function Card({ className, children, flush, variant = "default", ...rest }: CardProps) {
1050
1185
  return (
1051
1186
  <div
1052
1187
  {...rest}
1053
1188
  className={cn(
1054
- // Surface + border are theme tokens so the palette switcher (feedback
1055
- // #307) can re-skin every card; a caller's own bg-*/border-* override
1056
- // still wins via tailwind-merge.
1057
- "bg-[var(--bg-surface)]",
1058
- flush
1059
- ? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm"
1060
- : "rounded-lg border border-[var(--border)] shadow-sm",
1189
+ variant === "inset"
1190
+ ? "rounded-md bg-[var(--bg-surface-2)] p-3"
1191
+ : cn(
1192
+ // Surface + border are theme tokens so the palette switcher (feedback
1193
+ // #307) can re-skin every card; a caller's own bg-*/border-* override
1194
+ // still wins via tailwind-merge.
1195
+ "bg-[var(--bg-surface)]",
1196
+ flush
1197
+ ? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm"
1198
+ : "rounded-lg border border-[var(--border)] shadow-sm",
1199
+ ),
1061
1200
  className,
1062
1201
  )}
1063
1202
  >
@@ -1198,12 +1337,23 @@ export function Spinner({ className, label, ...rest }: SpinnerProps) {
1198
1337
  export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
1199
1338
  /** The box renders `title` and `hint` in its own two-line rhythm, which is what makes
1200
1339
  * every empty state in three apps look like the same thing — so there is no
1201
- * `children` slot to put arbitrary content in. */
1340
+ * `children` slot to put arbitrary content in. The two slots below are the only
1341
+ * other things an empty state has turned out to need, and each has a fixed place. */
1202
1342
  title: string;
1203
1343
  hint?: string;
1344
+ /** A glyph ABOVE the title — kastlan's InboxEmptyState (an inbox), keksdose's
1345
+ * offline card (a cloud with a slash). Sized by the box (`[&_svg]:size-8`) and
1346
+ * muted, so five call sites cannot pick five sizes; hidden from assistive tech,
1347
+ * because the title already says what it shows. */
1348
+ icon?: ReactNode;
1349
+ /** What to do about it, BELOW the hint: keksdose's query-state cards (Retry), its
1350
+ * error boundary (Retry / Reload / Go home — pass all three in a fragment, they
1351
+ * wrap and centre as one row), kastlan's "Create a rule". Buttons or links the
1352
+ * caller renders; the box only places them. */
1353
+ action?: ReactNode;
1204
1354
  }
1205
1355
 
1206
- export function EmptyState({ title, hint, className, ...rest }: EmptyStateProps) {
1356
+ export function EmptyState({ title, hint, icon, action, className, ...rest }: EmptyStateProps) {
1207
1357
  return (
1208
1358
  <div
1209
1359
  {...rest}
@@ -1212,8 +1362,16 @@ export function EmptyState({ title, hint, className, ...rest }: EmptyStateProps)
1212
1362
  className,
1213
1363
  )}
1214
1364
  >
1365
+ {icon != null && (
1366
+ <div aria-hidden className="mb-3 text-[var(--text-placeholder)] [&_svg]:size-8">
1367
+ {icon}
1368
+ </div>
1369
+ )}
1215
1370
  <div className="font-medium text-[var(--text-secondary)]">{title}</div>
1216
1371
  {hint && <div className="mt-1 text-xs">{hint}</div>}
1372
+ {action != null && (
1373
+ <div className="mt-4 flex flex-wrap items-center justify-center gap-2">{action}</div>
1374
+ )}
1217
1375
  </div>
1218
1376
  );
1219
1377
  }
@@ -1386,6 +1544,26 @@ export interface TabsProps<T extends string>
1386
1544
  /** An add or a remove is in flight: the × and the add button are disabled and
1387
1545
  * Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */
1388
1546
  busy?: boolean;
1547
+ /**
1548
+ * `"vertical"`: the same strip as a SIDE NAV — one item per row, the open one
1549
+ * filled, ↑/↓ walking it (Home/End as before) and `aria-orientation="vertical"` on
1550
+ * the tablist. For keksdose's admin page and settings page, which each hand-built
1551
+ * the identical sidebar with none of this strip's keyboard, roving tab stop or
1552
+ * routed-`href` handling. Everything else — `href` tabs, `badge`, `panelId`,
1553
+ * `onRemove`, `onAdd` — means exactly what it means on the horizontal strip; the
1554
+ * badge moves to the row's end.
1555
+ *
1556
+ * **On a phone ({@link PHONE_QUERY}) it becomes the horizontal strip**, `wrap` and
1557
+ * all, rather than staying a column. A side nav only works beside its content; on
1558
+ * a phone it has to go ABOVE it, and eight full-width rows there push the panel
1559
+ * the user picked below the fold on every visit — the two keksdose pages both
1560
+ * collapsed theirs into a scrolling row by hand for exactly that reason. The
1561
+ * orientation is switched in JavaScript, not with `md:` classes, so the
1562
+ * `aria-orientation` a screen reader hears and the arrow keys that work always
1563
+ * match the layout on screen. The caller's own two-column layout has to stack at
1564
+ * the same breakpoint (`md:grid-cols-[14rem_1fr]`).
1565
+ */
1566
+ orientation?: "horizontal" | "vertical";
1389
1567
  }
1390
1568
 
1391
1569
  // The two shapes are written out as whole strings rather than as one base plus a
@@ -1398,6 +1576,17 @@ export interface TabsProps<T extends string>
1398
1576
  // that the colours are tokens: there is no `md:dark:` tier left to keep in step.
1399
1577
  const TABLIST_CLASSES =
1400
1578
  "flex gap-1 overflow-x-auto overflow-y-hidden border-b border-[var(--border)]";
1579
+ // The side nav. A column of full-width rows with the open one FILLED rather than
1580
+ // underlined: an underline under one row of a list reads as a separator, not as a
1581
+ // selection. `--bg-active` is the kit's own selected-row fill (the sidebar's), so the
1582
+ // admin page's nav and the app's own sidebar mark "you are here" the same way.
1583
+ const TABLIST_VERTICAL_CLASSES = "flex flex-col gap-0.5";
1584
+ const TAB_VERTICAL_CLASSES =
1585
+ "flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)]";
1586
+ const TAB_VERTICAL_ACTIVE_CLASSES = "bg-[var(--bg-active)] text-[var(--text-primary)]";
1587
+ const TAB_VERTICAL_INACTIVE_CLASSES =
1588
+ "text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]";
1589
+
1401
1590
  const TABLIST_WRAP_CLASSES =
1402
1591
  "flex flex-wrap gap-1.5 md:flex-nowrap md:gap-1 md:overflow-x-auto md:overflow-y-hidden md:border-b md:border-[var(--border)]";
1403
1592
 
@@ -1445,10 +1634,14 @@ export function Tabs<T extends string>({
1445
1634
  addLabel,
1446
1635
  labels,
1447
1636
  busy = false,
1637
+ orientation = "horizontal",
1448
1638
  "aria-label": ariaLabel,
1449
1639
  ...rest
1450
1640
  }: TabsProps<T>) {
1451
1641
  const text = useKitLabels("tabs", DEFAULT_TABS_LABELS, labels);
1642
+ // See `orientation`: a vertical strip is the horizontal one on a phone.
1643
+ const phone = useMediaQuery(PHONE_QUERY, false);
1644
+ const vertical = orientation === "vertical" && !phone;
1452
1645
  const stripRef = useRef<HTMLDivElement>(null);
1453
1646
  const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
1454
1647
  // Whether Delete reaches this tab: every removable tab, or only the open one.
@@ -1514,8 +1707,16 @@ export function Tabs<T extends string>({
1514
1707
  return;
1515
1708
  }
1516
1709
  // Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits
1517
- // to the left, and ArrowLeft has to reach it.
1518
- const step = horizontalStep(e.key, e.currentTarget);
1710
+ // to the left, and ArrowLeft has to reach it. A vertical strip answers ↑/↓ instead
1711
+ // — and ONLY those, as the pattern has it: ←/→ are left to the page, where a side
1712
+ // nav's neighbour (the panel) may well want them.
1713
+ const step: 1 | -1 | 0 = vertical
1714
+ ? e.key === "ArrowDown"
1715
+ ? 1
1716
+ : e.key === "ArrowUp"
1717
+ ? -1
1718
+ : 0
1719
+ : horizontalStep(e.key, e.currentTarget);
1519
1720
  if (step === 0 && e.key !== "Home" && e.key !== "End") return;
1520
1721
  const strip = e.currentTarget.closest('[role="tablist"]');
1521
1722
  if (!strip) return;
@@ -1541,10 +1742,13 @@ export function Tabs<T extends string>({
1541
1742
  role="tablist"
1542
1743
  // The DOM spelling wins over the deprecated `label`; see {@link TabsProps}.
1543
1744
  aria-label={ariaLabel ?? label}
1745
+ // Only when vertical: `horizontal` is the tablist's implicit value, and the
1746
+ // attribute on every existing strip would be noise in every snapshot of them.
1747
+ aria-orientation={vertical ? "vertical" : undefined}
1544
1748
  // With an add button the strip gains an outer box, and `className` goes there
1545
1749
  // — it is the box a caller's margin or width is meant for.
1546
1750
  className={cn(
1547
- wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES,
1751
+ vertical ? TABLIST_VERTICAL_CLASSES : wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES,
1548
1752
  onAdd ? "min-w-0" : className,
1549
1753
  )}
1550
1754
  >
@@ -1574,20 +1778,33 @@ export function Tabs<T extends string>({
1574
1778
  id: isActive && panelId ? `${panelId}-tab` : undefined,
1575
1779
  "aria-keyshortcuts": deletesOnKey(tab) ? "Delete" : undefined,
1576
1780
  onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),
1577
- className: cn(
1578
- wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,
1579
- wrap
1580
- ? isActive
1581
- ? TAB_WRAP_ACTIVE_CLASSES
1582
- : TAB_WRAP_INACTIVE_CLASSES
1583
- : isActive
1584
- ? TAB_ACTIVE_CLASSES
1585
- : TAB_INACTIVE_CLASSES,
1586
- removable && TAB_REMOVABLE_CLASSES,
1587
- ),
1781
+ className: vertical
1782
+ ? cn(
1783
+ TAB_VERTICAL_CLASSES,
1784
+ isActive ? TAB_VERTICAL_ACTIVE_CLASSES : TAB_VERTICAL_INACTIVE_CLASSES,
1785
+ removable && "pe-8",
1786
+ )
1787
+ : cn(
1788
+ wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,
1789
+ wrap
1790
+ ? isActive
1791
+ ? TAB_WRAP_ACTIVE_CLASSES
1792
+ : TAB_WRAP_INACTIVE_CLASSES
1793
+ : isActive
1794
+ ? TAB_ACTIVE_CLASSES
1795
+ : TAB_INACTIVE_CLASSES,
1796
+ removable && TAB_REMOVABLE_CLASSES,
1797
+ ),
1588
1798
  };
1589
1799
  const inner = (
1590
- <span className={cn("inline-flex items-center gap-1.5", tab.empty && TAB_EMPTY_CLASSES)}>
1800
+ <span
1801
+ className={cn(
1802
+ // In a side nav the row is the full width, so the label takes it and a
1803
+ // badge lands at the row's end, where a column of counts lines up.
1804
+ vertical ? "flex min-w-0 flex-1 items-center gap-1.5" : "inline-flex items-center gap-1.5",
1805
+ tab.empty && TAB_EMPTY_CLASSES,
1806
+ )}
1807
+ >
1591
1808
  {tab.icon != null && (
1592
1809
  <span aria-hidden className="inline-flex shrink-0 items-center gap-0.5">
1593
1810
  {tab.icon}
@@ -1606,7 +1823,9 @@ export function Tabs<T extends string>({
1606
1823
  ) : (
1607
1824
  tab.label
1608
1825
  )}
1609
- {tab.badge != null ? tab.badge : null}
1826
+ {tab.badge != null ? (
1827
+ vertical ? <span className="ms-auto flex shrink-0 items-center">{tab.badge}</span> : tab.badge
1828
+ ) : null}
1610
1829
  </span>
1611
1830
  );
1612
1831
  const element = tab.href ? (
@@ -1654,7 +1873,7 @@ export function Tabs<T extends string>({
1654
1873
  // The × cannot go INSIDE the tab: a button in a button is invalid HTML, and
1655
1874
  // a tab's children are presentational, so a reader would flatten it into the
1656
1875
  // tab's name. It sits beside the tab, over the gutter the tab reserves.
1657
- <div key={tab.id} className="relative flex shrink-0">
1876
+ <div key={tab.id} className={cn("relative flex", !vertical && "shrink-0")}>
1658
1877
  {element}
1659
1878
  {isActive && (
1660
1879
  <IconButton
@@ -1684,6 +1903,18 @@ export function Tabs<T extends string>({
1684
1903
  </div>
1685
1904
  );
1686
1905
  if (!onAdd) return tablist;
1906
+ if (vertical) {
1907
+ // Under the column, not beside it, and with no rule to carry on: a side nav has none.
1908
+ return (
1909
+ <div className={cn("flex flex-col gap-0.5", className)}>
1910
+ {tablist}
1911
+ <button type="button" onClick={onAdd} disabled={busy} className={TAB_ADD_CLASSES}>
1912
+ <Plus aria-hidden />
1913
+ {addLabel ?? text.add}
1914
+ </button>
1915
+ </div>
1916
+ );
1917
+ }
1687
1918
  return (
1688
1919
  <div
1689
1920
  className={cn(
package/src/data-table.ts CHANGED
@@ -17,4 +17,6 @@ export type {
17
17
  ServerPagination,
18
18
  FilterState,
19
19
  MobileSwipeActions,
20
+ DataTableDensity,
21
+ DataTableChrome,
20
22
  } from "./components/data-table";
@@ -0,0 +1,122 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ export type CopyState = "idle" | "copied" | "failed";
4
+
5
+ export interface UseCopyToClipboardOptions {
6
+ /** How long `copied` / `failed` stays up before the state returns to `idle`, in
7
+ * ms. `0` keeps it until the next copy or {@link UseCopyToClipboardReturn.reset}. */
8
+ resetAfter?: number;
9
+ }
10
+
11
+ export interface UseCopyToClipboardReturn {
12
+ state: CopyState;
13
+ /** Copy `text`. Resolves to whether it actually reached the clipboard — the same
14
+ * answer `state` gives, for a caller that wants to act on it directly. */
15
+ copy: (text: string) => Promise<boolean>;
16
+ reset: () => void;
17
+ }
18
+
19
+ /**
20
+ * The legacy path: select a throwaway `<textarea>` and ask the document to copy it.
21
+ *
22
+ * `navigator.clipboard` exists only in a secure context, so an app opened over plain
23
+ * http on a LAN address (how keksdose is tested on a phone) has no Clipboard API at
24
+ * all. `execCommand("copy")` is deprecated but still implemented everywhere, and it
25
+ * answers with a boolean, which is all this needs. Focus and the user's selection are
26
+ * put back afterwards: copying must not move the keyboard.
27
+ */
28
+ function legacyCopy(text: string): boolean {
29
+ if (typeof document === "undefined" || typeof document.execCommand !== "function") return false;
30
+ const active = document.activeElement as HTMLElement | null;
31
+ const selection = document.getSelection();
32
+ const ranges: Range[] = [];
33
+ if (selection) for (let i = 0; i < selection.rangeCount; i++) ranges.push(selection.getRangeAt(i));
34
+
35
+ const area = document.createElement("textarea");
36
+ area.value = text;
37
+ area.setAttribute("readonly", "");
38
+ // Off-screen but rendered: a `display: none` textarea cannot be selected.
39
+ area.style.position = "fixed";
40
+ area.style.top = "0";
41
+ area.style.insetInlineStart = "-9999px";
42
+ area.style.opacity = "0";
43
+ document.body.appendChild(area);
44
+ area.select();
45
+ let ok = false;
46
+ try {
47
+ ok = document.execCommand("copy");
48
+ } catch {
49
+ ok = false;
50
+ }
51
+ area.remove();
52
+ if (selection) {
53
+ selection.removeAllRanges();
54
+ for (const r of ranges) selection.addRange(r);
55
+ }
56
+ active?.focus?.();
57
+ return ok;
58
+ }
59
+
60
+ /** Copy `text`, by the Clipboard API when there is one and the legacy path when
61
+ * there is not — or when the API refuses (a denied permission, a document that is
62
+ * not focused). Never throws. */
63
+ export async function copyToClipboard(text: string): Promise<boolean> {
64
+ const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
65
+ if (clipboard && typeof clipboard.writeText === "function") {
66
+ try {
67
+ await clipboard.writeText(text);
68
+ return true;
69
+ } catch {
70
+ // Fall through: the API exists but refused. The legacy path sometimes succeeds
71
+ // where the permission prompt was dismissed.
72
+ }
73
+ }
74
+ return legacyCopy(text);
75
+ }
76
+
77
+ /**
78
+ * Copy text and KNOW whether it worked.
79
+ *
80
+ * keksdose had two copy buttons that flipped to "Copied" on click and never looked at
81
+ * the promise — so on the http LAN build, where there is no Clipboard API, they
82
+ * confirmed a copy that had not happened. The state here is set from the result, and
83
+ * a failure is a state of its own rather than a quiet return to idle.
84
+ */
85
+ export function useCopyToClipboard({ resetAfter = 2000 }: UseCopyToClipboardOptions = {}): UseCopyToClipboardReturn {
86
+ const [state, setState] = useState<CopyState>("idle");
87
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
88
+ const mounted = useRef(true);
89
+
90
+ useEffect(() => {
91
+ mounted.current = true;
92
+ return () => {
93
+ mounted.current = false;
94
+ if (timer.current) clearTimeout(timer.current);
95
+ };
96
+ }, []);
97
+
98
+ const reset = useCallback(() => {
99
+ if (timer.current) clearTimeout(timer.current);
100
+ timer.current = null;
101
+ setState("idle");
102
+ }, []);
103
+
104
+ const copy = useCallback(
105
+ async (text: string) => {
106
+ if (timer.current) clearTimeout(timer.current);
107
+ const ok = await copyToClipboard(text);
108
+ if (!mounted.current) return ok;
109
+ setState(ok ? "copied" : "failed");
110
+ if (resetAfter > 0) {
111
+ timer.current = setTimeout(() => {
112
+ timer.current = null;
113
+ setState("idle");
114
+ }, resetAfter);
115
+ }
116
+ return ok;
117
+ },
118
+ [resetAfter],
119
+ );
120
+
121
+ return { state, copy, reset };
122
+ }