@eifi1/ui-kit 0.8.1 → 0.10.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 +25 -11
- package/dist/components/alert-banner.d.ts +32 -5
- package/dist/components/alert-banner.js +57 -14
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +6 -0
- package/dist/components/breadcrumbs.d.ts +60 -0
- package/dist/components/breadcrumbs.js +88 -0
- package/dist/components/breadcrumbs.js.map +1 -0
- package/dist/components/bulk-action-bar.d.ts +66 -0
- package/dist/components/bulk-action-bar.js +149 -0
- package/dist/components/bulk-action-bar.js.map +1 -0
- package/dist/components/calculator.d.ts +6 -0
- package/dist/components/chip.d.ts +58 -10
- package/dist/components/chip.js +91 -7
- package/dist/components/chip.js.map +1 -1
- package/dist/components/copy-button.d.ts +13 -4
- package/dist/components/copy-button.js +17 -4
- package/dist/components/copy-button.js.map +1 -1
- 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.d.ts +1 -1
- package/dist/components/data-table.js +122 -91
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +89 -3
- package/dist/components/date-picker.js +163 -68
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +43 -6
- package/dist/components/description-list.js +91 -11
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +7 -0
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/disclosure.d.ts +68 -4
- package/dist/components/disclosure.js +33 -15
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +3 -4
- package/dist/components/file-dropzone.js +2 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +29 -2
- package/dist/components/floating-panel.js +16 -2
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +23 -4
- package/dist/components/full-bleed-dialog.js +11 -2
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/list.d.ts +174 -0
- package/dist/components/list.js +215 -0
- package/dist/components/list.js.map +1 -0
- package/dist/components/menu-item.d.ts +99 -0
- package/dist/components/menu-item.js +83 -0
- package/dist/components/menu-item.js.map +1 -0
- package/dist/components/modal.d.ts +23 -1
- package/dist/components/modal.js +36 -10
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-field.d.ts +6 -0
- package/dist/components/number-input.d.ts +6 -0
- package/dist/components/numpad-sheet.d.ts +6 -0
- package/dist/components/page-contents.js +1 -2
- package/dist/components/page-contents.js.map +1 -1
- package/dist/components/page-header.d.ts +37 -0
- package/dist/components/page-header.js +36 -0
- package/dist/components/page-header.js.map +1 -0
- package/dist/components/progress-bar.d.ts +16 -2
- package/dist/components/progress-bar.js +4 -2
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +3 -0
- package/dist/components/series-chart-labels.js +2 -1
- package/dist/components/series-chart-labels.js.map +1 -1
- package/dist/components/series-chart.d.ts +16 -0
- package/dist/components/series-chart.js +137 -3
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/status-dot.d.ts +49 -0
- package/dist/components/status-dot.js +52 -0
- package/dist/components/status-dot.js.map +1 -0
- package/dist/components/table.d.ts +39 -6
- package/dist/components/table.js +43 -4
- package/dist/components/table.js.map +1 -1
- package/dist/components/text.d.ts +63 -0
- package/dist/components/text.js +23 -0
- package/dist/components/text.js.map +1 -0
- package/dist/components/toast.d.ts +167 -0
- package/dist/components/toast.js +226 -0
- package/dist/components/toast.js.map +1 -0
- package/dist/components/toggle-group.d.ts +29 -1
- package/dist/components/toggle-group.js +52 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/tooltip.d.ts +38 -15
- package/dist/components/tooltip.js +85 -47
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +139 -16
- package/dist/components/ui.js +136 -26
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-close-transition.d.ts +7 -1
- package/dist/hooks/use-close-transition.js +5 -2
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.js +1 -1
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +84 -4
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +6 -0
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +11 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
- package/dist/i18n/locales/de-CH.d.ts +6 -0
- package/dist/i18n/locales/de-informal.d.ts +6 -0
- package/dist/i18n/locales/de.d.ts +6 -0
- package/dist/i18n/locales/de.js +31 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +6 -0
- package/dist/i18n/locales/es.js +31 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +6 -0
- package/dist/i18n/locales/fr.js +31 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +6 -0
- package/dist/i18n/locales/hu.js +31 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +6 -0
- package/dist/i18n/locales/it.js +31 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +6 -0
- package/dist/i18n/locales/zh.js +31 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +18 -8
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/clipping.d.ts +9 -0
- package/dist/lib/clipping.js +14 -0
- package/dist/lib/clipping.js.map +1 -0
- package/dist/search/command-palette.d.ts +67 -2
- package/dist/search/command-palette.js +147 -33
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.d.ts +166 -0
- package/dist/search/global-search.js +293 -0
- package/dist/search/global-search.js.map +1 -0
- package/dist/search/search-index.d.ts +105 -0
- package/dist/search/search-index.js +142 -0
- package/dist/search/search-index.js.map +1 -0
- package/dist/search.d.ts +3 -1
- package/dist/search.js +2 -0
- package/dist/search.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +10 -1
- package/dist/wizard/stepper-nav.js +3 -2
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +2 -2
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +1 -1
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.js +3 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/package.json +25 -12
- package/src/components/alert-banner.tsx +117 -18
- package/src/components/breadcrumbs.tsx +168 -0
- package/src/components/bulk-action-bar.tsx +256 -0
- package/src/components/chip.tsx +164 -13
- package/src/components/copy-button.tsx +29 -7
- package/src/components/data-table.tsx +81 -5
- package/src/components/date-picker.tsx +377 -118
- package/src/components/description-list.tsx +168 -15
- package/src/components/dialog-frame.tsx +7 -0
- package/src/components/disclosure.tsx +116 -28
- package/src/components/file-dropzone.tsx +9 -10
- package/src/components/floating-panel.tsx +46 -2
- package/src/components/full-bleed-dialog.tsx +42 -5
- package/src/components/list.tsx +417 -0
- package/src/components/menu-item.tsx +214 -0
- package/src/components/modal.tsx +80 -17
- package/src/components/page-contents.tsx +4 -4
- package/src/components/page-header.tsx +68 -0
- package/src/components/progress-bar.tsx +27 -3
- package/src/components/series-chart-labels.ts +4 -0
- package/src/components/series-chart.tsx +253 -3
- package/src/components/status-dot.tsx +99 -0
- package/src/components/table.tsx +91 -6
- package/src/components/text.tsx +89 -0
- package/src/components/toast.tsx +441 -0
- package/src/components/toggle-group.tsx +82 -3
- package/src/components/tooltip.tsx +186 -100
- package/src/components/ui.tsx +321 -41
- package/src/hooks/use-close-transition.ts +14 -5
- package/src/hooks/use-copy-to-clipboard.ts +1 -1
- package/src/hooks/use-overlay-history.ts +181 -4
- package/src/i18n/defaults.ts +10 -0
- package/src/i18n/kit-labels.tsx +10 -0
- package/src/i18n/locales/de.ts +30 -0
- package/src/i18n/locales/es.ts +30 -0
- package/src/i18n/locales/fr.ts +30 -0
- package/src/i18n/locales/hu.ts +30 -0
- package/src/i18n/locales/it.ts +30 -0
- package/src/i18n/locales/zh.ts +30 -0
- package/src/index.ts +30 -0
- package/src/lib/clipping.ts +16 -0
- package/src/search/command-palette.tsx +241 -31
- package/src/search/global-search.tsx +525 -0
- package/src/search/search-index.ts +295 -0
- package/src/search.ts +2 -0
- package/src/wizard/stepper-nav.tsx +13 -2
- package/src/wizard/types.ts +2 -2
- package/src/wizard/use-wizard.ts +3 -3
- package/src/wizard/wizard-summary.tsx +14 -10
- package/tokens.css +97 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, CSSProperties, KeyboardEvent, ReactNode } from "react";
|
|
3
|
+
import { X } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { horizontalStep } from "../lib/direction";
|
|
7
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
8
|
+
import { useAnnounce } from "../hooks/use-announce";
|
|
9
|
+
import { OVERLAY_EXIT_MS, prefersReducedMotion } from "../hooks/use-close-transition";
|
|
10
|
+
import { Button, IconButton } from "./ui";
|
|
11
|
+
|
|
12
|
+
export interface BulkActionBarLabels {
|
|
13
|
+
/** The count, shown in the bar, spoken by its live region and used as the toolbar's
|
|
14
|
+
* name: "3 selected". */
|
|
15
|
+
selected: (count: number) => string;
|
|
16
|
+
/** The clear/cancel action's name — the X of the floating bar, the text button of the
|
|
17
|
+
* others. */
|
|
18
|
+
clear: string;
|
|
19
|
+
/** Spoken when the selection empties and the bar goes. */
|
|
20
|
+
cleared: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_BULK_ACTION_BAR_LABELS: BulkActionBarLabels = {
|
|
24
|
+
selected: (count) => `${count} selected`,
|
|
25
|
+
clear: "Clear selection",
|
|
26
|
+
cleared: "Selection cleared",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `floating` — the phone's: fixed above `AppShell`'s bottom nav (`--app-nav-h`) and the
|
|
31
|
+
* home indicator, inset from both sides, a rounded card with a shadow. `sticky` — the
|
|
32
|
+
* desktop's: pinned to the top of its scroll container, over the rows it acts on.
|
|
33
|
+
* `inline` — in the flow, where the caller places it.
|
|
34
|
+
*/
|
|
35
|
+
export type BulkActionBarVariant = "floating" | "sticky" | "inline";
|
|
36
|
+
|
|
37
|
+
export interface BulkActionBarProps extends Omit<ComponentPropsWithoutRef<"div">, "role" | "children"> {
|
|
38
|
+
/** How many items are selected. The bar shows only while this is above zero. */
|
|
39
|
+
count: number;
|
|
40
|
+
/** Clear the selection / leave selection mode. */
|
|
41
|
+
onClear: () => void;
|
|
42
|
+
/** The actions: buttons (`IconButton size="lg"` on a phone, `Button` from md up),
|
|
43
|
+
* in the caller's order. They are the toolbar's arrow-key stops, after the clear. */
|
|
44
|
+
children?: ReactNode;
|
|
45
|
+
/** Default `floating`. See {@link BulkActionBarVariant}. */
|
|
46
|
+
variant?: BulkActionBarVariant;
|
|
47
|
+
/** Default: `bulkActionBar` from the {@link UiKitProvider}, else English. */
|
|
48
|
+
labels?: Partial<BulkActionBarLabels>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Where the floating bar sits: clear of the phone nav and of the home indicator,
|
|
52
|
+
* whichever reaches higher — `FloatingPanel`'s rule, plus the bar's own 0.5rem gap.
|
|
53
|
+
* Set as inline style, so reposition it through `style` (which is merged over this,
|
|
54
|
+
* e.g. `style={{ insetInline: "auto 2rem", width: "24rem" }}`), not `className`. */
|
|
55
|
+
const FLOATING_STYLE: CSSProperties = {
|
|
56
|
+
bottom: "calc(max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px)) + 0.5rem)",
|
|
57
|
+
// Both insets on both sides: a logical side has no one physical inset to name, and
|
|
58
|
+
// the bar is symmetric, so RTL needs nothing more.
|
|
59
|
+
insetInline: "max(0.5rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** What the arrow keys walk: the toolbar's enabled controls. Matched by element, not by
|
|
63
|
+
* `tabindex`, because the roving below sets every one but the current to `-1` — a
|
|
64
|
+
* custom control joins with `data-toolbar-item`. */
|
|
65
|
+
const ITEMS =
|
|
66
|
+
"button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[data-toolbar-item]";
|
|
67
|
+
|
|
68
|
+
/** Keys a text field needs for itself — the toolbar must not take its caret away. */
|
|
69
|
+
function ownsArrows(el: Element): boolean {
|
|
70
|
+
if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) return true;
|
|
71
|
+
if (el instanceof HTMLInputElement) return !["button", "checkbox", "radio", "submit", "reset"].includes(el.type);
|
|
72
|
+
return (el as HTMLElement).isContentEditable;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The bar that appears while rows are selected: the count, a way out, and what can be
|
|
77
|
+
* done to all of them at once.
|
|
78
|
+
*
|
|
79
|
+
* keksdose had written it three times — the transactions phone bar
|
|
80
|
+
* (mobile-bulk-bar.tsx), the invoice lines' phone and desktop bars
|
|
81
|
+
* (invoice-lines-bulk-bar.tsx) and the payees selection bar — and the copies had
|
|
82
|
+
* drifted where it matters: two parked themselves with a guessed `bottom-16` that the
|
|
83
|
+
* third had already found leaves a 24px gap (live #314: the nav is content-sized and
|
|
84
|
+
* `AppShell` publishes its height as `--app-nav-h`), none of them announced the count,
|
|
85
|
+
* and all three were `role="toolbar"` without the arrow keys that role promises.
|
|
86
|
+
*
|
|
87
|
+
* ## Keep it mounted
|
|
88
|
+
*
|
|
89
|
+
* Render it unconditionally and let `count` decide. At zero it renders only its live
|
|
90
|
+
* region — which must already be in the DOM for the first "3 selected" to be heard —
|
|
91
|
+
* and when the count drops to zero it plays its exit before it goes, which a
|
|
92
|
+
* `{count > 0 && <Bar/>}` gate would cut off.
|
|
93
|
+
*
|
|
94
|
+
* ## The toolbar
|
|
95
|
+
*
|
|
96
|
+
* One Tab stop: ←/→ (mirrored in RTL), Home and End move between the controls, and Tab
|
|
97
|
+
* leaves — the WAI-ARIA toolbar pattern, since that is what `role="toolbar"` tells a
|
|
98
|
+
* screen reader to expect. The stop remembers the control last used. Arrow keys inside
|
|
99
|
+
* a text field stay the field's.
|
|
100
|
+
*
|
|
101
|
+
* It is NOT a dialog: no focus trap, no scroll lock — the list behind stays live,
|
|
102
|
+
* because tapping more rows is how a selection grows.
|
|
103
|
+
*/
|
|
104
|
+
export function BulkActionBar({
|
|
105
|
+
count,
|
|
106
|
+
onClear,
|
|
107
|
+
children,
|
|
108
|
+
variant = "floating",
|
|
109
|
+
labels: labelsProp,
|
|
110
|
+
className,
|
|
111
|
+
style,
|
|
112
|
+
onKeyDown,
|
|
113
|
+
onFocus,
|
|
114
|
+
...rest
|
|
115
|
+
}: BulkActionBarProps) {
|
|
116
|
+
const labels = useKitLabels("bulkActionBar", DEFAULT_BULK_ACTION_BAR_LABELS, labelsProp);
|
|
117
|
+
const { announce, regionProps } = useAnnounce();
|
|
118
|
+
const barRef = useRef<HTMLDivElement>(null);
|
|
119
|
+
const shown = count > 0;
|
|
120
|
+
|
|
121
|
+
// The count the bar last SHOWED, so a bar lowering itself after "clear" still reads
|
|
122
|
+
// "3 selected" on its way out rather than "0 selected". Adjusted during render
|
|
123
|
+
// (`Collapse`'s pattern), as is the exit below.
|
|
124
|
+
const [shownCount, setShownCount] = useState(count);
|
|
125
|
+
const [leaving, setLeaving] = useState(false);
|
|
126
|
+
const [prevCount, setPrevCount] = useState(count);
|
|
127
|
+
if (count !== prevCount) {
|
|
128
|
+
setPrevCount(count);
|
|
129
|
+
if (shown) {
|
|
130
|
+
setShownCount(count);
|
|
131
|
+
setLeaving(false);
|
|
132
|
+
} else {
|
|
133
|
+
setLeaving(prevCount > 0 && !prefersReducedMotion());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!leaving) return;
|
|
138
|
+
const timer = setTimeout(() => setLeaving(false), OVERLAY_EXIT_MS);
|
|
139
|
+
return () => clearTimeout(timer);
|
|
140
|
+
}, [leaving]);
|
|
141
|
+
|
|
142
|
+
// Spoken on every CHANGE, not on mount: a bar mounted with rows already selected
|
|
143
|
+
// was not the result of anything the user just did.
|
|
144
|
+
const spoken = useRef(count);
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (spoken.current === count) return;
|
|
147
|
+
spoken.current = count;
|
|
148
|
+
announce(count > 0 ? labels.selected(count) : labels.cleared);
|
|
149
|
+
}, [count, labels, announce]);
|
|
150
|
+
|
|
151
|
+
// Roving tab stop. The controls are the caller's, so the stop is kept on the DOM
|
|
152
|
+
// rather than through props: after every render the remembered control (or the
|
|
153
|
+
// first) is the one with `tabindex=0`, every other one `-1`.
|
|
154
|
+
const active = useRef<HTMLElement | null>(null);
|
|
155
|
+
// Every render: the caller's controls come and go (a "remove added" that exists
|
|
156
|
+
// only while some are removable), and a stop on a control that left must move on.
|
|
157
|
+
useLayoutEffect(() => {
|
|
158
|
+
rove(barRef.current, active);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
162
|
+
onKeyDown?.(e);
|
|
163
|
+
if (e.defaultPrevented || ownsArrows(e.target as Element)) return;
|
|
164
|
+
const list = itemsOf(barRef.current);
|
|
165
|
+
const at = list.indexOf(document.activeElement as HTMLElement);
|
|
166
|
+
if (at === -1 || list.length === 0) return;
|
|
167
|
+
const step = horizontalStep(e.key, barRef.current);
|
|
168
|
+
let next = -1;
|
|
169
|
+
if (step !== 0) next = (at + step + list.length) % list.length;
|
|
170
|
+
else if (e.key === "Home") next = 0;
|
|
171
|
+
else if (e.key === "End") next = list.length - 1;
|
|
172
|
+
if (next === -1) return;
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
active.current = list[next];
|
|
175
|
+
rove(barRef.current, active);
|
|
176
|
+
list[next].focus();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (!shown && !leaving) {
|
|
180
|
+
return <span {...regionProps} />;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const floating = variant === "floating";
|
|
184
|
+
const clear = floating ? (
|
|
185
|
+
<IconButton size="lg" onClick={onClear} aria-label={labels.clear}>
|
|
186
|
+
<X />
|
|
187
|
+
</IconButton>
|
|
188
|
+
) : (
|
|
189
|
+
<Button type="button" variant="secondary" size="sm" onClick={onClear}>
|
|
190
|
+
{labels.clear}
|
|
191
|
+
</Button>
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<>
|
|
196
|
+
<span {...regionProps} />
|
|
197
|
+
<div
|
|
198
|
+
{...rest}
|
|
199
|
+
ref={barRef}
|
|
200
|
+
role="toolbar"
|
|
201
|
+
aria-label={labels.selected(shownCount)}
|
|
202
|
+
aria-orientation="horizontal"
|
|
203
|
+
data-bulk-action-bar={variant}
|
|
204
|
+
// Leaving: out of the tab order and the accessibility tree at once, while the
|
|
205
|
+
// bar still animates. The selection it acted on is already gone.
|
|
206
|
+
inert={!shown || undefined}
|
|
207
|
+
onKeyDown={handleKeyDown}
|
|
208
|
+
onFocus={(e) => {
|
|
209
|
+
onFocus?.(e);
|
|
210
|
+
// Clicking a control is using it: the stop follows the pointer too.
|
|
211
|
+
const hit = itemsOf(barRef.current).find((el) => el === e.target);
|
|
212
|
+
if (hit) {
|
|
213
|
+
active.current = hit;
|
|
214
|
+
rove(barRef.current, active);
|
|
215
|
+
}
|
|
216
|
+
}}
|
|
217
|
+
style={floating ? { ...FLOATING_STYLE, ...style } : style}
|
|
218
|
+
className={cn(
|
|
219
|
+
"flex items-center",
|
|
220
|
+
floating
|
|
221
|
+
? cn(
|
|
222
|
+
"fixed z-30 gap-1 rounded-2xl border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 shadow-lg",
|
|
223
|
+
shown ? "animate-sheet" : "animate-sheet-out",
|
|
224
|
+
)
|
|
225
|
+
: cn(
|
|
226
|
+
"gap-2 px-3 py-2",
|
|
227
|
+
variant === "sticky" &&
|
|
228
|
+
"sticky top-0 z-20 border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--brand)_8%,var(--bg-surface))] backdrop-blur",
|
|
229
|
+
shown ? "animate-overlay" : "animate-overlay-out",
|
|
230
|
+
),
|
|
231
|
+
className,
|
|
232
|
+
)}
|
|
233
|
+
>
|
|
234
|
+
{floating && clear}
|
|
235
|
+
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--text-primary)]">
|
|
236
|
+
{labels.selected(shownCount)}
|
|
237
|
+
</span>
|
|
238
|
+
{/* `contents`, so the caller's controls are flex items of the bar itself. */}
|
|
239
|
+
<div className="contents">{children}</div>
|
|
240
|
+
{!floating && clear}
|
|
241
|
+
</div>
|
|
242
|
+
</>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function itemsOf(bar: HTMLElement | null): HTMLElement[] {
|
|
247
|
+
return bar ? Array.from(bar.querySelectorAll<HTMLElement>(ITEMS)) : [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Put the one Tab stop on the remembered control — or the first, when that one has
|
|
251
|
+
* gone or was never set — and take it off every other. */
|
|
252
|
+
function rove(bar: HTMLElement | null, active: { current: HTMLElement | null }): void {
|
|
253
|
+
const list = itemsOf(bar);
|
|
254
|
+
if (!active.current || !list.includes(active.current)) active.current = list[0] ?? null;
|
|
255
|
+
for (const el of list) el.tabIndex = el === active.current ? 0 : -1;
|
|
256
|
+
}
|
package/src/components/chip.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { forwardRef, useId, useRef, useState } from "react";
|
|
2
2
|
import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from "react";
|
|
3
|
-
import { X } from "lucide-react";
|
|
3
|
+
import { Check, X } from "lucide-react";
|
|
4
4
|
import type { LucideIcon } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
6
|
import { horizontalStep } from "../lib/direction";
|
|
@@ -30,9 +30,22 @@ export type ChipTone =
|
|
|
30
30
|
| "success"
|
|
31
31
|
| "info"
|
|
32
32
|
| "income"
|
|
33
|
-
| "expense"
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
| "expense"
|
|
34
|
+
| ChipHue;
|
|
35
|
+
/**
|
|
36
|
+
* Categorical hues (0.10.0): colour that says "a different value" and nothing more —
|
|
37
|
+
* kastlan's status-badge.tsx paints ~15 enums in 9 Tailwind hues by hand. Four of those
|
|
38
|
+
* nine are semantic and already tones (green = `success`, yellow = `warning`, red =
|
|
39
|
+
* `danger`, gray = `neutral`); these five are the rest, each a `--hue-*` token triple
|
|
40
|
+
* in tokens.css with a light and a dark value. Use a semantic tone when the value HAS a
|
|
41
|
+
* meaning (overdue is `danger`), a hue when it only needs telling apart (sent vs
|
|
42
|
+
* accepted vs invoiced).
|
|
43
|
+
*/
|
|
44
|
+
export type ChipHue = "blue" | "indigo" | "purple" | "teal" | "orange";
|
|
45
|
+
/** `lg` is the 44px touch target (`min-h-11`) a phone surface wants; `md` is ~28px.
|
|
46
|
+
* `xs` (0.10.0) is 11px type at `px-1.5` — keksdose's goal markers and budget-cell
|
|
47
|
+
* badges, which sit inside a figure's line and must not make it taller. */
|
|
48
|
+
export type ChipSize = "xs" | "sm" | "md" | "lg";
|
|
36
49
|
/**
|
|
37
50
|
* How much of the tone the chip wears.
|
|
38
51
|
*
|
|
@@ -43,8 +56,12 @@ export type ChipSize = "sm" | "md" | "lg";
|
|
|
43
56
|
* - `solid`: the tone as a fill under contrasting text — the "count" pill (unread
|
|
44
57
|
* messages, open tasks). It centres its content and sets figures tabular, so a
|
|
45
58
|
* one-digit count is a round dot and "9" → "10" does not jiggle the row.
|
|
59
|
+
* - `dot` (0.10.0): no pill at all — a dot in the tone's colour before plain text, for
|
|
60
|
+
* a status in a dense list or a table column where eight tinted pills in a column
|
|
61
|
+
* would outshout the figures beside them (kastlan's unit picker already draws its
|
|
62
|
+
* UnitStatus this way, from a separate `dotColor` map).
|
|
46
63
|
*/
|
|
47
|
-
export type ChipVariant = "soft" | "outline" | "solid";
|
|
64
|
+
export type ChipVariant = "soft" | "outline" | "solid" | "dot";
|
|
48
65
|
/** `pill` (default) is fully rounded; `square` has the small radius of a field or a
|
|
49
66
|
* button — for a chip that sits in a table cell or beside square controls. */
|
|
50
67
|
export type ChipShape = "pill" | "square";
|
|
@@ -86,6 +103,28 @@ const TONE: Record<ChipTone, { idle: string; selected: string }> = {
|
|
|
86
103
|
idle: "border-current/40 bg-[var(--bg-surface-2)] text-[var(--money-expense)]",
|
|
87
104
|
selected: "border-current bg-[var(--bg-active)] text-[var(--money-expense)]",
|
|
88
105
|
},
|
|
106
|
+
// The categorical hues: the `info` recipe with its own token triple, so a hue chip
|
|
107
|
+
// and a status chip in one row carry the same weight.
|
|
108
|
+
blue: {
|
|
109
|
+
idle: "border-[var(--hue-blue-border)] bg-[var(--hue-blue-bg)] text-[var(--hue-blue)]",
|
|
110
|
+
selected: "border-[var(--hue-blue)] bg-[var(--hue-blue-bg)] text-[var(--hue-blue)]",
|
|
111
|
+
},
|
|
112
|
+
indigo: {
|
|
113
|
+
idle: "border-[var(--hue-indigo-border)] bg-[var(--hue-indigo-bg)] text-[var(--hue-indigo)]",
|
|
114
|
+
selected: "border-[var(--hue-indigo)] bg-[var(--hue-indigo-bg)] text-[var(--hue-indigo)]",
|
|
115
|
+
},
|
|
116
|
+
purple: {
|
|
117
|
+
idle: "border-[var(--hue-purple-border)] bg-[var(--hue-purple-bg)] text-[var(--hue-purple)]",
|
|
118
|
+
selected: "border-[var(--hue-purple)] bg-[var(--hue-purple-bg)] text-[var(--hue-purple)]",
|
|
119
|
+
},
|
|
120
|
+
teal: {
|
|
121
|
+
idle: "border-[var(--hue-teal-border)] bg-[var(--hue-teal-bg)] text-[var(--hue-teal)]",
|
|
122
|
+
selected: "border-[var(--hue-teal)] bg-[var(--hue-teal-bg)] text-[var(--hue-teal)]",
|
|
123
|
+
},
|
|
124
|
+
orange: {
|
|
125
|
+
idle: "border-[var(--hue-orange-border)] bg-[var(--hue-orange-bg)] text-[var(--hue-orange)]",
|
|
126
|
+
selected: "border-[var(--hue-orange)] bg-[var(--hue-orange-bg)] text-[var(--hue-orange)]",
|
|
127
|
+
},
|
|
89
128
|
};
|
|
90
129
|
|
|
91
130
|
// `outline` idle looks: the tone's border and text, the surface left to whatever the
|
|
@@ -100,6 +139,11 @@ const OUTLINE: Record<ChipTone, string> = {
|
|
|
100
139
|
info: "border-[var(--info-border)] bg-transparent text-[var(--info)]",
|
|
101
140
|
income: "border-current/60 bg-transparent text-[var(--money-income)]",
|
|
102
141
|
expense: "border-current/60 bg-transparent text-[var(--money-expense)]",
|
|
142
|
+
blue: "border-[var(--hue-blue-border)] bg-transparent text-[var(--hue-blue)]",
|
|
143
|
+
indigo: "border-[var(--hue-indigo-border)] bg-transparent text-[var(--hue-indigo)]",
|
|
144
|
+
purple: "border-[var(--hue-purple-border)] bg-transparent text-[var(--hue-purple)]",
|
|
145
|
+
teal: "border-[var(--hue-teal-border)] bg-transparent text-[var(--hue-teal)]",
|
|
146
|
+
orange: "border-[var(--hue-orange-border)] bg-transparent text-[var(--hue-orange)]",
|
|
103
147
|
};
|
|
104
148
|
|
|
105
149
|
// `solid`: the fill under its contrast pair. Only brand and danger have a declared
|
|
@@ -116,12 +160,40 @@ const SOLID: Record<ChipTone, string> = {
|
|
|
116
160
|
info: "border-transparent bg-[var(--info)] text-[var(--text-inverse)]",
|
|
117
161
|
income: "border-transparent bg-[var(--money-income)] text-[var(--text-inverse)]",
|
|
118
162
|
expense: "border-transparent bg-[var(--money-expense)] text-[var(--text-inverse)]",
|
|
163
|
+
blue: "border-transparent bg-[var(--hue-blue)] text-[var(--text-inverse)]",
|
|
164
|
+
indigo: "border-transparent bg-[var(--hue-indigo)] text-[var(--text-inverse)]",
|
|
165
|
+
purple: "border-transparent bg-[var(--hue-purple)] text-[var(--text-inverse)]",
|
|
166
|
+
teal: "border-transparent bg-[var(--hue-teal)] text-[var(--text-inverse)]",
|
|
167
|
+
orange: "border-transparent bg-[var(--hue-orange)] text-[var(--text-inverse)]",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** `dot`: the fill of the dot itself — the tone's strongest colour, which is the one
|
|
171
|
+
* chosen to read on the page surface in both themes. Neutral takes the muted ink: a
|
|
172
|
+
* grey dot is "no particular state", not a darker black. */
|
|
173
|
+
const DOT: Record<ChipTone, string> = {
|
|
174
|
+
neutral: "bg-[var(--text-muted)]",
|
|
175
|
+
brand: "bg-[var(--brand)]",
|
|
176
|
+
danger: "bg-[var(--danger)]",
|
|
177
|
+
warning: "bg-[var(--warning)]",
|
|
178
|
+
success: "bg-[var(--success)]",
|
|
179
|
+
info: "bg-[var(--info)]",
|
|
180
|
+
income: "bg-[var(--money-income)]",
|
|
181
|
+
expense: "bg-[var(--money-expense)]",
|
|
182
|
+
blue: "bg-[var(--hue-blue)]",
|
|
183
|
+
indigo: "bg-[var(--hue-indigo)]",
|
|
184
|
+
purple: "bg-[var(--hue-purple)]",
|
|
185
|
+
teal: "bg-[var(--hue-teal)]",
|
|
186
|
+
orange: "bg-[var(--hue-orange)]",
|
|
119
187
|
};
|
|
120
188
|
|
|
189
|
+
/** Per size: the dot's diameter. */
|
|
190
|
+
const DOT_SIZE: Record<ChipSize, string> = { xs: "size-1.5", sm: "size-2", md: "size-2", lg: "size-2.5" };
|
|
191
|
+
|
|
121
192
|
/** The status-badge type (`caps`): a size step down, heavier and tracked, because
|
|
122
193
|
* capitals at body size shout and capitals untracked run together. Per size, so it
|
|
123
194
|
* replaces the size's own `text-*` through tailwind-merge. */
|
|
124
195
|
const CAPS: Record<ChipSize, string> = {
|
|
196
|
+
xs: "text-[10px] font-semibold uppercase tracking-wider",
|
|
125
197
|
sm: "text-[10px] font-semibold uppercase tracking-wider",
|
|
126
198
|
md: "text-[11px] font-semibold uppercase tracking-wider",
|
|
127
199
|
lg: "text-xs font-semibold uppercase tracking-wider",
|
|
@@ -129,6 +201,13 @@ const CAPS: Record<ChipSize, string> = {
|
|
|
129
201
|
|
|
130
202
|
function surfaceOf(tone: ChipTone, variant: ChipVariant, selected: boolean | undefined): string {
|
|
131
203
|
const palette = TONE[tone];
|
|
204
|
+
if (variant === "dot") {
|
|
205
|
+
// No surface and no border colour: the dot carries the tone, the text stays ink.
|
|
206
|
+
// The border goes transparent rather than away, so a dot chip lines up with a pill.
|
|
207
|
+
return selected
|
|
208
|
+
? "border-transparent bg-transparent font-medium text-[var(--text-primary)]"
|
|
209
|
+
: "border-transparent bg-transparent text-[var(--text-secondary)]";
|
|
210
|
+
}
|
|
132
211
|
if (variant === "solid") {
|
|
133
212
|
// A solid chip is already the strongest look there is, so "on" is a ring round it.
|
|
134
213
|
return cn(SOLID[tone], selected && "ring-2 ring-[var(--border-strong)] ring-offset-1 ring-offset-[var(--bg-surface)]");
|
|
@@ -148,6 +227,14 @@ export interface ChipLinkProps {
|
|
|
148
227
|
/** The chip's forwarded ref. React 19 passes it to a function component as a prop. */
|
|
149
228
|
ref?: Ref<HTMLAnchorElement>;
|
|
150
229
|
"aria-current"?: "true";
|
|
230
|
+
/**
|
|
231
|
+
* The chip's `onClick`, when it was given one — spread it onto the link with the
|
|
232
|
+
* rest. Carried here so a caller need not close over it twice: keksdose's admin pill
|
|
233
|
+
* in the account menu closes the menu on the way to /admin (account-menu.tsx), and
|
|
234
|
+
* with `onClick` on the Chip its `renderLink` stays the one-liner
|
|
235
|
+
* `({ href, ...p }) => <Link to={href} {...p} />`.
|
|
236
|
+
*/
|
|
237
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
151
238
|
id?: string;
|
|
152
239
|
title?: string;
|
|
153
240
|
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
@@ -164,6 +251,13 @@ const SIZE: Record<
|
|
|
164
251
|
ChipSize,
|
|
165
252
|
{ body: string; split: string; tail: string; icon: string; remove: string }
|
|
166
253
|
> = {
|
|
254
|
+
xs: {
|
|
255
|
+
body: "gap-1 px-1.5 py-0 text-[11px] leading-4",
|
|
256
|
+
split: "gap-1 ps-1.5 pe-0.5 py-0 text-[11px] leading-4",
|
|
257
|
+
tail: "pe-1",
|
|
258
|
+
icon: "size-3",
|
|
259
|
+
remove: "size-2.5",
|
|
260
|
+
},
|
|
167
261
|
sm: {
|
|
168
262
|
body: "gap-1 px-2 py-0.5 text-xs",
|
|
169
263
|
split: "gap-1 ps-2 pe-1 py-0.5 text-xs",
|
|
@@ -230,6 +324,16 @@ interface ChipBaseProps {
|
|
|
230
324
|
* it — "+ Outflow" in green next to an inflow (keksdose #417).
|
|
231
325
|
*/
|
|
232
326
|
selected?: boolean;
|
|
327
|
+
/**
|
|
328
|
+
* Checkbox mode (0.10.0), for a toggle chip (`onClick` + `selected`): a small box
|
|
329
|
+
* at the start that shows a tick when `selected`, and the button reports
|
|
330
|
+
* `role="checkbox"` + `aria-checked` instead of `aria-pressed`. For a set of
|
|
331
|
+
* include/exclude choices where "on" must read as TICKED, not merely tinted —
|
|
332
|
+
* keksdose's add-group-card (the "which categories come along" pills), which put a
|
|
333
|
+
* real `<Checkbox>` inside a `<label>` pill by hand. The box is drawn in both
|
|
334
|
+
* states so ticking one never shifts its label.
|
|
335
|
+
*/
|
|
336
|
+
checkbox?: boolean;
|
|
233
337
|
/** Renders a dismiss affordance. Works alongside `href`/`onClick` — see the note below. */
|
|
234
338
|
onRemove?: () => void;
|
|
235
339
|
/** Accessible name for the dismiss button. Default: `common.remove` from the
|
|
@@ -265,14 +369,16 @@ interface ChipBaseProps {
|
|
|
265
369
|
|
|
266
370
|
/**
|
|
267
371
|
* A chip is a link OR a button, never both — so the types say so. Before 0.7.0 both were
|
|
268
|
-
* accepted and `onClick` was silently dropped whenever `href` was set
|
|
269
|
-
*
|
|
270
|
-
*
|
|
372
|
+
* accepted and `onClick` was silently dropped whenever `href` was set. A LINK may still
|
|
373
|
+
* run code on its way (close the menu it sits in): that is an `onClick` typed for the
|
|
374
|
+
* anchor, on the link shape, and the pill stays a link — no `aria-pressed`, no button.
|
|
375
|
+
* That shape wants a definite `href` string, so a bare `onClick` is always the
|
|
376
|
+
* button's, with a button's event.
|
|
271
377
|
*/
|
|
272
378
|
export type ChipProps = ChipBaseProps &
|
|
273
379
|
(
|
|
274
380
|
| {
|
|
275
|
-
/** Renders the chip as a link.
|
|
381
|
+
/** Renders the chip as a link. */
|
|
276
382
|
href?: string;
|
|
277
383
|
onClick?: never;
|
|
278
384
|
/**
|
|
@@ -291,6 +397,19 @@ export type ChipProps = ChipBaseProps &
|
|
|
291
397
|
*/
|
|
292
398
|
renderLink?: (props: ChipLinkProps) => ReactElement;
|
|
293
399
|
}
|
|
400
|
+
| {
|
|
401
|
+
/** The link shape again, with an `onClick`. `href` is a required string here,
|
|
402
|
+
* which is what lets TypeScript tell this shape from the button's: a bare
|
|
403
|
+
* `onClick` can only be the button's, and keeps its button event. */
|
|
404
|
+
href: string;
|
|
405
|
+
/** Runs on the link's click, before the navigation — keksdose's admin pill
|
|
406
|
+
* closes the account menu with it. Reaches a `renderLink` link as
|
|
407
|
+
* {@link ChipLinkProps.onClick}. Not called on a disabled chip, which renders
|
|
408
|
+
* no link. */
|
|
409
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
410
|
+
/** See the link shape above. */
|
|
411
|
+
renderLink?: (props: ChipLinkProps) => ReactElement;
|
|
412
|
+
}
|
|
294
413
|
| {
|
|
295
414
|
href?: never;
|
|
296
415
|
renderLink?: never;
|
|
@@ -318,6 +437,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
318
437
|
caps = false,
|
|
319
438
|
icon: Icon,
|
|
320
439
|
selected,
|
|
440
|
+
checkbox = false,
|
|
321
441
|
href,
|
|
322
442
|
renderLink,
|
|
323
443
|
onClick,
|
|
@@ -339,7 +459,9 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
339
459
|
const interactive = !!href || !!onClick;
|
|
340
460
|
// One radius for every rounded piece — the pill, the body inside a split pill, the ×
|
|
341
461
|
// — so a square chip has no round focus ring inside it.
|
|
342
|
-
const radius =
|
|
462
|
+
const radius =
|
|
463
|
+
shape === "square" ? (size === "sm" || size === "xs" ? "rounded" : "rounded-md") : "rounded-full";
|
|
464
|
+
const dot = variant === "dot";
|
|
343
465
|
const type = cn(caps && CAPS[size], variant === "solid" && "justify-center tabular-nums");
|
|
344
466
|
const surface = cn(
|
|
345
467
|
surfaceOf(tone, variant, selected),
|
|
@@ -352,11 +474,29 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
352
474
|
radius,
|
|
353
475
|
surface,
|
|
354
476
|
interactive && !disabled && "cursor-pointer hover:brightness-[0.97] dark:hover:brightness-110",
|
|
477
|
+
// An inert dot chip is text in a column: no inset, so it aligns with the header.
|
|
478
|
+
dot && !interactive && "px-0",
|
|
355
479
|
className,
|
|
356
480
|
);
|
|
357
481
|
|
|
482
|
+
// The checkbox box, only on the toggle shape it has meaning on.
|
|
483
|
+
const isCheckbox = checkbox && !!onClick && !href;
|
|
358
484
|
const body = (
|
|
359
485
|
<>
|
|
486
|
+
{isCheckbox && (
|
|
487
|
+
<span
|
|
488
|
+
aria-hidden
|
|
489
|
+
data-chip-check=""
|
|
490
|
+
className={cn(
|
|
491
|
+
"flex shrink-0 items-center justify-center rounded-sm border",
|
|
492
|
+
s.icon,
|
|
493
|
+
selected ? "border-current bg-current/15" : "border-current/50",
|
|
494
|
+
)}
|
|
495
|
+
>
|
|
496
|
+
{selected && <Check className="size-full" strokeWidth={3} />}
|
|
497
|
+
</span>
|
|
498
|
+
)}
|
|
499
|
+
{dot && <span aria-hidden data-chip-dot="" className={cn("shrink-0 rounded-full", DOT_SIZE[size], DOT[tone])} />}
|
|
360
500
|
{Icon && <Icon className={cn(s.icon, "shrink-0")} aria-hidden />}
|
|
361
501
|
<span className="min-w-0 truncate">{children}</span>
|
|
362
502
|
</>
|
|
@@ -419,10 +559,13 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
419
559
|
|
|
420
560
|
// ── The three shapes.
|
|
421
561
|
if (href && !disabled) {
|
|
562
|
+
// On this shape `onClick` is the link shape's, typed for the anchor.
|
|
563
|
+
const onLinkClick = onClick as ChipLinkProps["onClick"];
|
|
422
564
|
const linkProps: ChipLinkProps = {
|
|
423
565
|
...rest,
|
|
424
566
|
ref: ref as Ref<HTMLAnchorElement>,
|
|
425
567
|
href,
|
|
568
|
+
onClick: onLinkClick,
|
|
426
569
|
"aria-current": selected ? "true" : undefined,
|
|
427
570
|
className: remove ? inner : look,
|
|
428
571
|
children: body,
|
|
@@ -436,6 +579,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
436
579
|
ref={ref as React.Ref<HTMLAnchorElement>}
|
|
437
580
|
href={href}
|
|
438
581
|
aria-current={selected ? "true" : undefined}
|
|
582
|
+
onClick={onLinkClick}
|
|
439
583
|
className={remove ? inner : look}
|
|
440
584
|
{...rest}
|
|
441
585
|
>
|
|
@@ -445,17 +589,24 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
445
589
|
return remove ? pill(link) : link;
|
|
446
590
|
}
|
|
447
591
|
|
|
448
|
-
|
|
592
|
+
// `!href`: a DISABLED link renders no link, and its link-shaped `onClick` must not
|
|
593
|
+
// turn it into a button instead — it falls through to the inert span, as before.
|
|
594
|
+
if (onClick && !href) {
|
|
595
|
+
const onButtonClick = onClick as (event: MouseEvent<HTMLButtonElement>) => void;
|
|
449
596
|
const button = (
|
|
450
597
|
<button
|
|
451
598
|
ref={ref as React.Ref<HTMLButtonElement>}
|
|
452
599
|
type="button"
|
|
453
|
-
onClick={
|
|
600
|
+
onClick={onButtonClick}
|
|
454
601
|
disabled={disabled}
|
|
455
602
|
// Only when `selected` is PASSED: a chip with `onClick` and no `selected` is an
|
|
456
603
|
// action button (it does something), not a toggle (it is on or off), and
|
|
457
604
|
// announcing it "not pressed" would claim a state it does not have.
|
|
458
|
-
|
|
605
|
+
// A checkbox chip says "checked", not "pressed" — the same state in the words
|
|
606
|
+
// of the control it looks like.
|
|
607
|
+
role={isCheckbox ? "checkbox" : undefined}
|
|
608
|
+
aria-pressed={isCheckbox ? undefined : selected}
|
|
609
|
+
aria-checked={isCheckbox ? !!selected : undefined}
|
|
459
610
|
className={remove ? inner : look}
|
|
460
611
|
{...rest}
|
|
461
612
|
>
|
|
@@ -6,7 +6,7 @@ import { useAnnounce } from "../hooks/use-announce";
|
|
|
6
6
|
import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
|
|
7
7
|
import type { CopyState } from "../hooks/use-copy-to-clipboard";
|
|
8
8
|
import { Button, IconButton } from "./ui";
|
|
9
|
-
import type { ButtonVariant, IconButtonSize } from "./ui";
|
|
9
|
+
import type { ButtonSize, ButtonTone, ButtonVariant, IconButtonSize, IconButtonTone } from "./ui";
|
|
10
10
|
import { Tooltip } from "./tooltip";
|
|
11
11
|
import type { TooltipSide } from "./tooltip";
|
|
12
12
|
|
|
@@ -43,8 +43,17 @@ export interface CopyButtonProps
|
|
|
43
43
|
label?: string;
|
|
44
44
|
/** The button's own look. Default: `ghost` for the icon, `secondary` for the label. */
|
|
45
45
|
buttonVariant?: ButtonVariant;
|
|
46
|
-
/**
|
|
47
|
-
size
|
|
46
|
+
/** The icon variant's IconButton size (default `sm`). The `label` variant takes a
|
|
47
|
+
* Button size, `sm` or `md` — passed only when given, so an existing label button
|
|
48
|
+
* keeps its look (keksdose budget-share-card and jobs-panel wanted the compact one). */
|
|
49
|
+
size?: IconButtonSize | ButtonSize;
|
|
50
|
+
/** The icon variant's IconButton tone (e.g. `muted`, so a row of copy icons is no
|
|
51
|
+
* heavier than the row's other actions); in the `label` variant a Button tone,
|
|
52
|
+
* which applies to `link` / `ghost`. The copied / failed colour still wins. */
|
|
53
|
+
tone?: IconButtonTone | ButtonTone;
|
|
54
|
+
/** Keep the click and Enter/Space from reaching a clickable row around the button,
|
|
55
|
+
* as IconButton's own `stopPropagation` does (keksdose feedback-page, users-panel). */
|
|
56
|
+
stopPropagation?: boolean;
|
|
48
57
|
/** Icon variant only: where the result tooltip opens. */
|
|
49
58
|
tooltipSide?: TooltipSide;
|
|
50
59
|
/** Pass through to the tooltip — needed inside a scroll container (see Tooltip). */
|
|
@@ -80,7 +89,9 @@ export function CopyButton({
|
|
|
80
89
|
variant = "icon",
|
|
81
90
|
label,
|
|
82
91
|
buttonVariant,
|
|
83
|
-
size
|
|
92
|
+
size,
|
|
93
|
+
tone,
|
|
94
|
+
stopPropagation,
|
|
84
95
|
tooltipSide = "top",
|
|
85
96
|
tooltipPortal = false,
|
|
86
97
|
resetAfter = 2000,
|
|
@@ -123,8 +134,17 @@ export function CopyButton({
|
|
|
123
134
|
{...rest}
|
|
124
135
|
disabled={disabled}
|
|
125
136
|
variant={buttonVariant ?? "secondary"}
|
|
137
|
+
size={size === "sm" || size === "md" ? size : undefined}
|
|
138
|
+
tone={tone === "default" || tone === "muted" || tone === "danger" ? tone : undefined}
|
|
126
139
|
data-state={state}
|
|
127
|
-
onClick={
|
|
140
|
+
onClick={(e) => {
|
|
141
|
+
if (stopPropagation) e.stopPropagation();
|
|
142
|
+
void onClick();
|
|
143
|
+
}}
|
|
144
|
+
onKeyDown={(e) => {
|
|
145
|
+
if (stopPropagation && (e.key === "Enter" || e.key === " ")) e.stopPropagation();
|
|
146
|
+
rest.onKeyDown?.(e);
|
|
147
|
+
}}
|
|
128
148
|
className={className}
|
|
129
149
|
>
|
|
130
150
|
<Icon aria-hidden className={cn("size-4 shrink-0", iconTone)} />
|
|
@@ -144,9 +164,11 @@ export function CopyButton({
|
|
|
144
164
|
disabled={disabled}
|
|
145
165
|
aria-label={idleText}
|
|
146
166
|
variant={buttonVariant ?? "ghost"}
|
|
147
|
-
size={size}
|
|
167
|
+
size={(size ?? "sm") as IconButtonSize}
|
|
168
|
+
tone={tone as IconButtonTone | undefined}
|
|
169
|
+
stopPropagation={stopPropagation}
|
|
148
170
|
data-state={state}
|
|
149
|
-
onClick={onClick}
|
|
171
|
+
onClick={() => void onClick()}
|
|
150
172
|
className={cn(iconTone, className)}
|
|
151
173
|
>
|
|
152
174
|
<Icon aria-hidden />
|