@eifi1/ui-kit 0.9.0 → 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 +21 -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 +4 -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 +4 -0
- package/dist/components/chip.d.ts +31 -5
- package/dist/components/chip.js +85 -5
- 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/date-picker.d.ts +22 -2
- package/dist/components/date-picker.js +11 -3
- 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 +57 -2
- package/dist/components/disclosure.js +24 -12
- 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/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 +4 -0
- package/dist/components/number-input.d.ts +4 -0
- package/dist/components/numpad-sheet.d.ts +4 -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/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 +104 -4
- package/dist/components/ui.js +112 -8
- package/dist/components/ui.js.map +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/i18n/defaults.d.ts +4 -0
- package/dist/i18n/defaults.js +9 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +8 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +4 -0
- package/dist/i18n/locales/de-CH.d.ts +4 -0
- package/dist/i18n/locales/de-informal.d.ts +4 -0
- package/dist/i18n/locales/de.d.ts +4 -0
- package/dist/i18n/locales/de.js +21 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +4 -0
- package/dist/i18n/locales/es.js +21 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +4 -0
- package/dist/i18n/locales/fr.js +21 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +4 -0
- package/dist/i18n/locales/hu.js +21 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +4 -0
- package/dist/i18n/locales/it.js +21 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +4 -0
- package/dist/i18n/locales/zh.js +21 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +15 -7
- package/dist/index.js +12 -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 +19 -2
- package/dist/search/command-palette.js +16 -5
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.d.ts +26 -1
- package/dist/search/global-search.js +29 -13
- package/dist/search/global-search.js.map +1 -1
- package/dist/search.d.ts +1 -1
- package/dist/wizard/stepper-nav.js +1 -1
- 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 +16 -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 +128 -7
- package/src/components/copy-button.tsx +29 -7
- package/src/components/date-picker.tsx +49 -1
- package/src/components/description-list.tsx +168 -15
- package/src/components/dialog-frame.tsx +7 -0
- package/src/components/disclosure.tsx +93 -21
- package/src/components/file-dropzone.tsx +9 -10
- 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/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 +237 -8
- package/src/hooks/use-close-transition.ts +14 -5
- package/src/hooks/use-copy-to-clipboard.ts +1 -1
- package/src/i18n/defaults.ts +8 -0
- package/src/i18n/kit-labels.tsx +8 -0
- package/src/i18n/locales/de.ts +20 -0
- package/src/i18n/locales/es.ts +20 -0
- package/src/i18n/locales/fr.ts +20 -0
- package/src/i18n/locales/hu.ts +20 -0
- package/src/i18n/locales/it.ts +20 -0
- package/src/i18n/locales/zh.ts +20 -0
- package/src/index.ts +26 -0
- package/src/lib/clipping.ts +16 -0
- package/src/search/command-palette.tsx +42 -5
- package/src/search/global-search.tsx +59 -18
- package/src/wizard/stepper-nav.tsx +2 -1
- 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,66 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface BulkActionBarLabels {
|
|
5
|
+
/** The count, shown in the bar, spoken by its live region and used as the toolbar's
|
|
6
|
+
* name: "3 selected". */
|
|
7
|
+
selected: (count: number) => string;
|
|
8
|
+
/** The clear/cancel action's name — the X of the floating bar, the text button of the
|
|
9
|
+
* others. */
|
|
10
|
+
clear: string;
|
|
11
|
+
/** Spoken when the selection empties and the bar goes. */
|
|
12
|
+
cleared: string;
|
|
13
|
+
}
|
|
14
|
+
declare const DEFAULT_BULK_ACTION_BAR_LABELS: BulkActionBarLabels;
|
|
15
|
+
/**
|
|
16
|
+
* `floating` — the phone's: fixed above `AppShell`'s bottom nav (`--app-nav-h`) and the
|
|
17
|
+
* home indicator, inset from both sides, a rounded card with a shadow. `sticky` — the
|
|
18
|
+
* desktop's: pinned to the top of its scroll container, over the rows it acts on.
|
|
19
|
+
* `inline` — in the flow, where the caller places it.
|
|
20
|
+
*/
|
|
21
|
+
type BulkActionBarVariant = "floating" | "sticky" | "inline";
|
|
22
|
+
interface BulkActionBarProps extends Omit<ComponentPropsWithoutRef<"div">, "role" | "children"> {
|
|
23
|
+
/** How many items are selected. The bar shows only while this is above zero. */
|
|
24
|
+
count: number;
|
|
25
|
+
/** Clear the selection / leave selection mode. */
|
|
26
|
+
onClear: () => void;
|
|
27
|
+
/** The actions: buttons (`IconButton size="lg"` on a phone, `Button` from md up),
|
|
28
|
+
* in the caller's order. They are the toolbar's arrow-key stops, after the clear. */
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
/** Default `floating`. See {@link BulkActionBarVariant}. */
|
|
31
|
+
variant?: BulkActionBarVariant;
|
|
32
|
+
/** Default: `bulkActionBar` from the {@link UiKitProvider}, else English. */
|
|
33
|
+
labels?: Partial<BulkActionBarLabels>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The bar that appears while rows are selected: the count, a way out, and what can be
|
|
37
|
+
* done to all of them at once.
|
|
38
|
+
*
|
|
39
|
+
* keksdose had written it three times — the transactions phone bar
|
|
40
|
+
* (mobile-bulk-bar.tsx), the invoice lines' phone and desktop bars
|
|
41
|
+
* (invoice-lines-bulk-bar.tsx) and the payees selection bar — and the copies had
|
|
42
|
+
* drifted where it matters: two parked themselves with a guessed `bottom-16` that the
|
|
43
|
+
* third had already found leaves a 24px gap (live #314: the nav is content-sized and
|
|
44
|
+
* `AppShell` publishes its height as `--app-nav-h`), none of them announced the count,
|
|
45
|
+
* and all three were `role="toolbar"` without the arrow keys that role promises.
|
|
46
|
+
*
|
|
47
|
+
* ## Keep it mounted
|
|
48
|
+
*
|
|
49
|
+
* Render it unconditionally and let `count` decide. At zero it renders only its live
|
|
50
|
+
* region — which must already be in the DOM for the first "3 selected" to be heard —
|
|
51
|
+
* and when the count drops to zero it plays its exit before it goes, which a
|
|
52
|
+
* `{count > 0 && <Bar/>}` gate would cut off.
|
|
53
|
+
*
|
|
54
|
+
* ## The toolbar
|
|
55
|
+
*
|
|
56
|
+
* One Tab stop: ←/→ (mirrored in RTL), Home and End move between the controls, and Tab
|
|
57
|
+
* leaves — the WAI-ARIA toolbar pattern, since that is what `role="toolbar"` tells a
|
|
58
|
+
* screen reader to expect. The stop remembers the control last used. Arrow keys inside
|
|
59
|
+
* a text field stay the field's.
|
|
60
|
+
*
|
|
61
|
+
* It is NOT a dialog: no focus trap, no scroll lock — the list behind stays live,
|
|
62
|
+
* because tapping more rows is how a selection grows.
|
|
63
|
+
*/
|
|
64
|
+
declare function BulkActionBar({ count, onClear, children, variant, labels: labelsProp, className, style, onKeyDown, onFocus, ...rest }: BulkActionBarProps): react.JSX.Element;
|
|
65
|
+
|
|
66
|
+
export { BulkActionBar, type BulkActionBarLabels, type BulkActionBarProps, type BulkActionBarVariant, DEFAULT_BULK_ACTION_BAR_LABELS };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { horizontalStep } from "../lib/direction.js";
|
|
7
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
8
|
+
import { useAnnounce } from "../hooks/use-announce.js";
|
|
9
|
+
import { OVERLAY_EXIT_MS, prefersReducedMotion } from "../hooks/use-close-transition.js";
|
|
10
|
+
import { Button, IconButton } from "./ui.js";
|
|
11
|
+
const DEFAULT_BULK_ACTION_BAR_LABELS = {
|
|
12
|
+
selected: (count) => `${count} selected`,
|
|
13
|
+
clear: "Clear selection",
|
|
14
|
+
cleared: "Selection cleared"
|
|
15
|
+
};
|
|
16
|
+
const FLOATING_STYLE = {
|
|
17
|
+
bottom: "calc(max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px)) + 0.5rem)",
|
|
18
|
+
// Both insets on both sides: a logical side has no one physical inset to name, and
|
|
19
|
+
// the bar is symmetric, so RTL needs nothing more.
|
|
20
|
+
insetInline: "max(0.5rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))"
|
|
21
|
+
};
|
|
22
|
+
const ITEMS = "button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[data-toolbar-item]";
|
|
23
|
+
function ownsArrows(el) {
|
|
24
|
+
if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) return true;
|
|
25
|
+
if (el instanceof HTMLInputElement) return !["button", "checkbox", "radio", "submit", "reset"].includes(el.type);
|
|
26
|
+
return el.isContentEditable;
|
|
27
|
+
}
|
|
28
|
+
function BulkActionBar({
|
|
29
|
+
count,
|
|
30
|
+
onClear,
|
|
31
|
+
children,
|
|
32
|
+
variant = "floating",
|
|
33
|
+
labels: labelsProp,
|
|
34
|
+
className,
|
|
35
|
+
style,
|
|
36
|
+
onKeyDown,
|
|
37
|
+
onFocus,
|
|
38
|
+
...rest
|
|
39
|
+
}) {
|
|
40
|
+
const labels = useKitLabels("bulkActionBar", DEFAULT_BULK_ACTION_BAR_LABELS, labelsProp);
|
|
41
|
+
const { announce, regionProps } = useAnnounce();
|
|
42
|
+
const barRef = useRef(null);
|
|
43
|
+
const shown = count > 0;
|
|
44
|
+
const [shownCount, setShownCount] = useState(count);
|
|
45
|
+
const [leaving, setLeaving] = useState(false);
|
|
46
|
+
const [prevCount, setPrevCount] = useState(count);
|
|
47
|
+
if (count !== prevCount) {
|
|
48
|
+
setPrevCount(count);
|
|
49
|
+
if (shown) {
|
|
50
|
+
setShownCount(count);
|
|
51
|
+
setLeaving(false);
|
|
52
|
+
} else {
|
|
53
|
+
setLeaving(prevCount > 0 && !prefersReducedMotion());
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!leaving) return;
|
|
58
|
+
const timer = setTimeout(() => setLeaving(false), OVERLAY_EXIT_MS);
|
|
59
|
+
return () => clearTimeout(timer);
|
|
60
|
+
}, [leaving]);
|
|
61
|
+
const spoken = useRef(count);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (spoken.current === count) return;
|
|
64
|
+
spoken.current = count;
|
|
65
|
+
announce(count > 0 ? labels.selected(count) : labels.cleared);
|
|
66
|
+
}, [count, labels, announce]);
|
|
67
|
+
const active = useRef(null);
|
|
68
|
+
useLayoutEffect(() => {
|
|
69
|
+
rove(barRef.current, active);
|
|
70
|
+
});
|
|
71
|
+
const handleKeyDown = (e) => {
|
|
72
|
+
onKeyDown?.(e);
|
|
73
|
+
if (e.defaultPrevented || ownsArrows(e.target)) return;
|
|
74
|
+
const list = itemsOf(barRef.current);
|
|
75
|
+
const at = list.indexOf(document.activeElement);
|
|
76
|
+
if (at === -1 || list.length === 0) return;
|
|
77
|
+
const step = horizontalStep(e.key, barRef.current);
|
|
78
|
+
let next = -1;
|
|
79
|
+
if (step !== 0) next = (at + step + list.length) % list.length;
|
|
80
|
+
else if (e.key === "Home") next = 0;
|
|
81
|
+
else if (e.key === "End") next = list.length - 1;
|
|
82
|
+
if (next === -1) return;
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
active.current = list[next];
|
|
85
|
+
rove(barRef.current, active);
|
|
86
|
+
list[next].focus();
|
|
87
|
+
};
|
|
88
|
+
if (!shown && !leaving) {
|
|
89
|
+
return /* @__PURE__ */ jsx("span", { ...regionProps });
|
|
90
|
+
}
|
|
91
|
+
const floating = variant === "floating";
|
|
92
|
+
const clear = floating ? /* @__PURE__ */ jsx(IconButton, { size: "lg", onClick: onClear, "aria-label": labels.clear, children: /* @__PURE__ */ jsx(X, {}) }) : /* @__PURE__ */ jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: onClear, children: labels.clear });
|
|
93
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
94
|
+
/* @__PURE__ */ jsx("span", { ...regionProps }),
|
|
95
|
+
/* @__PURE__ */ jsxs(
|
|
96
|
+
"div",
|
|
97
|
+
{
|
|
98
|
+
...rest,
|
|
99
|
+
ref: barRef,
|
|
100
|
+
role: "toolbar",
|
|
101
|
+
"aria-label": labels.selected(shownCount),
|
|
102
|
+
"aria-orientation": "horizontal",
|
|
103
|
+
"data-bulk-action-bar": variant,
|
|
104
|
+
inert: !shown || void 0,
|
|
105
|
+
onKeyDown: handleKeyDown,
|
|
106
|
+
onFocus: (e) => {
|
|
107
|
+
onFocus?.(e);
|
|
108
|
+
const hit = itemsOf(barRef.current).find((el) => el === e.target);
|
|
109
|
+
if (hit) {
|
|
110
|
+
active.current = hit;
|
|
111
|
+
rove(barRef.current, active);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
style: floating ? { ...FLOATING_STYLE, ...style } : style,
|
|
115
|
+
className: cn(
|
|
116
|
+
"flex items-center",
|
|
117
|
+
floating ? cn(
|
|
118
|
+
"fixed z-30 gap-1 rounded-2xl border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 shadow-lg",
|
|
119
|
+
shown ? "animate-sheet" : "animate-sheet-out"
|
|
120
|
+
) : cn(
|
|
121
|
+
"gap-2 px-3 py-2",
|
|
122
|
+
variant === "sticky" && "sticky top-0 z-20 border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--brand)_8%,var(--bg-surface))] backdrop-blur",
|
|
123
|
+
shown ? "animate-overlay" : "animate-overlay-out"
|
|
124
|
+
),
|
|
125
|
+
className
|
|
126
|
+
),
|
|
127
|
+
children: [
|
|
128
|
+
floating && clear,
|
|
129
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate text-sm font-medium text-[var(--text-primary)]", children: labels.selected(shownCount) }),
|
|
130
|
+
/* @__PURE__ */ jsx("div", { className: "contents", children }),
|
|
131
|
+
!floating && clear
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
] });
|
|
136
|
+
}
|
|
137
|
+
function itemsOf(bar) {
|
|
138
|
+
return bar ? Array.from(bar.querySelectorAll(ITEMS)) : [];
|
|
139
|
+
}
|
|
140
|
+
function rove(bar, active) {
|
|
141
|
+
const list = itemsOf(bar);
|
|
142
|
+
if (!active.current || !list.includes(active.current)) active.current = list[0] ?? null;
|
|
143
|
+
for (const el of list) el.tabIndex = el === active.current ? 0 : -1;
|
|
144
|
+
}
|
|
145
|
+
export {
|
|
146
|
+
BulkActionBar,
|
|
147
|
+
DEFAULT_BULK_ACTION_BAR_LABELS
|
|
148
|
+
};
|
|
149
|
+
//# sourceMappingURL=bulk-action-bar.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/bulk-action-bar.tsx"],"sourcesContent":["import { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, CSSProperties, KeyboardEvent, ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { OVERLAY_EXIT_MS, prefersReducedMotion } from \"../hooks/use-close-transition\";\nimport { Button, IconButton } from \"./ui\";\n\nexport interface BulkActionBarLabels {\n /** The count, shown in the bar, spoken by its live region and used as the toolbar's\n * name: \"3 selected\". */\n selected: (count: number) => string;\n /** The clear/cancel action's name — the X of the floating bar, the text button of the\n * others. */\n clear: string;\n /** Spoken when the selection empties and the bar goes. */\n cleared: string;\n}\n\nexport const DEFAULT_BULK_ACTION_BAR_LABELS: BulkActionBarLabels = {\n selected: (count) => `${count} selected`,\n clear: \"Clear selection\",\n cleared: \"Selection cleared\",\n};\n\n/**\n * `floating` — the phone's: fixed above `AppShell`'s bottom nav (`--app-nav-h`) and the\n * home indicator, inset from both sides, a rounded card with a shadow. `sticky` — the\n * desktop's: pinned to the top of its scroll container, over the rows it acts on.\n * `inline` — in the flow, where the caller places it.\n */\nexport type BulkActionBarVariant = \"floating\" | \"sticky\" | \"inline\";\n\nexport interface BulkActionBarProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"role\" | \"children\"> {\n /** How many items are selected. The bar shows only while this is above zero. */\n count: number;\n /** Clear the selection / leave selection mode. */\n onClear: () => void;\n /** The actions: buttons (`IconButton size=\"lg\"` on a phone, `Button` from md up),\n * in the caller's order. They are the toolbar's arrow-key stops, after the clear. */\n children?: ReactNode;\n /** Default `floating`. See {@link BulkActionBarVariant}. */\n variant?: BulkActionBarVariant;\n /** Default: `bulkActionBar` from the {@link UiKitProvider}, else English. */\n labels?: Partial<BulkActionBarLabels>;\n}\n\n/** Where the floating bar sits: clear of the phone nav and of the home indicator,\n * whichever reaches higher — `FloatingPanel`'s rule, plus the bar's own 0.5rem gap.\n * Set as inline style, so reposition it through `style` (which is merged over this,\n * e.g. `style={{ insetInline: \"auto 2rem\", width: \"24rem\" }}`), not `className`. */\nconst FLOATING_STYLE: CSSProperties = {\n bottom: \"calc(max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px)) + 0.5rem)\",\n // Both insets on both sides: a logical side has no one physical inset to name, and\n // the bar is symmetric, so RTL needs nothing more.\n insetInline: \"max(0.5rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))\",\n};\n\n/** What the arrow keys walk: the toolbar's enabled controls. Matched by element, not by\n * `tabindex`, because the roving below sets every one but the current to `-1` — a\n * custom control joins with `data-toolbar-item`. */\nconst ITEMS =\n \"button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[data-toolbar-item]\";\n\n/** Keys a text field needs for itself — the toolbar must not take its caret away. */\nfunction ownsArrows(el: Element): boolean {\n if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) return true;\n if (el instanceof HTMLInputElement) return ![\"button\", \"checkbox\", \"radio\", \"submit\", \"reset\"].includes(el.type);\n return (el as HTMLElement).isContentEditable;\n}\n\n/**\n * The bar that appears while rows are selected: the count, a way out, and what can be\n * done to all of them at once.\n *\n * keksdose had written it three times — the transactions phone bar\n * (mobile-bulk-bar.tsx), the invoice lines' phone and desktop bars\n * (invoice-lines-bulk-bar.tsx) and the payees selection bar — and the copies had\n * drifted where it matters: two parked themselves with a guessed `bottom-16` that the\n * third had already found leaves a 24px gap (live #314: the nav is content-sized and\n * `AppShell` publishes its height as `--app-nav-h`), none of them announced the count,\n * and all three were `role=\"toolbar\"` without the arrow keys that role promises.\n *\n * ## Keep it mounted\n *\n * Render it unconditionally and let `count` decide. At zero it renders only its live\n * region — which must already be in the DOM for the first \"3 selected\" to be heard —\n * and when the count drops to zero it plays its exit before it goes, which a\n * `{count > 0 && <Bar/>}` gate would cut off.\n *\n * ## The toolbar\n *\n * One Tab stop: ←/→ (mirrored in RTL), Home and End move between the controls, and Tab\n * leaves — the WAI-ARIA toolbar pattern, since that is what `role=\"toolbar\"` tells a\n * screen reader to expect. The stop remembers the control last used. Arrow keys inside\n * a text field stay the field's.\n *\n * It is NOT a dialog: no focus trap, no scroll lock — the list behind stays live,\n * because tapping more rows is how a selection grows.\n */\nexport function BulkActionBar({\n count,\n onClear,\n children,\n variant = \"floating\",\n labels: labelsProp,\n className,\n style,\n onKeyDown,\n onFocus,\n ...rest\n}: BulkActionBarProps) {\n const labels = useKitLabels(\"bulkActionBar\", DEFAULT_BULK_ACTION_BAR_LABELS, labelsProp);\n const { announce, regionProps } = useAnnounce();\n const barRef = useRef<HTMLDivElement>(null);\n const shown = count > 0;\n\n // The count the bar last SHOWED, so a bar lowering itself after \"clear\" still reads\n // \"3 selected\" on its way out rather than \"0 selected\". Adjusted during render\n // (`Collapse`'s pattern), as is the exit below.\n const [shownCount, setShownCount] = useState(count);\n const [leaving, setLeaving] = useState(false);\n const [prevCount, setPrevCount] = useState(count);\n if (count !== prevCount) {\n setPrevCount(count);\n if (shown) {\n setShownCount(count);\n setLeaving(false);\n } else {\n setLeaving(prevCount > 0 && !prefersReducedMotion());\n }\n }\n useEffect(() => {\n if (!leaving) return;\n const timer = setTimeout(() => setLeaving(false), OVERLAY_EXIT_MS);\n return () => clearTimeout(timer);\n }, [leaving]);\n\n // Spoken on every CHANGE, not on mount: a bar mounted with rows already selected\n // was not the result of anything the user just did.\n const spoken = useRef(count);\n useEffect(() => {\n if (spoken.current === count) return;\n spoken.current = count;\n announce(count > 0 ? labels.selected(count) : labels.cleared);\n }, [count, labels, announce]);\n\n // Roving tab stop. The controls are the caller's, so the stop is kept on the DOM\n // rather than through props: after every render the remembered control (or the\n // first) is the one with `tabindex=0`, every other one `-1`.\n const active = useRef<HTMLElement | null>(null);\n // Every render: the caller's controls come and go (a \"remove added\" that exists\n // only while some are removable), and a stop on a control that left must move on.\n useLayoutEffect(() => {\n rove(barRef.current, active);\n });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(e);\n if (e.defaultPrevented || ownsArrows(e.target as Element)) return;\n const list = itemsOf(barRef.current);\n const at = list.indexOf(document.activeElement as HTMLElement);\n if (at === -1 || list.length === 0) return;\n const step = horizontalStep(e.key, barRef.current);\n let next = -1;\n if (step !== 0) next = (at + step + list.length) % list.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = list.length - 1;\n if (next === -1) return;\n e.preventDefault();\n active.current = list[next];\n rove(barRef.current, active);\n list[next].focus();\n };\n\n if (!shown && !leaving) {\n return <span {...regionProps} />;\n }\n\n const floating = variant === \"floating\";\n const clear = floating ? (\n <IconButton size=\"lg\" onClick={onClear} aria-label={labels.clear}>\n <X />\n </IconButton>\n ) : (\n <Button type=\"button\" variant=\"secondary\" size=\"sm\" onClick={onClear}>\n {labels.clear}\n </Button>\n );\n\n return (\n <>\n <span {...regionProps} />\n <div\n {...rest}\n ref={barRef}\n role=\"toolbar\"\n aria-label={labels.selected(shownCount)}\n aria-orientation=\"horizontal\"\n data-bulk-action-bar={variant}\n // Leaving: out of the tab order and the accessibility tree at once, while the\n // bar still animates. The selection it acted on is already gone.\n inert={!shown || undefined}\n onKeyDown={handleKeyDown}\n onFocus={(e) => {\n onFocus?.(e);\n // Clicking a control is using it: the stop follows the pointer too.\n const hit = itemsOf(barRef.current).find((el) => el === e.target);\n if (hit) {\n active.current = hit;\n rove(barRef.current, active);\n }\n }}\n style={floating ? { ...FLOATING_STYLE, ...style } : style}\n className={cn(\n \"flex items-center\",\n floating\n ? cn(\n \"fixed z-30 gap-1 rounded-2xl border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 shadow-lg\",\n shown ? \"animate-sheet\" : \"animate-sheet-out\",\n )\n : cn(\n \"gap-2 px-3 py-2\",\n variant === \"sticky\" &&\n \"sticky top-0 z-20 border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--brand)_8%,var(--bg-surface))] backdrop-blur\",\n shown ? \"animate-overlay\" : \"animate-overlay-out\",\n ),\n className,\n )}\n >\n {floating && clear}\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium text-[var(--text-primary)]\">\n {labels.selected(shownCount)}\n </span>\n {/* `contents`, so the caller's controls are flex items of the bar itself. */}\n <div className=\"contents\">{children}</div>\n {!floating && clear}\n </div>\n </>\n );\n}\n\nfunction itemsOf(bar: HTMLElement | null): HTMLElement[] {\n return bar ? Array.from(bar.querySelectorAll<HTMLElement>(ITEMS)) : [];\n}\n\n/** Put the one Tab stop on the remembered control — or the first, when that one has\n * gone or was never set — and take it off every other. */\nfunction rove(bar: HTMLElement | null, active: { current: HTMLElement | null }): void {\n const list = itemsOf(bar);\n if (!active.current || !list.includes(active.current)) active.current = list[0] ?? null;\n for (const el of list) el.tabIndex = el === active.current ? 0 : -1;\n}\n"],"mappings":";AAmLW,SAeP,UAfO,KAiBL,YAjBK;AAnLX,SAAS,WAAW,iBAAiB,QAAQ,gBAAgB;AAE7D,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,sBAAsB;AAC/B,SAAS,oBAAoB;AAC7B,SAAS,mBAAmB;AAC5B,SAAS,iBAAiB,4BAA4B;AACtD,SAAS,QAAQ,kBAAkB;AAa5B,MAAM,iCAAsD;AAAA,EACjE,UAAU,CAAC,UAAU,GAAG,KAAK;AAAA,EAC7B,OAAO;AAAA,EACP,SAAS;AACX;AA4BA,MAAM,iBAAgC;AAAA,EACpC,QAAQ;AAAA;AAAA;AAAA,EAGR,aAAa;AACf;AAKA,MAAM,QACJ;AAGF,SAAS,WAAW,IAAsB;AACxC,MAAI,cAAc,uBAAuB,cAAc,kBAAmB,QAAO;AACjF,MAAI,cAAc,iBAAkB,QAAO,CAAC,CAAC,UAAU,YAAY,SAAS,UAAU,OAAO,EAAE,SAAS,GAAG,IAAI;AAC/G,SAAQ,GAAmB;AAC7B;AA+BO,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAuB;AACrB,QAAM,SAAS,aAAa,iBAAiB,gCAAgC,UAAU;AACvF,QAAM,EAAE,UAAU,YAAY,IAAI,YAAY;AAC9C,QAAM,SAAS,OAAuB,IAAI;AAC1C,QAAM,QAAQ,QAAQ;AAKtB,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,KAAK;AAClD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAC5C,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,KAAK;AAChD,MAAI,UAAU,WAAW;AACvB,iBAAa,KAAK;AAClB,QAAI,OAAO;AACT,oBAAc,KAAK;AACnB,iBAAW,KAAK;AAAA,IAClB,OAAO;AACL,iBAAW,YAAY,KAAK,CAAC,qBAAqB,CAAC;AAAA,IACrD;AAAA,EACF;AACA,YAAU,MAAM;AACd,QAAI,CAAC,QAAS;AACd,UAAM,QAAQ,WAAW,MAAM,WAAW,KAAK,GAAG,eAAe;AACjE,WAAO,MAAM,aAAa,KAAK;AAAA,EACjC,GAAG,CAAC,OAAO,CAAC;AAIZ,QAAM,SAAS,OAAO,KAAK;AAC3B,YAAU,MAAM;AACd,QAAI,OAAO,YAAY,MAAO;AAC9B,WAAO,UAAU;AACjB,aAAS,QAAQ,IAAI,OAAO,SAAS,KAAK,IAAI,OAAO,OAAO;AAAA,EAC9D,GAAG,CAAC,OAAO,QAAQ,QAAQ,CAAC;AAK5B,QAAM,SAAS,OAA2B,IAAI;AAG9C,kBAAgB,MAAM;AACpB,SAAK,OAAO,SAAS,MAAM;AAAA,EAC7B,CAAC;AAED,QAAM,gBAAgB,CAAC,MAAqC;AAC1D,gBAAY,CAAC;AACb,QAAI,EAAE,oBAAoB,WAAW,EAAE,MAAiB,EAAG;AAC3D,UAAM,OAAO,QAAQ,OAAO,OAAO;AACnC,UAAM,KAAK,KAAK,QAAQ,SAAS,aAA4B;AAC7D,QAAI,OAAO,MAAM,KAAK,WAAW,EAAG;AACpC,UAAM,OAAO,eAAe,EAAE,KAAK,OAAO,OAAO;AACjD,QAAI,OAAO;AACX,QAAI,SAAS,EAAG,SAAQ,KAAK,OAAO,KAAK,UAAU,KAAK;AAAA,aAC/C,EAAE,QAAQ,OAAQ,QAAO;AAAA,aACzB,EAAE,QAAQ,MAAO,QAAO,KAAK,SAAS;AAC/C,QAAI,SAAS,GAAI;AACjB,MAAE,eAAe;AACjB,WAAO,UAAU,KAAK,IAAI;AAC1B,SAAK,OAAO,SAAS,MAAM;AAC3B,SAAK,IAAI,EAAE,MAAM;AAAA,EACnB;AAEA,MAAI,CAAC,SAAS,CAAC,SAAS;AACtB,WAAO,oBAAC,UAAM,GAAG,aAAa;AAAA,EAChC;AAEA,QAAM,WAAW,YAAY;AAC7B,QAAM,QAAQ,WACZ,oBAAC,cAAW,MAAK,MAAK,SAAS,SAAS,cAAY,OAAO,OACzD,8BAAC,KAAE,GACL,IAEA,oBAAC,UAAO,MAAK,UAAS,SAAQ,aAAY,MAAK,MAAK,SAAS,SAC1D,iBAAO,OACV;AAGF,SACE,iCACE;AAAA,wBAAC,UAAM,GAAG,aAAa;AAAA,IACvB;AAAA,MAAC;AAAA;AAAA,QACE,GAAG;AAAA,QACJ,KAAK;AAAA,QACL,MAAK;AAAA,QACL,cAAY,OAAO,SAAS,UAAU;AAAA,QACtC,oBAAiB;AAAA,QACjB,wBAAsB;AAAA,QAGtB,OAAO,CAAC,SAAS;AAAA,QACjB,WAAW;AAAA,QACX,SAAS,CAAC,MAAM;AACd,oBAAU,CAAC;AAEX,gBAAM,MAAM,QAAQ,OAAO,OAAO,EAAE,KAAK,CAAC,OAAO,OAAO,EAAE,MAAM;AAChE,cAAI,KAAK;AACP,mBAAO,UAAU;AACjB,iBAAK,OAAO,SAAS,MAAM;AAAA,UAC7B;AAAA,QACF;AAAA,QACA,OAAO,WAAW,EAAE,GAAG,gBAAgB,GAAG,MAAM,IAAI;AAAA,QACpD,WAAW;AAAA,UACT;AAAA,UACA,WACI;AAAA,YACE;AAAA,YACA,QAAQ,kBAAkB;AAAA,UAC5B,IACA;AAAA,YACE;AAAA,YACA,YAAY,YACV;AAAA,YACF,QAAQ,oBAAoB;AAAA,UAC9B;AAAA,UACJ;AAAA,QACF;AAAA,QAEC;AAAA,sBAAY;AAAA,UACb,oBAAC,UAAK,WAAU,0EACb,iBAAO,SAAS,UAAU,GAC7B;AAAA,UAEA,oBAAC,SAAI,WAAU,YAAY,UAAS;AAAA,UACnC,CAAC,YAAY;AAAA;AAAA;AAAA,IAChB;AAAA,KACF;AAEJ;AAEA,SAAS,QAAQ,KAAwC;AACvD,SAAO,MAAM,MAAM,KAAK,IAAI,iBAA8B,KAAK,CAAC,IAAI,CAAC;AACvE;AAIA,SAAS,KAAK,KAAyB,QAA+C;AACpF,QAAM,OAAO,QAAQ,GAAG;AACxB,MAAI,CAAC,OAAO,WAAW,CAAC,KAAK,SAAS,OAAO,OAAO,EAAG,QAAO,UAAU,KAAK,CAAC,KAAK;AACnF,aAAW,MAAM,KAAM,IAAG,WAAW,OAAO,OAAO,UAAU,IAAI;AACnE;","names":[]}
|
|
@@ -34,6 +34,10 @@ import '../feedback/feedback-dialog.js';
|
|
|
34
34
|
import './confirm-dialog.js';
|
|
35
35
|
import './floating-panel.js';
|
|
36
36
|
import './copy-button.js';
|
|
37
|
+
import './bulk-action-bar.js';
|
|
38
|
+
import './list.js';
|
|
39
|
+
import './breadcrumbs.js';
|
|
40
|
+
import './toast.js';
|
|
37
41
|
|
|
38
42
|
/** Screen-reader names for the calculator panel's own controls — any part of the
|
|
39
43
|
* kit's `calculator` namespace. Once three optional keys of its own; widened, not
|
|
@@ -16,9 +16,21 @@ import { LucideIcon } from 'lucide-react';
|
|
|
16
16
|
* "choose one of eight things to do" and a row of eight chips says "here are eight
|
|
17
17
|
* things". Using `Button` for both is the mistake this exists to prevent.
|
|
18
18
|
*/
|
|
19
|
-
type ChipTone = "neutral" | "brand" | "danger" | "warning" | "success" | "info" | "income" | "expense";
|
|
20
|
-
/**
|
|
21
|
-
|
|
19
|
+
type ChipTone = "neutral" | "brand" | "danger" | "warning" | "success" | "info" | "income" | "expense" | ChipHue;
|
|
20
|
+
/**
|
|
21
|
+
* Categorical hues (0.10.0): colour that says "a different value" and nothing more —
|
|
22
|
+
* kastlan's status-badge.tsx paints ~15 enums in 9 Tailwind hues by hand. Four of those
|
|
23
|
+
* nine are semantic and already tones (green = `success`, yellow = `warning`, red =
|
|
24
|
+
* `danger`, gray = `neutral`); these five are the rest, each a `--hue-*` token triple
|
|
25
|
+
* in tokens.css with a light and a dark value. Use a semantic tone when the value HAS a
|
|
26
|
+
* meaning (overdue is `danger`), a hue when it only needs telling apart (sent vs
|
|
27
|
+
* accepted vs invoiced).
|
|
28
|
+
*/
|
|
29
|
+
type ChipHue = "blue" | "indigo" | "purple" | "teal" | "orange";
|
|
30
|
+
/** `lg` is the 44px touch target (`min-h-11`) a phone surface wants; `md` is ~28px.
|
|
31
|
+
* `xs` (0.10.0) is 11px type at `px-1.5` — keksdose's goal markers and budget-cell
|
|
32
|
+
* badges, which sit inside a figure's line and must not make it taller. */
|
|
33
|
+
type ChipSize = "xs" | "sm" | "md" | "lg";
|
|
22
34
|
/**
|
|
23
35
|
* How much of the tone the chip wears.
|
|
24
36
|
*
|
|
@@ -29,8 +41,12 @@ type ChipSize = "sm" | "md" | "lg";
|
|
|
29
41
|
* - `solid`: the tone as a fill under contrasting text — the "count" pill (unread
|
|
30
42
|
* messages, open tasks). It centres its content and sets figures tabular, so a
|
|
31
43
|
* one-digit count is a round dot and "9" → "10" does not jiggle the row.
|
|
44
|
+
* - `dot` (0.10.0): no pill at all — a dot in the tone's colour before plain text, for
|
|
45
|
+
* a status in a dense list or a table column where eight tinted pills in a column
|
|
46
|
+
* would outshout the figures beside them (kastlan's unit picker already draws its
|
|
47
|
+
* UnitStatus this way, from a separate `dotColor` map).
|
|
32
48
|
*/
|
|
33
|
-
type ChipVariant = "soft" | "outline" | "solid";
|
|
49
|
+
type ChipVariant = "soft" | "outline" | "solid" | "dot";
|
|
34
50
|
/** `pill` (default) is fully rounded; `square` has the small radius of a field or a
|
|
35
51
|
* button — for a chip that sits in a table cell or beside square controls. */
|
|
36
52
|
type ChipShape = "pill" | "square";
|
|
@@ -93,6 +109,16 @@ interface ChipBaseProps {
|
|
|
93
109
|
* it — "+ Outflow" in green next to an inflow (keksdose #417).
|
|
94
110
|
*/
|
|
95
111
|
selected?: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Checkbox mode (0.10.0), for a toggle chip (`onClick` + `selected`): a small box
|
|
114
|
+
* at the start that shows a tick when `selected`, and the button reports
|
|
115
|
+
* `role="checkbox"` + `aria-checked` instead of `aria-pressed`. For a set of
|
|
116
|
+
* include/exclude choices where "on" must read as TICKED, not merely tinted —
|
|
117
|
+
* keksdose's add-group-card (the "which categories come along" pills), which put a
|
|
118
|
+
* real `<Checkbox>` inside a `<label>` pill by hand. The box is drawn in both
|
|
119
|
+
* states so ticking one never shifts its label.
|
|
120
|
+
*/
|
|
121
|
+
checkbox?: boolean;
|
|
96
122
|
/** Renders a dismiss affordance. Works alongside `href`/`onClick` — see the note below. */
|
|
97
123
|
onRemove?: () => void;
|
|
98
124
|
/** Accessible name for the dismiss button. Default: `common.remove` from the
|
|
@@ -247,4 +273,4 @@ interface ChipInputProps extends Omit<ComponentPropsWithoutRef<"div">, "onChange
|
|
|
247
273
|
*/
|
|
248
274
|
declare function ChipInput({ value, onChange, placeholder, label, ariaLabel, validate, separators, allowDuplicates, max, tone, size, disabled, invalid, error, labels, className, "aria-label": ariaLabelAttr, ...rest }: ChipInputProps): react.JSX.Element;
|
|
249
275
|
|
|
250
|
-
export { Chip, ChipInput, type ChipInputLabels, type ChipInputProps, type ChipLinkProps, type ChipProps, type ChipShape, type ChipSize, type ChipTone, type ChipVariant, DEFAULT_CHIP_INPUT_LABELS, resolveChipInputLabels };
|
|
276
|
+
export { Chip, type ChipHue, ChipInput, type ChipInputLabels, type ChipInputProps, type ChipLinkProps, type ChipProps, type ChipShape, type ChipSize, type ChipTone, type ChipVariant, DEFAULT_CHIP_INPUT_LABELS, resolveChipInputLabels };
|
package/dist/components/chip.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { forwardRef, useId, useRef, useState } from "react";
|
|
4
|
-
import { X } from "lucide-react";
|
|
4
|
+
import { Check, X } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/cn.js";
|
|
6
6
|
import { horizontalStep } from "../lib/direction.js";
|
|
7
7
|
import { FIELD_INVALID } from "./ui.js";
|
|
@@ -42,6 +42,28 @@ const TONE = {
|
|
|
42
42
|
expense: {
|
|
43
43
|
idle: "border-current/40 bg-[var(--bg-surface-2)] text-[var(--money-expense)]",
|
|
44
44
|
selected: "border-current bg-[var(--bg-active)] text-[var(--money-expense)]"
|
|
45
|
+
},
|
|
46
|
+
// The categorical hues: the `info` recipe with its own token triple, so a hue chip
|
|
47
|
+
// and a status chip in one row carry the same weight.
|
|
48
|
+
blue: {
|
|
49
|
+
idle: "border-[var(--hue-blue-border)] bg-[var(--hue-blue-bg)] text-[var(--hue-blue)]",
|
|
50
|
+
selected: "border-[var(--hue-blue)] bg-[var(--hue-blue-bg)] text-[var(--hue-blue)]"
|
|
51
|
+
},
|
|
52
|
+
indigo: {
|
|
53
|
+
idle: "border-[var(--hue-indigo-border)] bg-[var(--hue-indigo-bg)] text-[var(--hue-indigo)]",
|
|
54
|
+
selected: "border-[var(--hue-indigo)] bg-[var(--hue-indigo-bg)] text-[var(--hue-indigo)]"
|
|
55
|
+
},
|
|
56
|
+
purple: {
|
|
57
|
+
idle: "border-[var(--hue-purple-border)] bg-[var(--hue-purple-bg)] text-[var(--hue-purple)]",
|
|
58
|
+
selected: "border-[var(--hue-purple)] bg-[var(--hue-purple-bg)] text-[var(--hue-purple)]"
|
|
59
|
+
},
|
|
60
|
+
teal: {
|
|
61
|
+
idle: "border-[var(--hue-teal-border)] bg-[var(--hue-teal-bg)] text-[var(--hue-teal)]",
|
|
62
|
+
selected: "border-[var(--hue-teal)] bg-[var(--hue-teal-bg)] text-[var(--hue-teal)]"
|
|
63
|
+
},
|
|
64
|
+
orange: {
|
|
65
|
+
idle: "border-[var(--hue-orange-border)] bg-[var(--hue-orange-bg)] text-[var(--hue-orange)]",
|
|
66
|
+
selected: "border-[var(--hue-orange)] bg-[var(--hue-orange-bg)] text-[var(--hue-orange)]"
|
|
45
67
|
}
|
|
46
68
|
};
|
|
47
69
|
const OUTLINE = {
|
|
@@ -52,7 +74,12 @@ const OUTLINE = {
|
|
|
52
74
|
success: "border-[var(--success-border)] bg-transparent text-[var(--success)]",
|
|
53
75
|
info: "border-[var(--info-border)] bg-transparent text-[var(--info)]",
|
|
54
76
|
income: "border-current/60 bg-transparent text-[var(--money-income)]",
|
|
55
|
-
expense: "border-current/60 bg-transparent text-[var(--money-expense)]"
|
|
77
|
+
expense: "border-current/60 bg-transparent text-[var(--money-expense)]",
|
|
78
|
+
blue: "border-[var(--hue-blue-border)] bg-transparent text-[var(--hue-blue)]",
|
|
79
|
+
indigo: "border-[var(--hue-indigo-border)] bg-transparent text-[var(--hue-indigo)]",
|
|
80
|
+
purple: "border-[var(--hue-purple-border)] bg-transparent text-[var(--hue-purple)]",
|
|
81
|
+
teal: "border-[var(--hue-teal-border)] bg-transparent text-[var(--hue-teal)]",
|
|
82
|
+
orange: "border-[var(--hue-orange-border)] bg-transparent text-[var(--hue-orange)]"
|
|
56
83
|
};
|
|
57
84
|
const SOLID = {
|
|
58
85
|
neutral: "border-transparent bg-[var(--bg-inverse)] text-[var(--text-inverse)]",
|
|
@@ -62,15 +89,40 @@ const SOLID = {
|
|
|
62
89
|
success: "border-transparent bg-[var(--success)] text-[var(--text-inverse)]",
|
|
63
90
|
info: "border-transparent bg-[var(--info)] text-[var(--text-inverse)]",
|
|
64
91
|
income: "border-transparent bg-[var(--money-income)] text-[var(--text-inverse)]",
|
|
65
|
-
expense: "border-transparent bg-[var(--money-expense)] text-[var(--text-inverse)]"
|
|
92
|
+
expense: "border-transparent bg-[var(--money-expense)] text-[var(--text-inverse)]",
|
|
93
|
+
blue: "border-transparent bg-[var(--hue-blue)] text-[var(--text-inverse)]",
|
|
94
|
+
indigo: "border-transparent bg-[var(--hue-indigo)] text-[var(--text-inverse)]",
|
|
95
|
+
purple: "border-transparent bg-[var(--hue-purple)] text-[var(--text-inverse)]",
|
|
96
|
+
teal: "border-transparent bg-[var(--hue-teal)] text-[var(--text-inverse)]",
|
|
97
|
+
orange: "border-transparent bg-[var(--hue-orange)] text-[var(--text-inverse)]"
|
|
98
|
+
};
|
|
99
|
+
const DOT = {
|
|
100
|
+
neutral: "bg-[var(--text-muted)]",
|
|
101
|
+
brand: "bg-[var(--brand)]",
|
|
102
|
+
danger: "bg-[var(--danger)]",
|
|
103
|
+
warning: "bg-[var(--warning)]",
|
|
104
|
+
success: "bg-[var(--success)]",
|
|
105
|
+
info: "bg-[var(--info)]",
|
|
106
|
+
income: "bg-[var(--money-income)]",
|
|
107
|
+
expense: "bg-[var(--money-expense)]",
|
|
108
|
+
blue: "bg-[var(--hue-blue)]",
|
|
109
|
+
indigo: "bg-[var(--hue-indigo)]",
|
|
110
|
+
purple: "bg-[var(--hue-purple)]",
|
|
111
|
+
teal: "bg-[var(--hue-teal)]",
|
|
112
|
+
orange: "bg-[var(--hue-orange)]"
|
|
66
113
|
};
|
|
114
|
+
const DOT_SIZE = { xs: "size-1.5", sm: "size-2", md: "size-2", lg: "size-2.5" };
|
|
67
115
|
const CAPS = {
|
|
116
|
+
xs: "text-[10px] font-semibold uppercase tracking-wider",
|
|
68
117
|
sm: "text-[10px] font-semibold uppercase tracking-wider",
|
|
69
118
|
md: "text-[11px] font-semibold uppercase tracking-wider",
|
|
70
119
|
lg: "text-xs font-semibold uppercase tracking-wider"
|
|
71
120
|
};
|
|
72
121
|
function surfaceOf(tone, variant, selected) {
|
|
73
122
|
const palette = TONE[tone];
|
|
123
|
+
if (variant === "dot") {
|
|
124
|
+
return selected ? "border-transparent bg-transparent font-medium text-[var(--text-primary)]" : "border-transparent bg-transparent text-[var(--text-secondary)]";
|
|
125
|
+
}
|
|
74
126
|
if (variant === "solid") {
|
|
75
127
|
return cn(SOLID[tone], selected && "ring-2 ring-[var(--border-strong)] ring-offset-1 ring-offset-[var(--bg-surface)]");
|
|
76
128
|
}
|
|
@@ -78,6 +130,13 @@ function surfaceOf(tone, variant, selected) {
|
|
|
78
130
|
return variant === "outline" ? OUTLINE[tone] : palette.idle;
|
|
79
131
|
}
|
|
80
132
|
const SIZE = {
|
|
133
|
+
xs: {
|
|
134
|
+
body: "gap-1 px-1.5 py-0 text-[11px] leading-4",
|
|
135
|
+
split: "gap-1 ps-1.5 pe-0.5 py-0 text-[11px] leading-4",
|
|
136
|
+
tail: "pe-1",
|
|
137
|
+
icon: "size-3",
|
|
138
|
+
remove: "size-2.5"
|
|
139
|
+
},
|
|
81
140
|
sm: {
|
|
82
141
|
body: "gap-1 px-2 py-0.5 text-xs",
|
|
83
142
|
split: "gap-1 ps-2 pe-1 py-0.5 text-xs",
|
|
@@ -112,6 +171,7 @@ const Chip = forwardRef(function Chip2({
|
|
|
112
171
|
caps = false,
|
|
113
172
|
icon: Icon,
|
|
114
173
|
selected,
|
|
174
|
+
checkbox = false,
|
|
115
175
|
href,
|
|
116
176
|
renderLink,
|
|
117
177
|
onClick,
|
|
@@ -129,7 +189,8 @@ const Chip = forwardRef(function Chip2({
|
|
|
129
189
|
const common = useKitLabels("common", DEFAULT_COMMON_LABELS, { remove: removeLabel });
|
|
130
190
|
const s = SIZE[size];
|
|
131
191
|
const interactive = !!href || !!onClick;
|
|
132
|
-
const radius = shape === "square" ? size === "sm" ? "rounded" : "rounded-md" : "rounded-full";
|
|
192
|
+
const radius = shape === "square" ? size === "sm" || size === "xs" ? "rounded" : "rounded-md" : "rounded-full";
|
|
193
|
+
const dot = variant === "dot";
|
|
133
194
|
const type = cn(caps && CAPS[size], variant === "solid" && "justify-center tabular-nums");
|
|
134
195
|
const surface = cn(
|
|
135
196
|
surfaceOf(tone, variant, selected),
|
|
@@ -142,9 +203,26 @@ const Chip = forwardRef(function Chip2({
|
|
|
142
203
|
radius,
|
|
143
204
|
surface,
|
|
144
205
|
interactive && !disabled && "cursor-pointer hover:brightness-[0.97] dark:hover:brightness-110",
|
|
206
|
+
// An inert dot chip is text in a column: no inset, so it aligns with the header.
|
|
207
|
+
dot && !interactive && "px-0",
|
|
145
208
|
className
|
|
146
209
|
);
|
|
210
|
+
const isCheckbox = checkbox && !!onClick && !href;
|
|
147
211
|
const body = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
212
|
+
isCheckbox && /* @__PURE__ */ jsx(
|
|
213
|
+
"span",
|
|
214
|
+
{
|
|
215
|
+
"aria-hidden": true,
|
|
216
|
+
"data-chip-check": "",
|
|
217
|
+
className: cn(
|
|
218
|
+
"flex shrink-0 items-center justify-center rounded-sm border",
|
|
219
|
+
s.icon,
|
|
220
|
+
selected ? "border-current bg-current/15" : "border-current/50"
|
|
221
|
+
),
|
|
222
|
+
children: selected && /* @__PURE__ */ jsx(Check, { className: "size-full", strokeWidth: 3 })
|
|
223
|
+
}
|
|
224
|
+
),
|
|
225
|
+
dot && /* @__PURE__ */ jsx("span", { "aria-hidden": true, "data-chip-dot": "", className: cn("shrink-0 rounded-full", DOT_SIZE[size], DOT[tone]) }),
|
|
148
226
|
Icon && /* @__PURE__ */ jsx(Icon, { className: cn(s.icon, "shrink-0"), "aria-hidden": true }),
|
|
149
227
|
/* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children })
|
|
150
228
|
] });
|
|
@@ -223,7 +301,9 @@ const Chip = forwardRef(function Chip2({
|
|
|
223
301
|
type: "button",
|
|
224
302
|
onClick: onButtonClick,
|
|
225
303
|
disabled,
|
|
226
|
-
"
|
|
304
|
+
role: isCheckbox ? "checkbox" : void 0,
|
|
305
|
+
"aria-pressed": isCheckbox ? void 0 : selected,
|
|
306
|
+
"aria-checked": isCheckbox ? !!selected : void 0,
|
|
227
307
|
className: remove ? inner : look,
|
|
228
308
|
...rest,
|
|
229
309
|
children: body
|