@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.
- package/README.md +32 -17
- package/dist/chart.d.ts +3 -2
- package/dist/components/alert-banner.d.ts +59 -10
- package/dist/components/alert-banner.js +114 -8
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +3 -0
- package/dist/components/button-group.d.ts +26 -0
- package/dist/components/button-group.js +49 -0
- package/dist/components/button-group.js.map +1 -0
- package/dist/components/calculator.d.ts +3 -0
- package/dist/components/chart-zoom.d.ts +63 -14
- package/dist/components/chart-zoom.js +82 -16
- package/dist/components/chart-zoom.js.map +1 -1
- package/dist/components/chip.d.ts +67 -2
- package/dist/components/chip.js +73 -8
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +120 -0
- package/dist/components/confirm-dialog.js +96 -0
- package/dist/components/confirm-dialog.js.map +1 -0
- package/dist/components/copy-button.d.ts +62 -0
- package/dist/components/copy-button.js +95 -0
- package/dist/components/copy-button.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +5 -1
- package/dist/components/data-table-pagination.js +71 -58
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.d.ts +28 -5
- package/dist/components/data-table-sort.js +11 -7
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +544 -423
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +51 -5
- package/dist/components/date-picker.js +152 -40
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +60 -0
- package/dist/components/description-list.js +112 -0
- package/dist/components/description-list.js.map +1 -0
- package/dist/components/disclosure.d.ts +37 -2
- package/dist/components/disclosure.js +14 -5
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/facing-pair.d.ts +2 -0
- package/dist/components/file-button.d.ts +17 -1
- package/dist/components/file-button.js +8 -1
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +54 -9
- package/dist/components/file-dropzone.js +100 -70
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +107 -0
- package/dist/components/floating-panel.js +206 -0
- package/dist/components/floating-panel.js.map +1 -0
- package/dist/components/full-bleed-dialog.d.ts +12 -1
- package/dist/components/full-bleed-dialog.js +26 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/modal.d.ts +6 -2
- package/dist/components/modal.js +2 -1
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-field.d.ts +3 -0
- package/dist/components/number-input.d.ts +3 -0
- package/dist/components/numpad-sheet.d.ts +3 -0
- package/dist/components/progress-bar.d.ts +54 -0
- package/dist/components/progress-bar.js +94 -0
- package/dist/components/progress-bar.js.map +1 -0
- package/dist/components/scroll-area.d.ts +53 -0
- package/dist/components/scroll-area.js +86 -0
- package/dist/components/scroll-area.js.map +1 -0
- package/dist/components/separator.d.ts +23 -0
- package/dist/components/separator.js +24 -0
- package/dist/components/separator.js.map +1 -0
- package/dist/components/series-chart-ticks.d.ts +34 -1
- package/dist/components/series-chart-ticks.js +83 -1
- package/dist/components/series-chart-ticks.js.map +1 -1
- package/dist/components/series-chart.d.ts +314 -19
- package/dist/components/series-chart.js +510 -120
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/skeleton.d.ts +36 -0
- package/dist/components/skeleton.js +35 -0
- package/dist/components/skeleton.js.map +1 -0
- package/dist/components/stat-tile.d.ts +60 -6
- package/dist/components/stat-tile.js +60 -26
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/table.d.ts +83 -0
- package/dist/components/table.js +166 -0
- package/dist/components/table.js.map +1 -0
- package/dist/components/toggle-legend.d.ts +3 -2
- package/dist/components/toggle-legend.js +1 -1
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tree-view.d.ts +129 -0
- package/dist/components/tree-view.js +376 -0
- package/dist/components/tree-view.js.map +1 -0
- package/dist/components/treemap.d.ts +9 -2
- package/dist/components/treemap.js +2 -1
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +87 -13
- package/dist/components/ui.js +111 -29
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
- package/dist/data-table.d.ts +2 -2
- package/dist/data-table.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
- package/dist/hooks/use-copy-to-clipboard.js +81 -0
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
- package/dist/hooks/use-debounce.d.ts +43 -0
- package/dist/hooks/use-debounce.js +56 -0
- package/dist/hooks/use-debounce.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +72 -0
- package/dist/hooks/use-file-drop.js +57 -0
- package/dist/hooks/use-file-drop.js.map +1 -0
- package/dist/i18n/defaults.d.ts +3 -0
- package/dist/i18n/defaults.js +7 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +15 -0
- package/dist/i18n/kit-labels.js +6 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
- package/dist/i18n/locales/de-CH-informal.js +8 -0
- package/dist/i18n/locales/de-CH-informal.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +3 -0
- package/dist/i18n/locales/de-CH.js +1 -14
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/de-informal.d.ts +64 -0
- package/dist/i18n/locales/de-informal.js +37 -0
- package/dist/i18n/locales/de-informal.js.map +1 -0
- package/dist/i18n/locales/de.d.ts +3 -0
- package/dist/i18n/locales/de.js +25 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +3 -0
- package/dist/i18n/locales/es.js +25 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +3 -0
- package/dist/i18n/locales/fr.js +25 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +3 -0
- package/dist/i18n/locales/hu.js +25 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +3 -0
- package/dist/i18n/locales/it.js +25 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +3 -0
- package/dist/i18n/locales/zh.js +25 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +13 -0
- package/dist/i18n/swiss.js +19 -0
- package/dist/i18n/swiss.js.map +1 -0
- package/dist/index.d.ts +25 -10
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/dates.d.ts +37 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/search/command-palette.d.ts +15 -1
- package/dist/search/command-palette.js +10 -2
- package/dist/search/command-palette.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +35 -2
- package/dist/wizard/stepper-nav.js +54 -22
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +107 -5
- package/dist/wizard/types.js +2 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +141 -21
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.d.ts +10 -2
- package/dist/wizard/wizard-summary.js +3 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +4 -1
- package/dist/wizard.js.map +1 -1
- package/package.json +1 -1
- package/src/components/alert-banner.tsx +211 -20
- package/src/components/button-group.tsx +75 -0
- package/src/components/chart-zoom.tsx +158 -20
- package/src/components/chip.tsx +160 -9
- package/src/components/confirm-dialog.tsx +242 -0
- package/src/components/copy-button.tsx +158 -0
- package/src/components/data-table-pagination.tsx +19 -6
- package/src/components/data-table-sort.ts +49 -10
- package/src/components/data-table.tsx +293 -24
- package/src/components/date-picker.tsx +262 -57
- package/src/components/description-list.tsx +174 -0
- package/src/components/disclosure.tsx +58 -5
- package/src/components/file-button.tsx +22 -2
- package/src/components/file-dropzone.tsx +198 -100
- package/src/components/floating-panel.tsx +341 -0
- package/src/components/full-bleed-dialog.tsx +48 -5
- package/src/components/modal.tsx +9 -2
- package/src/components/progress-bar.tsx +163 -0
- package/src/components/scroll-area.tsx +129 -0
- package/src/components/separator.tsx +37 -0
- package/src/components/series-chart-ticks.ts +135 -0
- package/src/components/series-chart.tsx +934 -67
- package/src/components/skeleton.tsx +65 -0
- package/src/components/stat-tile.tsx +160 -27
- package/src/components/table.tsx +263 -0
- package/src/components/toggle-legend.tsx +4 -3
- package/src/components/tree-view.tsx +589 -0
- package/src/components/treemap.tsx +10 -2
- package/src/components/ui.tsx +263 -39
- package/src/data-table.ts +2 -0
- package/src/hooks/use-copy-to-clipboard.ts +122 -0
- package/src/hooks/use-debounce.ts +105 -0
- package/src/hooks/use-file-drop.ts +123 -0
- package/src/i18n/defaults.ts +6 -0
- package/src/i18n/kit-labels.tsx +19 -0
- package/src/i18n/locales/de-CH-informal.ts +10 -0
- package/src/i18n/locales/de-CH.ts +1 -16
- package/src/i18n/locales/de-informal.ts +61 -0
- package/src/i18n/locales/de.ts +25 -0
- package/src/i18n/locales/es.ts +25 -0
- package/src/i18n/locales/fr.ts +25 -0
- package/src/i18n/locales/hu.ts +25 -0
- package/src/i18n/locales/it.ts +25 -0
- package/src/i18n/locales/zh.ts +25 -0
- package/src/i18n/swiss.ts +25 -0
- package/src/index.ts +29 -0
- package/src/lib/dates.ts +48 -0
- package/src/search/command-palette.tsx +29 -3
- package/src/wizard/stepper-nav.tsx +102 -28
- package/src/wizard/types.ts +106 -4
- package/src/wizard/use-wizard.ts +211 -31
- package/src/wizard/wizard-summary.tsx +24 -12
- 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
|
|
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
|
|
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="
|
|
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
|
-
"
|
|
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
|
|
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="
|
|
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
|
-
*
|
|
55
|
-
* (`additive`): append `key` as the next-priority
|
|
56
|
-
* entry
|
|
57
|
-
|
|
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
|
-
|
|
100
|
+
const dir = step(prev[0].dir);
|
|
101
|
+
return dir ? [{ key, dir }] : [];
|
|
62
102
|
}
|
|
63
|
-
return [{ key, dir:
|
|
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
|
}
|