@eifi1/ui-kit 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/README.md +25 -11
  2. package/dist/components/alert-banner.d.ts +32 -5
  3. package/dist/components/alert-banner.js +57 -14
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +6 -0
  6. package/dist/components/breadcrumbs.d.ts +60 -0
  7. package/dist/components/breadcrumbs.js +88 -0
  8. package/dist/components/breadcrumbs.js.map +1 -0
  9. package/dist/components/bulk-action-bar.d.ts +66 -0
  10. package/dist/components/bulk-action-bar.js +149 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/calculator.d.ts +6 -0
  13. package/dist/components/chip.d.ts +58 -10
  14. package/dist/components/chip.js +91 -7
  15. package/dist/components/chip.js.map +1 -1
  16. package/dist/components/copy-button.d.ts +13 -4
  17. package/dist/components/copy-button.js +17 -4
  18. package/dist/components/copy-button.js.map +1 -1
  19. package/dist/components/data-table-filter-popover.d.ts +1 -1
  20. package/dist/components/data-table-filters.d.ts +1 -1
  21. package/dist/components/data-table.d.ts +1 -1
  22. package/dist/components/data-table.js +122 -91
  23. package/dist/components/data-table.js.map +1 -1
  24. package/dist/components/date-picker.d.ts +89 -3
  25. package/dist/components/date-picker.js +163 -68
  26. package/dist/components/date-picker.js.map +1 -1
  27. package/dist/components/description-list.d.ts +43 -6
  28. package/dist/components/description-list.js +91 -11
  29. package/dist/components/description-list.js.map +1 -1
  30. package/dist/components/dialog-frame.d.ts +7 -0
  31. package/dist/components/dialog-frame.js.map +1 -1
  32. package/dist/components/disclosure.d.ts +68 -4
  33. package/dist/components/disclosure.js +33 -15
  34. package/dist/components/disclosure.js.map +1 -1
  35. package/dist/components/file-dropzone.d.ts +3 -4
  36. package/dist/components/file-dropzone.js +2 -1
  37. package/dist/components/file-dropzone.js.map +1 -1
  38. package/dist/components/floating-panel.d.ts +29 -2
  39. package/dist/components/floating-panel.js +16 -2
  40. package/dist/components/floating-panel.js.map +1 -1
  41. package/dist/components/full-bleed-dialog.d.ts +23 -4
  42. package/dist/components/full-bleed-dialog.js +11 -2
  43. package/dist/components/full-bleed-dialog.js.map +1 -1
  44. package/dist/components/list.d.ts +174 -0
  45. package/dist/components/list.js +215 -0
  46. package/dist/components/list.js.map +1 -0
  47. package/dist/components/menu-item.d.ts +99 -0
  48. package/dist/components/menu-item.js +83 -0
  49. package/dist/components/menu-item.js.map +1 -0
  50. package/dist/components/modal.d.ts +23 -1
  51. package/dist/components/modal.js +36 -10
  52. package/dist/components/modal.js.map +1 -1
  53. package/dist/components/number-field.d.ts +6 -0
  54. package/dist/components/number-input.d.ts +6 -0
  55. package/dist/components/numpad-sheet.d.ts +6 -0
  56. package/dist/components/page-contents.js +1 -2
  57. package/dist/components/page-contents.js.map +1 -1
  58. package/dist/components/page-header.d.ts +37 -0
  59. package/dist/components/page-header.js +36 -0
  60. package/dist/components/page-header.js.map +1 -0
  61. package/dist/components/progress-bar.d.ts +16 -2
  62. package/dist/components/progress-bar.js +4 -2
  63. package/dist/components/progress-bar.js.map +1 -1
  64. package/dist/components/series-chart-labels.d.ts +3 -0
  65. package/dist/components/series-chart-labels.js +2 -1
  66. package/dist/components/series-chart-labels.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +16 -0
  68. package/dist/components/series-chart.js +137 -3
  69. package/dist/components/series-chart.js.map +1 -1
  70. package/dist/components/status-dot.d.ts +49 -0
  71. package/dist/components/status-dot.js +52 -0
  72. package/dist/components/status-dot.js.map +1 -0
  73. package/dist/components/table.d.ts +39 -6
  74. package/dist/components/table.js +43 -4
  75. package/dist/components/table.js.map +1 -1
  76. package/dist/components/text.d.ts +63 -0
  77. package/dist/components/text.js +23 -0
  78. package/dist/components/text.js.map +1 -0
  79. package/dist/components/toast.d.ts +167 -0
  80. package/dist/components/toast.js +226 -0
  81. package/dist/components/toast.js.map +1 -0
  82. package/dist/components/toggle-group.d.ts +29 -1
  83. package/dist/components/toggle-group.js +52 -3
  84. package/dist/components/toggle-group.js.map +1 -1
  85. package/dist/components/tooltip.d.ts +38 -15
  86. package/dist/components/tooltip.js +85 -47
  87. package/dist/components/tooltip.js.map +1 -1
  88. package/dist/components/ui.d.ts +139 -16
  89. package/dist/components/ui.js +136 -26
  90. package/dist/components/ui.js.map +1 -1
  91. package/dist/components/use-table-state.d.ts +1 -1
  92. package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
  93. package/dist/data-table.d.ts +1 -1
  94. package/dist/hooks/use-close-transition.d.ts +7 -1
  95. package/dist/hooks/use-close-transition.js +5 -2
  96. package/dist/hooks/use-close-transition.js.map +1 -1
  97. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  98. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  99. package/dist/hooks/use-overlay-history.js +84 -4
  100. package/dist/hooks/use-overlay-history.js.map +1 -1
  101. package/dist/i18n/defaults.d.ts +6 -0
  102. package/dist/i18n/defaults.js +11 -1
  103. package/dist/i18n/defaults.js.map +1 -1
  104. package/dist/i18n/kit-labels.d.ts +11 -0
  105. package/dist/i18n/kit-labels.js.map +1 -1
  106. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  107. package/dist/i18n/locales/de-CH.d.ts +6 -0
  108. package/dist/i18n/locales/de-informal.d.ts +6 -0
  109. package/dist/i18n/locales/de.d.ts +6 -0
  110. package/dist/i18n/locales/de.js +31 -1
  111. package/dist/i18n/locales/de.js.map +1 -1
  112. package/dist/i18n/locales/es.d.ts +6 -0
  113. package/dist/i18n/locales/es.js +31 -1
  114. package/dist/i18n/locales/es.js.map +1 -1
  115. package/dist/i18n/locales/fr.d.ts +6 -0
  116. package/dist/i18n/locales/fr.js +31 -1
  117. package/dist/i18n/locales/fr.js.map +1 -1
  118. package/dist/i18n/locales/hu.d.ts +6 -0
  119. package/dist/i18n/locales/hu.js +31 -1
  120. package/dist/i18n/locales/hu.js.map +1 -1
  121. package/dist/i18n/locales/it.d.ts +6 -0
  122. package/dist/i18n/locales/it.js +31 -1
  123. package/dist/i18n/locales/it.js.map +1 -1
  124. package/dist/i18n/locales/zh.d.ts +6 -0
  125. package/dist/i18n/locales/zh.js +31 -1
  126. package/dist/i18n/locales/zh.js.map +1 -1
  127. package/dist/index.d.ts +18 -8
  128. package/dist/index.js +14 -0
  129. package/dist/index.js.map +1 -1
  130. package/dist/lib/clipping.d.ts +9 -0
  131. package/dist/lib/clipping.js +14 -0
  132. package/dist/lib/clipping.js.map +1 -0
  133. package/dist/search/command-palette.d.ts +67 -2
  134. package/dist/search/command-palette.js +147 -33
  135. package/dist/search/command-palette.js.map +1 -1
  136. package/dist/search/global-search.d.ts +166 -0
  137. package/dist/search/global-search.js +293 -0
  138. package/dist/search/global-search.js.map +1 -0
  139. package/dist/search/search-index.d.ts +105 -0
  140. package/dist/search/search-index.js +142 -0
  141. package/dist/search/search-index.js.map +1 -0
  142. package/dist/search.d.ts +3 -1
  143. package/dist/search.js +2 -0
  144. package/dist/search.js.map +1 -1
  145. package/dist/wizard/stepper-nav.d.ts +10 -1
  146. package/dist/wizard/stepper-nav.js +3 -2
  147. package/dist/wizard/stepper-nav.js.map +1 -1
  148. package/dist/wizard/types.d.ts +2 -2
  149. package/dist/wizard/types.js.map +1 -1
  150. package/dist/wizard/use-wizard.js +1 -1
  151. package/dist/wizard/use-wizard.js.map +1 -1
  152. package/dist/wizard/wizard-summary.js +3 -3
  153. package/dist/wizard/wizard-summary.js.map +1 -1
  154. package/package.json +25 -12
  155. package/src/components/alert-banner.tsx +117 -18
  156. package/src/components/breadcrumbs.tsx +168 -0
  157. package/src/components/bulk-action-bar.tsx +256 -0
  158. package/src/components/chip.tsx +164 -13
  159. package/src/components/copy-button.tsx +29 -7
  160. package/src/components/data-table.tsx +81 -5
  161. package/src/components/date-picker.tsx +377 -118
  162. package/src/components/description-list.tsx +168 -15
  163. package/src/components/dialog-frame.tsx +7 -0
  164. package/src/components/disclosure.tsx +116 -28
  165. package/src/components/file-dropzone.tsx +9 -10
  166. package/src/components/floating-panel.tsx +46 -2
  167. package/src/components/full-bleed-dialog.tsx +42 -5
  168. package/src/components/list.tsx +417 -0
  169. package/src/components/menu-item.tsx +214 -0
  170. package/src/components/modal.tsx +80 -17
  171. package/src/components/page-contents.tsx +4 -4
  172. package/src/components/page-header.tsx +68 -0
  173. package/src/components/progress-bar.tsx +27 -3
  174. package/src/components/series-chart-labels.ts +4 -0
  175. package/src/components/series-chart.tsx +253 -3
  176. package/src/components/status-dot.tsx +99 -0
  177. package/src/components/table.tsx +91 -6
  178. package/src/components/text.tsx +89 -0
  179. package/src/components/toast.tsx +441 -0
  180. package/src/components/toggle-group.tsx +82 -3
  181. package/src/components/tooltip.tsx +186 -100
  182. package/src/components/ui.tsx +321 -41
  183. package/src/hooks/use-close-transition.ts +14 -5
  184. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  185. package/src/hooks/use-overlay-history.ts +181 -4
  186. package/src/i18n/defaults.ts +10 -0
  187. package/src/i18n/kit-labels.tsx +10 -0
  188. package/src/i18n/locales/de.ts +30 -0
  189. package/src/i18n/locales/es.ts +30 -0
  190. package/src/i18n/locales/fr.ts +30 -0
  191. package/src/i18n/locales/hu.ts +30 -0
  192. package/src/i18n/locales/it.ts +30 -0
  193. package/src/i18n/locales/zh.ts +30 -0
  194. package/src/index.ts +30 -0
  195. package/src/lib/clipping.ts +16 -0
  196. package/src/search/command-palette.tsx +241 -31
  197. package/src/search/global-search.tsx +525 -0
  198. package/src/search/search-index.ts +295 -0
  199. package/src/search.ts +2 -0
  200. package/src/wizard/stepper-nav.tsx +13 -2
  201. package/src/wizard/types.ts +2 -2
  202. package/src/wizard/use-wizard.ts +3 -3
  203. package/src/wizard/wizard-summary.tsx +14 -10
  204. package/tokens.css +97 -0
@@ -26,8 +26,8 @@
26
26
  // "category"`) or real dates (`x.type: "time"`) instead of a number the app had to
27
27
  // invent. What each of those does to the zoom is decided in one place,
28
28
  // `defaultZoomAxes` in `chart-zoom.tsx`.
29
- import { useMemo } from "react";
30
- import type { ReactNode, RefObject } from "react";
29
+ import { useMemo, useRef, useState } from "react";
30
+ import type { KeyboardEvent, ReactNode, RefObject } from "react";
31
31
  import {
32
32
  Area,
33
33
  Bar,
@@ -37,8 +37,12 @@ import {
37
37
  Line,
38
38
  ReferenceDot,
39
39
  ReferenceLine,
40
+ Rectangle,
40
41
  XAxis,
41
42
  YAxis,
43
+ usePlotArea,
44
+ useXAxisScale,
45
+ type BarShapeProps,
42
46
  } from "recharts";
43
47
  import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "./chart";
44
48
  import {
@@ -202,6 +206,22 @@ interface SeriesChartAxisShape {
202
206
  * a hairline). A pinned `domain` is left alone either way.
203
207
  */
204
208
  includeZero?: boolean;
209
+ /**
210
+ * Tick only on whole numbers — the y counterpart of `SeriesChartX.integerTicks`, and
211
+ * what keksdose's user-count axis (`metrics-panel.tsx`, `wholeTicks`) and its runway
212
+ * in days (`cash-buffer-chart.tsx`, `wholeDayTicks`) each wrote by hand: a band of
213
+ * [0, 3.1] users is ticked 0 1 2 3, not 0 0.5 1 … 3, and a short runway is not ticked
214
+ * 0 / 0.5 / 1 under a rounding formatter that prints "1" twice. A (zoom) window that
215
+ * holds no whole number falls back to the ordinary ticks rather than to none.
216
+ *
217
+ * AUTOMATIC by default — on when every value this axis carries is a whole number
218
+ * (counts), because that is where a half tick is a value no sample can have, and it
219
+ * changes nothing on a span wider than about eight, which already ticks on whole
220
+ * steps. `true` forces it over fractional data (days of runway computed as 43.7);
221
+ * `false` keeps fractional ticks for a continuous quantity that merely happens to be
222
+ * whole in this sample. `tickValues` wins over either.
223
+ */
224
+ integerTicks?: boolean;
205
225
  }
206
226
 
207
227
  /**
@@ -818,6 +838,150 @@ const STEP_WIDTH = 1.5;
818
838
  * which recharts cannot tell apart per cell, so stacks stay square. */
819
839
  const BAR_RADIUS: [number, number, number, number] = [3, 3, 0, 0];
820
840
 
841
+ /**
842
+ * One keyboard stop of a clickable chart: a bar, or (a chart with no bars) a whole slot.
843
+ * `from`/`to` bound its slot on the plotted number line — half way to each neighbour,
844
+ * or to the edge of the window on show.
845
+ */
846
+ interface PointStop {
847
+ index: number;
848
+ /** The bar's series; absent for a slot. */
849
+ key?: string;
850
+ from: number;
851
+ to: number;
852
+ /** The accessible name: where it is and what it says. */
853
+ name: string;
854
+ }
855
+
856
+ /**
857
+ * The shape of a series whose bar has keyboard focus: recharts' own rectangle, with an
858
+ * outline on the one bar at `index` — drawn at the bar's exact geometry, which an
859
+ * overlay could only guess at. Not `activeBar`: that follows the tooltip's active index,
860
+ * which recharts stops taking from `defaultIndex` once a pointer has hovered the chart.
861
+ */
862
+ function focusedBarShape(index: number) {
863
+ return function FocusedBar(props: BarShapeProps) {
864
+ const focused = props.originalDataIndex === index;
865
+ return (
866
+ <Rectangle {...props} {...(focused ? { stroke: "var(--text-primary)", strokeWidth: 2 } : {})} />
867
+ );
868
+ };
869
+ }
870
+
871
+ /**
872
+ * The keyboard's way onto a clickable chart (`onPointClick`): one focusable `<rect>` per
873
+ * bar, or per slot when there are no bars, laid over the plot INSIDE the recharts chart,
874
+ * where the x scale answers.
875
+ *
876
+ * keksdose drew its weekday pattern as seven real `<button>`s (spending-tab.tsx) because
877
+ * a recharts bar is reachable by nothing but the pointer. This is that, for every chart:
878
+ *
879
+ * - ONE tab stop (roving `tabIndex`): Tab moves past the chart, not through 30 bars.
880
+ * - ←/→ step through the stops in order, ↑/↓ between the bars of one slot, Home/End.
881
+ * - ↵ and Space report the stop as a click would — with the series for a bar.
882
+ * - Each is a `role="button"` named "period — series: value".
883
+ *
884
+ * The stops take NO pointer events, so the drag-to-zoom layer, the tooltip and the
885
+ * pointer's own clicks underneath behave exactly as they did. Focus is shown by the
886
+ * slot tinted and, for a bar, its own outline (`focusedBarShape`) — and by the tooltip,
887
+ * moved to the stop through `defaultIndex`, which recharts honours until a pointer has
888
+ * hovered the chart.
889
+ */
890
+ function PointKeys({
891
+ stops,
892
+ current,
893
+ label,
894
+ onFocusStop,
895
+ onBlurStops,
896
+ onPick,
897
+ }: {
898
+ stops: readonly PointStop[];
899
+ current: number;
900
+ label: string;
901
+ onFocusStop: (at: number) => void;
902
+ onBlurStops: () => void;
903
+ onPick: (stop: PointStop) => void;
904
+ }) {
905
+ const plot = usePlotArea();
906
+ const scale = useXAxisScale();
907
+ const refs = useRef<(SVGRectElement | null)[]>([]);
908
+ if (!plot || !scale || plot.width <= 0 || plot.height <= 0) return null;
909
+
910
+ const move = (at: number) => {
911
+ const next = Math.max(0, Math.min(stops.length - 1, at));
912
+ refs.current[next]?.focus();
913
+ };
914
+ const onKeyDown = (at: number) => (event: KeyboardEvent<SVGRectElement>) => {
915
+ const stop = stops[at];
916
+ // ↑/↓ stay in the slot: the next or previous bar of the same period. The stops are
917
+ // in slot order, so a slot's bars are neighbours.
918
+ const inSlot = (step: 1 | -1) => (stops[at + step]?.index === stop.index ? at + step : at);
919
+ const target =
920
+ event.key === "ArrowRight"
921
+ ? at + 1
922
+ : event.key === "ArrowLeft"
923
+ ? at - 1
924
+ : event.key === "ArrowDown"
925
+ ? inSlot(1)
926
+ : event.key === "ArrowUp"
927
+ ? inSlot(-1)
928
+ : event.key === "Home"
929
+ ? 0
930
+ : event.key === "End"
931
+ ? stops.length - 1
932
+ : undefined;
933
+ if (target !== undefined) {
934
+ event.preventDefault();
935
+ move(target);
936
+ } else if (event.key === "Enter" || event.key === " ") {
937
+ event.preventDefault();
938
+ onPick(stop);
939
+ }
940
+ };
941
+
942
+ const left = plot.x;
943
+ const right = plot.x + plot.width;
944
+ return (
945
+ <g role="group" aria-label={label}>
946
+ {stops.map((stop, at) => {
947
+ // An edge with no neighbour runs to the plot's edge.
948
+ const a = finite(scale(stop.from)) ?? left;
949
+ const b = finite(scale(stop.to)) ?? right;
950
+ const x0 = Math.max(left, Math.min(a, b));
951
+ const x1 = Math.min(right, Math.max(a, b));
952
+ return (
953
+ <rect
954
+ key={`${stop.index}:${stop.key ?? ""}`}
955
+ ref={(node) => {
956
+ refs.current[at] = node;
957
+ }}
958
+ role="button"
959
+ tabIndex={at === current ? 0 : -1}
960
+ aria-label={stop.name}
961
+ data-point-key={stop.key}
962
+ x={x0}
963
+ y={plot.y}
964
+ width={Math.max(0, x1 - x0)}
965
+ height={plot.height}
966
+ fill="transparent"
967
+ // Focus only: the pointer's drag, hover and click belong to what is below.
968
+ pointerEvents="none"
969
+ strokeWidth={2}
970
+ className={cn(
971
+ "outline-none focus-visible:fill-[var(--brand)]/10",
972
+ // A bar outlines ITSELF (see `FOCUSED_BAR`); a slot has nothing else to.
973
+ stop.key === undefined && "focus-visible:stroke-[var(--brand)]",
974
+ )}
975
+ onFocus={() => onFocusStop(at)}
976
+ onBlur={onBlurStops}
977
+ onKeyDown={onKeyDown(at)}
978
+ />
979
+ );
980
+ })}
981
+ </g>
982
+ );
983
+ }
984
+
821
985
  interface PlotProps extends Omit<SeriesChartProps, "rows" | "x"> {
822
986
  /** The rows AS PLOTTED — what the zoom fits on. The caller's are `model.source`. */
823
987
  rows: readonly SeriesChartRow[];
@@ -851,6 +1015,10 @@ function SeriesPlot({
851
1015
  const labels = useKitLabels("seriesChart", DEFAULT_SERIES_CHART_LABELS, labelsProp);
852
1016
  const locale = useKitLocale(localeProp);
853
1017
  const number = useDefaultFormat(localeProp);
1018
+ // The keyboard stops' roving tab stop, and the one that has focus (see `PointKeys`).
1019
+ // Up here, above the empty state's early return, like every hook.
1020
+ const [keyStop, setKeyStop] = useState(0);
1021
+ const [keyFocus, setKeyFocus] = useState<number | undefined>(undefined);
854
1022
 
855
1023
  // A number is pixels, set inline; a string is a class. Either way the one value
856
1024
  // sizes both the chart and its empty state.
@@ -995,6 +1163,19 @@ function SeriesPlot({
995
1163
  series.some(
996
1164
  (entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id && (entry.type ?? "line") !== "line",
997
1165
  );
1166
+ // Whole-number ticks on an axis whose values are all whole — see
1167
+ // `SeriesChartAxis.integerTicks`. A stack of whole layers sums to a whole number, so
1168
+ // the layers are enough to look at.
1169
+ const wholeOnly = (axis: SeriesChartAxis) =>
1170
+ axis.integerTicks ??
1171
+ series
1172
+ .filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id)
1173
+ .every((entry) =>
1174
+ plotted.every((row) => {
1175
+ const value = finite(row[entry.key]);
1176
+ return value === undefined || Number.isInteger(value);
1177
+ }),
1178
+ );
998
1179
  const onAxis = (axisId: string) => (ref: { axis?: string }) => (ref.axis ?? DEFAULT_Y_AXIS) === axisId;
999
1180
  const fittedY = (axis: SeriesChartAxis): [number, number] | undefined => {
1000
1181
  const own = [
@@ -1024,6 +1205,48 @@ function SeriesPlot({
1024
1205
  return row ? { index, row, x: xOf(index), key } : undefined;
1025
1206
  };
1026
1207
 
1208
+ // The keyboard stops of a clickable chart — every bar with a value, slot by slot; or,
1209
+ // with no bars, every slot with any value. Only what is inside the window on show.
1210
+ const stops: PointStop[] = [];
1211
+ if (onPointClick) {
1212
+ const valueText = valueFormat ?? number;
1213
+ const nameOf = (entry: SeriesChartSeries) =>
1214
+ typeof entry.label === "string" || typeof entry.label === "number" ? String(entry.label) : entry.key;
1215
+ const said = (entry: SeriesChartSeries, value: number) => {
1216
+ const name = nameOf(entry);
1217
+ return name ? `${name}: ${valueText(value)}` : valueText(value);
1218
+ };
1219
+ const bars = series.filter((entry) => entry.type === "bar");
1220
+ const [low, high] = fittedX ?? [-Infinity, Infinity];
1221
+ const placed = plotted
1222
+ .map((row, index) => ({ index, at: model.kind === "category" ? index : finite(row[xKey]) }))
1223
+ .filter((slot): slot is { index: number; at: number } => slot.at !== undefined && slot.at >= low && slot.at <= high)
1224
+ .sort((a, b) => a.at - b.at);
1225
+ placed.forEach(({ index, at }, k) => {
1226
+ const from = k > 0 ? (placed[k - 1].at + at) / 2 : low;
1227
+ const to = k < placed.length - 1 ? (placed[k + 1].at + at) / 2 : high;
1228
+ // The heading the tooltip would give it, when that is text; else the tick.
1229
+ const heading = xLabel(at);
1230
+ const where = typeof heading === "string" && heading ? heading : xFormat(at);
1231
+ const valued = (bars.length ? bars : series)
1232
+ .map((entry) => ({ entry, value: finite(rows[index]?.[entry.key]) }))
1233
+ .filter((cell): cell is { entry: SeriesChartSeries; value: number } => cell.value !== undefined);
1234
+ if (bars.length) {
1235
+ for (const { entry, value } of valued) {
1236
+ stops.push({ index, key: entry.key, from, to, name: `${where} — ${said(entry, value)}` });
1237
+ }
1238
+ } else if (valued.length) {
1239
+ stops.push({
1240
+ index,
1241
+ from,
1242
+ to,
1243
+ name: `${where} — ${valued.map(({ entry, value }) => said(entry, value)).join(", ")}`,
1244
+ });
1245
+ }
1246
+ });
1247
+ }
1248
+ const focusedStop = keyFocus === undefined ? undefined : stops[keyFocus];
1249
+
1027
1250
  const dotFor = (entry: SeriesChartSeries) => {
1028
1251
  const want = entry.dot;
1029
1252
  if (!want) return false;
@@ -1080,6 +1303,9 @@ function SeriesPlot({
1080
1303
  // Positive and negative layers stacked apart, as `axisExtent` fits them — a
1081
1304
  // month's expenses hang below the axis instead of eating into its income.
1082
1305
  stackOffset="sign"
1306
+ // recharts' own keyboard layer makes the whole surface a tab stop whose arrows
1307
+ // move the tooltip — a second stop beside `PointKeys`, and one ↵ does nothing on.
1308
+ accessibilityLayer={!onPointClick}
1083
1309
  onClick={
1084
1310
  onPointClick
1085
1311
  ? (state) => {
@@ -1136,7 +1362,10 @@ function SeriesPlot({
1136
1362
  yAxisId={axis.id}
1137
1363
  hide={axis.hide}
1138
1364
  domain={domain}
1139
- ticks={resolveTicks(axis.tickValues, domain, finite) ?? niceTicks(domain)}
1365
+ ticks={
1366
+ resolveTicks(axis.tickValues, domain, finite) ??
1367
+ (wholeOnly(axis) ? (integerTicks(domain) ?? niceTicks(domain)) : niceTicks(domain))
1368
+ }
1140
1369
  allowDataOverflow={zoomed}
1141
1370
  orientation={axis.orientation ?? "left"}
1142
1371
  width={axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide)}
@@ -1170,6 +1399,8 @@ function SeriesPlot({
1170
1399
  // The content shifts itself 12 px off the cursor (or flips) against the
1171
1400
  // boundary; recharts' own offset on top would double it.
1172
1401
  {...(tooltip?.boundary ? { offset: 0 } : {})}
1402
+ // The keyboard's stop shows its tooltip as the pointer's would.
1403
+ defaultIndex={focusedStop?.index}
1173
1404
  content={
1174
1405
  <ChartTooltipContent
1175
1406
  labelFormatter={(value) => xLabel(Number(value))}
@@ -1196,6 +1427,9 @@ function SeriesPlot({
1196
1427
  fill={color}
1197
1428
  fillOpacity={entry.fillOpacity}
1198
1429
  radius={entry.stack === undefined ? BAR_RADIUS : 0}
1430
+ // Only the focused stop's series swaps its shape, and only for as long
1431
+ // as it has focus: every other bar keeps recharts' own.
1432
+ {...(focusedStop?.key === entry.key ? { shape: focusedBarShape(focusedStop.index) } : {})}
1199
1433
  onClick={
1200
1434
  onPointClick
1201
1435
  ? (_bar, index, event) => {
@@ -1320,6 +1554,22 @@ function SeriesPlot({
1320
1554
  />
1321
1555
  );
1322
1556
  })}
1557
+ {stops.length > 0 && (
1558
+ <PointKeys
1559
+ stops={stops}
1560
+ current={Math.min(keyStop, stops.length - 1)}
1561
+ label={labels.points}
1562
+ onFocusStop={(at) => {
1563
+ setKeyStop(at);
1564
+ setKeyFocus(at);
1565
+ }}
1566
+ onBlurStops={() => setKeyFocus(undefined)}
1567
+ onPick={(stop) => {
1568
+ const hit = hitAt(stop.index, stop.key);
1569
+ if (hit) onPointClick?.(hit);
1570
+ }}
1571
+ />
1572
+ )}
1323
1573
  {zoom?.layer}
1324
1574
  </ComposedChart>
1325
1575
  </ChartContainer>
@@ -0,0 +1,99 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+ import type { ProgressBarTone } from "./progress-bar";
4
+
5
+ /**
6
+ * The tones are `ProgressBar`'s, name for name — which are `Chip`'s, less nothing — so a
7
+ * dot in a legend, the bar it explains and the badge beside it can be given the same
8
+ * 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
+ */
11
+ export type StatusDotTone = ProgressBarTone;
12
+
13
+ /** `sm` 8px (keksdose's unread dot on the support trigger), `md` 10px (the avatar's
14
+ * unread dot), `lg` 12px (kastlan's occupancy legend swatch). */
15
+ export type StatusDotSize = "sm" | "md" | "lg";
16
+
17
+ const FILL: Record<StatusDotTone, string> = {
18
+ brand: "bg-[var(--brand)]",
19
+ neutral: "bg-[var(--text-muted)]",
20
+ success: "bg-[var(--success)]",
21
+ warning: "bg-[var(--warning)]",
22
+ danger: "bg-[var(--danger)]",
23
+ info: "bg-[var(--info)]",
24
+ income: "bg-[var(--money-income)]",
25
+ expense: "bg-[var(--money-expense)]",
26
+ };
27
+
28
+ const SIZE: Record<StatusDotSize, string> = { sm: "size-2", md: "size-2.5", lg: "size-3" };
29
+
30
+ export interface StatusDotProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
31
+ tone?: StatusDotTone;
32
+ size?: StatusDotSize;
33
+ /**
34
+ * Visible text beside the dot — a legend entry ("Rented") or a status ("Online"). It
35
+ * is also what a screen reader reads: the dot itself is decorative whenever there is
36
+ * a label.
37
+ *
38
+ * Without a label the dot is hidden from assistive technology, because a coloured
39
+ * circle says nothing to someone who cannot see it. Say the state where it IS read —
40
+ * keksdose's avatar trigger puts "3 unread" in its own `aria-label` — or give the dot
41
+ * an `aria-label` of its own, which makes it an image with that name.
42
+ */
43
+ label?: ReactNode;
44
+ /**
45
+ * A ring in the surface colour round the dot, so a dot laid over the corner of an
46
+ * icon or an avatar reads as a separate mark and not a smudge (keksdose top-bar.tsx
47
+ * :152, account-menu.tsx ~385, both hand-drawing `ring-2 ring-white
48
+ * dark:ring-slate-900`). Position it with `className` (`absolute -end-0.5 -top-0.5`).
49
+ */
50
+ ring?: boolean;
51
+ /** Extra classes for the dot itself; `className` goes on the outer element. */
52
+ dotClassName?: string;
53
+ }
54
+
55
+ /**
56
+ * A small token-coloured dot, with an optional label.
57
+ *
58
+ * kastlan's reporting dashboard (dashboard-tab.tsx:79) paints its occupancy legend
59
+ * with raw `bg-green-500` / `bg-red-500` / `bg-yellow-500`, which ignores the palette
60
+ * and the dark theme; keksdose draws two unread dots by hand in `rose-500`. Both are
61
+ * this.
62
+ */
63
+ export function StatusDot({
64
+ tone = "neutral",
65
+ size = "sm",
66
+ label,
67
+ ring = false,
68
+ className,
69
+ dotClassName,
70
+ "aria-label": ariaLabel,
71
+ ...rest
72
+ }: StatusDotProps) {
73
+ const dot = (
74
+ <span
75
+ className={cn(
76
+ "inline-block shrink-0 rounded-full",
77
+ SIZE[size],
78
+ FILL[tone],
79
+ ring && "ring-2 ring-[var(--bg-surface)]",
80
+ label == null && className,
81
+ dotClassName,
82
+ )}
83
+ {...(label == null
84
+ ? ariaLabel
85
+ ? { role: "img", "aria-label": ariaLabel, ...rest }
86
+ : { "aria-hidden": true, ...rest }
87
+ : { "aria-hidden": true })}
88
+ />
89
+ );
90
+ if (label == null) return dot;
91
+ return (
92
+ // No `aria-label` here: the visible label is the text, and a name on a role-less
93
+ // span is one ARIA forbids and screen readers ignore.
94
+ <span {...rest} className={cn("inline-flex min-w-0 items-center gap-1.5 text-sm", className)}>
95
+ {dot}
96
+ <span className="min-w-0 truncate">{label}</span>
97
+ </span>
98
+ );
99
+ }
@@ -1,9 +1,17 @@
1
- import { createContext, useContext, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
2
- import type { ComponentPropsWithoutRef } from "react";
1
+ import { Children, createContext, useContext, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { THIN_SCROLLBAR_CLASS, useScrollOverflow } from "./scroll-area";
5
5
 
6
- export type TableDensity = "comfortable" | "compact";
6
+ /** `none` (0.10.0): no cell padding at all — keksdose's VAT summary, a table set
7
+ * inside a card's own padding whose columns are spaced by hand (`pl-2` on exactly the
8
+ * columns that need it). Type stays at the body size, as `comfortable`. */
9
+ export type TableDensity = "comfortable" | "compact" | "none";
10
+ /** CSS `table-layout`. `auto` (the browser default) sizes columns to their content;
11
+ * `fixed` takes widths from the first row and ignores the rest — keksdose's VAT
12
+ * summary switches to it while editing so two inputs stop bidding against the
13
+ * figures for room. */
14
+ export type TableLayout = "auto" | "fixed";
7
15
  export type TableAlign = "start" | "center" | "end";
8
16
 
9
17
  interface TableContextValue {
@@ -28,6 +36,7 @@ const SectionContext = createContext<"head" | "body" | "foot">("body");
28
36
  const CELL_PAD: Record<TableDensity, string> = {
29
37
  comfortable: "px-3 py-2",
30
38
  compact: "px-2 py-1",
39
+ none: "p-0",
31
40
  };
32
41
 
33
42
  const ALIGN: Record<TableAlign, string> = {
@@ -54,6 +63,8 @@ export interface TableProps extends ComponentPropsWithoutRef<"table"> {
54
63
  hover?: boolean;
55
64
  /** Classes for the overflow wrapper around the `<table>` (a max-height, a border). */
56
65
  wrapperClassName?: string;
66
+ /** See {@link TableLayout}. Unset leaves the browser's `auto` and adds no class. */
67
+ layout?: TableLayout;
57
68
  }
58
69
 
59
70
  /**
@@ -78,6 +89,7 @@ export function Table({
78
89
  zebra = false,
79
90
  hover = false,
80
91
  wrapperClassName,
92
+ layout,
81
93
  className,
82
94
  "aria-label": ariaLabel,
83
95
  ...rest
@@ -127,6 +139,8 @@ export function Table({
127
139
  className={cn(
128
140
  "w-full caption-bottom border-collapse text-sm text-[var(--text-primary)]",
129
141
  density === "compact" && "text-xs",
142
+ layout === "fixed" && "table-fixed",
143
+ layout === "auto" && "table-auto",
130
144
  className,
131
145
  )}
132
146
  />
@@ -145,16 +159,87 @@ export function TableHead({ className, ...rest }: TableHeadProps) {
145
159
  );
146
160
  }
147
161
 
148
- export type TableBodyProps = ComponentPropsWithoutRef<"tbody">;
162
+ export interface TableBodyProps extends ComponentPropsWithoutRef<"tbody"> {
163
+ /**
164
+ * Shown as ONE full-width row (a {@link TableEmpty}) when the body has no rows —
165
+ * `children` that render nothing: an empty `.map`, a `false`, `null`. kastlan hand-
166
+ * writes that row with a counted `colSpan` in unit-values-editor, the meeting agenda
167
+ * and invitations tabs, the journal-entry and invoice detail pages, and a column
168
+ * added later leaves every one of those counts one short.
169
+ */
170
+ empty?: ReactNode;
171
+ }
149
172
 
150
- export function TableBody({ className, ...rest }: TableBodyProps) {
173
+ export function TableBody({ className, empty, children, ...rest }: TableBodyProps) {
174
+ // `toArray` drops null/undefined/booleans and flattens arrays, so `[[], false]` —
175
+ // an empty map beside a conditional row that is off — is no rows.
176
+ const hasRows = Children.toArray(children).length > 0;
151
177
  return (
152
178
  <SectionContext.Provider value="body">
153
- <tbody {...rest} className={cn("[&>tr:last-child]:border-b-0", className)} />
179
+ <tbody {...rest} className={cn("[&>tr:last-child]:border-b-0", className)}>
180
+ {hasRows || empty === undefined || empty === null || empty === false ? (
181
+ children
182
+ ) : (
183
+ <TableEmpty>{empty}</TableEmpty>
184
+ )}
185
+ </tbody>
154
186
  </SectionContext.Provider>
155
187
  );
156
188
  }
157
189
 
190
+ export interface TableEmptyProps extends Omit<ComponentPropsWithoutRef<"tr">, "children"> {
191
+ children: ReactNode;
192
+ /** Columns to span. Default: MEASURED — the widest row of the table it renders in,
193
+ * counted after mount, so the row stays full width when a column is added. */
194
+ colSpan?: number;
195
+ /** Classes for the `<td>`. */
196
+ cellClassName?: string;
197
+ }
198
+
199
+ /**
200
+ * The "nothing here" row: one cell across every column, centred, muted. What
201
+ * {@link TableBodyProps.empty} renders, and usable on its own for a body whose rows
202
+ * are not its direct children.
203
+ */
204
+ export function TableEmpty({ children, colSpan, cellClassName, className, ...rest }: TableEmptyProps) {
205
+ const { density } = useContext(TableContext);
206
+ const cell = useRef<HTMLTableCellElement | null>(null);
207
+ const [measured, setMeasured] = useState(1);
208
+ // Every render, deliberately: a column can appear in the head without anything this
209
+ // row depends on changing. It cannot loop — setting the same count is a bail-out.
210
+ // eslint-disable-next-line react-hooks/exhaustive-deps
211
+ useLayoutEffect(() => {
212
+ if (colSpan !== undefined) return;
213
+ const row = cell.current?.parentElement;
214
+ const table = cell.current?.closest("table");
215
+ if (!row || !table) return;
216
+ let widest = 1;
217
+ for (const r of Array.from(table.rows)) {
218
+ if (r === row) continue;
219
+ let n = 0;
220
+ for (const c of Array.from(r.cells)) n += c.colSpan || 1;
221
+ widest = Math.max(widest, n);
222
+ }
223
+ setMeasured(widest);
224
+ });
225
+ return (
226
+ <tr {...rest} data-table-empty="" className={className}>
227
+ <td
228
+ ref={cell}
229
+ colSpan={colSpan ?? measured}
230
+ className={cn(
231
+ // `none` keeps the message off the edges regardless: it is text, not a figure.
232
+ density === "none" ? "py-2" : CELL_PAD[density],
233
+ "text-center text-[var(--text-muted)]",
234
+ cellClassName,
235
+ )}
236
+ >
237
+ {children}
238
+ </td>
239
+ </tr>
240
+ );
241
+ }
242
+
158
243
  export type TableFootProps = ComponentPropsWithoutRef<"tfoot">;
159
244
 
160
245
  /** The totals row. Not in the brief, but every ledger view has one and a `<tfoot>`
@@ -0,0 +1,89 @@
1
+ import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ /* ── SectionLabel ─────────────────────────────────────────────────────────── */
5
+
6
+ /**
7
+ * `xs` is 10px — the size `LegendGroup`'s title and the top-bar menus' heading row draw;
8
+ * `sm` (default) is 12px — the size `StatTile`'s label and lenkbank's section headings
9
+ * (control-page.tsx:119/123/127) draw. Two sizes because the label sits either inside a
10
+ * dense panel (a legend, a menu) or above a block of page content, and one size looked
11
+ * wrong in the other place.
12
+ */
13
+ export type SectionLabelSize = "xs" | "sm";
14
+
15
+ /**
16
+ * The small, uppercase, tracked, muted label — as a class string, per size.
17
+ *
18
+ * Exported because `LegendGroup` and `StatTile` already draw it inline, and a class a
19
+ * caller has to position itself (a `<th>`, a `<legend>`, a menu `<li>`) is better
20
+ * reached as a string than wrapped in a component that only takes a `className`. The
21
+ * uppercase is CSS, so a screen reader reads the text as written: write it in normal
22
+ * case.
23
+ */
24
+ export const SECTION_LABEL_CLASS: Record<SectionLabelSize, string> = {
25
+ xs: "text-[10px] font-semibold uppercase tracking-wide text-[var(--text-muted)]",
26
+ sm: "text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]",
27
+ };
28
+
29
+ type SectionLabelElement = "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div" | "legend";
30
+
31
+ export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
32
+ /**
33
+ * The element — which is the heading LEVEL, and so is the caller's to pick: the kit
34
+ * cannot know where in the outline the label sits. Default `h3`, which is what
35
+ * lenkbank's control page writes under its `h2` card title. Pass `span` or `p` for a
36
+ * label that is not a heading at all (a legend group's title).
37
+ */
38
+ as?: SectionLabelElement;
39
+ size?: SectionLabelSize;
40
+ children: ReactNode;
41
+ }
42
+
43
+ /**
44
+ * A section's small uppercase label. lenkbank spells
45
+ * `text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400`
46
+ * on every `<h3>` of its control page and more; keksdose's budget switcher writes the
47
+ * 10px version on a menu row. One component, the token colour, and the level chosen
48
+ * where the outline is known.
49
+ */
50
+ export function SectionLabel({ as = "h3", size = "sm", className, children, ...rest }: SectionLabelProps) {
51
+ const Tag = as as ElementType;
52
+ return (
53
+ <Tag {...rest} className={cn(SECTION_LABEL_CLASS[size], className)}>
54
+ {children}
55
+ </Tag>
56
+ );
57
+ }
58
+
59
+ /* ── Caption ──────────────────────────────────────────────────────────────── */
60
+
61
+ /**
62
+ * The caption type as a class string: 11px, snug, muted. lenkbank's `CAPTION`
63
+ * (shared/ui/caption.ts:23) — written out 31 times before it was a constant — as a
64
+ * token colour rather than `slate-500 dark:slate-400`, so it follows the palette.
65
+ * Exported for the same reason lenkbank's is a string: every caller positions it
66
+ * differently (`mt-1`, `self-end pb-2`, `border-t`), and what must be one thing is the
67
+ * type, not the box.
68
+ */
69
+ export const CAPTION_CLASS = "text-[11px] leading-snug text-[var(--text-muted)]";
70
+
71
+ export interface CaptionProps extends ComponentPropsWithoutRef<"p"> {
72
+ /** Default `p`; `span` for a caption inside a line of other content. */
73
+ as?: "p" | "span" | "div";
74
+ children: ReactNode;
75
+ }
76
+
77
+ /**
78
+ * The small grey sentence under a field, a table or a button — prose ABOUT the thing
79
+ * above it. Not `FieldHint`, which hides behind a field label's "?" so a paragraph does
80
+ * not make one field taller than its neighbour (lenkbank feedback #27).
81
+ */
82
+ export function Caption({ as = "p", className, children, ...rest }: CaptionProps) {
83
+ const Tag = as as ElementType;
84
+ return (
85
+ <Tag {...rest} className={cn(CAPTION_CLASS, className)}>
86
+ {children}
87
+ </Tag>
88
+ );
89
+ }