@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
package/src/components/ui.tsx
CHANGED
|
@@ -7,13 +7,27 @@ import { useMediaQuery } from "../hooks/use-media-query";
|
|
|
7
7
|
import { Tooltip, type TooltipSide } from "./tooltip";
|
|
8
8
|
import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
|
|
9
9
|
|
|
10
|
-
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand";
|
|
10
|
+
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
|
|
11
11
|
|
|
12
12
|
// Shared base ring for every button-styled element. Kept as a named const so the
|
|
13
13
|
// <Button> component and the {@link buttonClasses} helper draw from one source and
|
|
14
14
|
// can never drift apart.
|
|
15
15
|
const BUTTON_BASE =
|
|
16
|
-
"inline-flex items-center justify-center
|
|
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
|
|
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(
|
|
45
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
/>
|
|
@@ -1044,20 +1162,34 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
1044
1162
|
* content cards on data pages; leave off for centered dialog/panel cards.
|
|
1045
1163
|
*/
|
|
1046
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";
|
|
1047
1175
|
}
|
|
1048
1176
|
|
|
1049
|
-
export function Card({ className, children, flush, ...rest }: CardProps) {
|
|
1177
|
+
export function Card({ className, children, flush, variant = "default", ...rest }: CardProps) {
|
|
1050
1178
|
return (
|
|
1051
1179
|
<div
|
|
1052
1180
|
{...rest}
|
|
1053
1181
|
className={cn(
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
+
),
|
|
1061
1193
|
className,
|
|
1062
1194
|
)}
|
|
1063
1195
|
>
|
|
@@ -1198,12 +1330,23 @@ export function Spinner({ className, label, ...rest }: SpinnerProps) {
|
|
|
1198
1330
|
export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
|
1199
1331
|
/** The box renders `title` and `hint` in its own two-line rhythm, which is what makes
|
|
1200
1332
|
* every empty state in three apps look like the same thing — so there is no
|
|
1201
|
-
* `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. */
|
|
1202
1335
|
title: string;
|
|
1203
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;
|
|
1204
1347
|
}
|
|
1205
1348
|
|
|
1206
|
-
export function EmptyState({ title, hint, className, ...rest }: EmptyStateProps) {
|
|
1349
|
+
export function EmptyState({ title, hint, icon, action, className, ...rest }: EmptyStateProps) {
|
|
1207
1350
|
return (
|
|
1208
1351
|
<div
|
|
1209
1352
|
{...rest}
|
|
@@ -1212,8 +1355,16 @@ export function EmptyState({ title, hint, className, ...rest }: EmptyStateProps)
|
|
|
1212
1355
|
className,
|
|
1213
1356
|
)}
|
|
1214
1357
|
>
|
|
1358
|
+
{icon != null && (
|
|
1359
|
+
<div aria-hidden className="mb-3 text-[var(--text-placeholder)] [&_svg]:size-8">
|
|
1360
|
+
{icon}
|
|
1361
|
+
</div>
|
|
1362
|
+
)}
|
|
1215
1363
|
<div className="font-medium text-[var(--text-secondary)]">{title}</div>
|
|
1216
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
|
+
)}
|
|
1217
1368
|
</div>
|
|
1218
1369
|
);
|
|
1219
1370
|
}
|
|
@@ -1386,6 +1537,26 @@ export interface TabsProps<T extends string>
|
|
|
1386
1537
|
/** An add or a remove is in flight: the × and the add button are disabled and
|
|
1387
1538
|
* Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */
|
|
1388
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";
|
|
1389
1560
|
}
|
|
1390
1561
|
|
|
1391
1562
|
// The two shapes are written out as whole strings rather than as one base plus a
|
|
@@ -1398,6 +1569,17 @@ export interface TabsProps<T extends string>
|
|
|
1398
1569
|
// that the colours are tokens: there is no `md:dark:` tier left to keep in step.
|
|
1399
1570
|
const TABLIST_CLASSES =
|
|
1400
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
|
+
|
|
1401
1583
|
const TABLIST_WRAP_CLASSES =
|
|
1402
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)]";
|
|
1403
1585
|
|
|
@@ -1445,10 +1627,14 @@ export function Tabs<T extends string>({
|
|
|
1445
1627
|
addLabel,
|
|
1446
1628
|
labels,
|
|
1447
1629
|
busy = false,
|
|
1630
|
+
orientation = "horizontal",
|
|
1448
1631
|
"aria-label": ariaLabel,
|
|
1449
1632
|
...rest
|
|
1450
1633
|
}: TabsProps<T>) {
|
|
1451
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;
|
|
1452
1638
|
const stripRef = useRef<HTMLDivElement>(null);
|
|
1453
1639
|
const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
|
|
1454
1640
|
// Whether Delete reaches this tab: every removable tab, or only the open one.
|
|
@@ -1514,8 +1700,16 @@ export function Tabs<T extends string>({
|
|
|
1514
1700
|
return;
|
|
1515
1701
|
}
|
|
1516
1702
|
// Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits
|
|
1517
|
-
// to the left, and ArrowLeft has to reach it.
|
|
1518
|
-
|
|
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);
|
|
1519
1713
|
if (step === 0 && e.key !== "Home" && e.key !== "End") return;
|
|
1520
1714
|
const strip = e.currentTarget.closest('[role="tablist"]');
|
|
1521
1715
|
if (!strip) return;
|
|
@@ -1541,10 +1735,13 @@ export function Tabs<T extends string>({
|
|
|
1541
1735
|
role="tablist"
|
|
1542
1736
|
// The DOM spelling wins over the deprecated `label`; see {@link TabsProps}.
|
|
1543
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}
|
|
1544
1741
|
// With an add button the strip gains an outer box, and `className` goes there
|
|
1545
1742
|
// — it is the box a caller's margin or width is meant for.
|
|
1546
1743
|
className={cn(
|
|
1547
|
-
wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES,
|
|
1744
|
+
vertical ? TABLIST_VERTICAL_CLASSES : wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES,
|
|
1548
1745
|
onAdd ? "min-w-0" : className,
|
|
1549
1746
|
)}
|
|
1550
1747
|
>
|
|
@@ -1574,20 +1771,33 @@ export function Tabs<T extends string>({
|
|
|
1574
1771
|
id: isActive && panelId ? `${panelId}-tab` : undefined,
|
|
1575
1772
|
"aria-keyshortcuts": deletesOnKey(tab) ? "Delete" : undefined,
|
|
1576
1773
|
onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),
|
|
1577
|
-
className:
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
?
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
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
|
+
),
|
|
1588
1791
|
};
|
|
1589
1792
|
const inner = (
|
|
1590
|
-
<span
|
|
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
|
+
>
|
|
1591
1801
|
{tab.icon != null && (
|
|
1592
1802
|
<span aria-hidden className="inline-flex shrink-0 items-center gap-0.5">
|
|
1593
1803
|
{tab.icon}
|
|
@@ -1606,7 +1816,9 @@ export function Tabs<T extends string>({
|
|
|
1606
1816
|
) : (
|
|
1607
1817
|
tab.label
|
|
1608
1818
|
)}
|
|
1609
|
-
{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}
|
|
1610
1822
|
</span>
|
|
1611
1823
|
);
|
|
1612
1824
|
const element = tab.href ? (
|
|
@@ -1654,7 +1866,7 @@ export function Tabs<T extends string>({
|
|
|
1654
1866
|
// The × cannot go INSIDE the tab: a button in a button is invalid HTML, and
|
|
1655
1867
|
// a tab's children are presentational, so a reader would flatten it into the
|
|
1656
1868
|
// tab's name. It sits beside the tab, over the gutter the tab reserves.
|
|
1657
|
-
<div key={tab.id} className="relative flex shrink-0">
|
|
1869
|
+
<div key={tab.id} className={cn("relative flex", !vertical && "shrink-0")}>
|
|
1658
1870
|
{element}
|
|
1659
1871
|
{isActive && (
|
|
1660
1872
|
<IconButton
|
|
@@ -1684,6 +1896,18 @@ export function Tabs<T extends string>({
|
|
|
1684
1896
|
</div>
|
|
1685
1897
|
);
|
|
1686
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
|
+
}
|
|
1687
1911
|
return (
|
|
1688
1912
|
<div
|
|
1689
1913
|
className={cn(
|
package/src/data-table.ts
CHANGED
|
@@ -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
|
+
}
|