@eifi1/ui-kit 0.7.1 → 0.8.1

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.
Files changed (222) hide show
  1. package/README.md +32 -17
  2. package/dist/chart.d.ts +3 -2
  3. package/dist/components/alert-banner.d.ts +59 -10
  4. package/dist/components/alert-banner.js +114 -8
  5. package/dist/components/alert-banner.js.map +1 -1
  6. package/dist/components/amount-input.d.ts +3 -0
  7. package/dist/components/button-group.d.ts +26 -0
  8. package/dist/components/button-group.js +49 -0
  9. package/dist/components/button-group.js.map +1 -0
  10. package/dist/components/calculator.d.ts +3 -0
  11. package/dist/components/chart-zoom.d.ts +63 -14
  12. package/dist/components/chart-zoom.js +82 -16
  13. package/dist/components/chart-zoom.js.map +1 -1
  14. package/dist/components/chip.d.ts +67 -2
  15. package/dist/components/chip.js +73 -8
  16. package/dist/components/chip.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +120 -0
  18. package/dist/components/confirm-dialog.js +96 -0
  19. package/dist/components/confirm-dialog.js.map +1 -0
  20. package/dist/components/copy-button.d.ts +62 -0
  21. package/dist/components/copy-button.js +95 -0
  22. package/dist/components/copy-button.js.map +1 -0
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table-pagination.d.ts +5 -1
  26. package/dist/components/data-table-pagination.js +71 -58
  27. package/dist/components/data-table-pagination.js.map +1 -1
  28. package/dist/components/data-table-sort.d.ts +28 -5
  29. package/dist/components/data-table-sort.js +11 -7
  30. package/dist/components/data-table-sort.js.map +1 -1
  31. package/dist/components/data-table.d.ts +1 -1
  32. package/dist/components/data-table.js +544 -423
  33. package/dist/components/data-table.js.map +1 -1
  34. package/dist/components/date-picker.d.ts +51 -5
  35. package/dist/components/date-picker.js +152 -40
  36. package/dist/components/date-picker.js.map +1 -1
  37. package/dist/components/description-list.d.ts +60 -0
  38. package/dist/components/description-list.js +112 -0
  39. package/dist/components/description-list.js.map +1 -0
  40. package/dist/components/disclosure.d.ts +37 -2
  41. package/dist/components/disclosure.js +14 -5
  42. package/dist/components/disclosure.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +2 -0
  44. package/dist/components/file-button.d.ts +17 -1
  45. package/dist/components/file-button.js +8 -1
  46. package/dist/components/file-button.js.map +1 -1
  47. package/dist/components/file-dropzone.d.ts +54 -9
  48. package/dist/components/file-dropzone.js +100 -70
  49. package/dist/components/file-dropzone.js.map +1 -1
  50. package/dist/components/floating-panel.d.ts +107 -0
  51. package/dist/components/floating-panel.js +206 -0
  52. package/dist/components/floating-panel.js.map +1 -0
  53. package/dist/components/full-bleed-dialog.d.ts +12 -1
  54. package/dist/components/full-bleed-dialog.js +28 -5
  55. package/dist/components/full-bleed-dialog.js.map +1 -1
  56. package/dist/components/modal.d.ts +6 -2
  57. package/dist/components/modal.js +2 -1
  58. package/dist/components/modal.js.map +1 -1
  59. package/dist/components/number-field.d.ts +3 -0
  60. package/dist/components/number-input.d.ts +3 -0
  61. package/dist/components/numpad-sheet.d.ts +3 -0
  62. package/dist/components/progress-bar.d.ts +54 -0
  63. package/dist/components/progress-bar.js +94 -0
  64. package/dist/components/progress-bar.js.map +1 -0
  65. package/dist/components/scroll-area.d.ts +53 -0
  66. package/dist/components/scroll-area.js +86 -0
  67. package/dist/components/scroll-area.js.map +1 -0
  68. package/dist/components/separator.d.ts +23 -0
  69. package/dist/components/separator.js +24 -0
  70. package/dist/components/separator.js.map +1 -0
  71. package/dist/components/series-chart-ticks.d.ts +34 -1
  72. package/dist/components/series-chart-ticks.js +83 -1
  73. package/dist/components/series-chart-ticks.js.map +1 -1
  74. package/dist/components/series-chart.d.ts +314 -19
  75. package/dist/components/series-chart.js +510 -120
  76. package/dist/components/series-chart.js.map +1 -1
  77. package/dist/components/skeleton.d.ts +36 -0
  78. package/dist/components/skeleton.js +35 -0
  79. package/dist/components/skeleton.js.map +1 -0
  80. package/dist/components/stat-tile.d.ts +60 -6
  81. package/dist/components/stat-tile.js +60 -26
  82. package/dist/components/stat-tile.js.map +1 -1
  83. package/dist/components/table.d.ts +83 -0
  84. package/dist/components/table.js +166 -0
  85. package/dist/components/table.js.map +1 -0
  86. package/dist/components/toggle-legend.d.ts +3 -2
  87. package/dist/components/toggle-legend.js +1 -1
  88. package/dist/components/toggle-legend.js.map +1 -1
  89. package/dist/components/tree-view.d.ts +129 -0
  90. package/dist/components/tree-view.js +376 -0
  91. package/dist/components/tree-view.js.map +1 -0
  92. package/dist/components/treemap.d.ts +9 -2
  93. package/dist/components/treemap.js +6 -2
  94. package/dist/components/treemap.js.map +1 -1
  95. package/dist/components/ui.d.ts +94 -13
  96. package/dist/components/ui.js +111 -29
  97. package/dist/components/ui.js.map +1 -1
  98. package/dist/components/use-table-state.d.ts +1 -1
  99. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  100. package/dist/data-table.d.ts +2 -2
  101. package/dist/data-table.js.map +1 -1
  102. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  103. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  104. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  105. package/dist/hooks/use-debounce.d.ts +43 -0
  106. package/dist/hooks/use-debounce.js +56 -0
  107. package/dist/hooks/use-debounce.js.map +1 -0
  108. package/dist/hooks/use-file-drop.d.ts +72 -0
  109. package/dist/hooks/use-file-drop.js +57 -0
  110. package/dist/hooks/use-file-drop.js.map +1 -0
  111. package/dist/i18n/defaults.d.ts +3 -0
  112. package/dist/i18n/defaults.js +7 -1
  113. package/dist/i18n/defaults.js.map +1 -1
  114. package/dist/i18n/kit-labels.d.ts +15 -0
  115. package/dist/i18n/kit-labels.js +6 -2
  116. package/dist/i18n/kit-labels.js.map +1 -1
  117. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  118. package/dist/i18n/locales/de-CH-informal.js +8 -0
  119. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  120. package/dist/i18n/locales/de-CH.d.ts +3 -0
  121. package/dist/i18n/locales/de-CH.js +1 -14
  122. package/dist/i18n/locales/de-CH.js.map +1 -1
  123. package/dist/i18n/locales/de-informal.d.ts +64 -0
  124. package/dist/i18n/locales/de-informal.js +37 -0
  125. package/dist/i18n/locales/de-informal.js.map +1 -0
  126. package/dist/i18n/locales/de.d.ts +3 -0
  127. package/dist/i18n/locales/de.js +25 -0
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +3 -0
  130. package/dist/i18n/locales/es.js +25 -0
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +3 -0
  133. package/dist/i18n/locales/fr.js +25 -0
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +3 -0
  136. package/dist/i18n/locales/hu.js +25 -0
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +3 -0
  139. package/dist/i18n/locales/it.js +25 -0
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +3 -0
  142. package/dist/i18n/locales/zh.js +25 -0
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/i18n/swiss.d.ts +13 -0
  145. package/dist/i18n/swiss.js +19 -0
  146. package/dist/i18n/swiss.js.map +1 -0
  147. package/dist/index.d.ts +25 -10
  148. package/dist/index.js +22 -0
  149. package/dist/index.js.map +1 -1
  150. package/dist/lib/dates.d.ts +37 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/search/command-palette.d.ts +15 -1
  154. package/dist/search/command-palette.js +10 -2
  155. package/dist/search/command-palette.js.map +1 -1
  156. package/dist/wizard/stepper-nav.d.ts +35 -2
  157. package/dist/wizard/stepper-nav.js +54 -22
  158. package/dist/wizard/stepper-nav.js.map +1 -1
  159. package/dist/wizard/types.d.ts +107 -5
  160. package/dist/wizard/types.js +2 -1
  161. package/dist/wizard/types.js.map +1 -1
  162. package/dist/wizard/use-wizard.js +141 -21
  163. package/dist/wizard/use-wizard.js.map +1 -1
  164. package/dist/wizard/wizard-summary.d.ts +10 -2
  165. package/dist/wizard/wizard-summary.js +3 -1
  166. package/dist/wizard/wizard-summary.js.map +1 -1
  167. package/dist/wizard.d.ts +4 -1
  168. package/dist/wizard.js.map +1 -1
  169. package/package.json +1 -1
  170. package/src/components/alert-banner.tsx +211 -20
  171. package/src/components/button-group.tsx +75 -0
  172. package/src/components/chart-zoom.tsx +158 -20
  173. package/src/components/chip.tsx +160 -9
  174. package/src/components/confirm-dialog.tsx +242 -0
  175. package/src/components/copy-button.tsx +158 -0
  176. package/src/components/data-table-pagination.tsx +19 -6
  177. package/src/components/data-table-sort.ts +49 -10
  178. package/src/components/data-table.tsx +293 -24
  179. package/src/components/date-picker.tsx +262 -57
  180. package/src/components/description-list.tsx +174 -0
  181. package/src/components/disclosure.tsx +58 -5
  182. package/src/components/file-button.tsx +22 -2
  183. package/src/components/file-dropzone.tsx +198 -100
  184. package/src/components/floating-panel.tsx +341 -0
  185. package/src/components/full-bleed-dialog.tsx +53 -5
  186. package/src/components/modal.tsx +9 -2
  187. package/src/components/progress-bar.tsx +163 -0
  188. package/src/components/scroll-area.tsx +129 -0
  189. package/src/components/separator.tsx +37 -0
  190. package/src/components/series-chart-ticks.ts +135 -0
  191. package/src/components/series-chart.tsx +934 -67
  192. package/src/components/skeleton.tsx +65 -0
  193. package/src/components/stat-tile.tsx +160 -27
  194. package/src/components/table.tsx +263 -0
  195. package/src/components/toggle-legend.tsx +4 -3
  196. package/src/components/tree-view.tsx +589 -0
  197. package/src/components/treemap.tsx +13 -2
  198. package/src/components/ui.tsx +270 -39
  199. package/src/data-table.ts +2 -0
  200. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  201. package/src/hooks/use-debounce.ts +105 -0
  202. package/src/hooks/use-file-drop.ts +123 -0
  203. package/src/i18n/defaults.ts +6 -0
  204. package/src/i18n/kit-labels.tsx +19 -0
  205. package/src/i18n/locales/de-CH-informal.ts +10 -0
  206. package/src/i18n/locales/de-CH.ts +1 -16
  207. package/src/i18n/locales/de-informal.ts +61 -0
  208. package/src/i18n/locales/de.ts +25 -0
  209. package/src/i18n/locales/es.ts +25 -0
  210. package/src/i18n/locales/fr.ts +25 -0
  211. package/src/i18n/locales/hu.ts +25 -0
  212. package/src/i18n/locales/it.ts +25 -0
  213. package/src/i18n/locales/zh.ts +25 -0
  214. package/src/i18n/swiss.ts +25 -0
  215. package/src/index.ts +29 -0
  216. package/src/lib/dates.ts +48 -0
  217. package/src/search/command-palette.tsx +29 -3
  218. package/src/wizard/stepper-nav.tsx +102 -28
  219. package/src/wizard/types.ts +106 -4
  220. package/src/wizard/use-wizard.ts +211 -31
  221. package/src/wizard/wizard-summary.tsx +24 -12
  222. 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
- tone?: StatTileTone;
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
- /** A visible line under the value: "Last 12 months", "3 estates / 9 buildings". */
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 = typeof label === "string" ? label : undefined;
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(SKELETON, "block h-6 w-24 max-w-full")} />
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
- <span className="min-w-0">{labelNode}</span>
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
- const svTone = sv.tone ?? (tone === "neutral" ? undefined : tone);
407
- return (
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
- dash?: number;
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
  );