@eifi1/ui-kit 0.7.1 → 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 (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 +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 +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 +2 -1
  94. package/dist/components/treemap.js.map +1 -1
  95. package/dist/components/ui.d.ts +87 -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 +48 -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 +10 -2
  198. package/src/components/ui.tsx +263 -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
@@ -0,0 +1,242 @@
1
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { useKitLabels } from "../i18n/kit-labels";
5
+ import { DialogFrame } from "./dialog-frame";
6
+ import { Button } from "./ui";
7
+
8
+ /**
9
+ * The `confirmDialog` namespace of `<UiKitProvider labels>`: the two buttons' fallback
10
+ * text. A call that knows what it is confirming says so per call (`confirmLabel:
11
+ * "Delete budget"`), which is what the dialog is for — these are for the rest.
12
+ */
13
+ export interface ConfirmDialogLabels {
14
+ confirm: string;
15
+ cancel: string;
16
+ }
17
+
18
+ export const DEFAULT_CONFIRM_DIALOG_LABELS: ConfirmDialogLabels = {
19
+ confirm: "Confirm",
20
+ cancel: "Cancel",
21
+ };
22
+
23
+ export type ConfirmTone = "danger" | "warning" | "neutral";
24
+
25
+ export interface ConfirmOptions {
26
+ /** The question, and the dialog's accessible name. */
27
+ title: ReactNode;
28
+ /** The smaller line under it — the consequence. Wired to `aria-describedby`. */
29
+ body?: ReactNode;
30
+ /** Default: `confirmDialog.confirm` from the provider, else "Confirm". */
31
+ confirmLabel?: ReactNode;
32
+ /** Default: `confirmDialog.cancel` from the provider, else "Cancel". */
33
+ cancelLabel?: ReactNode;
34
+ /**
35
+ * `"danger"` for what cannot be undone (a delete): the confirm is the destructive
36
+ * button and focus starts on CANCEL. `"warning"` and `"neutral"` (default) start on
37
+ * the confirm. See {@link ConfirmProvider} for why the two differ.
38
+ */
39
+ tone?: ConfirmTone;
40
+ }
41
+
42
+ export type ConfirmFn = (options: ConfirmOptions) => Promise<boolean>;
43
+
44
+ const ConfirmContext = createContext<ConfirmFn | null>(null);
45
+
46
+ /**
47
+ * `const confirm = useConfirm()` → `if (!(await confirm({ title }))) return`.
48
+ *
49
+ * The returned function is STABLE for the life of the provider, so it can sit in an
50
+ * effect's or a `useCallback`'s dependency list without re-running it.
51
+ *
52
+ * Throws outside a {@link ConfirmProvider}, rather than falling back to
53
+ * `window.confirm`: a silent fallback is exactly the native dialog the three apps are
54
+ * migrating away from, and it would look like the migration had worked.
55
+ */
56
+ export function useConfirm(): ConfirmFn {
57
+ const confirm = useContext(ConfirmContext);
58
+ if (!confirm) {
59
+ throw new Error("useConfirm() must be called below a <ConfirmProvider>.");
60
+ }
61
+ return confirm;
62
+ }
63
+
64
+ interface Request extends ConfirmOptions {
65
+ id: number;
66
+ resolve: (answer: boolean) => void;
67
+ }
68
+
69
+ export interface ConfirmProviderProps {
70
+ children: ReactNode;
71
+ }
72
+
73
+ /**
74
+ * The host for {@link useConfirm}: ONE confirm dialog for the whole app, rendered on
75
+ * {@link DialogFrame}, answering a promise.
76
+ *
77
+ * Asked for by all three apps at once — keksdose has 20+ `window.confirm` calls,
78
+ * kastlan 7, lenkbank 1. The native dialog cannot be translated past its OK/Cancel
79
+ * (those are the BROWSER's language, not the app's), cannot say which button is the
80
+ * destructive one, and on an installed PWA it prints the origin over the question.
81
+ * A promise is what lets each of those call sites change by one `await`:
82
+ *
83
+ * if (!window.confirm(t("budget.delete_confirm"))) return;
84
+ * // becomes
85
+ * if (!(await confirm({ title: t("budget.delete_confirm"), tone: "danger" }))) return;
86
+ *
87
+ * ## Its own provider, not part of `UiKitProvider`
88
+ *
89
+ * `UiKitProvider` renders nothing: it is strings and a locale, it is optional, and it
90
+ * NESTS (an inner one re-labels one table). A dialog host inside it would mount a host
91
+ * per nested provider, and a component under the inner one would open a second dialog
92
+ * layer of its own. So the host is its own component, mounted once — inside the
93
+ * `UiKitProvider` whose strings it should speak, which is also the order the labels
94
+ * need.
95
+ *
96
+ * ## Focus
97
+ *
98
+ * On CANCEL for `tone: "danger"`, on CONFIRM otherwise. A confirm is often answered by
99
+ * a reflexive Enter — the key that pressed the Delete button in the first place may
100
+ * still be down — and for an action that cannot be undone that reflex must land on the
101
+ * harmless answer; `DangerConfirm` makes the same call ("never to the destructive
102
+ * button itself"). For everything else the confirm is the expected answer and Enter
103
+ * accepting it is what `window.confirm` taught users, which the migrating call sites'
104
+ * users already rely on. Focus returns to whatever opened it, through `Modal`'s trap.
105
+ *
106
+ * ## Escape, the backdrop, Back and Cancel all answer `false`
107
+ *
108
+ * Only the confirm button answers `true`. Anything that merely dismisses is a no.
109
+ *
110
+ * ## A second `confirm` while one is open QUEUES
111
+ *
112
+ * It is shown when the first is answered, and each call gets its own answer. The
113
+ * alternative — answering the open one `false` to make room — would answer a question
114
+ * the user never answered: a background prompt ("your session is about to expire")
115
+ * would cancel the delete they were reading. `false` has to mean "the user said no".
116
+ * A double-click cannot queue two of the same: the first click opens the modal
117
+ * synchronously (a discrete event's update is flushed before the next one), so the
118
+ * second lands on the open dialog rather than on the button — at worst on its
119
+ * backdrop, which answers `false`, the safe way to be wrong.
120
+ *
121
+ * Unmounting the provider answers everything still pending `false`, so no caller's
122
+ * `await` hangs forever.
123
+ *
124
+ * ## keksdose's synchronous call (transaction-swipe-plan.ts)
125
+ *
126
+ * `buildTxSwipeActions` is synchronous, but its `window.confirm` sits inside the
127
+ * action THUNK (`confirmed(key, run)` returns `() => { if (confirm(…)) run() }`), and a
128
+ * thunk may be async without its caller knowing. So the plan builder stays
129
+ * synchronous; it takes the confirm as an argument and the thunk awaits it:
130
+ *
131
+ * // mobile-transaction-list.tsx
132
+ * const confirm = useConfirm();
133
+ * buildTxSwipeActions({ tx, …, confirm });
134
+ *
135
+ * // transaction-swipe-plan.ts
136
+ * const confirmed = (messageKey: string, run: () => void) => () => {
137
+ * void confirm({ title: t(messageKey), tone: "danger" }).then((ok) => ok && run());
138
+ * };
139
+ *
140
+ * Its test then passes `confirm: vi.fn(async () => false)` instead of stubbing the
141
+ * global, and asserts after an `await` (the mutation now runs a microtask later). The
142
+ * row snaps back when the swipe commits, before the answer — which is right: the
143
+ * dialog, not the half-swiped row, is where the question now is.
144
+ */
145
+ export function ConfirmProvider({ children }: ConfirmProviderProps) {
146
+ const [queue, setQueue] = useState<Request[]>([]);
147
+ const nextId = useRef(0);
148
+ // Every unanswered request, including the ones not yet in state — the unmount
149
+ // cleanup below must reach a request made in the same tick it unmounts.
150
+ const pending = useRef(new Set<Request>());
151
+
152
+ const confirm = useCallback<ConfirmFn>(
153
+ (options) =>
154
+ new Promise<boolean>((resolve) => {
155
+ const request: Request = { ...options, id: ++nextId.current, resolve };
156
+ pending.current.add(request);
157
+ setQueue((q) => [...q, request]);
158
+ }),
159
+ [],
160
+ );
161
+
162
+ useEffect(() => {
163
+ const open = pending.current;
164
+ return () => {
165
+ for (const r of open) r.resolve(false);
166
+ open.clear();
167
+ };
168
+ }, []);
169
+
170
+ const answer = useCallback((request: Request, value: boolean) => {
171
+ if (!pending.current.delete(request)) return;
172
+ request.resolve(value);
173
+ setQueue((q) => q.filter((r) => r !== request));
174
+ }, []);
175
+
176
+ const current = queue[0];
177
+ return (
178
+ <ConfirmContext.Provider value={confirm}>
179
+ {children}
180
+ {current && (
181
+ // Keyed per request, so a queued one gets a fresh Modal: the first one's trap
182
+ // hands focus back to the page, and the next one's captures that as its own
183
+ // restore target instead of a button that has just been unmounted.
184
+ <ConfirmDialogView key={current.id} request={current} onAnswer={answer} />
185
+ )}
186
+ </ConfirmContext.Provider>
187
+ );
188
+ }
189
+
190
+ function ConfirmDialogView({
191
+ request,
192
+ onAnswer,
193
+ }: {
194
+ request: Request;
195
+ onAnswer: (request: Request, value: boolean) => void;
196
+ }) {
197
+ const labels = useKitLabels("confirmDialog", DEFAULT_CONFIRM_DIALOG_LABELS);
198
+ const tone = request.tone ?? "neutral";
199
+ const confirmRef = useRef<HTMLButtonElement>(null);
200
+ const cancelRef = useRef<HTMLButtonElement>(null);
201
+ // Set by the confirm button, read once the panel has finished lowering. Everything
202
+ // else that ends in `onClose` — Escape, the backdrop, Back, Cancel — leaves it false.
203
+ const accepted = useRef(false);
204
+
205
+ // In THIS component's effect, which runs after `Modal`'s own (a parent's effects run
206
+ // after its children's). `Modal` engages its focus trap on the panel first — and
207
+ // records the page's focused element as the restore target — and only then does
208
+ // focus move to the button. Focusing it from inside the panel would run first and
209
+ // make the button the restore target.
210
+ useEffect(() => {
211
+ (tone === "danger" ? cancelRef : confirmRef).current?.focus();
212
+ }, [tone]);
213
+
214
+ return (
215
+ <DialogFrame
216
+ title={request.title}
217
+ description={request.body}
218
+ onClose={() => onAnswer(request, accepted.current)}
219
+ // A confirmation interrupts to ask a question that must be answered.
220
+ role="alertdialog"
221
+ data-confirm-tone={tone}
222
+ actions={(close) => (
223
+ <>
224
+ <Button ref={cancelRef} type="button" variant="ghost" onClick={close}>
225
+ {request.cancelLabel ?? labels.cancel}
226
+ </Button>
227
+ <Button
228
+ ref={confirmRef}
229
+ type="button"
230
+ variant={tone === "danger" ? "danger" : "primary"}
231
+ onClick={() => {
232
+ accepted.current = true;
233
+ close();
234
+ }}
235
+ >
236
+ {request.confirmLabel ?? labels.confirm}
237
+ </Button>
238
+ </>
239
+ )}
240
+ />
241
+ );
242
+ }
@@ -0,0 +1,158 @@
1
+ import type { ButtonHTMLAttributes } from "react";
2
+ import { Check, Copy, X } from "lucide-react";
3
+ import { cn } from "../lib/cn";
4
+ import { useKitLabels } from "../i18n/kit-labels";
5
+ import { useAnnounce } from "../hooks/use-announce";
6
+ import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
7
+ import type { CopyState } from "../hooks/use-copy-to-clipboard";
8
+ import { Button, IconButton } from "./ui";
9
+ import type { ButtonVariant, IconButtonSize } from "./ui";
10
+ import { Tooltip } from "./tooltip";
11
+ import type { TooltipSide } from "./tooltip";
12
+
13
+ /** The words a copy button says. The visible three are short on purpose (they
14
+ * replace the button's own text in the `label` variant); the two announcements
15
+ * are the sentences a screen reader hears, since the button's focus never moves. */
16
+ export interface CopyButtonLabels {
17
+ copy: string;
18
+ copied: string;
19
+ failed: string;
20
+ copiedAnnouncement: string;
21
+ failedAnnouncement: string;
22
+ }
23
+
24
+ export const DEFAULT_COPY_BUTTON_LABELS: CopyButtonLabels = {
25
+ copy: "Copy",
26
+ copied: "Copied",
27
+ failed: "Couldn’t copy",
28
+ copiedAnnouncement: "Copied to clipboard",
29
+ failedAnnouncement: "Couldn’t copy to the clipboard",
30
+ };
31
+
32
+ export interface CopyButtonProps
33
+ extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick"> {
34
+ /** What to copy. A function is read at click time — for a value that is expensive
35
+ * to build (a CSV of the table) or that changes under the button. */
36
+ text: string | (() => string);
37
+ /** `icon` (default): a square icon button whose tooltip states the result.
38
+ * `label`: a text button whose words change to the result. */
39
+ variant?: "icon" | "label";
40
+ /** The idle accessible name — and, in the `label` variant, the visible text:
41
+ * "Copy IBAN" says WHAT is copied, which a row of three copy icons needs. Default
42
+ * `copyButton.copy`. */
43
+ label?: string;
44
+ /** The button's own look. Default: `ghost` for the icon, `secondary` for the label. */
45
+ buttonVariant?: ButtonVariant;
46
+ /** Icon variant only. */
47
+ size?: IconButtonSize;
48
+ /** Icon variant only: where the result tooltip opens. */
49
+ tooltipSide?: TooltipSide;
50
+ /** Pass through to the tooltip — needed inside a scroll container (see Tooltip). */
51
+ tooltipPortal?: boolean;
52
+ /** ms until the button returns to idle. Default 2000. */
53
+ resetAfter?: number;
54
+ /** Called with the real outcome once the copy settles. */
55
+ onCopied?: (ok: boolean) => void;
56
+ labels?: Partial<CopyButtonLabels>;
57
+ }
58
+
59
+ const ICONS: Record<CopyState, typeof Copy> = { idle: Copy, copied: Check, failed: X };
60
+
61
+ /**
62
+ * A button that copies a value and tells the truth about whether it did.
63
+ *
64
+ * keksdose asked for it after two of its own copy buttons said "Copied" on a build
65
+ * where the copy had failed (no Clipboard API over plain http). This one takes its
66
+ * state from {@link useCopyToClipboard}, which reads the result, and has a `failed`
67
+ * state that looks and sounds different from success.
68
+ *
69
+ * The result is spoken through {@link useAnnounce}: activating the button moves no
70
+ * focus, so without a live region a screen-reader user hears nothing at all. A
71
+ * failure goes through an ASSERTIVE region and a success through a polite one — the
72
+ * failure is the news that changes what they do next.
73
+ *
74
+ * The icon variant keeps the SAME accessible name in every state. A button renaming
75
+ * itself under the focus is announced inconsistently across readers; the live region
76
+ * is the channel that is actually heard.
77
+ */
78
+ export function CopyButton({
79
+ text,
80
+ variant = "icon",
81
+ label,
82
+ buttonVariant,
83
+ size = "sm",
84
+ tooltipSide = "top",
85
+ tooltipPortal = false,
86
+ resetAfter = 2000,
87
+ onCopied,
88
+ labels,
89
+ className,
90
+ disabled,
91
+ ...rest
92
+ }: CopyButtonProps) {
93
+ const words = useKitLabels("copyButton", DEFAULT_COPY_BUTTON_LABELS, labels);
94
+ const { state, copy } = useCopyToClipboard({ resetAfter });
95
+ const polite = useAnnounce();
96
+ const assertive = useAnnounce({ politeness: "assertive" });
97
+
98
+ const idleText = label ?? words.copy;
99
+ const stateText = state === "copied" ? words.copied : state === "failed" ? words.failed : idleText;
100
+ const Icon = ICONS[state];
101
+ const iconTone =
102
+ state === "copied" ? "text-[var(--success)]" : state === "failed" ? "text-[var(--danger)]" : undefined;
103
+
104
+ const onClick = async () => {
105
+ const ok = await copy(typeof text === "function" ? text() : text);
106
+ if (ok) polite.announce(words.copiedAnnouncement);
107
+ else assertive.announce(words.failedAnnouncement);
108
+ onCopied?.(ok);
109
+ };
110
+
111
+ const regions = (
112
+ <>
113
+ <span {...polite.regionProps} />
114
+ <span {...assertive.regionProps} />
115
+ </>
116
+ );
117
+
118
+ if (variant === "label") {
119
+ return (
120
+ <>
121
+ <Button
122
+ type="button"
123
+ {...rest}
124
+ disabled={disabled}
125
+ variant={buttonVariant ?? "secondary"}
126
+ data-state={state}
127
+ onClick={onClick}
128
+ className={className}
129
+ >
130
+ <Icon aria-hidden className={cn("size-4 shrink-0", iconTone)} />
131
+ {stateText}
132
+ </Button>
133
+ {regions}
134
+ </>
135
+ );
136
+ }
137
+
138
+ return (
139
+ <>
140
+ <Tooltip label={stateText} side={tooltipSide} portal={tooltipPortal}>
141
+ <IconButton
142
+ type="button"
143
+ {...rest}
144
+ disabled={disabled}
145
+ aria-label={idleText}
146
+ variant={buttonVariant ?? "ghost"}
147
+ size={size}
148
+ data-state={state}
149
+ onClick={onClick}
150
+ className={cn(iconTone, className)}
151
+ >
152
+ <Icon aria-hidden />
153
+ </IconButton>
154
+ </Tooltip>
155
+ {regions}
156
+ </>
157
+ );
158
+ }
@@ -19,6 +19,10 @@ interface PaginationProps {
19
19
  labels?: Partial<DataTableLabels>;
20
20
  /** For the page numbers and page-size options; falls back to the provider's. */
21
21
  locale?: string;
22
+ /** `"compact"` for a pager under a compact {@link DataTable} (lenkbank's in-card
23
+ * tables): a slimmer footer and page buttons, so the pager is not the tallest row
24
+ * of a table that was made compact on purpose. */
25
+ density?: "comfortable" | "compact";
22
26
  }
23
27
 
24
28
  /** Footer for {@link DataTable}: range summary, page-size select, and a
@@ -32,7 +36,9 @@ export function Pagination({
32
36
  onPageSize,
33
37
  labels: labelsProp,
34
38
  locale: localeProp,
39
+ density = "comfortable",
35
40
  }: PaginationProps) {
41
+ const compact = density === "compact";
36
42
  // prop > provider > English, like every other kit component. This defaulted straight
37
43
  // to the English object, so a standalone pager under a translated provider still
38
44
  // said "Rows per page" — only the pager INSIDE a DataTable was translated, because
@@ -61,7 +67,13 @@ export function Pagination({
61
67
  });
62
68
 
63
69
  return (
64
- <div className="flex flex-wrap items-center justify-between gap-2 border-t border-[var(--border)] px-3 py-2 text-xs text-[var(--text-secondary)]">
70
+ <div
71
+ data-density={density}
72
+ className={cn(
73
+ "flex flex-wrap items-center justify-between gap-2 border-t border-[var(--border)] text-xs text-[var(--text-secondary)]",
74
+ compact ? "px-2 py-1" : "px-3 py-2",
75
+ )}
76
+ >
65
77
  <div className="flex items-center gap-2">
66
78
  <span>
67
79
  {pageSize === Infinity
@@ -92,11 +104,11 @@ export function Pagination({
92
104
  type="button"
93
105
  onClick={() => onPage(Math.max(0, page - 1))}
94
106
  disabled={page === 0}
95
- className="rounded p-1 hover:bg-[var(--bg-hover)] disabled:opacity-40"
107
+ className={cn("rounded hover:bg-[var(--bg-hover)] disabled:opacity-40", compact ? "p-0.5" : "p-1")}
96
108
  aria-label={labels.prevPage}
97
109
  >
98
110
  {/* "Previous" points back along the line — left in LTR, right in RTL. */}
99
- <ChevronLeft className="size-4 rtl:-scale-x-100" />
111
+ <ChevronLeft className={cn("rtl:-scale-x-100", compact ? "size-3.5" : "size-4")} />
100
112
  </button>
101
113
  {items.map((it, i) =>
102
114
  it === "ellipsis" ? (
@@ -113,7 +125,8 @@ export function Pagination({
113
125
  // else to tell them apart.
114
126
  aria-current={it === page ? "page" : undefined}
115
127
  className={cn(
116
- "min-w-7 rounded px-2 py-0.5",
128
+ "rounded",
129
+ compact ? "min-w-6 px-1.5 py-0" : "min-w-7 px-2 py-0.5",
117
130
  it === page
118
131
  ? "bg-[var(--bg-inverse)] text-[var(--text-inverse)]"
119
132
  : "hover:bg-[var(--bg-hover)]",
@@ -127,10 +140,10 @@ export function Pagination({
127
140
  type="button"
128
141
  onClick={() => onPage(Math.min(totalPages - 1, page + 1))}
129
142
  disabled={page >= totalPages - 1}
130
- className="rounded p-1 hover:bg-[var(--bg-hover)] disabled:opacity-40"
143
+ className={cn("rounded hover:bg-[var(--bg-hover)] disabled:opacity-40", compact ? "p-0.5" : "p-1")}
131
144
  aria-label={labels.nextPage}
132
145
  >
133
- <ChevronRight className="size-4 rtl:-scale-x-100" />
146
+ <ChevronRight className={cn("rtl:-scale-x-100", compact ? "size-3.5" : "size-4")} />
134
147
  </button>
135
148
  </div>
136
149
  )}
@@ -50,21 +50,60 @@ export function decodeSorts(raw: string | null, validKeys: ReadonlySet<string>):
50
50
  return out;
51
51
  }
52
52
 
53
+ /**
54
+ * How a header click steps through the directions (see {@link nextSorts}).
55
+ *
56
+ * * `"tri"` — first direction → the other → unsorted. The default, and the only cycle
57
+ * before 0.8.0.
58
+ * * `"toggle"` — first direction ⇄ the other, never unsorted: a report table that is
59
+ * ALWAYS ranked by something (keksdose's aggregated tables) must not have a third
60
+ * click that silently drops the ranking. Clicking another column still replaces
61
+ * the sort, so the table can always be re-ranked; a shift-click criterion flips in
62
+ * place and stays for the same reason.
63
+ */
64
+ export type SortCycle = "tri" | "toggle";
65
+
66
+ /** Per-click options for {@link nextSorts}; every field is optional and the
67
+ * defaults reproduce the pre-0.8.0 cycle exactly. */
68
+ export interface SortStepOptions {
69
+ /** The direction a column starts in on its first click. `"desc"` for money and
70
+ * count columns, where the interesting rows are the large ones. Default `"asc"`. */
71
+ firstDir?: SortDir;
72
+ /** Default `"tri"`. */
73
+ cycle?: SortCycle;
74
+ }
75
+
76
+ const flip = (d: SortDir): SortDir => (d === "asc" ? "desc" : "asc");
77
+
53
78
  /** Header-click semantics. Plain click: make `key` the only sort — cycling
54
- * asc → desc → none when it already is the sole criterion. Shift-click
55
- * (`additive`): append `key` as the next-priority sort, or cycle an existing
56
- * entry's direction in place (asc → desc → removed). */
57
- export function nextSorts(prev: SortState[], key: string, additive: boolean): SortState[] {
79
+ * first → other → none (`"tri"`) or first ⇄ other (`"toggle"`) when it already is
80
+ * the sole criterion. Shift-click (`additive`): append `key` as the next-priority
81
+ * sort, or step an existing entry in place (first → other → removed under `"tri"`,
82
+ * a plain flip under `"toggle"`). `firstDir` defaults to `"asc"`, `cycle` to
83
+ * `"tri"` — the cycle every table had before the options existed. */
84
+ export function nextSorts(
85
+ prev: SortState[],
86
+ key: string,
87
+ additive: boolean,
88
+ options: SortStepOptions = {},
89
+ ): SortState[] {
90
+ const first = options.firstDir ?? "asc";
91
+ const toggle = options.cycle === "toggle";
58
92
  const idx = prev.findIndex((s) => s.key === key);
93
+ // One step from `dir`: the second direction after the first, then either back to
94
+ // the first (toggle) or off (null). Judged against THIS column's first direction,
95
+ // so a desc-first column goes desc → asc → none rather than skipping a step.
96
+ const step = (dir: SortDir): SortDir | null =>
97
+ dir === first ? flip(first) : toggle ? first : null;
59
98
  if (!additive) {
60
99
  if (prev.length === 1 && idx === 0) {
61
- return prev[0].dir === "asc" ? [{ key, dir: "desc" }] : [];
100
+ const dir = step(prev[0].dir);
101
+ return dir ? [{ key, dir }] : [];
62
102
  }
63
- return [{ key, dir: "asc" }];
64
- }
65
- if (idx === -1) return [...prev, { key, dir: "asc" }];
66
- if (prev[idx].dir === "asc") {
67
- return prev.map((s, i) => (i === idx ? { key, dir: "desc" as SortDir } : s));
103
+ return [{ key, dir: first }];
68
104
  }
105
+ if (idx === -1) return [...prev, { key, dir: first }];
106
+ const dir = step(prev[idx].dir);
107
+ if (dir) return prev.map((s, i) => (i === idx ? { key, dir } : s));
69
108
  return prev.filter((_, i) => i !== idx);
70
109
  }