@eifi1/ui-kit 0.7.0 → 0.8.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 (233) 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 +5 -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 +5 -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 +19 -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 +56 -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 +26 -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 +5 -0
  60. package/dist/components/number-input.d.ts +5 -0
  61. package/dist/components/numpad-sheet.d.ts +5 -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/settings-fields.d.ts +2 -0
  78. package/dist/components/skeleton.d.ts +36 -0
  79. package/dist/components/skeleton.js +35 -0
  80. package/dist/components/skeleton.js.map +1 -0
  81. package/dist/components/stat-tile.d.ts +60 -6
  82. package/dist/components/stat-tile.js +60 -26
  83. package/dist/components/stat-tile.js.map +1 -1
  84. package/dist/components/table.d.ts +83 -0
  85. package/dist/components/table.js +166 -0
  86. package/dist/components/table.js.map +1 -0
  87. package/dist/components/time-input.d.ts +2 -0
  88. package/dist/components/toggle-legend.d.ts +3 -2
  89. package/dist/components/toggle-legend.js +1 -1
  90. package/dist/components/toggle-legend.js.map +1 -1
  91. package/dist/components/tooltip.d.ts +10 -4
  92. package/dist/components/tooltip.js +23 -6
  93. package/dist/components/tooltip.js.map +1 -1
  94. package/dist/components/tree-view.d.ts +129 -0
  95. package/dist/components/tree-view.js +376 -0
  96. package/dist/components/tree-view.js.map +1 -0
  97. package/dist/components/treemap.d.ts +9 -2
  98. package/dist/components/treemap.js +2 -1
  99. package/dist/components/treemap.js.map +1 -1
  100. package/dist/components/ui.d.ts +92 -14
  101. package/dist/components/ui.js +112 -30
  102. package/dist/components/ui.js.map +1 -1
  103. package/dist/components/use-table-state.d.ts +1 -1
  104. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  105. package/dist/data-table.d.ts +2 -2
  106. package/dist/data-table.js.map +1 -1
  107. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  108. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  109. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  110. package/dist/hooks/use-debounce.d.ts +43 -0
  111. package/dist/hooks/use-debounce.js +56 -0
  112. package/dist/hooks/use-debounce.js.map +1 -0
  113. package/dist/hooks/use-file-drop.d.ts +72 -0
  114. package/dist/hooks/use-file-drop.js +57 -0
  115. package/dist/hooks/use-file-drop.js.map +1 -0
  116. package/dist/i18n/defaults.d.ts +5 -0
  117. package/dist/i18n/defaults.js +7 -1
  118. package/dist/i18n/defaults.js.map +1 -1
  119. package/dist/i18n/kit-labels.d.ts +17 -0
  120. package/dist/i18n/kit-labels.js +6 -2
  121. package/dist/i18n/kit-labels.js.map +1 -1
  122. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  123. package/dist/i18n/locales/de-CH-informal.js +8 -0
  124. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  125. package/dist/i18n/locales/de-CH.d.ts +5 -0
  126. package/dist/i18n/locales/de-CH.js +1 -14
  127. package/dist/i18n/locales/de-CH.js.map +1 -1
  128. package/dist/i18n/locales/de-informal.d.ts +64 -0
  129. package/dist/i18n/locales/de-informal.js +37 -0
  130. package/dist/i18n/locales/de-informal.js.map +1 -0
  131. package/dist/i18n/locales/de.d.ts +5 -0
  132. package/dist/i18n/locales/de.js +25 -0
  133. package/dist/i18n/locales/de.js.map +1 -1
  134. package/dist/i18n/locales/es.d.ts +5 -0
  135. package/dist/i18n/locales/es.js +25 -0
  136. package/dist/i18n/locales/es.js.map +1 -1
  137. package/dist/i18n/locales/fr.d.ts +5 -0
  138. package/dist/i18n/locales/fr.js +25 -0
  139. package/dist/i18n/locales/fr.js.map +1 -1
  140. package/dist/i18n/locales/hu.d.ts +5 -0
  141. package/dist/i18n/locales/hu.js +25 -0
  142. package/dist/i18n/locales/hu.js.map +1 -1
  143. package/dist/i18n/locales/it.d.ts +5 -0
  144. package/dist/i18n/locales/it.js +25 -0
  145. package/dist/i18n/locales/it.js.map +1 -1
  146. package/dist/i18n/locales/zh.d.ts +5 -0
  147. package/dist/i18n/locales/zh.js +25 -0
  148. package/dist/i18n/locales/zh.js.map +1 -1
  149. package/dist/i18n/swiss.d.ts +13 -0
  150. package/dist/i18n/swiss.js +19 -0
  151. package/dist/i18n/swiss.js.map +1 -0
  152. package/dist/index.d.ts +26 -11
  153. package/dist/index.js +22 -0
  154. package/dist/index.js.map +1 -1
  155. package/dist/lib/dates.d.ts +37 -1
  156. package/dist/lib/dates.js +19 -0
  157. package/dist/lib/dates.js.map +1 -1
  158. package/dist/rhf/form.d.ts +2 -0
  159. package/dist/rhf.d.ts +2 -0
  160. package/dist/search/command-palette.d.ts +15 -1
  161. package/dist/search/command-palette.js +10 -2
  162. package/dist/search/command-palette.js.map +1 -1
  163. package/dist/shell/app-shell.js +2 -2
  164. package/dist/shell/app-shell.js.map +1 -1
  165. package/dist/wizard/stepper-nav.d.ts +35 -2
  166. package/dist/wizard/stepper-nav.js +54 -22
  167. package/dist/wizard/stepper-nav.js.map +1 -1
  168. package/dist/wizard/types.d.ts +107 -5
  169. package/dist/wizard/types.js +2 -1
  170. package/dist/wizard/types.js.map +1 -1
  171. package/dist/wizard/use-wizard.js +141 -21
  172. package/dist/wizard/use-wizard.js.map +1 -1
  173. package/dist/wizard/wizard-summary.d.ts +10 -2
  174. package/dist/wizard/wizard-summary.js +3 -1
  175. package/dist/wizard/wizard-summary.js.map +1 -1
  176. package/dist/wizard.d.ts +4 -1
  177. package/dist/wizard.js.map +1 -1
  178. package/package.json +1 -1
  179. package/src/components/alert-banner.tsx +211 -20
  180. package/src/components/button-group.tsx +75 -0
  181. package/src/components/chart-zoom.tsx +158 -20
  182. package/src/components/chip.tsx +160 -9
  183. package/src/components/confirm-dialog.tsx +242 -0
  184. package/src/components/copy-button.tsx +158 -0
  185. package/src/components/data-table-pagination.tsx +19 -6
  186. package/src/components/data-table-sort.ts +49 -10
  187. package/src/components/data-table.tsx +293 -24
  188. package/src/components/date-picker.tsx +262 -57
  189. package/src/components/description-list.tsx +174 -0
  190. package/src/components/disclosure.tsx +58 -5
  191. package/src/components/file-button.tsx +22 -2
  192. package/src/components/file-dropzone.tsx +198 -100
  193. package/src/components/floating-panel.tsx +341 -0
  194. package/src/components/full-bleed-dialog.tsx +48 -5
  195. package/src/components/modal.tsx +9 -2
  196. package/src/components/progress-bar.tsx +163 -0
  197. package/src/components/scroll-area.tsx +129 -0
  198. package/src/components/separator.tsx +37 -0
  199. package/src/components/series-chart-ticks.ts +135 -0
  200. package/src/components/series-chart.tsx +934 -67
  201. package/src/components/skeleton.tsx +65 -0
  202. package/src/components/stat-tile.tsx +160 -27
  203. package/src/components/table.tsx +263 -0
  204. package/src/components/toggle-legend.tsx +4 -3
  205. package/src/components/tooltip.tsx +40 -12
  206. package/src/components/tree-view.tsx +589 -0
  207. package/src/components/treemap.tsx +10 -2
  208. package/src/components/ui.tsx +268 -42
  209. package/src/data-table.ts +2 -0
  210. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  211. package/src/hooks/use-debounce.ts +105 -0
  212. package/src/hooks/use-file-drop.ts +123 -0
  213. package/src/i18n/defaults.ts +6 -0
  214. package/src/i18n/kit-labels.tsx +19 -0
  215. package/src/i18n/locales/de-CH-informal.ts +10 -0
  216. package/src/i18n/locales/de-CH.ts +1 -16
  217. package/src/i18n/locales/de-informal.ts +61 -0
  218. package/src/i18n/locales/de.ts +25 -0
  219. package/src/i18n/locales/es.ts +25 -0
  220. package/src/i18n/locales/fr.ts +25 -0
  221. package/src/i18n/locales/hu.ts +25 -0
  222. package/src/i18n/locales/it.ts +25 -0
  223. package/src/i18n/locales/zh.ts +25 -0
  224. package/src/i18n/swiss.ts +25 -0
  225. package/src/index.ts +29 -0
  226. package/src/lib/dates.ts +48 -0
  227. package/src/search/command-palette.tsx +29 -3
  228. package/src/shell/app-shell.tsx +2 -2
  229. package/src/wizard/stepper-nav.tsx +102 -28
  230. package/src/wizard/types.ts +106 -4
  231. package/src/wizard/use-wizard.ts +211 -31
  232. package/src/wizard/wizard-summary.tsx +24 -12
  233. package/src/wizard.ts +3 -2
@@ -4,16 +4,30 @@ import type { ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttribute
4
4
  import { cn } from "../lib/cn";
5
5
  import { horizontalStep } from "../lib/direction";
6
6
  import { useMediaQuery } from "../hooks/use-media-query";
7
- import { Tooltip } from "./tooltip";
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,51 @@ 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
 
48
87
  export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
49
88
  variant?: ButtonVariant;
89
+ /** `md` (default) is the page's action button. `sm` is the compact one — 12px text
90
+ * and `px-2 py-1` — for the secondary actions in a toolbar, a card header or a
91
+ * table's header row (keksdose writes `px-2 py-1 text-xs` over `secondary` by hand
92
+ * there). Every variant takes either size. */
93
+ size?: ButtonSize;
50
94
  /** In a flex row next to a taller labelled field, fill the field's height so the
51
95
  * two line up. No effect outside a flex row. */
52
96
  stretch?: boolean;
@@ -56,12 +100,13 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
56
100
  ref?: Ref<HTMLButtonElement>;
57
101
  }
58
102
 
59
- export function Button({ variant = "primary", stretch, className, ...rest }: ButtonProps) {
103
+ export function Button({ variant = "primary", size = "md", stretch, className, ...rest }: ButtonProps) {
60
104
  return (
61
105
  <button
62
106
  {...rest}
63
107
  className={cn(
64
108
  BUTTON_BASE,
109
+ BUTTON_SIZES[size],
65
110
  // In a flex row next to a taller labelled field, `stretch` makes the button
66
111
  // fill the field's height so the two line up (self-stretch overrides the row's
67
112
  // align-items). No effect outside a flex row / when it's already the tallest.
@@ -86,12 +131,15 @@ export function Button({ variant = "primary", stretch, className, ...rest }: But
86
131
  const ICON_BUTTON_BASE =
87
132
  "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
88
133
 
89
- export type IconButtonSize = "md" | "sm" | "xs" | "2xs";
134
+ export type IconButtonSize = "lg" | "md" | "sm" | "xs" | "2xs";
90
135
 
91
136
  // Box and glyph together, so a 24px chip action cannot end up holding a 20px icon
92
137
  // that touches its edges. The two small steps are lenkbank's list-row (28px) and
93
138
  // chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.
94
139
  const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
140
+ // The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's
141
+ // bulk-action bars write `size-11` by hand over an `md` button to get it on phones.
142
+ lg: "size-11 [&_svg]:size-5",
95
143
  md: "size-9 [&_svg]:size-5",
96
144
  sm: "size-8 [&_svg]:size-5",
97
145
  xs: "size-7 rounded [&_svg]:size-4",
@@ -109,18 +157,83 @@ const ICON_BUTTON_TONES = {
109
157
  "text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]",
110
158
  danger:
111
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)]",
112
170
  } as const;
113
171
 
172
+ // A disabled button must not answer the pointer. The hover classes above are plain
173
+ // `hover:` (so a caller's `className="hover:…"` still replaces them through
174
+ // tailwind-merge), which means they fire on a disabled button too — the old grey
175
+ // icon lit up on hover while refusing the click. Rather than rewrite every hover as
176
+ // `enabled:hover:` (which never matches the `<a>` that `buttonClasses` also styles,
177
+ // and would out-rank a caller's plain `hover:` override), each variant pins its
178
+ // RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity
179
+ // and only ever matches a disabled button.
180
+ const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
181
+ primary: "disabled:hover:bg-[var(--bg-surface-2)]",
182
+ secondary: "disabled:hover:bg-transparent",
183
+ ghost: "disabled:hover:bg-transparent",
184
+ danger: "disabled:hover:bg-[var(--danger)]",
185
+ brand: "disabled:hover:bg-[var(--brand)]",
186
+ link: "disabled:hover:bg-transparent disabled:hover:no-underline",
187
+ overlay: "disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]",
188
+ };
189
+
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
+ };
195
+
196
+ // `pressed`: a toggle that is on. The brand glyph on the quiet brand fill — the
197
+ // "selected" look of a Chip or a SegmentedControl option, so an on toggle reads as on
198
+ // beside them. After the tone, so a pressed `muted` button is brand, not grey.
199
+ const ICON_BUTTON_PRESSED =
200
+ "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)]";
201
+
202
+ // `variant="overlay"`: a round, translucent disc for a control that sits ON a photo
203
+ // (keksdose's receipt-scan preview: close, rotate, retake over the camera image).
204
+ // The inverse pair, not the surface one: what has to hold is the contrast between the
205
+ // disc and its glyph, whatever the picture underneath is, and `--bg-inverse` /
206
+ // `--text-inverse` are the one token pair defined as each other's opposite in both
207
+ // themes. The disc is the inverse at 60% (`color-mix`, so it stays a token a palette
208
+ // can re-point); the blur keeps a busy background from breaking the glyph's edge.
209
+ const ICON_BUTTON_OVERLAY =
210
+ "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)]";
211
+
114
212
  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;
213
+ /** Any {@link ButtonVariant}, or `overlay` — a round translucent disc for use over
214
+ * an image (see `ICON_BUTTON_OVERLAY`). */
215
+ variant?: ButtonVariant | "overlay";
216
+ /** Box size: lg = 44px (a phone's touch target), md = 36px (matches the top bar),
217
+ * sm = 32px — all three with a 20px icon;
117
218
  * xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
118
219
  * icon (an action on a chip or a tab). */
119
220
  size?: IconButtonSize;
120
221
  /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
121
222
  * 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. */
223
+ * remove/delete that repeats down a list. `warning`: amber at rest — a flag that
224
+ * wants attention. `info`: sky at rest — a notice-worthy but harmless action
225
+ * (keksdose's reconcile). Default: the variant's own colours. */
123
226
  tone?: keyof typeof ICON_BUTTON_TONES;
227
+ /**
228
+ * Make it a toggle button. `true` sets `aria-pressed="true"` and draws the "on" look
229
+ * (brand glyph on the quiet brand fill); `false` sets `aria-pressed="false"` with the
230
+ * normal look, so a screen reader still hears a toggle that is off. Left out, it is
231
+ * an ordinary button with no `aria-pressed` — or whatever `aria-pressed` the caller
232
+ * passes. For keksdose's budget share toggle (budgets-page:291), which paints its own
233
+ * brand colour over a ghost button today. Keep the `aria-label` the same in both
234
+ * states ("Share budget"): the pressed state already says whether it is on.
235
+ */
236
+ pressed?: boolean;
124
237
  /** Keep the click (and the Enter/Space that produces it) from reaching an
125
238
  * ancestor's handler — for an action inside a clickable table row or card.
126
239
  *
@@ -149,13 +262,14 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
149
262
  }
150
263
 
151
264
  export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
152
- { variant = "ghost", size = "md", tone = "default", stopPropagation, className, onClick, onKeyDown, ...rest },
265
+ { variant = "ghost", size = "md", tone = "default", pressed, stopPropagation, className, onClick, onKeyDown, ...rest },
153
266
  ref,
154
267
  ) {
155
268
  return (
156
269
  <button
157
270
  ref={ref}
158
271
  {...rest}
272
+ aria-pressed={pressed ?? rest["aria-pressed"]}
159
273
  onClick={(e) => {
160
274
  if (stopPropagation) e.stopPropagation();
161
275
  onClick?.(e);
@@ -169,8 +283,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
169
283
  className={cn(
170
284
  ICON_BUTTON_BASE,
171
285
  ICON_BUTTON_SIZES[size],
172
- buttonVariantClasses[variant],
286
+ // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
287
+ variant === "overlay" ? ICON_BUTTON_OVERLAY : buttonVariantClasses[variant],
288
+ ICON_BUTTON_DISABLED_REST[variant],
173
289
  ICON_BUTTON_TONES[tone],
290
+ ICON_BUTTON_TONES_DISABLED_REST[tone],
291
+ pressed && ICON_BUTTON_PRESSED,
174
292
  className,
175
293
  )}
176
294
  />
@@ -472,12 +590,14 @@ export interface FieldHintProps extends Omit<ComponentPropsWithoutRef<"button">,
472
590
  /** The explanation. It is both the tooltip's text and, by default, the button's
473
591
  * accessible name, so a screen reader gets it without the bubble ever opening. */
474
592
  label: string;
475
- side?: "left" | "right" | "top" | "bottom";
593
+ /** Default `"start"`: before the hint in the reading direction (the left in LTR, the
594
+ * right in RTL). `left` / `right` stay physical. */
595
+ side?: TooltipSide;
476
596
  }
477
597
 
478
598
  export function FieldHint({
479
599
  label,
480
- side = "left",
600
+ side = "start",
481
601
  className,
482
602
  "aria-label": ariaLabel,
483
603
  ...rest
@@ -1042,20 +1162,34 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
1042
1162
  * content cards on data pages; leave off for centered dialog/panel cards.
1043
1163
  */
1044
1164
  flush?: boolean;
1165
+ /**
1166
+ * `inset`: a panel INSIDE a card rather than a card on the page — the raised
1167
+ * `--bg-surface-2`, a small radius, `p-3` of its own and no border or shadow, since
1168
+ * it is already sitting on the card that has them. Lenkbank's results blocks and
1169
+ * corner panels write this by hand (`rounded-md bg-surface-2 p-3`) under a Card of
1170
+ * their own. Unlike the default card it carries its padding, because every one of
1171
+ * those copies wanted the same one; a caller's `p-*` still wins. `flush` is ignored:
1172
+ * an inset panel never runs edge to edge.
1173
+ */
1174
+ variant?: "default" | "inset";
1045
1175
  }
1046
1176
 
1047
- export function Card({ className, children, flush, ...rest }: CardProps) {
1177
+ export function Card({ className, children, flush, variant = "default", ...rest }: CardProps) {
1048
1178
  return (
1049
1179
  <div
1050
1180
  {...rest}
1051
1181
  className={cn(
1052
- // Surface + border are theme tokens so the palette switcher (feedback
1053
- // #307) can re-skin every card; a caller's own bg-*/border-* override
1054
- // still wins via tailwind-merge.
1055
- "bg-[var(--bg-surface)]",
1056
- flush
1057
- ? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm"
1058
- : "rounded-lg border border-[var(--border)] shadow-sm",
1182
+ variant === "inset"
1183
+ ? "rounded-md bg-[var(--bg-surface-2)] p-3"
1184
+ : cn(
1185
+ // Surface + border are theme tokens so the palette switcher (feedback
1186
+ // #307) can re-skin every card; a caller's own bg-*/border-* override
1187
+ // still wins via tailwind-merge.
1188
+ "bg-[var(--bg-surface)]",
1189
+ flush
1190
+ ? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm"
1191
+ : "rounded-lg border border-[var(--border)] shadow-sm",
1192
+ ),
1059
1193
  className,
1060
1194
  )}
1061
1195
  >
@@ -1196,12 +1330,23 @@ export function Spinner({ className, label, ...rest }: SpinnerProps) {
1196
1330
  export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
1197
1331
  /** The box renders `title` and `hint` in its own two-line rhythm, which is what makes
1198
1332
  * every empty state in three apps look like the same thing — so there is no
1199
- * `children` slot to put arbitrary content in. */
1333
+ * `children` slot to put arbitrary content in. The two slots below are the only
1334
+ * other things an empty state has turned out to need, and each has a fixed place. */
1200
1335
  title: string;
1201
1336
  hint?: string;
1337
+ /** A glyph ABOVE the title — kastlan's InboxEmptyState (an inbox), keksdose's
1338
+ * offline card (a cloud with a slash). Sized by the box (`[&_svg]:size-8`) and
1339
+ * muted, so five call sites cannot pick five sizes; hidden from assistive tech,
1340
+ * because the title already says what it shows. */
1341
+ icon?: ReactNode;
1342
+ /** What to do about it, BELOW the hint: keksdose's query-state cards (Retry), its
1343
+ * error boundary (Retry / Reload / Go home — pass all three in a fragment, they
1344
+ * wrap and centre as one row), kastlan's "Create a rule". Buttons or links the
1345
+ * caller renders; the box only places them. */
1346
+ action?: ReactNode;
1202
1347
  }
1203
1348
 
1204
- export function EmptyState({ title, hint, className, ...rest }: EmptyStateProps) {
1349
+ export function EmptyState({ title, hint, icon, action, className, ...rest }: EmptyStateProps) {
1205
1350
  return (
1206
1351
  <div
1207
1352
  {...rest}
@@ -1210,8 +1355,16 @@ export function EmptyState({ title, hint, className, ...rest }: EmptyStateProps)
1210
1355
  className,
1211
1356
  )}
1212
1357
  >
1358
+ {icon != null && (
1359
+ <div aria-hidden className="mb-3 text-[var(--text-placeholder)] [&_svg]:size-8">
1360
+ {icon}
1361
+ </div>
1362
+ )}
1213
1363
  <div className="font-medium text-[var(--text-secondary)]">{title}</div>
1214
1364
  {hint && <div className="mt-1 text-xs">{hint}</div>}
1365
+ {action != null && (
1366
+ <div className="mt-4 flex flex-wrap items-center justify-center gap-2">{action}</div>
1367
+ )}
1215
1368
  </div>
1216
1369
  );
1217
1370
  }
@@ -1384,6 +1537,26 @@ export interface TabsProps<T extends string>
1384
1537
  /** An add or a remove is in flight: the × and the add button are disabled and
1385
1538
  * Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */
1386
1539
  busy?: boolean;
1540
+ /**
1541
+ * `"vertical"`: the same strip as a SIDE NAV — one item per row, the open one
1542
+ * filled, ↑/↓ walking it (Home/End as before) and `aria-orientation="vertical"` on
1543
+ * the tablist. For keksdose's admin page and settings page, which each hand-built
1544
+ * the identical sidebar with none of this strip's keyboard, roving tab stop or
1545
+ * routed-`href` handling. Everything else — `href` tabs, `badge`, `panelId`,
1546
+ * `onRemove`, `onAdd` — means exactly what it means on the horizontal strip; the
1547
+ * badge moves to the row's end.
1548
+ *
1549
+ * **On a phone ({@link PHONE_QUERY}) it becomes the horizontal strip**, `wrap` and
1550
+ * all, rather than staying a column. A side nav only works beside its content; on
1551
+ * a phone it has to go ABOVE it, and eight full-width rows there push the panel
1552
+ * the user picked below the fold on every visit — the two keksdose pages both
1553
+ * collapsed theirs into a scrolling row by hand for exactly that reason. The
1554
+ * orientation is switched in JavaScript, not with `md:` classes, so the
1555
+ * `aria-orientation` a screen reader hears and the arrow keys that work always
1556
+ * match the layout on screen. The caller's own two-column layout has to stack at
1557
+ * the same breakpoint (`md:grid-cols-[14rem_1fr]`).
1558
+ */
1559
+ orientation?: "horizontal" | "vertical";
1387
1560
  }
1388
1561
 
1389
1562
  // The two shapes are written out as whole strings rather than as one base plus a
@@ -1396,6 +1569,17 @@ export interface TabsProps<T extends string>
1396
1569
  // that the colours are tokens: there is no `md:dark:` tier left to keep in step.
1397
1570
  const TABLIST_CLASSES =
1398
1571
  "flex gap-1 overflow-x-auto overflow-y-hidden border-b border-[var(--border)]";
1572
+ // The side nav. A column of full-width rows with the open one FILLED rather than
1573
+ // underlined: an underline under one row of a list reads as a separator, not as a
1574
+ // selection. `--bg-active` is the kit's own selected-row fill (the sidebar's), so the
1575
+ // admin page's nav and the app's own sidebar mark "you are here" the same way.
1576
+ const TABLIST_VERTICAL_CLASSES = "flex flex-col gap-0.5";
1577
+ const TAB_VERTICAL_CLASSES =
1578
+ "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)]";
1579
+ const TAB_VERTICAL_ACTIVE_CLASSES = "bg-[var(--bg-active)] text-[var(--text-primary)]";
1580
+ const TAB_VERTICAL_INACTIVE_CLASSES =
1581
+ "text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]";
1582
+
1399
1583
  const TABLIST_WRAP_CLASSES =
1400
1584
  "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)]";
1401
1585
 
@@ -1443,10 +1627,14 @@ export function Tabs<T extends string>({
1443
1627
  addLabel,
1444
1628
  labels,
1445
1629
  busy = false,
1630
+ orientation = "horizontal",
1446
1631
  "aria-label": ariaLabel,
1447
1632
  ...rest
1448
1633
  }: TabsProps<T>) {
1449
1634
  const text = useKitLabels("tabs", DEFAULT_TABS_LABELS, labels);
1635
+ // See `orientation`: a vertical strip is the horizontal one on a phone.
1636
+ const phone = useMediaQuery(PHONE_QUERY, false);
1637
+ const vertical = orientation === "vertical" && !phone;
1450
1638
  const stripRef = useRef<HTMLDivElement>(null);
1451
1639
  const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
1452
1640
  // Whether Delete reaches this tab: every removable tab, or only the open one.
@@ -1512,8 +1700,16 @@ export function Tabs<T extends string>({
1512
1700
  return;
1513
1701
  }
1514
1702
  // Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits
1515
- // to the left, and ArrowLeft has to reach it.
1516
- const step = horizontalStep(e.key, e.currentTarget);
1703
+ // to the left, and ArrowLeft has to reach it. A vertical strip answers ↑/↓ instead
1704
+ // — and ONLY those, as the pattern has it: ←/→ are left to the page, where a side
1705
+ // nav's neighbour (the panel) may well want them.
1706
+ const step: 1 | -1 | 0 = vertical
1707
+ ? e.key === "ArrowDown"
1708
+ ? 1
1709
+ : e.key === "ArrowUp"
1710
+ ? -1
1711
+ : 0
1712
+ : horizontalStep(e.key, e.currentTarget);
1517
1713
  if (step === 0 && e.key !== "Home" && e.key !== "End") return;
1518
1714
  const strip = e.currentTarget.closest('[role="tablist"]');
1519
1715
  if (!strip) return;
@@ -1539,10 +1735,13 @@ export function Tabs<T extends string>({
1539
1735
  role="tablist"
1540
1736
  // The DOM spelling wins over the deprecated `label`; see {@link TabsProps}.
1541
1737
  aria-label={ariaLabel ?? label}
1738
+ // Only when vertical: `horizontal` is the tablist's implicit value, and the
1739
+ // attribute on every existing strip would be noise in every snapshot of them.
1740
+ aria-orientation={vertical ? "vertical" : undefined}
1542
1741
  // With an add button the strip gains an outer box, and `className` goes there
1543
1742
  // — it is the box a caller's margin or width is meant for.
1544
1743
  className={cn(
1545
- wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES,
1744
+ vertical ? TABLIST_VERTICAL_CLASSES : wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES,
1546
1745
  onAdd ? "min-w-0" : className,
1547
1746
  )}
1548
1747
  >
@@ -1572,20 +1771,33 @@ export function Tabs<T extends string>({
1572
1771
  id: isActive && panelId ? `${panelId}-tab` : undefined,
1573
1772
  "aria-keyshortcuts": deletesOnKey(tab) ? "Delete" : undefined,
1574
1773
  onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),
1575
- className: cn(
1576
- wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,
1577
- wrap
1578
- ? isActive
1579
- ? TAB_WRAP_ACTIVE_CLASSES
1580
- : TAB_WRAP_INACTIVE_CLASSES
1581
- : isActive
1582
- ? TAB_ACTIVE_CLASSES
1583
- : TAB_INACTIVE_CLASSES,
1584
- removable && TAB_REMOVABLE_CLASSES,
1585
- ),
1774
+ className: vertical
1775
+ ? cn(
1776
+ TAB_VERTICAL_CLASSES,
1777
+ isActive ? TAB_VERTICAL_ACTIVE_CLASSES : TAB_VERTICAL_INACTIVE_CLASSES,
1778
+ removable && "pe-8",
1779
+ )
1780
+ : cn(
1781
+ wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,
1782
+ wrap
1783
+ ? isActive
1784
+ ? TAB_WRAP_ACTIVE_CLASSES
1785
+ : TAB_WRAP_INACTIVE_CLASSES
1786
+ : isActive
1787
+ ? TAB_ACTIVE_CLASSES
1788
+ : TAB_INACTIVE_CLASSES,
1789
+ removable && TAB_REMOVABLE_CLASSES,
1790
+ ),
1586
1791
  };
1587
1792
  const inner = (
1588
- <span className={cn("inline-flex items-center gap-1.5", tab.empty && TAB_EMPTY_CLASSES)}>
1793
+ <span
1794
+ className={cn(
1795
+ // In a side nav the row is the full width, so the label takes it and a
1796
+ // badge lands at the row's end, where a column of counts lines up.
1797
+ vertical ? "flex min-w-0 flex-1 items-center gap-1.5" : "inline-flex items-center gap-1.5",
1798
+ tab.empty && TAB_EMPTY_CLASSES,
1799
+ )}
1800
+ >
1589
1801
  {tab.icon != null && (
1590
1802
  <span aria-hidden className="inline-flex shrink-0 items-center gap-0.5">
1591
1803
  {tab.icon}
@@ -1604,7 +1816,9 @@ export function Tabs<T extends string>({
1604
1816
  ) : (
1605
1817
  tab.label
1606
1818
  )}
1607
- {tab.badge != null ? tab.badge : null}
1819
+ {tab.badge != null ? (
1820
+ vertical ? <span className="ms-auto flex shrink-0 items-center">{tab.badge}</span> : tab.badge
1821
+ ) : null}
1608
1822
  </span>
1609
1823
  );
1610
1824
  const element = tab.href ? (
@@ -1652,7 +1866,7 @@ export function Tabs<T extends string>({
1652
1866
  // The × cannot go INSIDE the tab: a button in a button is invalid HTML, and
1653
1867
  // a tab's children are presentational, so a reader would flatten it into the
1654
1868
  // tab's name. It sits beside the tab, over the gutter the tab reserves.
1655
- <div key={tab.id} className="relative flex shrink-0">
1869
+ <div key={tab.id} className={cn("relative flex", !vertical && "shrink-0")}>
1656
1870
  {element}
1657
1871
  {isActive && (
1658
1872
  <IconButton
@@ -1682,6 +1896,18 @@ export function Tabs<T extends string>({
1682
1896
  </div>
1683
1897
  );
1684
1898
  if (!onAdd) return tablist;
1899
+ if (vertical) {
1900
+ // Under the column, not beside it, and with no rule to carry on: a side nav has none.
1901
+ return (
1902
+ <div className={cn("flex flex-col gap-0.5", className)}>
1903
+ {tablist}
1904
+ <button type="button" onClick={onAdd} disabled={busy} className={TAB_ADD_CLASSES}>
1905
+ <Plus aria-hidden />
1906
+ {addLabel ?? text.add}
1907
+ </button>
1908
+ </div>
1909
+ );
1910
+ }
1685
1911
  return (
1686
1912
  <div
1687
1913
  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
+ }