@eifi1/ui-kit 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -17
- package/dist/chart.d.ts +3 -2
- package/dist/components/alert-banner.d.ts +59 -10
- package/dist/components/alert-banner.js +114 -8
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +3 -0
- package/dist/components/button-group.d.ts +26 -0
- package/dist/components/button-group.js +49 -0
- package/dist/components/button-group.js.map +1 -0
- package/dist/components/calculator.d.ts +3 -0
- package/dist/components/chart-zoom.d.ts +63 -14
- package/dist/components/chart-zoom.js +82 -16
- package/dist/components/chart-zoom.js.map +1 -1
- package/dist/components/chip.d.ts +67 -2
- package/dist/components/chip.js +73 -8
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +120 -0
- package/dist/components/confirm-dialog.js +96 -0
- package/dist/components/confirm-dialog.js.map +1 -0
- package/dist/components/copy-button.d.ts +62 -0
- package/dist/components/copy-button.js +95 -0
- package/dist/components/copy-button.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +5 -1
- package/dist/components/data-table-pagination.js +71 -58
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.d.ts +28 -5
- package/dist/components/data-table-sort.js +11 -7
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +544 -423
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +51 -5
- package/dist/components/date-picker.js +152 -40
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +60 -0
- package/dist/components/description-list.js +112 -0
- package/dist/components/description-list.js.map +1 -0
- package/dist/components/disclosure.d.ts +37 -2
- package/dist/components/disclosure.js +14 -5
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/facing-pair.d.ts +2 -0
- package/dist/components/file-button.d.ts +17 -1
- package/dist/components/file-button.js +8 -1
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +54 -9
- package/dist/components/file-dropzone.js +100 -70
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +107 -0
- package/dist/components/floating-panel.js +206 -0
- package/dist/components/floating-panel.js.map +1 -0
- package/dist/components/full-bleed-dialog.d.ts +12 -1
- package/dist/components/full-bleed-dialog.js +26 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/modal.d.ts +6 -2
- package/dist/components/modal.js +2 -1
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-field.d.ts +3 -0
- package/dist/components/number-input.d.ts +3 -0
- package/dist/components/numpad-sheet.d.ts +3 -0
- package/dist/components/progress-bar.d.ts +54 -0
- package/dist/components/progress-bar.js +94 -0
- package/dist/components/progress-bar.js.map +1 -0
- package/dist/components/scroll-area.d.ts +53 -0
- package/dist/components/scroll-area.js +86 -0
- package/dist/components/scroll-area.js.map +1 -0
- package/dist/components/separator.d.ts +23 -0
- package/dist/components/separator.js +24 -0
- package/dist/components/separator.js.map +1 -0
- package/dist/components/series-chart-ticks.d.ts +34 -1
- package/dist/components/series-chart-ticks.js +83 -1
- package/dist/components/series-chart-ticks.js.map +1 -1
- package/dist/components/series-chart.d.ts +314 -19
- package/dist/components/series-chart.js +510 -120
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/skeleton.d.ts +36 -0
- package/dist/components/skeleton.js +35 -0
- package/dist/components/skeleton.js.map +1 -0
- package/dist/components/stat-tile.d.ts +60 -6
- package/dist/components/stat-tile.js +60 -26
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/table.d.ts +83 -0
- package/dist/components/table.js +166 -0
- package/dist/components/table.js.map +1 -0
- package/dist/components/toggle-legend.d.ts +3 -2
- package/dist/components/toggle-legend.js +1 -1
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tree-view.d.ts +129 -0
- package/dist/components/tree-view.js +376 -0
- package/dist/components/tree-view.js.map +1 -0
- package/dist/components/treemap.d.ts +9 -2
- package/dist/components/treemap.js +2 -1
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +87 -13
- package/dist/components/ui.js +111 -29
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
- package/dist/data-table.d.ts +2 -2
- package/dist/data-table.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
- package/dist/hooks/use-copy-to-clipboard.js +81 -0
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
- package/dist/hooks/use-debounce.d.ts +43 -0
- package/dist/hooks/use-debounce.js +56 -0
- package/dist/hooks/use-debounce.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +72 -0
- package/dist/hooks/use-file-drop.js +57 -0
- package/dist/hooks/use-file-drop.js.map +1 -0
- package/dist/i18n/defaults.d.ts +3 -0
- package/dist/i18n/defaults.js +7 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +15 -0
- package/dist/i18n/kit-labels.js +6 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
- package/dist/i18n/locales/de-CH-informal.js +8 -0
- package/dist/i18n/locales/de-CH-informal.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +3 -0
- package/dist/i18n/locales/de-CH.js +1 -14
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/de-informal.d.ts +64 -0
- package/dist/i18n/locales/de-informal.js +37 -0
- package/dist/i18n/locales/de-informal.js.map +1 -0
- package/dist/i18n/locales/de.d.ts +3 -0
- package/dist/i18n/locales/de.js +25 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +3 -0
- package/dist/i18n/locales/es.js +25 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +3 -0
- package/dist/i18n/locales/fr.js +25 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +3 -0
- package/dist/i18n/locales/hu.js +25 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +3 -0
- package/dist/i18n/locales/it.js +25 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +3 -0
- package/dist/i18n/locales/zh.js +25 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +13 -0
- package/dist/i18n/swiss.js +19 -0
- package/dist/i18n/swiss.js.map +1 -0
- package/dist/index.d.ts +25 -10
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/dates.d.ts +37 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/search/command-palette.d.ts +15 -1
- package/dist/search/command-palette.js +10 -2
- package/dist/search/command-palette.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +35 -2
- package/dist/wizard/stepper-nav.js +54 -22
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +107 -5
- package/dist/wizard/types.js +2 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +141 -21
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.d.ts +10 -2
- package/dist/wizard/wizard-summary.js +3 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +4 -1
- package/dist/wizard.js.map +1 -1
- package/package.json +1 -1
- package/src/components/alert-banner.tsx +211 -20
- package/src/components/button-group.tsx +75 -0
- package/src/components/chart-zoom.tsx +158 -20
- package/src/components/chip.tsx +160 -9
- package/src/components/confirm-dialog.tsx +242 -0
- package/src/components/copy-button.tsx +158 -0
- package/src/components/data-table-pagination.tsx +19 -6
- package/src/components/data-table-sort.ts +49 -10
- package/src/components/data-table.tsx +293 -24
- package/src/components/date-picker.tsx +262 -57
- package/src/components/description-list.tsx +174 -0
- package/src/components/disclosure.tsx +58 -5
- package/src/components/file-button.tsx +22 -2
- package/src/components/file-dropzone.tsx +198 -100
- package/src/components/floating-panel.tsx +341 -0
- package/src/components/full-bleed-dialog.tsx +48 -5
- package/src/components/modal.tsx +9 -2
- package/src/components/progress-bar.tsx +163 -0
- package/src/components/scroll-area.tsx +129 -0
- package/src/components/separator.tsx +37 -0
- package/src/components/series-chart-ticks.ts +135 -0
- package/src/components/series-chart.tsx +934 -67
- package/src/components/skeleton.tsx +65 -0
- package/src/components/stat-tile.tsx +160 -27
- package/src/components/table.tsx +263 -0
- package/src/components/toggle-legend.tsx +4 -3
- package/src/components/tree-view.tsx +589 -0
- package/src/components/treemap.tsx +10 -2
- package/src/components/ui.tsx +263 -39
- package/src/data-table.ts +2 -0
- package/src/hooks/use-copy-to-clipboard.ts +122 -0
- package/src/hooks/use-debounce.ts +105 -0
- package/src/hooks/use-file-drop.ts +123 -0
- package/src/i18n/defaults.ts +6 -0
- package/src/i18n/kit-labels.tsx +19 -0
- package/src/i18n/locales/de-CH-informal.ts +10 -0
- package/src/i18n/locales/de-CH.ts +1 -16
- package/src/i18n/locales/de-informal.ts +61 -0
- package/src/i18n/locales/de.ts +25 -0
- package/src/i18n/locales/es.ts +25 -0
- package/src/i18n/locales/fr.ts +25 -0
- package/src/i18n/locales/hu.ts +25 -0
- package/src/i18n/locales/it.ts +25 -0
- package/src/i18n/locales/zh.ts +25 -0
- package/src/i18n/swiss.ts +25 -0
- package/src/index.ts +29 -0
- package/src/lib/dates.ts +48 -0
- package/src/search/command-palette.tsx +29 -3
- package/src/wizard/stepper-nav.tsx +102 -28
- package/src/wizard/types.ts +106 -4
- package/src/wizard/use-wizard.ts +211 -31
- package/src/wizard/wizard-summary.tsx +24 -12
- package/src/wizard.ts +3 -2
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The look of a placeholder, as one class string — the same fill and pulse
|
|
6
|
+
* `StatTile`'s `loading` headline draws (it imports this), so a tile and the list
|
|
7
|
+
* beside it shimmer as one surface.
|
|
8
|
+
*
|
|
9
|
+
* `motion-reduce:animate-none`: a pulse is motion with no information in it, which
|
|
10
|
+
* is exactly what `prefers-reduced-motion` asks to be dropped. The fill stays, so the
|
|
11
|
+
* shape of what is coming is still there.
|
|
12
|
+
*/
|
|
13
|
+
export const SKELETON_CLASS = "animate-pulse rounded bg-[var(--bg-active)] motion-reduce:animate-none";
|
|
14
|
+
|
|
15
|
+
export type SkeletonShape = "line" | "block" | "circle";
|
|
16
|
+
|
|
17
|
+
export interface SkeletonProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
|
18
|
+
/** `line` (default): a bar the height of a line of small text. `block`: an area
|
|
19
|
+
* (an image, a chart, a card body). `circle`: an avatar. Size any of them with
|
|
20
|
+
* `className` (`w-32`, `h-40`, `size-8`). */
|
|
21
|
+
shape?: SkeletonShape;
|
|
22
|
+
/** For `line`: this many lines, the last one shorter — a paragraph's outline. */
|
|
23
|
+
lines?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SHAPE: Record<SkeletonShape, string> = {
|
|
27
|
+
line: "h-3 w-full",
|
|
28
|
+
block: "h-24 w-full rounded-md",
|
|
29
|
+
circle: "size-10 rounded-full",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A grey stand-in for content that is on its way.
|
|
34
|
+
*
|
|
35
|
+
* keksdose and kastlan both drew these by hand — `animate-pulse bg-slate-200` in one
|
|
36
|
+
* place, a token in the next, a pulse that ran under reduced motion everywhere.
|
|
37
|
+
*
|
|
38
|
+
* ALWAYS `aria-hidden`. A placeholder is not content, and a reader that met twelve
|
|
39
|
+
* unnamed grey boxes would have learned nothing. Say "loading" once, where it
|
|
40
|
+
* belongs: `aria-busy` on the region being filled, or a `Spinner` / sr-only line
|
|
41
|
+
* beside the skeletons.
|
|
42
|
+
*/
|
|
43
|
+
export function Skeleton({ shape = "line", lines, className, ...rest }: SkeletonProps) {
|
|
44
|
+
if (shape === "line" && lines !== undefined && lines > 1) {
|
|
45
|
+
return (
|
|
46
|
+
<div {...rest} aria-hidden className={cn("flex flex-col gap-2", className)} data-skeleton="lines">
|
|
47
|
+
{Array.from({ length: lines }, (_, i) => (
|
|
48
|
+
<div
|
|
49
|
+
key={i}
|
|
50
|
+
className={cn(SKELETON_CLASS, SHAPE.line, i === lines - 1 && "w-2/3")}
|
|
51
|
+
data-skeleton="line"
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
{...rest}
|
|
60
|
+
aria-hidden
|
|
61
|
+
data-skeleton={shape}
|
|
62
|
+
className={cn(SKELETON_CLASS, SHAPE[shape], className)}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { useId } from "react";
|
|
1
|
+
import { isValidElement, 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";
|
|
5
5
|
import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
6
6
|
import { Card, FieldHint } from "./ui";
|
|
7
7
|
import { Sparkline } from "./sparkline";
|
|
8
|
+
import { Tooltip } from "./tooltip";
|
|
9
|
+
// The kit's one placeholder look, so a loading tile and the Skeleton list beside it
|
|
10
|
+
// shimmer as one surface — and both stop under `prefers-reduced-motion`, which this
|
|
11
|
+
// tile's private copy of the string did not.
|
|
12
|
+
import { SKELETON_CLASS } from "./skeleton";
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* The words a stat tile speaks. The delta's arrow is an icon, and an icon is not a
|
|
@@ -94,8 +99,14 @@ export interface StatTileSubValue {
|
|
|
94
99
|
value: ReactNode;
|
|
95
100
|
/** Defaults to the tile's own `tone`, read per figure (so a `signed` tile shows a
|
|
96
101
|
* teal September above an amber August). Sub-values are always the muted weight
|
|
97
|
-
* of their tone: they are history, and must not outshout the figure above them.
|
|
98
|
-
|
|
102
|
+
* of their tone: they are history, and must not outshout the figure above them.
|
|
103
|
+
*
|
|
104
|
+
* `"none"` leaves the figure untinted — the row's own muted text, like its label —
|
|
105
|
+
* even on a toned tile: keksdose's report KpiCard (reports/charts/kpi-card.tsx),
|
|
106
|
+
* whose `negative` convention tints only a figure below zero and leaves the rest
|
|
107
|
+
* plain. (`"neutral"` is not that: it is the headline's full text colour, at the
|
|
108
|
+
* sub-value's 75 %.) */
|
|
109
|
+
tone?: StatTileTone | "none";
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
/** What {@link StatTileProps.renderLink} is handed. Spread it onto your router's
|
|
@@ -136,13 +147,61 @@ export interface StatTileProps
|
|
|
136
147
|
/** Which way is good news. Unset, the delta is stated and not judged (users up and
|
|
137
148
|
* cost up are the same arrow); set, it is coloured and its sentence says so. */
|
|
138
149
|
goodDirection?: "up" | "down";
|
|
139
|
-
/** The explanation behind a "?" beside the label (a {@link FieldHint})
|
|
150
|
+
/** The explanation behind a "?" beside the label (a {@link FieldHint}) — hidden
|
|
151
|
+
* until asked for, so it is for what a reader MIGHT want to know ("excludes
|
|
152
|
+
* transfers between your own accounts"). For a line everyone should read, use
|
|
153
|
+
* {@link StatTileProps.description} instead. */
|
|
140
154
|
hint?: string;
|
|
141
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* Keep the label to ONE line, cut with an ellipsis, and show the whole of it in a
|
|
157
|
+
* {@link Tooltip} on hover and focus — but only while it is actually cut, so a
|
|
158
|
+
* label that fits shows no bubble repeating itself. keksdose's admin MetricTile
|
|
159
|
+
* wall: twenty tiles whose German labels ("Aktive Abonnements (30 Tage)") wrapped
|
|
160
|
+
* onto two or three lines and pushed each tile's figure to a different height.
|
|
161
|
+
*
|
|
162
|
+
* The label element keeps the FULL text — the ellipsis is paint — so it is still
|
|
163
|
+
* the tile's accessible name (and the stretched link's, on a link tile) exactly as
|
|
164
|
+
* without this. Off by default: a wrapping label is what every existing tile does,
|
|
165
|
+
* and a tile standing alone has room to.
|
|
166
|
+
*
|
|
167
|
+
* This is the tooltip: pass the label as a plain STRING rather than wrapping it in
|
|
168
|
+
* your own `<Tooltip>` for the full text (keksdose's admin metrics-panel.tsx:134
|
|
169
|
+
* does, from before this prop). A wrapped label shows a bubble even when nothing is
|
|
170
|
+
* cut, and nests its trigger inside this one's.
|
|
171
|
+
*/
|
|
172
|
+
truncateLabel?: boolean;
|
|
173
|
+
/**
|
|
174
|
+
* The tile's name as plain text, for the places that need a string rather than a
|
|
175
|
+
* node — today the {@link StatTileProps.trend} sparkline's accessible name. Only
|
|
176
|
+
* needed when `label` is not a string AND its text cannot be read from it: a label
|
|
177
|
+
* element's text children are found on their own (a `<Tooltip label="…">Spend
|
|
178
|
+
* </Tooltip>` label still names its sparkline "Spend"), an icon or a formatted
|
|
179
|
+
* message component's are not.
|
|
180
|
+
*/
|
|
181
|
+
name?: string;
|
|
182
|
+
/** A visible line under the value: "Last 12 months", "3 estates / 9 buildings" —
|
|
183
|
+
* always shown, so it is for what every reader needs. An explanation only some
|
|
184
|
+
* will want belongs in {@link StatTileProps.hint}, the "?" beside the label. */
|
|
142
185
|
description?: ReactNode;
|
|
186
|
+
/**
|
|
187
|
+
* One figure that belongs WITH the headline rather than under it: keksdose's
|
|
188
|
+
* "Projected €1,850" under a to-date "€420" (KpiCard, feedback #51), which it
|
|
189
|
+
* currently smuggles into `description` with hand-set margins so the prior-month
|
|
190
|
+
* sub-values do not come between the two. It is drawn directly under the value
|
|
191
|
+
* (before the delta, the description and the sparkline) as a sub-value row, with
|
|
192
|
+
* only a hair of space above it.
|
|
193
|
+
*
|
|
194
|
+
* A slot of its own rather than a placement switch for `subValues`: the history
|
|
195
|
+
* rows (last month, last year) should stay at the bottom where they are, and moving
|
|
196
|
+
* the whole list up to bring one row with it would put them between the headline
|
|
197
|
+
* and its delta. Same shape as a sub-value, so the `tone` rules are the same.
|
|
198
|
+
* Withheld while `loading`, like the sub-values.
|
|
199
|
+
*/
|
|
200
|
+
projection?: StatTileSubValue;
|
|
143
201
|
subValues?: StatTileSubValue[];
|
|
144
202
|
/** Shorthand for a fluid {@link Sparkline} named after the tile and speaking in
|
|
145
|
-
* the tile's number format.
|
|
203
|
+
* the tile's number format. The name is the label's text; see
|
|
204
|
+
* {@link StatTileProps.name} for a label that has none. */
|
|
146
205
|
trend?: ReadonlyArray<number | null | undefined>;
|
|
147
206
|
/** A custom sparkline (or any small graphic) in the same place. Wins over `trend`. */
|
|
148
207
|
sparkline?: ReactNode;
|
|
@@ -188,7 +247,6 @@ const VALUE_TEXT = {
|
|
|
188
247
|
sm: "text-xs @[8.5rem]:text-sm @[13rem]:text-base",
|
|
189
248
|
} as const;
|
|
190
249
|
const PADDING = { md: "p-3 sm:p-4", sm: "px-2.5 py-2" } as const;
|
|
191
|
-
const SKELETON = "animate-pulse rounded bg-[var(--bg-active)]";
|
|
192
250
|
|
|
193
251
|
/**
|
|
194
252
|
* The KPI card: a label, one number, and optionally how it moved, what it is made of
|
|
@@ -219,7 +277,10 @@ export function StatTile({
|
|
|
219
277
|
delta,
|
|
220
278
|
goodDirection,
|
|
221
279
|
hint,
|
|
280
|
+
truncateLabel = false,
|
|
281
|
+
name,
|
|
222
282
|
description,
|
|
283
|
+
projection,
|
|
223
284
|
subValues,
|
|
224
285
|
trend,
|
|
225
286
|
sparkline,
|
|
@@ -236,6 +297,8 @@ export function StatTile({
|
|
|
236
297
|
...rest
|
|
237
298
|
}: StatTileProps) {
|
|
238
299
|
const text = useKitLabels("statTile", DEFAULT_STAT_TILE_LABELS, labels);
|
|
300
|
+
const [labelEl, setLabelEl] = useState<HTMLElement | null>(null);
|
|
301
|
+
const labelCut = useIsTruncated(truncateLabel ? labelEl : null);
|
|
239
302
|
const kitLocale = useKitLocale(locale);
|
|
240
303
|
const id = useId();
|
|
241
304
|
const valueId = `${id}-value`;
|
|
@@ -254,7 +317,7 @@ export function StatTile({
|
|
|
254
317
|
|
|
255
318
|
const d = typeof delta === "number" ? { value: delta } : delta;
|
|
256
319
|
const hasValue = value !== null && value !== undefined && value !== "";
|
|
257
|
-
const labelText =
|
|
320
|
+
const labelText = name ?? plainText(label);
|
|
258
321
|
|
|
259
322
|
// ── The label, which is also the link/button when the tile is interactive ──
|
|
260
323
|
const stretched = cn(
|
|
@@ -326,7 +389,7 @@ export function StatTile({
|
|
|
326
389
|
);
|
|
327
390
|
const headline = loading ? (
|
|
328
391
|
<span id={valueId} className="relative block py-0.5">
|
|
329
|
-
<span aria-hidden className={cn(
|
|
392
|
+
<span aria-hidden className={cn(SKELETON_CLASS, "block h-6 w-24 max-w-full")} />
|
|
330
393
|
<span className="sr-only">{text.loading}</span>
|
|
331
394
|
</span>
|
|
332
395
|
) : hasValue ? (
|
|
@@ -374,7 +437,19 @@ export function StatTile({
|
|
|
374
437
|
>
|
|
375
438
|
<div className="flex items-start gap-2">
|
|
376
439
|
<div className="flex min-w-0 flex-1 items-center gap-1 break-words text-xs uppercase tracking-wide text-[var(--text-muted)]">
|
|
377
|
-
|
|
440
|
+
{truncateLabel && labelCut ? (
|
|
441
|
+
// Portalled: the tile is `overflow-hidden`, and a bubble inside it would be
|
|
442
|
+
// clipped by the very box whose edge cut the label.
|
|
443
|
+
<Tooltip label={label} side="top" portal className="flex min-w-0">
|
|
444
|
+
<span ref={setLabelEl} className="block min-w-0 truncate">
|
|
445
|
+
{labelNode}
|
|
446
|
+
</span>
|
|
447
|
+
</Tooltip>
|
|
448
|
+
) : (
|
|
449
|
+
<span ref={setLabelEl} className={cn("min-w-0", truncateLabel && "block truncate")}>
|
|
450
|
+
{labelNode}
|
|
451
|
+
</span>
|
|
452
|
+
)}
|
|
378
453
|
{/* Above the stretched link's overlay, so it stays hoverable and focusable. */}
|
|
379
454
|
{hint && <FieldHint label={hint} side="top" className="relative z-10 shrink-0" />}
|
|
380
455
|
</div>
|
|
@@ -396,29 +471,21 @@ export function StatTile({
|
|
|
396
471
|
<div className="mt-1">{headline}</div>
|
|
397
472
|
)}
|
|
398
473
|
|
|
474
|
+
{!loading && projection && (
|
|
475
|
+
<dl className="mt-0.5">
|
|
476
|
+
<FigureRow figure={projection} tileTone={tone} show={show} priv={priv} />
|
|
477
|
+
</dl>
|
|
478
|
+
)}
|
|
479
|
+
|
|
399
480
|
{deltaNode}
|
|
400
481
|
{description != null && <div className="mt-1 text-xs text-[var(--text-muted)]">{description}</div>}
|
|
401
482
|
{graphic && <div className="mt-2">{graphic}</div>}
|
|
402
483
|
|
|
403
484
|
{!loading && subValues && subValues.length > 0 && (
|
|
404
485
|
<dl className="mt-1.5 space-y-0.5">
|
|
405
|
-
{subValues.map((sv, i) =>
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
<div
|
|
409
|
-
key={i}
|
|
410
|
-
className="flex flex-wrap items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]"
|
|
411
|
-
>
|
|
412
|
-
<dt className="uppercase tracking-wide">{sv.label}</dt>
|
|
413
|
-
<dd
|
|
414
|
-
data-private={priv}
|
|
415
|
-
className={cn("ms-auto whitespace-nowrap", svTone && [toneClass(svTone, sv.value), "opacity-75"])}
|
|
416
|
-
>
|
|
417
|
-
{show(sv.value)}
|
|
418
|
-
</dd>
|
|
419
|
-
</div>
|
|
420
|
-
);
|
|
421
|
-
})}
|
|
486
|
+
{subValues.map((sv, i) => (
|
|
487
|
+
<FigureRow key={i} figure={sv} tileTone={tone} show={show} priv={priv} />
|
|
488
|
+
))}
|
|
422
489
|
</dl>
|
|
423
490
|
)}
|
|
424
491
|
|
|
@@ -427,6 +494,72 @@ export function StatTile({
|
|
|
427
494
|
);
|
|
428
495
|
}
|
|
429
496
|
|
|
497
|
+
/** One label/value row of a `<dl>`: a sub-value, or the projection. */
|
|
498
|
+
function FigureRow({
|
|
499
|
+
figure,
|
|
500
|
+
tileTone,
|
|
501
|
+
show,
|
|
502
|
+
priv,
|
|
503
|
+
}: {
|
|
504
|
+
figure: StatTileSubValue;
|
|
505
|
+
tileTone: StatTileTone;
|
|
506
|
+
show: (v: ReactNode) => ReactNode;
|
|
507
|
+
priv: "" | undefined;
|
|
508
|
+
}) {
|
|
509
|
+
const own = figure.tone ?? (tileTone === "neutral" ? undefined : tileTone);
|
|
510
|
+
const svTone = own === "none" ? undefined : own;
|
|
511
|
+
return (
|
|
512
|
+
<div className="flex flex-wrap items-baseline gap-x-2 text-xs tabular-nums text-[var(--text-muted)]">
|
|
513
|
+
<dt className="uppercase tracking-wide">{figure.label}</dt>
|
|
514
|
+
<dd
|
|
515
|
+
data-private={priv}
|
|
516
|
+
className={cn("ms-auto whitespace-nowrap", svTone && [toneClass(svTone, figure.value), "opacity-75"])}
|
|
517
|
+
>
|
|
518
|
+
{show(figure.value)}
|
|
519
|
+
</dd>
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The text of a label node, for the sparkline's name: a string or number as is, an
|
|
526
|
+
* element by its children, recursively. A `<Tooltip label="…">Spend</Tooltip>` label
|
|
527
|
+
* yields "Spend" — the text the reader sees, not the bubble's. `undefined` when there
|
|
528
|
+
* is no text to find (an icon), so the sparkline falls back to its unnamed summary.
|
|
529
|
+
*/
|
|
530
|
+
function plainText(node: ReactNode): string | undefined {
|
|
531
|
+
const walk = (n: ReactNode): string => {
|
|
532
|
+
if (typeof n === "string" || typeof n === "number") return String(n);
|
|
533
|
+
if (Array.isArray(n)) return n.map(walk).join("");
|
|
534
|
+
if (isValidElement<{ children?: ReactNode }>(n)) return walk(n.props.children);
|
|
535
|
+
return "";
|
|
536
|
+
};
|
|
537
|
+
const text = walk(node).replace(/\s+/g, " ").trim();
|
|
538
|
+
return text === "" ? undefined : text;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Whether `el`'s text is wider than its box — i.e. whether `truncate` is cutting it.
|
|
543
|
+
* Re-measured whenever the box resizes (a grid reflowing, a translation loading), not
|
|
544
|
+
* only on mount. Keyed on the ELEMENT (a callback ref), because wrapping the label in
|
|
545
|
+
* its tooltip remounts it, and an observer on the old node would watch nothing.
|
|
546
|
+
*/
|
|
547
|
+
function useIsTruncated(el: HTMLElement | null): boolean {
|
|
548
|
+
const [cut, setCut] = useState(false);
|
|
549
|
+
useLayoutEffect(() => {
|
|
550
|
+
// No reset while there is no node: between the unwrapped label unmounting and the
|
|
551
|
+
// wrapped one mounting there is none, and a reset there would unwrap it again.
|
|
552
|
+
if (!el) return;
|
|
553
|
+
const measure = () => setCut(el.scrollWidth > el.clientWidth);
|
|
554
|
+
measure();
|
|
555
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
556
|
+
const observer = new ResizeObserver(measure);
|
|
557
|
+
observer.observe(el);
|
|
558
|
+
return () => observer.disconnect();
|
|
559
|
+
}, [el]);
|
|
560
|
+
return cut;
|
|
561
|
+
}
|
|
562
|
+
|
|
430
563
|
export interface StatTileGridProps extends ComponentPropsWithoutRef<"div"> {
|
|
431
564
|
/** The narrowest a tile may get before the grid drops a column. The default fits
|
|
432
565
|
* two tiles on a 320px phone. */
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { createContext, useContext, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { THIN_SCROLLBAR_CLASS, useScrollOverflow } from "./scroll-area";
|
|
5
|
+
|
|
6
|
+
export type TableDensity = "comfortable" | "compact";
|
|
7
|
+
export type TableAlign = "start" | "center" | "end";
|
|
8
|
+
|
|
9
|
+
interface TableContextValue {
|
|
10
|
+
density: TableDensity;
|
|
11
|
+
zebra: boolean;
|
|
12
|
+
hover: boolean;
|
|
13
|
+
captionId: string;
|
|
14
|
+
registerCaption: () => () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TableContext = createContext<TableContextValue>({
|
|
18
|
+
density: "comfortable",
|
|
19
|
+
zebra: false,
|
|
20
|
+
hover: false,
|
|
21
|
+
captionId: "",
|
|
22
|
+
registerCaption: () => () => {},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Which section a row sits in — zebra and hover apply to body rows only. */
|
|
26
|
+
const SectionContext = createContext<"head" | "body" | "foot">("body");
|
|
27
|
+
|
|
28
|
+
const CELL_PAD: Record<TableDensity, string> = {
|
|
29
|
+
comfortable: "px-3 py-2",
|
|
30
|
+
compact: "px-2 py-1",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const ALIGN: Record<TableAlign, string> = {
|
|
34
|
+
start: "text-start",
|
|
35
|
+
center: "text-center",
|
|
36
|
+
end: "text-end",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The alignment a figure column wants: end-aligned (so the units line up under each
|
|
41
|
+
* other in either reading direction) with tabular digits (so they line up at all).
|
|
42
|
+
* Exported for a cell the kit did not render — a DataTable column, a `<td>` of the
|
|
43
|
+
* app's own.
|
|
44
|
+
*/
|
|
45
|
+
export const NUMERIC_CELL_CLASS = "text-end tabular-nums";
|
|
46
|
+
|
|
47
|
+
export interface TableProps extends ComponentPropsWithoutRef<"table"> {
|
|
48
|
+
/** `compact` for a dense detail view (a ledger, a spec sheet). */
|
|
49
|
+
density?: TableDensity;
|
|
50
|
+
/** Tint every other body row. */
|
|
51
|
+
zebra?: boolean;
|
|
52
|
+
/** Tint the body row under the pointer. Off by default: a static table whose rows
|
|
53
|
+
* light up promises a click that does nothing. */
|
|
54
|
+
hover?: boolean;
|
|
55
|
+
/** Classes for the overflow wrapper around the `<table>` (a max-height, a border). */
|
|
56
|
+
wrapperClassName?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A plain, static HTML table in the kit's tokens — for the detail views that need
|
|
61
|
+
* rows and columns and none of {@link DataTable}'s sorting, filtering or paging.
|
|
62
|
+
*
|
|
63
|
+
* kastlan renders about twenty of these on shadcn's Table parts; the part names here
|
|
64
|
+
* are the same idea (`TableHead` is the `<thead>`, `TableHeaderCell` the `<th>`), so
|
|
65
|
+
* the move is a rename rather than a rewrite.
|
|
66
|
+
*
|
|
67
|
+
* The table sits in an overflow-x wrapper, because a table cannot shrink below its
|
|
68
|
+
* content and on a phone it would otherwise widen the page. While it overflows that
|
|
69
|
+
* wrapper becomes a tab stop and a `role="region"` named by the `TableCaption` (or the
|
|
70
|
+
* table's own `aria-label`), so a keyboard user can scroll it and a reader can say
|
|
71
|
+
* what it is — the same contract as {@link ScrollArea}.
|
|
72
|
+
*
|
|
73
|
+
* Alignment is logical throughout (`text-start` / `text-end`), so a right-to-left
|
|
74
|
+
* page gets its first column on the right without a class changing.
|
|
75
|
+
*/
|
|
76
|
+
export function Table({
|
|
77
|
+
density = "comfortable",
|
|
78
|
+
zebra = false,
|
|
79
|
+
hover = false,
|
|
80
|
+
wrapperClassName,
|
|
81
|
+
className,
|
|
82
|
+
"aria-label": ariaLabel,
|
|
83
|
+
...rest
|
|
84
|
+
}: TableProps) {
|
|
85
|
+
const captionId = useId();
|
|
86
|
+
const [captions, setCaptions] = useState(0);
|
|
87
|
+
const wrapper = useRef<HTMLDivElement | null>(null);
|
|
88
|
+
const overflowing = useScrollOverflow(wrapper);
|
|
89
|
+
|
|
90
|
+
const ctx = useMemo<TableContextValue>(
|
|
91
|
+
() => ({
|
|
92
|
+
density,
|
|
93
|
+
zebra,
|
|
94
|
+
hover,
|
|
95
|
+
captionId,
|
|
96
|
+
registerCaption: () => {
|
|
97
|
+
setCaptions((n) => n + 1);
|
|
98
|
+
return () => setCaptions((n) => n - 1);
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
[density, zebra, hover, captionId],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const regionName = captions > 0 ? { "aria-labelledby": captionId } : ariaLabel ? { "aria-label": ariaLabel } : null;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<TableContext.Provider value={ctx}>
|
|
108
|
+
<div
|
|
109
|
+
ref={wrapper}
|
|
110
|
+
// Only a region when it scrolls AND has a name: a landmark for every short
|
|
111
|
+
// table on a page would bury the real ones.
|
|
112
|
+
role={overflowing && regionName ? "region" : undefined}
|
|
113
|
+
{...(overflowing ? regionName : null)}
|
|
114
|
+
tabIndex={overflowing ? 0 : undefined}
|
|
115
|
+
data-overflowing={overflowing || undefined}
|
|
116
|
+
className={cn(
|
|
117
|
+
// `relative`: the containing block for an `sr-only` caption.
|
|
118
|
+
"relative w-full overflow-x-auto",
|
|
119
|
+
THIN_SCROLLBAR_CLASS,
|
|
120
|
+
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--brand)]",
|
|
121
|
+
wrapperClassName,
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
<table
|
|
125
|
+
{...rest}
|
|
126
|
+
aria-label={ariaLabel}
|
|
127
|
+
className={cn(
|
|
128
|
+
"w-full caption-bottom border-collapse text-sm text-[var(--text-primary)]",
|
|
129
|
+
density === "compact" && "text-xs",
|
|
130
|
+
className,
|
|
131
|
+
)}
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
</TableContext.Provider>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type TableHeadProps = ComponentPropsWithoutRef<"thead">;
|
|
139
|
+
|
|
140
|
+
export function TableHead({ className, ...rest }: TableHeadProps) {
|
|
141
|
+
return (
|
|
142
|
+
<SectionContext.Provider value="head">
|
|
143
|
+
<thead {...rest} className={cn("border-b border-[var(--border)]", className)} />
|
|
144
|
+
</SectionContext.Provider>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type TableBodyProps = ComponentPropsWithoutRef<"tbody">;
|
|
149
|
+
|
|
150
|
+
export function TableBody({ className, ...rest }: TableBodyProps) {
|
|
151
|
+
return (
|
|
152
|
+
<SectionContext.Provider value="body">
|
|
153
|
+
<tbody {...rest} className={cn("[&>tr:last-child]:border-b-0", className)} />
|
|
154
|
+
</SectionContext.Provider>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export type TableFootProps = ComponentPropsWithoutRef<"tfoot">;
|
|
159
|
+
|
|
160
|
+
/** The totals row. Not in the brief, but every ledger view has one and a `<tfoot>`
|
|
161
|
+
* is what tells a reader it is a total rather than one more row. */
|
|
162
|
+
export function TableFoot({ className, ...rest }: TableFootProps) {
|
|
163
|
+
return (
|
|
164
|
+
<SectionContext.Provider value="foot">
|
|
165
|
+
<tfoot {...rest} className={cn("border-t border-[var(--border-strong)] font-medium", className)} />
|
|
166
|
+
</SectionContext.Provider>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type TableRowProps = ComponentPropsWithoutRef<"tr">;
|
|
171
|
+
|
|
172
|
+
export function TableRow({ className, ...rest }: TableRowProps) {
|
|
173
|
+
const { zebra, hover } = useContext(TableContext);
|
|
174
|
+
const section = useContext(SectionContext);
|
|
175
|
+
const body = section === "body";
|
|
176
|
+
return (
|
|
177
|
+
<tr
|
|
178
|
+
{...rest}
|
|
179
|
+
className={cn(
|
|
180
|
+
body && "border-b border-[var(--border)]",
|
|
181
|
+
body && zebra && "even:bg-[var(--bg-surface-2)]",
|
|
182
|
+
body && hover && "transition-colors hover:bg-[var(--bg-hover)]",
|
|
183
|
+
className,
|
|
184
|
+
)}
|
|
185
|
+
/>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface CellAlignProps {
|
|
190
|
+
/** Shorthand for a figure column: end-aligned, tabular digits. */
|
|
191
|
+
numeric?: boolean;
|
|
192
|
+
/** Logical alignment. Default `start` (`end` when `numeric`). */
|
|
193
|
+
align?: TableAlign;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The deprecated HTML `align` attribute is replaced by a logical one. */
|
|
197
|
+
export interface TableHeaderCellProps extends Omit<ComponentPropsWithoutRef<"th">, "align">, CellAlignProps {}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A `<th>`. `scope` defaults to `col` in the head and `row` in the body — a header
|
|
201
|
+
* cell in a body row is the row's label ("Net rent" in a key-value table), and a
|
|
202
|
+
* reader needs the scope to read it with each cell beside it.
|
|
203
|
+
*/
|
|
204
|
+
export function TableHeaderCell({ numeric = false, align, scope, className, ...rest }: TableHeaderCellProps) {
|
|
205
|
+
const { density } = useContext(TableContext);
|
|
206
|
+
const section = useContext(SectionContext);
|
|
207
|
+
return (
|
|
208
|
+
<th
|
|
209
|
+
{...rest}
|
|
210
|
+
scope={scope ?? (section === "head" ? "col" : "row")}
|
|
211
|
+
className={cn(
|
|
212
|
+
CELL_PAD[density],
|
|
213
|
+
ALIGN[align ?? (numeric ? "end" : "start")],
|
|
214
|
+
numeric && "tabular-nums",
|
|
215
|
+
"align-bottom font-medium",
|
|
216
|
+
section === "head" ? "text-xs text-[var(--text-muted)]" : "align-top text-[var(--text-secondary)]",
|
|
217
|
+
className,
|
|
218
|
+
)}
|
|
219
|
+
/>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface TableCellProps extends Omit<ComponentPropsWithoutRef<"td">, "align">, CellAlignProps {}
|
|
224
|
+
|
|
225
|
+
export function TableCell({ numeric = false, align, className, ...rest }: TableCellProps) {
|
|
226
|
+
const { density } = useContext(TableContext);
|
|
227
|
+
return (
|
|
228
|
+
<td
|
|
229
|
+
{...rest}
|
|
230
|
+
className={cn(
|
|
231
|
+
CELL_PAD[density],
|
|
232
|
+
"align-top",
|
|
233
|
+
ALIGN[align ?? (numeric ? "end" : "start")],
|
|
234
|
+
numeric && "tabular-nums whitespace-nowrap",
|
|
235
|
+
className,
|
|
236
|
+
)}
|
|
237
|
+
/>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export type TableCaptionProps = ComponentPropsWithoutRef<"caption">;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The table's title. Beneath the table by default (`caption-bottom`, the shadcn
|
|
245
|
+
* convention kastlan's views already follow); `className="caption-top"` moves it.
|
|
246
|
+
* `className="sr-only"` keeps it for readers only — the wrapper is `relative`, so
|
|
247
|
+
* the hidden caption stays inside it.
|
|
248
|
+
*
|
|
249
|
+
* It also names the scroll region when the table overflows: it registers itself
|
|
250
|
+
* with the {@link Table} so the wrapper points at an id that exists, and never at
|
|
251
|
+
* one that does not.
|
|
252
|
+
*/
|
|
253
|
+
export function TableCaption({ className, id, ...rest }: TableCaptionProps) {
|
|
254
|
+
const { captionId, registerCaption } = useContext(TableContext);
|
|
255
|
+
useLayoutEffect(() => (id ? undefined : registerCaption()), [id, registerCaption]);
|
|
256
|
+
return (
|
|
257
|
+
<caption
|
|
258
|
+
{...rest}
|
|
259
|
+
id={id ?? captionId}
|
|
260
|
+
className={cn("mt-2 text-start text-sm text-[var(--text-muted)]", className)}
|
|
261
|
+
/>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -73,8 +73,9 @@ export interface LegendEntry {
|
|
|
73
73
|
marker?: "swatch" | "stroke";
|
|
74
74
|
/** Which of {@link STROKE_PATTERNS}, when the marker is a stroke — the same index
|
|
75
75
|
* `SeriesChartSeries.dash` takes, so a legend cannot promise a dot-dash the plot
|
|
76
|
-
* draws dashed.
|
|
77
|
-
|
|
76
|
+
* draws dashed. Or the same custom `stroke-dasharray` string the series draws with
|
|
77
|
+
* (keksdose's `"4 3"`). */
|
|
78
|
+
dash?: number | string;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
export interface ToggleLegendProps {
|
|
@@ -214,7 +215,7 @@ function LegendMark({ entry, off }: { entry: LegendEntry; off: boolean }) {
|
|
|
214
215
|
y2={1}
|
|
215
216
|
stroke={entry.color}
|
|
216
217
|
strokeWidth={2}
|
|
217
|
-
strokeDasharray={strokeDash(entry.dash ?? 0)}
|
|
218
|
+
strokeDasharray={typeof entry.dash === "string" ? entry.dash : strokeDash(entry.dash ?? 0)}
|
|
218
219
|
/>
|
|
219
220
|
</svg>
|
|
220
221
|
);
|