@eifi1/ui-kit 0.15.4 → 0.16.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 +20 -15
- package/dist/chart.d.ts +3 -4
- package/dist/components/alert-banner.js +1 -1
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +13 -4
- package/dist/components/amount-input.js +166 -157
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.d.ts +14 -3
- package/dist/components/authed-image.js +32 -23
- package/dist/components/authed-image.js.map +1 -1
- package/dist/components/button-group.d.ts +3 -4
- package/dist/components/calculator.d.ts +3 -4
- package/dist/components/checkbox.js +1 -1
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/choice-card.js +1 -1
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/copy-button.d.ts +3 -4
- package/dist/components/error-boundary.js +1 -1
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/facing-pair.d.ts +3 -4
- package/dist/components/file-button.d.ts +3 -4
- package/dist/components/file-button.js +64 -31
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +3 -4
- package/dist/components/file-dropzone.js +1 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/form-actions.d.ts +4 -5
- package/dist/components/form-actions.js +18 -6
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/line-items.d.ts +53 -2
- package/dist/components/line-items.js +73 -7
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +99 -5
- package/dist/components/list.js +103 -38
- package/dist/components/list.js.map +1 -1
- package/dist/components/loading-state.d.ts +19 -3
- package/dist/components/loading-state.js +14 -3
- package/dist/components/loading-state.js.map +1 -1
- package/dist/components/money-field.d.ts +3 -4
- package/dist/components/number-field.d.ts +3 -4
- package/dist/components/number-input.d.ts +3 -4
- package/dist/components/number-input.js +4 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +3 -4
- package/dist/components/passkeys-setting.d.ts +30 -10
- package/dist/components/passkeys-setting.js +19 -11
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +12 -3
- package/dist/components/progress-bar.js +21 -11
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart.d.ts +3 -4
- package/dist/components/settings-fields.d.ts +3 -4
- package/dist/components/signed-amount.d.ts +28 -4
- package/dist/components/signed-amount.js +17 -8
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/stat-tile.d.ts +31 -2
- package/dist/components/stat-tile.js +37 -6
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/status-dot.d.ts +4 -1
- package/dist/components/status-dot.js +20 -1
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +3 -1
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/table.d.ts +18 -13
- package/dist/components/table.js +20 -5
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +3 -4
- package/dist/components/time-input.d.ts +3 -4
- package/dist/components/toggle-legend.d.ts +12 -1
- package/dist/components/toggle-legend.js +6 -2
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/ui.d.ts +3 -4
- package/dist/components/ui.js +156 -52
- package/dist/components/ui.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +2 -82
- package/dist/feedback/feedback-attachment.js +285 -55
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +2 -105
- package/dist/feedback/feedback-dialog.js +57 -25
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +1 -1
- package/dist/feedback/feedback-thread.d.ts +2 -2
- package/dist/feedback-attachment-WFiXJ8We.d.ts +302 -0
- package/dist/feedback.d.ts +1 -2
- package/dist/hooks/use-file-drop.d.ts +3 -4
- package/dist/i18n/defaults.d.ts +3 -4
- package/dist/i18n/kit-labels.d.ts +2 -3
- package/dist/i18n/locales/de-CH-informal.d.ts +3 -4
- package/dist/i18n/locales/de-CH.d.ts +3 -4
- package/dist/i18n/locales/de-informal.d.ts +3 -4
- package/dist/i18n/locales/de.d.ts +3 -4
- package/dist/i18n/locales/de.js +5 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +3 -4
- package/dist/i18n/locales/es.js +5 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +3 -4
- package/dist/i18n/locales/fr.js +5 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +3 -4
- package/dist/i18n/locales/hu.js +5 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +3 -4
- package/dist/i18n/locales/it.js +5 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +3 -4
- package/dist/i18n/locales/zh.js +5 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +10 -11
- package/dist/{kit-labels-CnnBd-2N.d.ts → kit-labels-DE--LmCk.d.ts} +154 -14
- package/dist/lib/format.d.ts +18 -3
- package/dist/lib/format.js +2 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/rhf/fields.d.ts +3 -4
- package/dist/rhf/form.d.ts +3 -4
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf.d.ts +3 -4
- package/dist/shell/app-shell.d.ts +3 -4
- package/dist/shell/top-bar-brand.d.ts +3 -4
- package/dist/shell.d.ts +3 -4
- package/dist/theme/palette-derive.d.ts +3 -0
- package/dist/theme/palette-derive.js +7 -0
- package/dist/theme/palette-derive.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +3 -4
- package/dist/wizard.d.ts +3 -4
- package/package.json +4 -2
- package/src/components/alert-banner.tsx +1 -1
- package/src/components/amount-input.tsx +47 -16
- package/src/components/authed-image.tsx +20 -2
- package/src/components/checkbox.tsx +1 -1
- package/src/components/choice-card.tsx +1 -1
- package/src/components/error-boundary.tsx +1 -1
- package/src/components/file-button.tsx +77 -27
- package/src/components/file-dropzone.tsx +1 -1
- package/src/components/form-actions.tsx +86 -8
- package/src/components/line-items.tsx +131 -14
- package/src/components/list.tsx +240 -50
- package/src/components/loading-state.tsx +35 -4
- package/src/components/number-input.tsx +18 -2
- package/src/components/passkeys-setting.tsx +45 -18
- package/src/components/progress-bar.tsx +46 -21
- package/src/components/signed-amount.tsx +47 -8
- package/src/components/stat-tile.tsx +76 -6
- package/src/components/status-dot.tsx +31 -0
- package/src/components/swatch-picker.tsx +3 -1
- package/src/components/table.tsx +42 -15
- package/src/components/toggle-legend.tsx +21 -4
- package/src/components/ui.tsx +280 -70
- package/src/feedback/feedback-attachment.tsx +472 -96
- package/src/feedback/feedback-dialog.tsx +162 -50
- package/src/i18n/locales/de.ts +4 -0
- package/src/i18n/locales/es.ts +4 -0
- package/src/i18n/locales/fr.ts +4 -0
- package/src/i18n/locales/hu.ts +4 -0
- package/src/i18n/locales/it.ts +4 -0
- package/src/i18n/locales/zh.ts +4 -0
- package/src/lib/format.ts +24 -3
- package/src/rhf/line-items.tsx +6 -0
- package/src/theme/palette-derive.ts +10 -0
- package/tokens.css +9 -1
|
@@ -8,9 +8,18 @@ import { Skeleton } from "./skeleton";
|
|
|
8
8
|
import { useAccountSettingsLabels } from "./account-settings-labels";
|
|
9
9
|
import type { PasskeysSettingLabels } from "./account-settings-labels";
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
export
|
|
13
|
-
|
|
11
|
+
/** What a passkey's `id` may be: the app's own key type, string or numeric. */
|
|
12
|
+
export type PasskeyId = string | number;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One registered passkey, as the app's API lists it.
|
|
16
|
+
*
|
|
17
|
+
* Generic in its `id` (0.16.0): `PasskeysSetting` infers `Id` from `passkeys`, so an
|
|
18
|
+
* app whose ids are numbers gets numbers back in `onRename` / `onDelete` — keksdose
|
|
19
|
+
* cast `id as number` in both. Left unparameterised it is the old `string | number`.
|
|
20
|
+
*/
|
|
21
|
+
export interface PasskeyItem<Id extends PasskeyId = PasskeyId> {
|
|
22
|
+
id: Id;
|
|
14
23
|
name: string;
|
|
15
24
|
/** A `Date`, an ISO string or epoch ms. */
|
|
16
25
|
createdAt?: Date | string | number | null;
|
|
@@ -18,9 +27,12 @@ export interface PasskeyItem {
|
|
|
18
27
|
lastUsedAt?: Date | string | number | null;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
|
-
|
|
30
|
+
/** `data-*` attributes, passed through to the element they are given for. */
|
|
31
|
+
export type PasskeyDataAttributes = { [key: `data-${string}`]: string | number | boolean | undefined };
|
|
32
|
+
|
|
33
|
+
export interface PasskeysSettingProps<Id extends PasskeyId = PasskeyId> extends PasskeyDataAttributes {
|
|
22
34
|
/** `undefined` while the first load is in flight (or pass `loading`). */
|
|
23
|
-
passkeys: readonly PasskeyItem[] | undefined;
|
|
35
|
+
passkeys: readonly PasskeyItem<Id>[] | undefined;
|
|
24
36
|
loading?: boolean;
|
|
25
37
|
/**
|
|
26
38
|
* Run the WebAuthn ceremony and register the passkey — app-side, since the options
|
|
@@ -33,11 +45,11 @@ export interface PasskeysSettingProps {
|
|
|
33
45
|
/** The ceremony is running. The add button says so and does not start a second one. */
|
|
34
46
|
adding?: boolean;
|
|
35
47
|
/** Offer "Rename" on each row. Return a promise to leave edit mode only on success. */
|
|
36
|
-
onRename?: (id:
|
|
48
|
+
onRename?: (id: Id, name: string) => void | Promise<unknown>;
|
|
37
49
|
/** Offer "Delete" on each row — always behind an inline confirmation. */
|
|
38
|
-
onDelete?: (id:
|
|
50
|
+
onDelete?: (id: Id) => void | Promise<unknown>;
|
|
39
51
|
/** A row whose rename or delete is in flight; its buttons are disabled. */
|
|
40
|
-
busyId?:
|
|
52
|
+
busyId?: Id | null;
|
|
41
53
|
/** Show the name field above the add button. Default true. */
|
|
42
54
|
nameField?: boolean;
|
|
43
55
|
/**
|
|
@@ -53,6 +65,12 @@ export interface PasskeysSettingProps {
|
|
|
53
65
|
/** Prop > `<UiKitProvider labels={{ accountSettings: { passkeys } }}>` > English. */
|
|
54
66
|
labels?: Partial<PasskeysSettingLabels>;
|
|
55
67
|
className?: string;
|
|
68
|
+
/** On the card (0.16.0), for an in-page link (`#passkeys`) or a test hook. `data-*`
|
|
69
|
+
* attributes go there too. */
|
|
70
|
+
id?: string;
|
|
71
|
+
/** Extra attributes for one row's `<li>` (0.16.0) — an `id` to scroll a just-added
|
|
72
|
+
* key into view, `data-*` for a test. Every row also carries `data-passkey-id`. */
|
|
73
|
+
rowProps?: (item: PasskeyItem<Id>) => ({ id?: string } & PasskeyDataAttributes) | undefined;
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
/**
|
|
@@ -70,7 +88,7 @@ export interface PasskeysSettingProps {
|
|
|
70
88
|
* the passkey it names. Focus moves to Cancel, as in every destructive confirm in the
|
|
71
89
|
* kit, so the Enter that pressed Delete cannot also answer it.
|
|
72
90
|
*/
|
|
73
|
-
export function PasskeysSetting({
|
|
91
|
+
export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
|
|
74
92
|
passkeys,
|
|
75
93
|
loading,
|
|
76
94
|
onAdd,
|
|
@@ -84,7 +102,9 @@ export function PasskeysSetting({
|
|
|
84
102
|
locale: localeProp,
|
|
85
103
|
labels: labelsProp,
|
|
86
104
|
className,
|
|
87
|
-
|
|
105
|
+
rowProps,
|
|
106
|
+
...rest
|
|
107
|
+
}: PasskeysSettingProps<Id>) {
|
|
88
108
|
const labels = useAccountSettingsLabels("passkeys", labelsProp);
|
|
89
109
|
const locale = useKitLocale(localeProp);
|
|
90
110
|
const [name, setName] = useState("");
|
|
@@ -126,6 +146,7 @@ export function PasskeysSetting({
|
|
|
126
146
|
item={item}
|
|
127
147
|
created={date(item.createdAt)}
|
|
128
148
|
lastUsed={date(item.lastUsedAt)}
|
|
149
|
+
attributes={rowProps?.(item)}
|
|
129
150
|
busy={busyId !== undefined && busyId !== null && busyId === item.id}
|
|
130
151
|
onRename={onRename}
|
|
131
152
|
onDelete={onDelete}
|
|
@@ -160,7 +181,8 @@ export function PasskeysSetting({
|
|
|
160
181
|
})();
|
|
161
182
|
|
|
162
183
|
return (
|
|
163
|
-
|
|
184
|
+
// `rest` is only `id` and `data-*`: everything else the props name is destructured.
|
|
185
|
+
<Card {...rest} className={cn("p-4 space-y-3", className)}>
|
|
164
186
|
<div>
|
|
165
187
|
<div className="text-sm font-medium">{labels.title}</div>
|
|
166
188
|
<div className="text-xs text-[var(--text-muted)]">
|
|
@@ -174,21 +196,23 @@ export function PasskeysSetting({
|
|
|
174
196
|
|
|
175
197
|
type RowMode = "view" | "rename" | "delete";
|
|
176
198
|
|
|
177
|
-
function PasskeyRow({
|
|
199
|
+
function PasskeyRow<Id extends PasskeyId>({
|
|
178
200
|
item,
|
|
179
201
|
created,
|
|
180
202
|
lastUsed,
|
|
203
|
+
attributes,
|
|
181
204
|
busy,
|
|
182
205
|
onRename,
|
|
183
206
|
onDelete,
|
|
184
207
|
labels,
|
|
185
208
|
}: {
|
|
186
|
-
item: PasskeyItem
|
|
209
|
+
item: PasskeyItem<Id>;
|
|
187
210
|
created: string | null;
|
|
188
211
|
lastUsed: string | null;
|
|
212
|
+
attributes?: { id?: string } & PasskeyDataAttributes;
|
|
189
213
|
busy: boolean;
|
|
190
|
-
onRename?: PasskeysSettingProps["onRename"];
|
|
191
|
-
onDelete?: PasskeysSettingProps["onDelete"];
|
|
214
|
+
onRename?: PasskeysSettingProps<Id>["onRename"];
|
|
215
|
+
onDelete?: PasskeysSettingProps<Id>["onDelete"];
|
|
192
216
|
labels: PasskeysSettingLabels;
|
|
193
217
|
}) {
|
|
194
218
|
const [mode, setMode] = useState<RowMode>("view");
|
|
@@ -241,13 +265,16 @@ function PasskeyRow({
|
|
|
241
265
|
.catch(() => {});
|
|
242
266
|
};
|
|
243
267
|
|
|
268
|
+
// On every mode's <li>, so an id the app scrolls to survives a rename or a confirm.
|
|
269
|
+
const li = { ...attributes, "data-passkey-id": String(item.id) };
|
|
270
|
+
|
|
244
271
|
const meta = [created && labels.created(created), lastUsed ? labels.lastUsed(lastUsed) : labels.neverUsed]
|
|
245
272
|
.filter(Boolean)
|
|
246
273
|
.join(" · ");
|
|
247
274
|
|
|
248
275
|
if (mode === "rename") {
|
|
249
276
|
return (
|
|
250
|
-
<li className="flex flex-wrap items-center gap-2 py-2">
|
|
277
|
+
<li {...li} className="flex flex-wrap items-center gap-2 py-2">
|
|
251
278
|
<Input
|
|
252
279
|
ref={renameRef}
|
|
253
280
|
aria-label={labels.renameField(item.name)}
|
|
@@ -278,7 +305,7 @@ function PasskeyRow({
|
|
|
278
305
|
|
|
279
306
|
if (mode === "delete") {
|
|
280
307
|
return (
|
|
281
|
-
<li className="space-y-2 py-2">
|
|
308
|
+
<li {...li} className="space-y-2 py-2">
|
|
282
309
|
<p role="alert" className="text-sm">
|
|
283
310
|
{labels.deleteConfirm(item.name)}
|
|
284
311
|
</p>
|
|
@@ -295,7 +322,7 @@ function PasskeyRow({
|
|
|
295
322
|
}
|
|
296
323
|
|
|
297
324
|
return (
|
|
298
|
-
<li className="flex items-center gap-3 py-2">
|
|
325
|
+
<li {...li} className="flex items-center gap-3 py-2">
|
|
299
326
|
<KeyRound className="size-4 shrink-0 text-[var(--text-muted)]" aria-hidden />
|
|
300
327
|
<div className="min-w-0 flex-1">
|
|
301
328
|
<div className="truncate text-sm font-medium">{item.name}</div>
|
|
@@ -105,6 +105,12 @@ export interface ProgressBarSegment {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
|
|
108
|
+
/**
|
|
109
|
+
* `"span"` builds the bar from spans, for a place only phrasing content may go: inside
|
|
110
|
+
* a button or a link (ListItem's `content`). The `legend` stays a list, so leave it off
|
|
111
|
+
* there. Default `"div"`.
|
|
112
|
+
*/
|
|
113
|
+
as?: "div" | "span";
|
|
108
114
|
/** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way
|
|
109
115
|
* and nobody knows how much is left (an import that reports no count). */
|
|
110
116
|
value?: number;
|
|
@@ -155,8 +161,11 @@ export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
155
161
|
legend?: boolean;
|
|
156
162
|
/**
|
|
157
163
|
* What a legend row shows at its end, in place of the part's formatted value — which
|
|
158
|
-
* it is handed, with the segment and its index.
|
|
159
|
-
*
|
|
164
|
+
* it is handed, with the segment and its index. That value is the part's OWN, signed
|
|
165
|
+
* and unclamped (`formatValue`, or a percentage of the scale): a part of −40 on a
|
|
166
|
+
* 0–100 scale says "-40%" though the bar draws nothing for it. keksdose's cut card
|
|
167
|
+
* shows the share AND a private outflow amount per bucket (cut-card:150), which one
|
|
168
|
+
* figure could not:
|
|
160
169
|
* `legendValue={(seg, share) => <>{share} <Amount … /></>}`.
|
|
161
170
|
*/
|
|
162
171
|
legendValue?: (segment: ProgressBarSegment, formatted: string, index: number) => ReactNode;
|
|
@@ -209,11 +218,17 @@ export function ProgressBar({
|
|
|
209
218
|
overage,
|
|
210
219
|
labels,
|
|
211
220
|
className,
|
|
221
|
+
as = "div",
|
|
212
222
|
"aria-label": ariaLabel,
|
|
213
223
|
"aria-labelledby": ariaLabelledBy,
|
|
214
224
|
"aria-describedby": ariaDescribedBy,
|
|
215
225
|
...rest
|
|
216
226
|
}: ProgressBarProps) {
|
|
227
|
+
// `as="span"`: the same bar from phrasing elements only, so it is valid inside a
|
|
228
|
+
// <button> or <a> — ListItem's `content` puts a budget bar inside the row's target
|
|
229
|
+
// (keksdose P2). Every span is made a block except those already laid out as flex.
|
|
230
|
+
const Tag = as;
|
|
231
|
+
const inlineClass = as === "span" ? "block [&_span:not(.flex):not(.inline-flex)]:block" : undefined;
|
|
217
232
|
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
218
233
|
const text = useKitLabels("progressBar", DEFAULT_PROGRESS_BAR_LABELS, labels);
|
|
219
234
|
const kitLocale = useKitLocale(locale);
|
|
@@ -236,8 +251,8 @@ export function ProgressBar({
|
|
|
236
251
|
? formatValue(value, Infinity, min)
|
|
237
252
|
: new Intl.NumberFormat(kitLocale).format(value);
|
|
238
253
|
return (
|
|
239
|
-
<
|
|
240
|
-
<
|
|
254
|
+
<Tag {...rest} className={cn("min-w-0", inlineClass, className)} data-state="unlimited">
|
|
255
|
+
<Tag className="flex items-baseline gap-2 text-sm">
|
|
241
256
|
{label != null && (
|
|
242
257
|
<span id={labelId} className="min-w-0 text-[var(--text-secondary)]">
|
|
243
258
|
{label}
|
|
@@ -247,9 +262,9 @@ export function ProgressBar({
|
|
|
247
262
|
{shown !== undefined && `${shown} · `}
|
|
248
263
|
{text.unlimited}
|
|
249
264
|
</span>
|
|
250
|
-
</
|
|
265
|
+
</Tag>
|
|
251
266
|
{hintNode}
|
|
252
|
-
</
|
|
267
|
+
</Tag>
|
|
253
268
|
);
|
|
254
269
|
}
|
|
255
270
|
const max = maxProp;
|
|
@@ -269,9 +284,21 @@ export function ProgressBar({
|
|
|
269
284
|
: new Intl.NumberFormat(kitLocale, { style: "percent", maximumFractionDigits: 0 }).format(
|
|
270
285
|
span <= 0 ? 0 : (Math.min(max, Math.max(min, v)) - min) / span,
|
|
271
286
|
);
|
|
287
|
+
// A PART is stated as its own value, sign and all — not as the slice of track it
|
|
288
|
+
// draws. The bar cannot draw a negative part (a refund inside a month's spending) or
|
|
289
|
+
// one past `max`, but the legend and the reader can still say it: before 0.16 a
|
|
290
|
+
// −CHF 40 part read "0 %" in both, and keksdose had to pass `legendValue` just to get
|
|
291
|
+
// the minus back. For a part inside `min`–`max` this is the same text as before.
|
|
292
|
+
const formatPart = (v: number) => {
|
|
293
|
+
if (!Number.isFinite(v)) return format(min);
|
|
294
|
+
if (formatValue) return formatValue(v, max, min);
|
|
295
|
+
return new Intl.NumberFormat(kitLocale, { style: "percent", maximumFractionDigits: 0 }).format(
|
|
296
|
+
span <= 0 ? 0 : (v - min) / span,
|
|
297
|
+
);
|
|
298
|
+
};
|
|
272
299
|
const partTexts = stacked
|
|
273
300
|
? barParts.map((seg) => {
|
|
274
|
-
const text =
|
|
301
|
+
const text = formatPart(seg.value);
|
|
275
302
|
return typeof seg.label === "string" || typeof seg.label === "number"
|
|
276
303
|
? common.fieldValue(String(seg.label), text)
|
|
277
304
|
: text;
|
|
@@ -306,9 +333,9 @@ export function ProgressBar({
|
|
|
306
333
|
const name = labelledBy ? undefined : (ariaLabel ?? (indeterminate ? common.loading : undefined));
|
|
307
334
|
|
|
308
335
|
return (
|
|
309
|
-
<
|
|
336
|
+
<Tag className={cn("min-w-0", inlineClass, className)} data-state={indeterminate ? "indeterminate" : "determinate"}>
|
|
310
337
|
{(label != null || (showValue && valueText !== undefined)) && (
|
|
311
|
-
<
|
|
338
|
+
<Tag className="mb-1 flex items-baseline gap-2 text-sm">
|
|
312
339
|
{label != null && (
|
|
313
340
|
<span id={labelId} className="min-w-0 text-[var(--text-secondary)]">
|
|
314
341
|
{label}
|
|
@@ -321,9 +348,9 @@ export function ProgressBar({
|
|
|
321
348
|
{valueText}
|
|
322
349
|
</span>
|
|
323
350
|
)}
|
|
324
|
-
</
|
|
351
|
+
</Tag>
|
|
325
352
|
)}
|
|
326
|
-
<
|
|
353
|
+
<Tag
|
|
327
354
|
{...rest}
|
|
328
355
|
role={meter ? "meter" : "progressbar"}
|
|
329
356
|
aria-label={name}
|
|
@@ -341,10 +368,10 @@ export function ProgressBar({
|
|
|
341
368
|
{stacked ? (
|
|
342
369
|
// The parts in a flex row with a 2px gap, so two neighbouring steps of one
|
|
343
370
|
// hue are parted by the track rather than by their difference in lightness.
|
|
344
|
-
<
|
|
371
|
+
<Tag className="flex h-full w-full gap-0.5">
|
|
345
372
|
{segments.map((seg, i) =>
|
|
346
373
|
seg.legendOnly ? null : (
|
|
347
|
-
<
|
|
374
|
+
<Tag
|
|
348
375
|
key={seg.key ?? i}
|
|
349
376
|
data-part="segment"
|
|
350
377
|
className={cn(
|
|
@@ -358,9 +385,9 @@ export function ProgressBar({
|
|
|
358
385
|
/>
|
|
359
386
|
),
|
|
360
387
|
)}
|
|
361
|
-
</
|
|
388
|
+
</Tag>
|
|
362
389
|
) : indeterminate ? (
|
|
363
|
-
<
|
|
390
|
+
<Tag
|
|
364
391
|
data-part="fill"
|
|
365
392
|
className={cn(
|
|
366
393
|
// `left-0`, deliberately physical: the keyframes (tokens.css
|
|
@@ -375,7 +402,7 @@ export function ProgressBar({
|
|
|
375
402
|
)}
|
|
376
403
|
/>
|
|
377
404
|
) : (
|
|
378
|
-
<
|
|
405
|
+
<Tag
|
|
379
406
|
data-part="fill"
|
|
380
407
|
// Block flow starts at the inline start, so a plain width fills from the
|
|
381
408
|
// right under `dir="rtl"` with nothing extra.
|
|
@@ -383,7 +410,7 @@ export function ProgressBar({
|
|
|
383
410
|
style={{ width: `${fraction * 100}%` }}
|
|
384
411
|
/>
|
|
385
412
|
)}
|
|
386
|
-
</
|
|
413
|
+
</Tag>
|
|
387
414
|
{stacked && legend && segments.length > 0 && (
|
|
388
415
|
<ul className="mt-2 space-y-1 text-sm" data-part="legend">
|
|
389
416
|
{segments.map((seg, i) => (
|
|
@@ -400,9 +427,7 @@ export function ProgressBar({
|
|
|
400
427
|
<span className="min-w-0 flex-1 truncate text-[var(--text-secondary)]">{seg.label}</span>
|
|
401
428
|
)}
|
|
402
429
|
<span className="ms-auto shrink-0 text-xs tabular-nums text-[var(--text-muted)]">
|
|
403
|
-
{legendValue
|
|
404
|
-
? legendValue(seg, format(min + partValue(seg.value)), i)
|
|
405
|
-
: format(min + partValue(seg.value))}
|
|
430
|
+
{legendValue ? legendValue(seg, formatPart(seg.value), i) : formatPart(seg.value)}
|
|
406
431
|
</span>
|
|
407
432
|
</li>
|
|
408
433
|
))}
|
|
@@ -410,6 +435,6 @@ export function ProgressBar({
|
|
|
410
435
|
)}
|
|
411
436
|
{overageNode}
|
|
412
437
|
{hintNode}
|
|
413
|
-
</
|
|
438
|
+
</Tag>
|
|
414
439
|
);
|
|
415
440
|
}
|
|
@@ -78,6 +78,17 @@ export type GoodDirection = "up" | "down";
|
|
|
78
78
|
*/
|
|
79
79
|
export type SignedAmountTone = "signed" | "income" | "expense" | "neutral" | "verdict";
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The colours a VERDICT is painted in (`goodDirection` set, or `tone="verdict"`):
|
|
83
|
+
* - `status` (default) — `success` / `danger`: good news, bad news.
|
|
84
|
+
* - `money` — `income` / `expense` (the `.text-money-pos` / `.text-money-neg` pair):
|
|
85
|
+
* the app's ONE money palette (keksdose dev#434). A grocery price going UP is money
|
|
86
|
+
* going out, so with `goodDirection="down"` it wears the expense colour every other
|
|
87
|
+
* outflow in the app wears — not a red that says "error". keksdose coloured its
|
|
88
|
+
* three price-change sites by hand for want of this.
|
|
89
|
+
*/
|
|
90
|
+
export type VerdictPalette = "status" | "money";
|
|
91
|
+
|
|
81
92
|
/** What a signed figure says to a screen reader, whose readers disagree about a
|
|
82
93
|
* leading "+" (most skip it) and a "−" (some read "dash", some nothing). */
|
|
83
94
|
export interface SignedAmountLabels {
|
|
@@ -117,18 +128,28 @@ function useMagnitudeFormatter({ currency, unit, compact, digits, format, locale
|
|
|
117
128
|
};
|
|
118
129
|
}
|
|
119
130
|
|
|
120
|
-
|
|
121
|
-
|
|
131
|
+
/** `flatWithin` widens "no change" from exactly zero to a band round it: a price that
|
|
132
|
+
* moved by a fraction of a cent, a ratio that rounds to "0.0 %", is not a rise, and an
|
|
133
|
+
* arrow and a colour for it claim one. Inclusive — `|value| ≤ flatWithin` is flat. */
|
|
134
|
+
function directionOf(value: number, flatWithin = 0): "up" | "down" | "flat" {
|
|
135
|
+
if (Math.abs(value) <= Math.max(0, flatWithin)) return "flat";
|
|
136
|
+
return value > 0 ? "up" : "down";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function verdictClass(good: boolean, palette: VerdictPalette): string {
|
|
140
|
+
if (palette === "money") return good ? TEXT_TONE.income : TEXT_TONE.expense;
|
|
141
|
+
return good ? TEXT_TONE.success : TEXT_TONE.danger;
|
|
122
142
|
}
|
|
123
143
|
|
|
124
144
|
function signedToneClass(
|
|
125
145
|
tone: SignedAmountTone,
|
|
126
146
|
dir: "up" | "down" | "flat",
|
|
127
147
|
goodDirection: GoodDirection | undefined,
|
|
148
|
+
palette: VerdictPalette,
|
|
128
149
|
): string {
|
|
129
150
|
if (tone === "verdict" || (goodDirection && tone === "signed")) {
|
|
130
151
|
if (dir === "flat" || !goodDirection) return TEXT_TONE.muted;
|
|
131
|
-
return dir === goodDirection
|
|
152
|
+
return verdictClass(dir === goodDirection, palette);
|
|
132
153
|
}
|
|
133
154
|
if (tone === "signed") return dir === "flat" ? TEXT_TONE.muted : dir === "up" ? TEXT_TONE.income : TEXT_TONE.expense;
|
|
134
155
|
if (tone === "neutral") return "";
|
|
@@ -143,8 +164,16 @@ export interface SignedAmountProps
|
|
|
143
164
|
value: number;
|
|
144
165
|
/** See {@link SignedAmountTone}. Default `signed`, or `verdict` with `goodDirection`. */
|
|
145
166
|
tone?: SignedAmountTone;
|
|
146
|
-
/** Which way is good news — colours by verdict (`success` / `danger
|
|
167
|
+
/** Which way is good news — colours by verdict (`success` / `danger`, or the money
|
|
168
|
+
* pair with `palette="money"`). */
|
|
147
169
|
goodDirection?: GoodDirection;
|
|
170
|
+
/** See {@link VerdictPalette}. Default `status`. Only a verdict reads it. */
|
|
171
|
+
palette?: VerdictPalette;
|
|
172
|
+
/** A magnitude up to which the figure counts as NO change: no sign, no arrow but the
|
|
173
|
+
* flat dash, muted, spoken without "plus"/"minus" (inclusive; default 0). The digits
|
|
174
|
+
* still print `|value|` — use a band below the display precision, where they round
|
|
175
|
+
* to zero anyway (`flatWithin={0.005}` at two digits). */
|
|
176
|
+
flatWithin?: number;
|
|
148
177
|
/** Print "+" before a positive figure (default `true`). A negative always shows its
|
|
149
178
|
* minus; zero shows neither. */
|
|
150
179
|
showPlus?: boolean;
|
|
@@ -177,6 +206,8 @@ export function SignedAmount({
|
|
|
177
206
|
locale,
|
|
178
207
|
tone = "signed",
|
|
179
208
|
goodDirection,
|
|
209
|
+
palette = "status",
|
|
210
|
+
flatWithin,
|
|
180
211
|
showPlus = true,
|
|
181
212
|
arrow = false,
|
|
182
213
|
sensitive = false,
|
|
@@ -187,7 +218,7 @@ export function SignedAmount({
|
|
|
187
218
|
const text = useKitLabels("signedAmount", DEFAULT_SIGNED_AMOUNT_LABELS, labels);
|
|
188
219
|
const fmt = useMagnitudeFormatter({ currency, unit, compact, digits, format, locale });
|
|
189
220
|
const finite = Number.isFinite(value);
|
|
190
|
-
const dir = finite ? directionOf(value) : "flat";
|
|
221
|
+
const dir = finite ? directionOf(value, flatWithin) : "flat";
|
|
191
222
|
const amount = finite ? fmt(Math.abs(value)) : "—";
|
|
192
223
|
// U+2212, the typographic minus: the same width as "+", so a column of signed figures
|
|
193
224
|
// lines up under `tabular-nums`.
|
|
@@ -201,7 +232,7 @@ export function SignedAmount({
|
|
|
201
232
|
data-direction={dir}
|
|
202
233
|
className={cn(
|
|
203
234
|
"relative inline-flex items-center gap-0.5 whitespace-nowrap tabular-nums",
|
|
204
|
-
signedToneClass(tone, dir, goodDirection),
|
|
235
|
+
signedToneClass(tone, dir, goodDirection, palette),
|
|
205
236
|
className,
|
|
206
237
|
)}
|
|
207
238
|
>
|
|
@@ -221,6 +252,12 @@ export interface DeltaProps extends SignedFormatProps, Omit<ComponentPropsWithou
|
|
|
221
252
|
/** Which way is good news. Unset, the change is stated and not judged (muted); set,
|
|
222
253
|
* it is coloured `success` / `danger` and its sentence says "(better)" / "(worse)". */
|
|
223
254
|
goodDirection?: GoodDirection;
|
|
255
|
+
/** See {@link VerdictPalette}: `money` paints good news `income` and bad news
|
|
256
|
+
* `expense` instead of `success` / `danger`. Default `status`. */
|
|
257
|
+
palette?: VerdictPalette;
|
|
258
|
+
/** See {@link SignedAmountProps.flatWithin}: up to this magnitude the change is
|
|
259
|
+
* "unchanged" — flat dash, muted, no verdict. Default 0. */
|
|
260
|
+
flatWithin?: number;
|
|
224
261
|
/** What it is measured against, after it: "vs last month". */
|
|
225
262
|
label?: ReactNode;
|
|
226
263
|
/** Hide the arrow (shown by default, as on a StatTile). At zero the arrow is a flat
|
|
@@ -249,6 +286,8 @@ export function Delta({
|
|
|
249
286
|
format,
|
|
250
287
|
locale,
|
|
251
288
|
goodDirection,
|
|
289
|
+
palette = "status",
|
|
290
|
+
flatWithin,
|
|
252
291
|
label,
|
|
253
292
|
arrow = true,
|
|
254
293
|
sensitive = false,
|
|
@@ -260,14 +299,14 @@ export function Delta({
|
|
|
260
299
|
const fmt = useMagnitudeFormatter({ currency, unit, compact, digits, format, locale });
|
|
261
300
|
if (!Number.isFinite(value)) return null;
|
|
262
301
|
const amount = fmt(Math.abs(value));
|
|
263
|
-
const dir = directionOf(value);
|
|
302
|
+
const dir = directionOf(value, flatWithin);
|
|
264
303
|
const Icon = ARROW[dir];
|
|
265
304
|
let spoken = dir === "up" ? text.increase(amount) : dir === "down" ? text.decrease(amount) : text.unchanged;
|
|
266
305
|
let tone = TEXT_TONE.muted;
|
|
267
306
|
if (goodDirection && dir !== "flat") {
|
|
268
307
|
const good = dir === goodDirection;
|
|
269
308
|
spoken = good ? text.better(spoken) : text.worse(spoken);
|
|
270
|
-
tone = good
|
|
309
|
+
tone = verdictClass(good, palette);
|
|
271
310
|
}
|
|
272
311
|
return (
|
|
273
312
|
<span
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isValidElement, useId, useLayoutEffect, useState } from "react";
|
|
1
|
+
import { createContext, isValidElement, useContext, useId, useLayoutEffect, useState } from "react";
|
|
2
2
|
import type { ComponentPropsWithoutRef, CSSProperties, ReactElement, ReactNode } from "react";
|
|
3
3
|
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
@@ -217,6 +217,16 @@ export interface StatTileProps
|
|
|
217
217
|
loading?: boolean;
|
|
218
218
|
/** `"sm"` for dense grids (a metrics wall); `"md"` the default KPI. */
|
|
219
219
|
size?: "sm" | "md";
|
|
220
|
+
/**
|
|
221
|
+
* The tile's frame. `"card"` (default) is the bordered, shadowed card on the page.
|
|
222
|
+
* `"inset"` is a tile INSIDE a card: {@link Card}'s `inset` — the raised
|
|
223
|
+
* `--bg-surface-2`, a small radius, no border or shadow, since the card round it
|
|
224
|
+
* already has them. `"plain"` drops the frame altogether — no fill and no padding —
|
|
225
|
+
* for figures set straight into a card's content (a summary row under a card title),
|
|
226
|
+
* where any box would be a box in a box. keksdose wrote both by hand over the card
|
|
227
|
+
* tile (`border-0 shadow-none bg-transparent p-0`) for its tiles inside a card.
|
|
228
|
+
*/
|
|
229
|
+
variant?: "card" | "inset" | "plain";
|
|
220
230
|
/** Makes the tile a link. See {@link renderLink} for a router. */
|
|
221
231
|
href?: string;
|
|
222
232
|
/** Renders the link for `href` — pass your router's `<Link>` here, since a plain
|
|
@@ -291,6 +301,7 @@ export function StatTile({
|
|
|
291
301
|
sensitive = false,
|
|
292
302
|
loading = false,
|
|
293
303
|
size = "md",
|
|
304
|
+
variant = "card",
|
|
294
305
|
href,
|
|
295
306
|
renderLink,
|
|
296
307
|
onClick,
|
|
@@ -299,6 +310,11 @@ export function StatTile({
|
|
|
299
310
|
...rest
|
|
300
311
|
}: StatTileProps) {
|
|
301
312
|
const text = useKitLabels("statTile", DEFAULT_STAT_TILE_LABELS, labels);
|
|
313
|
+
// A loading StatTileGrid puts every tile in it into the loading state and says
|
|
314
|
+
// "Loading…" ONCE for all of them; the tile then keeps its own sentence to itself
|
|
315
|
+
// (see StatTileGrid's `loading`).
|
|
316
|
+
const gridLoading = useContext(StatTileGridLoadingContext);
|
|
317
|
+
loading = loading || gridLoading;
|
|
302
318
|
const kitLink = useKitLink();
|
|
303
319
|
const [labelEl, setLabelEl] = useState<HTMLElement | null>(null);
|
|
304
320
|
const labelCut = useIsTruncated(truncateLabel ? labelEl : null);
|
|
@@ -398,7 +414,7 @@ export function StatTile({
|
|
|
398
414
|
const headline = loading ? (
|
|
399
415
|
<span id={valueId} className="relative block py-0.5">
|
|
400
416
|
<span aria-hidden className={cn(SKELETON_CLASS, "block h-6 w-24 max-w-full")} />
|
|
401
|
-
<span className="sr-only">{text.loading}</span>
|
|
417
|
+
{!gridLoading && <span className="sr-only">{text.loading}</span>}
|
|
402
418
|
</span>
|
|
403
419
|
) : hasValue ? (
|
|
404
420
|
<span id={valueId} data-private={priv} className={cn(valueClass, valueLabel != null && "ms-auto")}>
|
|
@@ -432,13 +448,18 @@ export function StatTile({
|
|
|
432
448
|
return (
|
|
433
449
|
<Card
|
|
434
450
|
{...rest}
|
|
451
|
+
variant={variant === "card" ? "default" : "inset"}
|
|
452
|
+
data-variant={variant === "card" ? undefined : variant}
|
|
435
453
|
aria-busy={loading || undefined}
|
|
436
454
|
className={cn(
|
|
437
455
|
// @container: the headline sizes read the tile's width. relative: the stretched
|
|
438
456
|
// link's overlay. overflow-hidden: at pathological widths a figure clips inside
|
|
439
457
|
// the border instead of painting over the neighbouring tile.
|
|
440
458
|
"@container relative flex min-w-0 flex-col overflow-hidden",
|
|
441
|
-
|
|
459
|
+
// `plain` is the inset card with its fill and padding taken away — the figures
|
|
460
|
+
// align with the content edge of the card they sit in. (Not `p-0` over the
|
|
461
|
+
// padding: the padding's `sm:p-4` would still win from `sm` up.)
|
|
462
|
+
variant === "plain" ? "rounded-none bg-transparent p-0" : PADDING[size],
|
|
442
463
|
interactive && "transition-colors hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover)]",
|
|
443
464
|
className,
|
|
444
465
|
)}
|
|
@@ -577,8 +598,30 @@ export interface StatTileGridProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
577
598
|
/** The narrowest a tile may get before the grid drops a column. The default fits
|
|
578
599
|
* two tiles on a 320px phone. */
|
|
579
600
|
minTileWidth?: string;
|
|
601
|
+
/**
|
|
602
|
+
* Let a row that is not full stretch its tiles across the whole width. Default
|
|
603
|
+
* `true` (CSS `auto-fit`): one or two tiles fill the row, as they always have.
|
|
604
|
+
* `false` (`auto-fill`): the empty columns are kept, so every tile is the width of
|
|
605
|
+
* a column whatever the count — a lone "Balance" tile in a card stays tile-sized
|
|
606
|
+
* instead of becoming a banner the width of the card, and the tiles of a short row
|
|
607
|
+
* line up with the columns of a full one above it.
|
|
608
|
+
*/
|
|
609
|
+
stretch?: boolean;
|
|
610
|
+
/**
|
|
611
|
+
* Every tile in the grid is loading: each shows its skeleton (as with its own
|
|
612
|
+
* `loading`), and the grid says "Loading…" ONCE, in a polite status region, instead
|
|
613
|
+
* of each tile carrying its own sr-only "Loading…" — a wall of eight tiles was read
|
|
614
|
+
* as eight "Loading…"s in a row. The grid is `aria-busy` meanwhile. A tile's own
|
|
615
|
+
* `loading` inside a grid that is not loading still speaks for itself.
|
|
616
|
+
*/
|
|
617
|
+
loading?: boolean;
|
|
618
|
+
/** The grid's one sentence — `loading` from {@link StatTileLabels}. */
|
|
619
|
+
labels?: Partial<Pick<StatTileLabels, "loading">>;
|
|
580
620
|
}
|
|
581
621
|
|
|
622
|
+
/** Set by a loading {@link StatTileGrid}; read by each tile in it. */
|
|
623
|
+
const StatTileGridLoadingContext = createContext(false);
|
|
624
|
+
|
|
582
625
|
/**
|
|
583
626
|
* The row of tiles every dashboard in the three apps lays out by hand, each with its
|
|
584
627
|
* own breakpoint ladder (`grid-cols-2 md:grid-cols-4 min-[2400px]:grid-cols-8` …).
|
|
@@ -586,9 +629,36 @@ export interface StatTileGridProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
586
629
|
* the band where the sidebar was open. This fits as many columns as `minTileWidth`
|
|
587
630
|
* allows in the space the grid actually has, and needs no breakpoint at all.
|
|
588
631
|
*/
|
|
589
|
-
export function StatTileGrid({
|
|
632
|
+
export function StatTileGrid({
|
|
633
|
+
minTileWidth = "9rem",
|
|
634
|
+
stretch = true,
|
|
635
|
+
loading = false,
|
|
636
|
+
labels,
|
|
637
|
+
className,
|
|
638
|
+
style,
|
|
639
|
+
children,
|
|
640
|
+
...rest
|
|
641
|
+
}: StatTileGridProps) {
|
|
642
|
+
const text = useKitLabels("statTile", DEFAULT_STAT_TILE_LABELS, labels);
|
|
590
643
|
const columns: CSSProperties = {
|
|
591
|
-
gridTemplateColumns: `repeat(auto-fit, minmax(min(100%, ${minTileWidth}), 1fr))`,
|
|
644
|
+
gridTemplateColumns: `repeat(${stretch ? "auto-fit" : "auto-fill"}, minmax(min(100%, ${minTileWidth}), 1fr))`,
|
|
592
645
|
};
|
|
593
|
-
return
|
|
646
|
+
return (
|
|
647
|
+
<div
|
|
648
|
+
{...rest}
|
|
649
|
+
aria-busy={loading || rest["aria-busy"] || undefined}
|
|
650
|
+
// `relative`: the containing block for the sr-only status, which is absolutely
|
|
651
|
+
// positioned and so also takes no grid cell.
|
|
652
|
+
className={cn("relative grid gap-3", className)}
|
|
653
|
+
style={{ ...columns, ...style }}
|
|
654
|
+
>
|
|
655
|
+
{/* Always mounted, filled while loading: a live region announces a CHANGE of
|
|
656
|
+
its content, so one that appeared already holding the sentence might say
|
|
657
|
+
nothing. */}
|
|
658
|
+
<span role="status" className="sr-only">
|
|
659
|
+
{loading ? text.loading : ""}
|
|
660
|
+
</span>
|
|
661
|
+
<StatTileGridLoadingContext.Provider value={loading}>{children}</StatTileGridLoadingContext.Provider>
|
|
662
|
+
</div>
|
|
663
|
+
);
|
|
594
664
|
}
|
|
@@ -33,6 +33,37 @@ const FILL: Record<StatusDotTone, string> = {
|
|
|
33
33
|
orange: "bg-[var(--hue-orange)]",
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The colour of each tone as a CSS value — the same `var(--…)` the dot's class paints
|
|
38
|
+
* with. For a mark the kit does not draw: a `SwatchPicker` option (`color`), a chart
|
|
39
|
+
* series, an inline `style`. keksdose's category picker spelled `var(--hue-teal)` out by
|
|
40
|
+
* hand to match the dot beside the category name; a renamed token would have parted
|
|
41
|
+
* them silently. (The class table above stays literal: Tailwind finds classes by
|
|
42
|
+
* reading the source, so `bg-[${…}]` would never be generated. A test keeps the two
|
|
43
|
+
* tables agreeing.)
|
|
44
|
+
*/
|
|
45
|
+
const COLOR: Record<StatusDotTone, string> = {
|
|
46
|
+
brand: "var(--brand)",
|
|
47
|
+
neutral: "var(--text-muted)",
|
|
48
|
+
success: "var(--success)",
|
|
49
|
+
warning: "var(--warning)",
|
|
50
|
+
danger: "var(--danger)",
|
|
51
|
+
info: "var(--info)",
|
|
52
|
+
income: "var(--money-income)",
|
|
53
|
+
expense: "var(--money-expense)",
|
|
54
|
+
blue: "var(--hue-blue)",
|
|
55
|
+
indigo: "var(--hue-indigo)",
|
|
56
|
+
purple: "var(--hue-purple)",
|
|
57
|
+
teal: "var(--hue-teal)",
|
|
58
|
+
orange: "var(--hue-orange)",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The CSS colour a {@link StatusDot} of `tone` is filled with — `statusDotColor("teal")`
|
|
62
|
+
* is `"var(--hue-teal)"`. See the table above for when to reach for it. */
|
|
63
|
+
export function statusDotColor(tone: StatusDotTone): string {
|
|
64
|
+
return COLOR[tone];
|
|
65
|
+
}
|
|
66
|
+
|
|
36
67
|
const SIZE: Record<StatusDotSize, string> = { sm: "size-2", md: "size-2.5", lg: "size-3" };
|
|
37
68
|
|
|
38
69
|
export interface StatusDotProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
|
|
@@ -21,7 +21,9 @@ export interface SwatchOption<T extends string> {
|
|
|
21
21
|
value: T;
|
|
22
22
|
/** Any CSS colour — a token (`var(--chart-3)`), a hex, an `oklch()`. Painted as an
|
|
23
23
|
* inline background, so it does not have to be a class the kit's CSS knows.
|
|
24
|
-
* Leave it out when `swatchClassName` paints the dot instead.
|
|
24
|
+
* Leave it out when `swatchClassName` paints the dot instead. For a colour that
|
|
25
|
+
* must match a `StatusDot` / `Chip` hue elsewhere, `statusDotColor("teal")` rather
|
|
26
|
+
* than `"var(--hue-teal)"` written out — one table, so the two cannot drift. */
|
|
25
27
|
color?: string;
|
|
26
28
|
/** The colour's name, in the user's language. Shown in the bubble and read out;
|
|
27
29
|
* required, because a swatch with no name is a colour only some people can read. */
|