@eifi1/ui-kit 0.10.0 → 0.11.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.
Files changed (162) hide show
  1. package/README.md +20 -16
  2. package/dist/components/alert-banner.d.ts +26 -1
  3. package/dist/components/alert-banner.js +10 -7
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +2 -0
  6. package/dist/components/bulk-action-bar.d.ts +51 -5
  7. package/dist/components/bulk-action-bar.js +88 -41
  8. package/dist/components/bulk-action-bar.js.map +1 -1
  9. package/dist/components/button-group.d.ts +58 -3
  10. package/dist/components/button-group.js +56 -5
  11. package/dist/components/button-group.js.map +1 -1
  12. package/dist/components/calculator.d.ts +2 -0
  13. package/dist/components/calendar-heatmap.d.ts +124 -0
  14. package/dist/components/calendar-heatmap.js +295 -0
  15. package/dist/components/calendar-heatmap.js.map +1 -0
  16. package/dist/components/choice-card.d.ts +34 -2
  17. package/dist/components/choice-card.js +49 -0
  18. package/dist/components/choice-card.js.map +1 -1
  19. package/dist/components/copy-button.d.ts +1 -0
  20. package/dist/components/data-table.js +2 -0
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/field.d.ts +43 -0
  23. package/dist/components/field.js +52 -0
  24. package/dist/components/field.js.map +1 -0
  25. package/dist/components/file-button.d.ts +1 -0
  26. package/dist/components/file-dropzone.d.ts +1 -0
  27. package/dist/components/floating-panel.d.ts +147 -4
  28. package/dist/components/floating-panel.js +188 -28
  29. package/dist/components/floating-panel.js.map +1 -1
  30. package/dist/components/list.d.ts +14 -1
  31. package/dist/components/list.js +7 -1
  32. package/dist/components/list.js.map +1 -1
  33. package/dist/components/menu-item.d.ts +11 -1
  34. package/dist/components/menu-item.js +6 -1
  35. package/dist/components/menu-item.js.map +1 -1
  36. package/dist/components/mini-calendar.d.ts +70 -5
  37. package/dist/components/mini-calendar.js +166 -62
  38. package/dist/components/mini-calendar.js.map +1 -1
  39. package/dist/components/nav-pills.d.ts +66 -0
  40. package/dist/components/nav-pills.js +65 -0
  41. package/dist/components/nav-pills.js.map +1 -0
  42. package/dist/components/number-field.d.ts +2 -0
  43. package/dist/components/number-input.d.ts +2 -0
  44. package/dist/components/numpad-sheet.d.ts +2 -0
  45. package/dist/components/progress-bar.d.ts +31 -2
  46. package/dist/components/progress-bar.js +73 -7
  47. package/dist/components/progress-bar.js.map +1 -1
  48. package/dist/components/series-chart.d.ts +16 -0
  49. package/dist/components/series-chart.js +4 -1
  50. package/dist/components/series-chart.js.map +1 -1
  51. package/dist/components/settings-fields.d.ts +1 -0
  52. package/dist/components/status-dot.d.ts +6 -2
  53. package/dist/components/status-dot.js +6 -1
  54. package/dist/components/status-dot.js.map +1 -1
  55. package/dist/components/table.d.ts +40 -8
  56. package/dist/components/table.js +41 -7
  57. package/dist/components/table.js.map +1 -1
  58. package/dist/components/text.d.ts +8 -1
  59. package/dist/components/text.js +1 -0
  60. package/dist/components/text.js.map +1 -1
  61. package/dist/components/time-input.d.ts +1 -0
  62. package/dist/components/toggle-group.d.ts +15 -0
  63. package/dist/components/toggle-group.js +23 -3
  64. package/dist/components/toggle-group.js.map +1 -1
  65. package/dist/components/tooltip.d.ts +11 -3
  66. package/dist/components/tooltip.js +2 -0
  67. package/dist/components/tooltip.js.map +1 -1
  68. package/dist/components/ui.d.ts +55 -4
  69. package/dist/components/ui.js +84 -7
  70. package/dist/components/ui.js.map +1 -1
  71. package/dist/components/user-avatar.d.ts +25 -3
  72. package/dist/components/user-avatar.js +32 -3
  73. package/dist/components/user-avatar.js.map +1 -1
  74. package/dist/hooks/use-file-drop.d.ts +1 -0
  75. package/dist/i18n/defaults.d.ts +2 -0
  76. package/dist/i18n/defaults.js +2 -0
  77. package/dist/i18n/defaults.js.map +1 -1
  78. package/dist/i18n/kit-labels.d.ts +3 -0
  79. package/dist/i18n/kit-labels.js.map +1 -1
  80. package/dist/i18n/locales/de-CH-informal.d.ts +2 -0
  81. package/dist/i18n/locales/de-CH.d.ts +2 -0
  82. package/dist/i18n/locales/de-informal.d.ts +2 -0
  83. package/dist/i18n/locales/de.d.ts +2 -0
  84. package/dist/i18n/locales/de.js +9 -1
  85. package/dist/i18n/locales/de.js.map +1 -1
  86. package/dist/i18n/locales/es.d.ts +2 -0
  87. package/dist/i18n/locales/es.js +9 -1
  88. package/dist/i18n/locales/es.js.map +1 -1
  89. package/dist/i18n/locales/fr.d.ts +2 -0
  90. package/dist/i18n/locales/fr.js +9 -1
  91. package/dist/i18n/locales/fr.js.map +1 -1
  92. package/dist/i18n/locales/hu.d.ts +2 -0
  93. package/dist/i18n/locales/hu.js +9 -1
  94. package/dist/i18n/locales/hu.js.map +1 -1
  95. package/dist/i18n/locales/it.d.ts +2 -0
  96. package/dist/i18n/locales/it.js +9 -1
  97. package/dist/i18n/locales/it.js.map +1 -1
  98. package/dist/i18n/locales/zh.d.ts +2 -0
  99. package/dist/i18n/locales/zh.js +9 -1
  100. package/dist/i18n/locales/zh.js.map +1 -1
  101. package/dist/index.d.ts +14 -10
  102. package/dist/index.js +3 -0
  103. package/dist/index.js.map +1 -1
  104. package/dist/lib/clipping.d.ts +21 -5
  105. package/dist/lib/clipping.js +3 -0
  106. package/dist/lib/clipping.js.map +1 -1
  107. package/dist/lib/dates.d.ts +17 -1
  108. package/dist/lib/dates.js +19 -0
  109. package/dist/lib/dates.js.map +1 -1
  110. package/dist/rhf/form.d.ts +1 -0
  111. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  112. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  113. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  114. package/dist/rhf.d.ts +2 -0
  115. package/dist/rhf.js +1 -0
  116. package/dist/rhf.js.map +1 -1
  117. package/dist/shell/topbar-action-menu.d.ts +73 -3
  118. package/dist/shell/topbar-action-menu.js +100 -27
  119. package/dist/shell/topbar-action-menu.js.map +1 -1
  120. package/dist/shell.d.ts +2 -1
  121. package/dist/wizard/stepper-nav.d.ts +1 -0
  122. package/dist/wizard/wizard-context.d.ts +7 -1
  123. package/dist/wizard/wizard-context.js +4 -0
  124. package/dist/wizard/wizard-context.js.map +1 -1
  125. package/dist/wizard.d.ts +2 -1
  126. package/package.json +1 -1
  127. package/src/components/alert-banner.tsx +36 -7
  128. package/src/components/bulk-action-bar.tsx +163 -57
  129. package/src/components/button-group.tsx +124 -4
  130. package/src/components/calendar-heatmap.tsx +504 -0
  131. package/src/components/choice-card.tsx +97 -1
  132. package/src/components/data-table.tsx +7 -0
  133. package/src/components/field.tsx +137 -0
  134. package/src/components/floating-panel.tsx +366 -15
  135. package/src/components/list.tsx +28 -2
  136. package/src/components/menu-item.tsx +22 -1
  137. package/src/components/mini-calendar.tsx +245 -54
  138. package/src/components/nav-pills.tsx +145 -0
  139. package/src/components/progress-bar.tsx +129 -11
  140. package/src/components/series-chart.tsx +21 -0
  141. package/src/components/status-dot.tsx +10 -2
  142. package/src/components/table.tsx +85 -11
  143. package/src/components/text.tsx +9 -1
  144. package/src/components/toggle-group.tsx +48 -3
  145. package/src/components/tooltip.tsx +17 -3
  146. package/src/components/ui.tsx +149 -11
  147. package/src/components/user-avatar.tsx +57 -3
  148. package/src/i18n/defaults.ts +2 -0
  149. package/src/i18n/kit-labels.tsx +2 -0
  150. package/src/i18n/locales/de.ts +9 -0
  151. package/src/i18n/locales/es.ts +9 -0
  152. package/src/i18n/locales/fr.ts +9 -0
  153. package/src/i18n/locales/hu.ts +9 -0
  154. package/src/i18n/locales/it.ts +9 -0
  155. package/src/i18n/locales/zh.ts +9 -0
  156. package/src/index.ts +5 -0
  157. package/src/lib/clipping.ts +22 -4
  158. package/src/lib/dates.ts +37 -0
  159. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  160. package/src/rhf.ts +2 -0
  161. package/src/shell/topbar-action-menu.tsx +239 -49
  162. package/src/wizard/wizard-context.tsx +9 -0
@@ -41,6 +41,49 @@ const FILL: Record<ProgressBarTone, string> = {
41
41
 
42
42
  const TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: "h-1", slim: "h-1.5", md: "h-2", lg: "h-3" };
43
43
 
44
+ // A segment with no tone of its own takes the next categorical chart colour, so a
45
+ // stacked bar of unnamed parts is still a set of tellable parts rather than one fill.
46
+ const SERIES_FILL = [
47
+ "bg-[var(--chart-1)]",
48
+ "bg-[var(--chart-2)]",
49
+ "bg-[var(--chart-3)]",
50
+ "bg-[var(--chart-4)]",
51
+ "bg-[var(--chart-5)]",
52
+ "bg-[var(--chart-6)]",
53
+ "bg-[var(--chart-7)]",
54
+ "bg-[var(--chart-8)]",
55
+ "bg-[var(--chart-9)]",
56
+ ];
57
+
58
+ /** Each segment's fill classes: its own classes, else its tone, else (with no `color`)
59
+ * the next chart colour. "Next" counts the UNCOLOURED segments only — a segment with
60
+ * its own `className`/`tone`/`color` does not use up `--chart-1`, so an "Other" in grey
61
+ * ahead of two plain parts leaves them `--chart-1` and `--chart-2`. */
62
+ function segmentFills(segments: ProgressBarSegment[]): Array<string | undefined> {
63
+ let next = 0;
64
+ return segments.map((seg) => {
65
+ if (seg.className) return seg.className;
66
+ if (seg.tone) return FILL[seg.tone];
67
+ return seg.color ? undefined : SERIES_FILL[next++ % SERIES_FILL.length];
68
+ });
69
+ }
70
+
71
+ /** One part of a stacked bar. See {@link ProgressBarProps.segments}. */
72
+ export interface ProgressBarSegment {
73
+ /** Its size, on the bar's own `min`–`max` scale. */
74
+ value: number;
75
+ /** A kit tone. Left out (and no `color`/`className`), the next `--chart-N` colour. */
76
+ tone?: ProgressBarTone;
77
+ /** Any CSS colour (`var(--token)`), for a palette the tones do not cover. Wins over `tone`. */
78
+ color?: string;
79
+ /** Classes for the segment's fill — keksdose's `STEP_CLASS` ramp. Wins over `tone`. */
80
+ className?: string;
81
+ /** The part's name: said in the bar's `aria-valuetext` and shown in the legend. */
82
+ label?: ReactNode;
83
+ /** A stable React key, when `label` is not a string. */
84
+ key?: string;
85
+ }
86
+
44
87
  export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
45
88
  /** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way
46
89
  * and nobody knows how much is left (an import that reports no count). */
@@ -69,6 +112,20 @@ export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">,
69
112
  tone?: ProgressBarTone;
70
113
  size?: ProgressBarSize;
71
114
  locale?: string;
115
+ /**
116
+ * Draw a METER OF PARTS: one bar, several fills side by side in order, a 2px gap
117
+ * between them — keksdose's discretionary-spending bar (cut-card:133), a whole
118
+ * split into the steps of a scale. Forces `variant="meter"`: parts of a whole are
119
+ * never "busy". `aria-valuenow` is the sum; `aria-valuetext` names every part
120
+ * ("Rent: 40%, Food: 25%"), each part's value through `formatValue` and joined in the
121
+ * kit's locale — the words keksdose's hand-drawn bar had to hide from a reader
122
+ * altogether, leaving the legend as the only way in. The sum is clamped to `max`,
123
+ * and parts past it are cut off at the track's end.
124
+ */
125
+ segments?: ProgressBarSegment[];
126
+ /** With `segments`: a list under the bar naming each part with its swatch and value
127
+ * — the table view of the bar, in text tokens rather than the segment colour. */
128
+ legend?: boolean;
72
129
  }
73
130
 
74
131
  /**
@@ -99,6 +156,8 @@ export function ProgressBar({
99
156
  tone = "brand",
100
157
  size = "md",
101
158
  locale,
159
+ segments,
160
+ legend = false,
102
161
  className,
103
162
  "aria-label": ariaLabel,
104
163
  "aria-labelledby": ariaLabelledBy,
@@ -108,19 +167,37 @@ export function ProgressBar({
108
167
  const kitLocale = useKitLocale(locale);
109
168
  const labelId = useId();
110
169
 
111
- const meter = variant === "meter";
112
- const current = value === undefined || !Number.isFinite(value) ? (meter ? min : undefined) : value;
113
- const indeterminate = current === undefined;
170
+ const stacked = segments !== undefined;
171
+ const meter = stacked || variant === "meter";
114
172
  const span = max - min;
173
+ const partValue = (v: number) => (Number.isFinite(v) ? Math.max(0, v - min) : 0);
174
+ const fills = stacked ? segmentFills(segments) : [];
175
+ const partTotal = stacked ? min + segments.reduce((sum, seg) => sum + partValue(seg.value), 0) : undefined;
176
+ const format = (v: number) =>
177
+ formatValue
178
+ ? formatValue(v, max, min)
179
+ : new Intl.NumberFormat(kitLocale, { style: "percent", maximumFractionDigits: 0 }).format(
180
+ span <= 0 ? 0 : (Math.min(max, Math.max(min, v)) - min) / span,
181
+ );
182
+ const partTexts = stacked
183
+ ? segments.map((seg) => {
184
+ const text = format(min + partValue(seg.value));
185
+ return typeof seg.label === "string" || typeof seg.label === "number"
186
+ ? common.fieldValue(String(seg.label), text)
187
+ : text;
188
+ })
189
+ : [];
190
+ const current =
191
+ partTotal ?? (value === undefined || !Number.isFinite(value) ? (meter ? min : undefined) : value);
192
+ const indeterminate = current === undefined;
115
193
  const clamped = indeterminate ? undefined : Math.min(max, Math.max(min, current));
116
194
  const fraction = clamped === undefined || span <= 0 ? 0 : (clamped - min) / span;
117
195
 
118
- const valueText =
119
- clamped === undefined
120
- ? undefined
121
- : formatValue
122
- ? formatValue(clamped, max, min)
123
- : new Intl.NumberFormat(kitLocale, { style: "percent", maximumFractionDigits: 0 }).format(fraction);
196
+ const valueText = clamped === undefined ? undefined : format(clamped);
197
+ const ariaValueText =
198
+ stacked && partTexts.length > 0
199
+ ? new Intl.ListFormat(kitLocale, { style: "short", type: "unit" }).format(partTexts)
200
+ : valueText;
124
201
 
125
202
  const labelledBy = ariaLabelledBy ?? (label != null ? labelId : undefined);
126
203
  const name = labelledBy ? undefined : (ariaLabel ?? (indeterminate ? common.loading : undefined));
@@ -151,13 +228,32 @@ export function ProgressBar({
151
228
  aria-valuemin={indeterminate ? undefined : min}
152
229
  aria-valuemax={indeterminate ? undefined : max}
153
230
  aria-valuenow={clamped}
154
- aria-valuetext={valueText}
231
+ aria-valuetext={ariaValueText}
155
232
  aria-busy={!meter && indeterminate ? true : undefined}
156
233
  // `relative` + `overflow-hidden`: the indeterminate segment is positioned in
157
234
  // here and must not paint past the rounded ends.
158
235
  className={cn("relative w-full overflow-hidden rounded-full bg-[var(--bg-active)]", TRACK_HEIGHT[size])}
159
236
  >
160
- {indeterminate ? (
237
+ {stacked ? (
238
+ // The parts in a flex row with a 2px gap, so two neighbouring steps of one
239
+ // hue are parted by the track rather than by their difference in lightness.
240
+ <div className="flex h-full w-full gap-0.5">
241
+ {segments.map((seg, i) => (
242
+ <div
243
+ key={seg.key ?? i}
244
+ data-part="segment"
245
+ className={cn(
246
+ "h-full shrink-0 rounded-full transition-[width] duration-300 motion-reduce:transition-none",
247
+ fills[i],
248
+ )}
249
+ style={{
250
+ width: `${span <= 0 ? 0 : (partValue(seg.value) / span) * 100}%`,
251
+ backgroundColor: seg.className ? undefined : seg.color,
252
+ }}
253
+ />
254
+ ))}
255
+ </div>
256
+ ) : indeterminate ? (
161
257
  <div
162
258
  data-part="fill"
163
259
  className={cn(
@@ -182,6 +278,28 @@ export function ProgressBar({
182
278
  />
183
279
  )}
184
280
  </div>
281
+ {stacked && legend && segments.length > 0 && (
282
+ <ul className="mt-2 space-y-1 text-sm" data-part="legend">
283
+ {segments.map((seg, i) => (
284
+ <li key={seg.key ?? i} className="flex items-center gap-2">
285
+ <span
286
+ aria-hidden
287
+ className={cn(
288
+ "size-2.5 shrink-0 rounded-sm",
289
+ fills[i],
290
+ )}
291
+ style={{ backgroundColor: seg.className ? undefined : seg.color }}
292
+ />
293
+ {seg.label != null && (
294
+ <span className="min-w-0 flex-1 truncate text-[var(--text-secondary)]">{seg.label}</span>
295
+ )}
296
+ <span className="ms-auto shrink-0 text-xs tabular-nums text-[var(--text-muted)]">
297
+ {format(min + partValue(seg.value))}
298
+ </span>
299
+ </li>
300
+ ))}
301
+ </ul>
302
+ )}
185
303
  </div>
186
304
  );
187
305
  }
@@ -129,6 +129,18 @@ export interface SeriesChartSeries {
129
129
  * `{ r }` sets its radius. Default: recharts' dot. Bars have none, and ignore it.
130
130
  */
131
131
  activeDot?: boolean | { r?: number };
132
+ /**
133
+ * The shortest a bar of this series is drawn, in px — recharts' `minPointSize`. A
134
+ * zero or a value too small to see keeps a visible stub, so an empty slot still reads
135
+ * as a slot with a value rather than a gap: keksdose's weekday pattern, where a
136
+ * Sunday with no spending drew a 2% stub by hand. Only the DRAWING changes: the axis
137
+ * is fitted to the values, and the tooltip, the keyboard stops and every accessible
138
+ * name still say 0. A hole (no value in the row) stays a hole. Overrides the chart's
139
+ * {@link SeriesChartProps.minBarLength}; `0` turns it off for this series. Meant for
140
+ * unstacked bars — in a `stack` the stub is drawn over the layer above it. Lines and
141
+ * areas ignore it.
142
+ */
143
+ minBarLength?: number;
132
144
  }
133
145
 
134
146
  /** What a series draws. See {@link SeriesChartSeries.type}. */
@@ -507,6 +519,10 @@ export interface SeriesChartProps {
507
519
  locale?: string;
508
520
  /** Onto the chart's root (`ChartContainer`) — e.g. an app's own grid ink. */
509
521
  className?: string;
522
+ /** The shortest bar drawn, in px, for every bar series that does not set its own
523
+ * {@link SeriesChartSeries.minBarLength}. Drawing only: domain, tooltip and names
524
+ * keep the real value. Default: none — a zero draws nothing. */
525
+ minBarLength?: number;
510
526
  }
511
527
 
512
528
  /** How much of an axis band the rotated title takes, in px. Reserved rather than
@@ -1011,6 +1027,7 @@ function SeriesPlot({
1011
1027
  labels: labelsProp,
1012
1028
  locale: localeProp,
1013
1029
  className,
1030
+ minBarLength,
1014
1031
  }: PlotProps) {
1015
1032
  const labels = useKitLabels("seriesChart", DEFAULT_SERIES_CHART_LABELS, labelsProp);
1016
1033
  const locale = useKitLocale(localeProp);
@@ -1418,6 +1435,7 @@ function SeriesPlot({
1418
1435
  const curve = entry.step ? "stepAfter" : (entry.curve ?? "monotone");
1419
1436
  const width = entry.strokeWidth ?? (entry.step ? STEP_WIDTH : LINE_WIDTH);
1420
1437
  if (type === "bar") {
1438
+ const stub = entry.minBarLength ?? minBarLength;
1421
1439
  return (
1422
1440
  <Bar
1423
1441
  key={entry.key}
@@ -1427,6 +1445,9 @@ function SeriesPlot({
1427
1445
  fill={color}
1428
1446
  fillOpacity={entry.fillOpacity}
1429
1447
  radius={entry.stack === undefined ? BAR_RADIUS : 0}
1448
+ // Read off the row rather than recharts' `value`: in a stack that is the
1449
+ // layer's running top, which a hole has too — and a hole gets no stub.
1450
+ {...(stub ? { minPointSize: (_value: unknown, index: number) => (finite(plotted[index]?.[entry.key]) === undefined ? 0 : stub) } : {})}
1430
1451
  // Only the focused stop's series swaps its shape, and only for as long
1431
1452
  // as it has focus: every other bar keeps recharts' own.
1432
1453
  {...(focusedStop?.key === entry.key ? { shape: focusedBarShape(focusedStop.index) } : {})}
@@ -1,14 +1,17 @@
1
1
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
2
  import { cn } from "../lib/cn";
3
3
  import type { ProgressBarTone } from "./progress-bar";
4
+ import type { ChipHue } from "./chip";
4
5
 
5
6
  /**
6
7
  * The tones are `ProgressBar`'s, name for name — which are `Chip`'s, less nothing — so a
7
8
  * dot in a legend, the bar it explains and the badge beside it can be given the same
8
9
  * word and agree. The money pair is here for the same reason it is there: a legend
9
- * swatch for income is money, not "success".
10
+ * swatch for income is money, not "success". Chip's categorical hues are here too
11
+ * (0.11): a unit-status dot in a combobox (kastlan lease-unit-step.tsx:35, "rented"
12
+ * is blue) must match the Chip that states the same status elsewhere.
10
13
  */
11
- export type StatusDotTone = ProgressBarTone;
14
+ export type StatusDotTone = ProgressBarTone | ChipHue;
12
15
 
13
16
  /** `sm` 8px (keksdose's unread dot on the support trigger), `md` 10px (the avatar's
14
17
  * unread dot), `lg` 12px (kastlan's occupancy legend swatch). */
@@ -23,6 +26,11 @@ const FILL: Record<StatusDotTone, string> = {
23
26
  info: "bg-[var(--info)]",
24
27
  income: "bg-[var(--money-income)]",
25
28
  expense: "bg-[var(--money-expense)]",
29
+ blue: "bg-[var(--hue-blue)]",
30
+ indigo: "bg-[var(--hue-indigo)]",
31
+ purple: "bg-[var(--hue-purple)]",
32
+ teal: "bg-[var(--hue-teal)]",
33
+ orange: "bg-[var(--hue-orange)]",
26
34
  };
27
35
 
28
36
  const SIZE: Record<StatusDotSize, string> = { sm: "size-2", md: "size-2.5", lg: "size-3" };
@@ -13,6 +13,21 @@ export type TableDensity = "comfortable" | "compact" | "none";
13
13
  * figures for room. */
14
14
  export type TableLayout = "auto" | "fixed";
15
15
  export type TableAlign = "start" | "center" | "end";
16
+ /**
17
+ * Vertical alignment of a cell's content (0.11.0). The defaults stay as they were — a
18
+ * head cell sits on the `bottom` (a wrapped heading ends on the line above the
19
+ * figures), a body cell at the `top` (a wrapped note starts level with its row). A
20
+ * table whose cells are INPUTS wants `middle`: keksdose's VAT summary
21
+ * (invoices/vat-summary.tsx) sets fields beside derived read-out figures in one row,
22
+ * and a figure at the top of its cell sat above the middle of the field next to it —
23
+ * so it writes `<tr className="align-middle">` over raw `<th>`/`<td>`, because the
24
+ * kit's cells each stated `align-top` and a class on the row could not reach them.
25
+ */
26
+ export type TableVAlign = "top" | "middle" | "bottom";
27
+ /** The head cell's type size: `xs` (the head's default) or `sm` (the body's). */
28
+ export type TableHeaderCellSize = "xs" | "sm";
29
+ /** The header cell's weight. Default `medium`. */
30
+ export type TableHeaderCellWeight = "normal" | "medium" | "semibold";
16
31
 
17
32
  interface TableContextValue {
18
33
  density: TableDensity;
@@ -33,6 +48,28 @@ const TableContext = createContext<TableContextValue>({
33
48
  /** Which section a row sits in — zebra and hover apply to body rows only. */
34
49
  const SectionContext = createContext<"head" | "body" | "foot">("body");
35
50
 
51
+ /** A {@link TableRow}'s `valign`, which its cells take unless they set their own. It
52
+ * cannot be left to CSS inheritance: every cell states its own default alignment,
53
+ * and a class on the cell beats one on the row. */
54
+ const RowVAlignContext = createContext<TableVAlign | undefined>(undefined);
55
+
56
+ const VALIGN: Record<TableVAlign, string> = {
57
+ top: "align-top",
58
+ middle: "align-middle",
59
+ bottom: "align-bottom",
60
+ };
61
+
62
+ const HEADER_SIZE: Record<TableHeaderCellSize, string> = {
63
+ xs: "text-xs",
64
+ sm: "text-sm",
65
+ };
66
+
67
+ const HEADER_WEIGHT: Record<TableHeaderCellWeight, string> = {
68
+ normal: "font-normal",
69
+ medium: "font-medium",
70
+ semibold: "font-semibold",
71
+ };
72
+
36
73
  const CELL_PAD: Record<TableDensity, string> = {
37
74
  comfortable: "px-3 py-2",
38
75
  compact: "px-2 py-1",
@@ -125,6 +162,7 @@ export function Table({
125
162
  {...(overflowing ? regionName : null)}
126
163
  tabIndex={overflowing ? 0 : undefined}
127
164
  data-overflowing={overflowing || undefined}
165
+ data-clips=""
128
166
  className={cn(
129
167
  // `relative`: the containing block for an `sr-only` caption.
130
168
  "relative w-full overflow-x-auto",
@@ -252,23 +290,31 @@ export function TableFoot({ className, ...rest }: TableFootProps) {
252
290
  );
253
291
  }
254
292
 
255
- export type TableRowProps = ComponentPropsWithoutRef<"tr">;
293
+ /** The deprecated HTML `valign` attribute is replaced by a class-backed one. */
294
+ export interface TableRowProps extends ComponentPropsWithoutRef<"tr"> {
295
+ /** Vertical alignment for every cell of the row that does not set its own. See
296
+ * {@link TableVAlign}. Left out, each cell keeps its default. */
297
+ valign?: TableVAlign;
298
+ }
256
299
 
257
- export function TableRow({ className, ...rest }: TableRowProps) {
300
+ export function TableRow({ valign, className, ...rest }: TableRowProps) {
258
301
  const { zebra, hover } = useContext(TableContext);
259
302
  const section = useContext(SectionContext);
260
303
  const body = section === "body";
261
- return (
304
+ const row = (
262
305
  <tr
263
306
  {...rest}
264
307
  className={cn(
265
308
  body && "border-b border-[var(--border)]",
266
309
  body && zebra && "even:bg-[var(--bg-surface-2)]",
267
310
  body && hover && "transition-colors hover:bg-[var(--bg-hover)]",
311
+ // On the row as well, for a raw `<td>` of the caller's, which inherits it.
312
+ valign && VALIGN[valign],
268
313
  className,
269
314
  )}
270
315
  />
271
316
  );
317
+ return valign ? <RowVAlignContext.Provider value={valign}>{row}</RowVAlignContext.Provider> : row;
272
318
  }
273
319
 
274
320
  interface CellAlignProps {
@@ -276,19 +322,44 @@ interface CellAlignProps {
276
322
  numeric?: boolean;
277
323
  /** Logical alignment. Default `start` (`end` when `numeric`). */
278
324
  align?: TableAlign;
325
+ /** Vertical alignment. Default: the row's `valign`, else `bottom` for a head cell and
326
+ * `top` for a body cell. See {@link TableVAlign}. */
327
+ valign?: TableVAlign;
279
328
  }
280
329
 
281
- /** The deprecated HTML `align` attribute is replaced by a logical one. */
282
- export interface TableHeaderCellProps extends Omit<ComponentPropsWithoutRef<"th">, "align">, CellAlignProps {}
330
+ /** The deprecated HTML `align` and `valign` attributes are replaced by class-backed ones. */
331
+ export interface TableHeaderCellProps extends Omit<ComponentPropsWithoutRef<"th">, "align" | "valign">, CellAlignProps {
332
+ /**
333
+ * Type size. Left out: `xs` in the head, the table's own size in the body — as
334
+ * before. keksdose's VAT summary heads its columns at the body size and normal weight
335
+ * (vat-summary.tsx:158–168, `font-normal` on each `<th>`), a quiet header over a
336
+ * small table of figures, and could not say so without overriding classes.
337
+ */
338
+ size?: TableHeaderCellSize;
339
+ /** Default `medium`. `normal` for the quiet header above; see {@link size}. */
340
+ weight?: TableHeaderCellWeight;
341
+ }
283
342
 
284
343
  /**
285
344
  * A `<th>`. `scope` defaults to `col` in the head and `row` in the body — a header
286
345
  * cell in a body row is the row's label ("Net rent" in a key-value table), and a
287
346
  * reader needs the scope to read it with each cell beside it.
288
347
  */
289
- export function TableHeaderCell({ numeric = false, align, scope, className, ...rest }: TableHeaderCellProps) {
348
+ export function TableHeaderCell({
349
+ numeric = false,
350
+ align,
351
+ valign,
352
+ size,
353
+ weight = "medium",
354
+ scope,
355
+ className,
356
+ ...rest
357
+ }: TableHeaderCellProps) {
290
358
  const { density } = useContext(TableContext);
291
359
  const section = useContext(SectionContext);
360
+ const rowVAlign = useContext(RowVAlignContext);
361
+ const head = section === "head";
362
+ const textSize = size ?? (head ? "xs" : undefined);
292
363
  return (
293
364
  <th
294
365
  {...rest}
@@ -297,24 +368,27 @@ export function TableHeaderCell({ numeric = false, align, scope, className, ...r
297
368
  CELL_PAD[density],
298
369
  ALIGN[align ?? (numeric ? "end" : "start")],
299
370
  numeric && "tabular-nums",
300
- "align-bottom font-medium",
301
- section === "head" ? "text-xs text-[var(--text-muted)]" : "align-top text-[var(--text-secondary)]",
371
+ VALIGN[valign ?? rowVAlign ?? (head ? "bottom" : "top")],
372
+ HEADER_WEIGHT[weight],
373
+ head ? "text-[var(--text-muted)]" : "text-[var(--text-secondary)]",
374
+ textSize && HEADER_SIZE[textSize],
302
375
  className,
303
376
  )}
304
377
  />
305
378
  );
306
379
  }
307
380
 
308
- export interface TableCellProps extends Omit<ComponentPropsWithoutRef<"td">, "align">, CellAlignProps {}
381
+ export interface TableCellProps extends Omit<ComponentPropsWithoutRef<"td">, "align" | "valign">, CellAlignProps {}
309
382
 
310
- export function TableCell({ numeric = false, align, className, ...rest }: TableCellProps) {
383
+ export function TableCell({ numeric = false, align, valign, className, ...rest }: TableCellProps) {
311
384
  const { density } = useContext(TableContext);
385
+ const rowVAlign = useContext(RowVAlignContext);
312
386
  return (
313
387
  <td
314
388
  {...rest}
315
389
  className={cn(
316
390
  CELL_PAD[density],
317
- "align-top",
391
+ VALIGN[valign ?? rowVAlign ?? "top"],
318
392
  ALIGN[align ?? (numeric ? "end" : "start")],
319
393
  numeric && "tabular-nums whitespace-nowrap",
320
394
  className,
@@ -9,8 +9,15 @@ import { cn } from "../lib/cn";
9
9
  * (control-page.tsx:119/123/127) draw. Two sizes because the label sits either inside a
10
10
  * dense panel (a legend, a menu) or above a block of page content, and one size looked
11
11
  * wrong in the other place.
12
+ *
13
+ * `md` (0.11.0) is 11px, the rung between them: keksdose's budget-summary card labels
14
+ * its figures ("Assigned", "Activity", "Upcoming", "Available" —
15
+ * budget-summary-card:258/264/286/297) and its support thread its day divider and a message's meta line
16
+ * (support-thread:153/223) at `text-[11px]` by hand — 10px read as a footnote under a
17
+ * figure that size, 12px competed with it. Named `md` rather than slotted in order
18
+ * because `sm` was already the default and renaming it would move every caller.
12
19
  */
13
- export type SectionLabelSize = "xs" | "sm";
20
+ export type SectionLabelSize = "xs" | "md" | "sm";
14
21
 
15
22
  /**
16
23
  * The small, uppercase, tracked, muted label — as a class string, per size.
@@ -23,6 +30,7 @@ export type SectionLabelSize = "xs" | "sm";
23
30
  */
24
31
  export const SECTION_LABEL_CLASS: Record<SectionLabelSize, string> = {
25
32
  xs: "text-[10px] font-semibold uppercase tracking-wide text-[var(--text-muted)]",
33
+ md: "text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]",
26
34
  sm: "text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]",
27
35
  };
28
36
 
@@ -76,11 +76,25 @@ export interface ToggleGroupBaseProps<T extends string>
76
76
  error?: ReactNode;
77
77
  }
78
78
 
79
+ /**
80
+ * A line under the group saying what the CHOSEN option means — lenkbank's ToggleField
81
+ * `hint` (features/gear/common.tsx:97): not help behind a "?" but a caption, and one
82
+ * that changes as the choice does. Pass a function of the value for that; it is
83
+ * attached with `aria-describedby`, and a function caption is also a polite live
84
+ * region, because a description is read when the group is entered and not again when
85
+ * an arrow key changes the choice underneath it.
86
+ *
87
+ * In muted 11px text under the field (or the bare group), above an `error`.
88
+ */
89
+ type ToggleGroupCaption<V> = ReactNode | ((value: V) => ReactNode);
90
+
79
91
  /** The group as it has always been: one option is always the answer. */
80
92
  export interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupBaseProps<T> {
81
93
  allowEmpty?: false;
82
94
  value: T;
83
95
  onChange: (value: T) => void;
96
+ /** See {@link ToggleGroupCaption}. */
97
+ caption?: ToggleGroupCaption<T>;
84
98
  }
85
99
 
86
100
  /**
@@ -97,6 +111,8 @@ export interface ToggleGroupClearableProps<T extends string> extends ToggleGroup
97
111
  allowEmpty: true;
98
112
  value: T | null;
99
113
  onChange: (value: T | null) => void;
114
+ /** See {@link ToggleGroupCaption}. `null` while nothing is chosen. */
115
+ caption?: ToggleGroupCaption<T | null>;
100
116
  }
101
117
 
102
118
  /** `allowEmpty` picks the shape, so `onChange` is typed `(T) => void` unless the group
@@ -138,7 +154,11 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
138
154
  const hasError = field && error !== undefined && error !== null && error !== false && error !== "";
139
155
  // Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is
140
156
  // what lets the `onChange` below be called with `null` only in the mode that allows it.
141
- const { allowEmpty: _allowEmpty, onChange: _onChange, ...rest } = restWithMode;
157
+ const { allowEmpty: _allowEmpty, onChange: _onChange, caption, ...rest } = restWithMode;
158
+ const captionId = useId();
159
+ const captionIsLive = typeof caption === "function";
160
+ const captionNode = captionIsLive ? (caption as (v: T | null) => ReactNode)(value) : caption;
161
+ const hasCaption = captionNode !== undefined && captionNode !== null && captionNode !== false && captionNode !== "";
142
162
  const choose = (next: T) => {
143
163
  if (props.allowEmpty) props.onChange(next === value ? null : next);
144
164
  else props.onChange(next);
@@ -185,7 +205,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
185
205
  aria-labelledby={field && ariaLabelAttr === undefined && ariaLabel === undefined ? labelId : rest["aria-labelledby"]}
186
206
  aria-invalid={hasError || rest["aria-invalid"] || undefined}
187
207
  aria-describedby={
188
- hasError ? (rest["aria-describedby"] ? `${rest["aria-describedby"]} ${errorId}` : errorId) : rest["aria-describedby"]
208
+ [rest["aria-describedby"], hasCaption && captionId, hasError && errorId].filter(Boolean).join(" ") || undefined
189
209
  }
190
210
  // `aria-disabled` on the group as well as `disabled` on each button: a radio
191
211
  // group is what the user is being refused, and a screen reader announcing
@@ -273,7 +293,31 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
273
293
  })}
274
294
  </div>
275
295
  );
276
- if (!field) return group;
296
+ // Rendered whenever the caption is — `aria-live` must be on the element BEFORE its
297
+ // text changes, or the change is not announced — but empty (no height, no margin)
298
+ // when there is nothing to say, so a function caption that returns null for some
299
+ // options leaves no gap. Empty rather than `hidden`: some readers do not announce
300
+ // text that appears inside an element coming back from `display: none`.
301
+ const captionEl =
302
+ hasCaption || captionIsLive ? (
303
+ <p
304
+ id={captionId}
305
+ aria-live={captionIsLive ? "polite" : undefined}
306
+ className={cn("text-[11px] leading-snug text-[var(--text-muted)]", hasCaption && "mt-1")}
307
+ >
308
+ {hasCaption ? captionNode : null}
309
+ </p>
310
+ ) : null;
311
+ if (!field) {
312
+ if (!captionEl) return group;
313
+ // The group keeps its `className`, as without a caption; the wrapper only stacks.
314
+ return (
315
+ <div className="min-w-0">
316
+ {group}
317
+ {captionEl}
318
+ </div>
319
+ );
320
+ }
277
321
  return (
278
322
  // `h-full` + `flex-1`: the chrome fills a grid or stretched flex row, which is what
279
323
  // levels it with a select beside it whatever the browser makes of the select.
@@ -294,6 +338,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
294
338
  {group}
295
339
  </div>
296
340
  </FloatingField>
341
+ {captionEl}
297
342
  {hasError && (
298
343
  <p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
299
344
  {error}
@@ -17,6 +17,13 @@ import { useAnchoredRect, type AnchorRect } from "../hooks/use-anchored-rect";
17
17
  import { dirOf, type Direction } from "../lib/direction";
18
18
  import { hasClippingAncestor } from "../lib/clipping";
19
19
 
20
+ /** The attribute that marks an element as a clipping container for {@link Tooltip}'s
21
+ * auto-portal, whatever its computed `overflow` — `<div data-clips>` or
22
+ * `<div {...{ [CLIPS_ATTRIBUTE]: "" }}>`. Put it on an app's own scroller so a test
23
+ * environment without stylesheets (jsdom) portals the same tooltips the browser
24
+ * does. DataTable's body and Table's wrapper already carry it. */
25
+ export { CLIPS_ATTRIBUTE } from "../lib/clipping";
26
+
20
27
  /** Where the bubble sits. `start` / `end` follow the reading direction — `end` is the
21
28
  * right in LTR and the left in RTL — and are what a layout that mirrors should ask
22
29
  * for. `left` / `right` stay physical, for a bubble tied to something that does not
@@ -75,8 +82,8 @@ export interface TooltipProps extends ComponentPropsWithoutRef<"span"> {
75
82
  /**
76
83
  * Where the bubble lives. Left out (the default since 0.10.0), the tooltip decides for
77
84
  * itself: the bubble stays next to the trigger unless an ancestor clips or scrolls
78
- * (`overflow` other than `visible`), in which case it is portalled — see "Inside a
79
- * scroll container" below. `true` always portals, `false` never does; both are exactly
85
+ * (`overflow` other than `visible`, or the {@link CLIPS_ATTRIBUTE} marker), in which
86
+ * case it is portalled — see "Inside a scroll container" below. `true` always portals, `false` never does; both are exactly
80
87
  * what they were before the default existed.
81
88
  */
82
89
  portal?: boolean;
@@ -144,7 +151,14 @@ type TooltipVariantProps = Omit<TooltipProps, "side" | "portal"> & { side: Toolt
144
151
  * being one: every tooltip in a table, a drawer or a scrolling card had to remember it,
145
152
  * and the ones that forgot were only found by someone scrolling sideways. So with
146
153
  * `portal` left out, the tooltip looks for a clipping ancestor itself — any element
147
- * between it and `<body>` whose computed `overflow-x` / `overflow-y` is not `visible`.
154
+ * between it and `<body>` whose computed `overflow-x` / `overflow-y` is not `visible`,
155
+ * or that carries {@link CLIPS_ATTRIBUTE} (`data-clips`). The marker is what makes the
156
+ * answer the same under test: jsdom computes no Tailwind, so there every scroller
157
+ * reads `visible` and a table-cell tooltip used to stay in place — its always-mounted
158
+ * bubble then repeated the label in the cell's accessible name and `textContent`
159
+ * ("CheckingChecking"), and apps pinned `portal` to stop it. The kit's own scrollers
160
+ * are marked, so a tooltip in a DataTable or Table cell portals in jsdom exactly as it
161
+ * does in the browser.
148
162
  * It looks at MOUNT, not only on open: dev#488's phantom scroll is caused by a bubble
149
163
  * nobody opened, so a check that waited for the hover would find the damage already
150
164
  * done. It looks again on every open, for a container that started scrolling after