@eifi1/ui-kit 0.9.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 (243) hide show
  1. package/README.md +37 -23
  2. package/dist/components/alert-banner.d.ts +57 -5
  3. package/dist/components/alert-banner.js +63 -17
  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 +112 -0
  10. package/dist/components/bulk-action-bar.js +196 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/button-group.d.ts +58 -3
  13. package/dist/components/button-group.js +56 -5
  14. package/dist/components/button-group.js.map +1 -1
  15. package/dist/components/calculator.d.ts +6 -0
  16. package/dist/components/calendar-heatmap.d.ts +124 -0
  17. package/dist/components/calendar-heatmap.js +295 -0
  18. package/dist/components/calendar-heatmap.js.map +1 -0
  19. package/dist/components/chip.d.ts +31 -5
  20. package/dist/components/chip.js +85 -5
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +34 -2
  23. package/dist/components/choice-card.js +49 -0
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/copy-button.d.ts +14 -4
  26. package/dist/components/copy-button.js +17 -4
  27. package/dist/components/copy-button.js.map +1 -1
  28. package/dist/components/data-table.js +2 -0
  29. package/dist/components/data-table.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +22 -2
  31. package/dist/components/date-picker.js +11 -3
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/description-list.d.ts +43 -6
  34. package/dist/components/description-list.js +91 -11
  35. package/dist/components/description-list.js.map +1 -1
  36. package/dist/components/dialog-frame.d.ts +7 -0
  37. package/dist/components/dialog-frame.js.map +1 -1
  38. package/dist/components/disclosure.d.ts +57 -2
  39. package/dist/components/disclosure.js +24 -12
  40. package/dist/components/disclosure.js.map +1 -1
  41. package/dist/components/field.d.ts +43 -0
  42. package/dist/components/field.js +52 -0
  43. package/dist/components/field.js.map +1 -0
  44. package/dist/components/file-button.d.ts +1 -0
  45. package/dist/components/file-dropzone.d.ts +4 -4
  46. package/dist/components/file-dropzone.js +2 -1
  47. package/dist/components/file-dropzone.js.map +1 -1
  48. package/dist/components/floating-panel.d.ts +147 -4
  49. package/dist/components/floating-panel.js +188 -28
  50. package/dist/components/floating-panel.js.map +1 -1
  51. package/dist/components/full-bleed-dialog.d.ts +23 -4
  52. package/dist/components/full-bleed-dialog.js +11 -2
  53. package/dist/components/full-bleed-dialog.js.map +1 -1
  54. package/dist/components/list.d.ts +187 -0
  55. package/dist/components/list.js +221 -0
  56. package/dist/components/list.js.map +1 -0
  57. package/dist/components/menu-item.d.ts +109 -0
  58. package/dist/components/menu-item.js +88 -0
  59. package/dist/components/menu-item.js.map +1 -0
  60. package/dist/components/mini-calendar.d.ts +70 -5
  61. package/dist/components/mini-calendar.js +166 -62
  62. package/dist/components/mini-calendar.js.map +1 -1
  63. package/dist/components/modal.d.ts +23 -1
  64. package/dist/components/modal.js +36 -10
  65. package/dist/components/modal.js.map +1 -1
  66. package/dist/components/nav-pills.d.ts +66 -0
  67. package/dist/components/nav-pills.js +65 -0
  68. package/dist/components/nav-pills.js.map +1 -0
  69. package/dist/components/number-field.d.ts +6 -0
  70. package/dist/components/number-input.d.ts +6 -0
  71. package/dist/components/numpad-sheet.d.ts +6 -0
  72. package/dist/components/page-contents.js +1 -2
  73. package/dist/components/page-contents.js.map +1 -1
  74. package/dist/components/page-header.d.ts +37 -0
  75. package/dist/components/page-header.js +36 -0
  76. package/dist/components/page-header.js.map +1 -0
  77. package/dist/components/progress-bar.d.ts +31 -2
  78. package/dist/components/progress-bar.js +73 -7
  79. package/dist/components/progress-bar.js.map +1 -1
  80. package/dist/components/series-chart-labels.d.ts +3 -0
  81. package/dist/components/series-chart-labels.js +2 -1
  82. package/dist/components/series-chart-labels.js.map +1 -1
  83. package/dist/components/series-chart.d.ts +32 -0
  84. package/dist/components/series-chart.js +141 -4
  85. package/dist/components/series-chart.js.map +1 -1
  86. package/dist/components/settings-fields.d.ts +1 -0
  87. package/dist/components/status-dot.d.ts +53 -0
  88. package/dist/components/status-dot.js +57 -0
  89. package/dist/components/status-dot.js.map +1 -0
  90. package/dist/components/table.d.ts +78 -13
  91. package/dist/components/table.js +84 -11
  92. package/dist/components/table.js.map +1 -1
  93. package/dist/components/text.d.ts +70 -0
  94. package/dist/components/text.js +24 -0
  95. package/dist/components/text.js.map +1 -0
  96. package/dist/components/time-input.d.ts +1 -0
  97. package/dist/components/toast.d.ts +167 -0
  98. package/dist/components/toast.js +226 -0
  99. package/dist/components/toast.js.map +1 -0
  100. package/dist/components/toggle-group.d.ts +44 -1
  101. package/dist/components/toggle-group.js +73 -4
  102. package/dist/components/toggle-group.js.map +1 -1
  103. package/dist/components/tooltip.d.ts +46 -15
  104. package/dist/components/tooltip.js +87 -47
  105. package/dist/components/tooltip.js.map +1 -1
  106. package/dist/components/ui.d.ts +158 -7
  107. package/dist/components/ui.js +191 -10
  108. package/dist/components/ui.js.map +1 -1
  109. package/dist/components/user-avatar.d.ts +25 -3
  110. package/dist/components/user-avatar.js +32 -3
  111. package/dist/components/user-avatar.js.map +1 -1
  112. package/dist/hooks/use-close-transition.d.ts +7 -1
  113. package/dist/hooks/use-close-transition.js +5 -2
  114. package/dist/hooks/use-close-transition.js.map +1 -1
  115. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  116. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  117. package/dist/hooks/use-file-drop.d.ts +1 -0
  118. package/dist/i18n/defaults.d.ts +6 -0
  119. package/dist/i18n/defaults.js +11 -1
  120. package/dist/i18n/defaults.js.map +1 -1
  121. package/dist/i18n/kit-labels.d.ts +11 -0
  122. package/dist/i18n/kit-labels.js.map +1 -1
  123. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  124. package/dist/i18n/locales/de-CH.d.ts +6 -0
  125. package/dist/i18n/locales/de-informal.d.ts +6 -0
  126. package/dist/i18n/locales/de.d.ts +6 -0
  127. package/dist/i18n/locales/de.js +30 -2
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +6 -0
  130. package/dist/i18n/locales/es.js +30 -2
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +6 -0
  133. package/dist/i18n/locales/fr.js +30 -2
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +6 -0
  136. package/dist/i18n/locales/hu.js +30 -2
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +6 -0
  139. package/dist/i18n/locales/it.js +30 -2
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +6 -0
  142. package/dist/i18n/locales/zh.js +30 -2
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/index.d.ts +27 -15
  145. package/dist/index.js +15 -0
  146. package/dist/index.js.map +1 -1
  147. package/dist/lib/clipping.d.ts +25 -0
  148. package/dist/lib/clipping.js +17 -0
  149. package/dist/lib/clipping.js.map +1 -0
  150. package/dist/lib/dates.d.ts +17 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/rhf/form.d.ts +1 -0
  154. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  155. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  156. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  157. package/dist/rhf.d.ts +2 -0
  158. package/dist/rhf.js +1 -0
  159. package/dist/rhf.js.map +1 -1
  160. package/dist/search/command-palette.d.ts +19 -2
  161. package/dist/search/command-palette.js +16 -5
  162. package/dist/search/command-palette.js.map +1 -1
  163. package/dist/search/global-search.d.ts +26 -1
  164. package/dist/search/global-search.js +29 -13
  165. package/dist/search/global-search.js.map +1 -1
  166. package/dist/search.d.ts +1 -1
  167. package/dist/shell/topbar-action-menu.d.ts +73 -3
  168. package/dist/shell/topbar-action-menu.js +100 -27
  169. package/dist/shell/topbar-action-menu.js.map +1 -1
  170. package/dist/shell.d.ts +2 -1
  171. package/dist/wizard/stepper-nav.d.ts +1 -0
  172. package/dist/wizard/stepper-nav.js +1 -1
  173. package/dist/wizard/stepper-nav.js.map +1 -1
  174. package/dist/wizard/types.d.ts +2 -2
  175. package/dist/wizard/types.js.map +1 -1
  176. package/dist/wizard/use-wizard.js +1 -1
  177. package/dist/wizard/use-wizard.js.map +1 -1
  178. package/dist/wizard/wizard-context.d.ts +7 -1
  179. package/dist/wizard/wizard-context.js +4 -0
  180. package/dist/wizard/wizard-context.js.map +1 -1
  181. package/dist/wizard/wizard-summary.js +3 -3
  182. package/dist/wizard/wizard-summary.js.map +1 -1
  183. package/dist/wizard.d.ts +2 -1
  184. package/package.json +16 -12
  185. package/src/components/alert-banner.tsx +149 -21
  186. package/src/components/breadcrumbs.tsx +168 -0
  187. package/src/components/bulk-action-bar.tsx +362 -0
  188. package/src/components/button-group.tsx +124 -4
  189. package/src/components/calendar-heatmap.tsx +504 -0
  190. package/src/components/chip.tsx +128 -7
  191. package/src/components/choice-card.tsx +97 -1
  192. package/src/components/copy-button.tsx +29 -7
  193. package/src/components/data-table.tsx +7 -0
  194. package/src/components/date-picker.tsx +49 -1
  195. package/src/components/description-list.tsx +168 -15
  196. package/src/components/dialog-frame.tsx +7 -0
  197. package/src/components/disclosure.tsx +93 -21
  198. package/src/components/field.tsx +137 -0
  199. package/src/components/file-dropzone.tsx +9 -10
  200. package/src/components/floating-panel.tsx +366 -15
  201. package/src/components/full-bleed-dialog.tsx +42 -5
  202. package/src/components/list.tsx +443 -0
  203. package/src/components/menu-item.tsx +235 -0
  204. package/src/components/mini-calendar.tsx +245 -54
  205. package/src/components/modal.tsx +80 -17
  206. package/src/components/nav-pills.tsx +145 -0
  207. package/src/components/page-contents.tsx +4 -4
  208. package/src/components/page-header.tsx +68 -0
  209. package/src/components/progress-bar.tsx +129 -11
  210. package/src/components/series-chart-labels.ts +4 -0
  211. package/src/components/series-chart.tsx +274 -3
  212. package/src/components/status-dot.tsx +107 -0
  213. package/src/components/table.tsx +176 -17
  214. package/src/components/text.tsx +97 -0
  215. package/src/components/toast.tsx +441 -0
  216. package/src/components/toggle-group.tsx +128 -4
  217. package/src/components/tooltip.tsx +200 -100
  218. package/src/components/ui.tsx +381 -14
  219. package/src/components/user-avatar.tsx +57 -3
  220. package/src/hooks/use-close-transition.ts +14 -5
  221. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  222. package/src/i18n/defaults.ts +10 -0
  223. package/src/i18n/kit-labels.tsx +10 -0
  224. package/src/i18n/locales/de.ts +29 -0
  225. package/src/i18n/locales/es.ts +29 -0
  226. package/src/i18n/locales/fr.ts +29 -0
  227. package/src/i18n/locales/hu.ts +29 -0
  228. package/src/i18n/locales/it.ts +29 -0
  229. package/src/i18n/locales/zh.ts +29 -0
  230. package/src/index.ts +31 -0
  231. package/src/lib/clipping.ts +34 -0
  232. package/src/lib/dates.ts +37 -0
  233. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  234. package/src/rhf.ts +2 -0
  235. package/src/search/command-palette.tsx +42 -5
  236. package/src/search/global-search.tsx +59 -18
  237. package/src/shell/topbar-action-menu.tsx +239 -49
  238. package/src/wizard/stepper-nav.tsx +2 -1
  239. package/src/wizard/types.ts +2 -2
  240. package/src/wizard/use-wizard.ts +3 -3
  241. package/src/wizard/wizard-context.tsx +9 -0
  242. package/src/wizard/wizard-summary.tsx +14 -10
  243. 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 {
@@ -125,6 +129,18 @@ export interface SeriesChartSeries {
125
129
  * `{ r }` sets its radius. Default: recharts' dot. Bars have none, and ignore it.
126
130
  */
127
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;
128
144
  }
129
145
 
130
146
  /** What a series draws. See {@link SeriesChartSeries.type}. */
@@ -202,6 +218,22 @@ interface SeriesChartAxisShape {
202
218
  * a hairline). A pinned `domain` is left alone either way.
203
219
  */
204
220
  includeZero?: boolean;
221
+ /**
222
+ * Tick only on whole numbers — the y counterpart of `SeriesChartX.integerTicks`, and
223
+ * what keksdose's user-count axis (`metrics-panel.tsx`, `wholeTicks`) and its runway
224
+ * in days (`cash-buffer-chart.tsx`, `wholeDayTicks`) each wrote by hand: a band of
225
+ * [0, 3.1] users is ticked 0 1 2 3, not 0 0.5 1 … 3, and a short runway is not ticked
226
+ * 0 / 0.5 / 1 under a rounding formatter that prints "1" twice. A (zoom) window that
227
+ * holds no whole number falls back to the ordinary ticks rather than to none.
228
+ *
229
+ * AUTOMATIC by default — on when every value this axis carries is a whole number
230
+ * (counts), because that is where a half tick is a value no sample can have, and it
231
+ * changes nothing on a span wider than about eight, which already ticks on whole
232
+ * steps. `true` forces it over fractional data (days of runway computed as 43.7);
233
+ * `false` keeps fractional ticks for a continuous quantity that merely happens to be
234
+ * whole in this sample. `tickValues` wins over either.
235
+ */
236
+ integerTicks?: boolean;
205
237
  }
206
238
 
207
239
  /**
@@ -487,6 +519,10 @@ export interface SeriesChartProps {
487
519
  locale?: string;
488
520
  /** Onto the chart's root (`ChartContainer`) — e.g. an app's own grid ink. */
489
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;
490
526
  }
491
527
 
492
528
  /** How much of an axis band the rotated title takes, in px. Reserved rather than
@@ -818,6 +854,150 @@ const STEP_WIDTH = 1.5;
818
854
  * which recharts cannot tell apart per cell, so stacks stay square. */
819
855
  const BAR_RADIUS: [number, number, number, number] = [3, 3, 0, 0];
820
856
 
857
+ /**
858
+ * One keyboard stop of a clickable chart: a bar, or (a chart with no bars) a whole slot.
859
+ * `from`/`to` bound its slot on the plotted number line — half way to each neighbour,
860
+ * or to the edge of the window on show.
861
+ */
862
+ interface PointStop {
863
+ index: number;
864
+ /** The bar's series; absent for a slot. */
865
+ key?: string;
866
+ from: number;
867
+ to: number;
868
+ /** The accessible name: where it is and what it says. */
869
+ name: string;
870
+ }
871
+
872
+ /**
873
+ * The shape of a series whose bar has keyboard focus: recharts' own rectangle, with an
874
+ * outline on the one bar at `index` — drawn at the bar's exact geometry, which an
875
+ * overlay could only guess at. Not `activeBar`: that follows the tooltip's active index,
876
+ * which recharts stops taking from `defaultIndex` once a pointer has hovered the chart.
877
+ */
878
+ function focusedBarShape(index: number) {
879
+ return function FocusedBar(props: BarShapeProps) {
880
+ const focused = props.originalDataIndex === index;
881
+ return (
882
+ <Rectangle {...props} {...(focused ? { stroke: "var(--text-primary)", strokeWidth: 2 } : {})} />
883
+ );
884
+ };
885
+ }
886
+
887
+ /**
888
+ * The keyboard's way onto a clickable chart (`onPointClick`): one focusable `<rect>` per
889
+ * bar, or per slot when there are no bars, laid over the plot INSIDE the recharts chart,
890
+ * where the x scale answers.
891
+ *
892
+ * keksdose drew its weekday pattern as seven real `<button>`s (spending-tab.tsx) because
893
+ * a recharts bar is reachable by nothing but the pointer. This is that, for every chart:
894
+ *
895
+ * - ONE tab stop (roving `tabIndex`): Tab moves past the chart, not through 30 bars.
896
+ * - ←/→ step through the stops in order, ↑/↓ between the bars of one slot, Home/End.
897
+ * - ↵ and Space report the stop as a click would — with the series for a bar.
898
+ * - Each is a `role="button"` named "period — series: value".
899
+ *
900
+ * The stops take NO pointer events, so the drag-to-zoom layer, the tooltip and the
901
+ * pointer's own clicks underneath behave exactly as they did. Focus is shown by the
902
+ * slot tinted and, for a bar, its own outline (`focusedBarShape`) — and by the tooltip,
903
+ * moved to the stop through `defaultIndex`, which recharts honours until a pointer has
904
+ * hovered the chart.
905
+ */
906
+ function PointKeys({
907
+ stops,
908
+ current,
909
+ label,
910
+ onFocusStop,
911
+ onBlurStops,
912
+ onPick,
913
+ }: {
914
+ stops: readonly PointStop[];
915
+ current: number;
916
+ label: string;
917
+ onFocusStop: (at: number) => void;
918
+ onBlurStops: () => void;
919
+ onPick: (stop: PointStop) => void;
920
+ }) {
921
+ const plot = usePlotArea();
922
+ const scale = useXAxisScale();
923
+ const refs = useRef<(SVGRectElement | null)[]>([]);
924
+ if (!plot || !scale || plot.width <= 0 || plot.height <= 0) return null;
925
+
926
+ const move = (at: number) => {
927
+ const next = Math.max(0, Math.min(stops.length - 1, at));
928
+ refs.current[next]?.focus();
929
+ };
930
+ const onKeyDown = (at: number) => (event: KeyboardEvent<SVGRectElement>) => {
931
+ const stop = stops[at];
932
+ // ↑/↓ stay in the slot: the next or previous bar of the same period. The stops are
933
+ // in slot order, so a slot's bars are neighbours.
934
+ const inSlot = (step: 1 | -1) => (stops[at + step]?.index === stop.index ? at + step : at);
935
+ const target =
936
+ event.key === "ArrowRight"
937
+ ? at + 1
938
+ : event.key === "ArrowLeft"
939
+ ? at - 1
940
+ : event.key === "ArrowDown"
941
+ ? inSlot(1)
942
+ : event.key === "ArrowUp"
943
+ ? inSlot(-1)
944
+ : event.key === "Home"
945
+ ? 0
946
+ : event.key === "End"
947
+ ? stops.length - 1
948
+ : undefined;
949
+ if (target !== undefined) {
950
+ event.preventDefault();
951
+ move(target);
952
+ } else if (event.key === "Enter" || event.key === " ") {
953
+ event.preventDefault();
954
+ onPick(stop);
955
+ }
956
+ };
957
+
958
+ const left = plot.x;
959
+ const right = plot.x + plot.width;
960
+ return (
961
+ <g role="group" aria-label={label}>
962
+ {stops.map((stop, at) => {
963
+ // An edge with no neighbour runs to the plot's edge.
964
+ const a = finite(scale(stop.from)) ?? left;
965
+ const b = finite(scale(stop.to)) ?? right;
966
+ const x0 = Math.max(left, Math.min(a, b));
967
+ const x1 = Math.min(right, Math.max(a, b));
968
+ return (
969
+ <rect
970
+ key={`${stop.index}:${stop.key ?? ""}`}
971
+ ref={(node) => {
972
+ refs.current[at] = node;
973
+ }}
974
+ role="button"
975
+ tabIndex={at === current ? 0 : -1}
976
+ aria-label={stop.name}
977
+ data-point-key={stop.key}
978
+ x={x0}
979
+ y={plot.y}
980
+ width={Math.max(0, x1 - x0)}
981
+ height={plot.height}
982
+ fill="transparent"
983
+ // Focus only: the pointer's drag, hover and click belong to what is below.
984
+ pointerEvents="none"
985
+ strokeWidth={2}
986
+ className={cn(
987
+ "outline-none focus-visible:fill-[var(--brand)]/10",
988
+ // A bar outlines ITSELF (see `FOCUSED_BAR`); a slot has nothing else to.
989
+ stop.key === undefined && "focus-visible:stroke-[var(--brand)]",
990
+ )}
991
+ onFocus={() => onFocusStop(at)}
992
+ onBlur={onBlurStops}
993
+ onKeyDown={onKeyDown(at)}
994
+ />
995
+ );
996
+ })}
997
+ </g>
998
+ );
999
+ }
1000
+
821
1001
  interface PlotProps extends Omit<SeriesChartProps, "rows" | "x"> {
822
1002
  /** The rows AS PLOTTED — what the zoom fits on. The caller's are `model.source`. */
823
1003
  rows: readonly SeriesChartRow[];
@@ -847,10 +1027,15 @@ function SeriesPlot({
847
1027
  labels: labelsProp,
848
1028
  locale: localeProp,
849
1029
  className,
1030
+ minBarLength,
850
1031
  }: PlotProps) {
851
1032
  const labels = useKitLabels("seriesChart", DEFAULT_SERIES_CHART_LABELS, labelsProp);
852
1033
  const locale = useKitLocale(localeProp);
853
1034
  const number = useDefaultFormat(localeProp);
1035
+ // The keyboard stops' roving tab stop, and the one that has focus (see `PointKeys`).
1036
+ // Up here, above the empty state's early return, like every hook.
1037
+ const [keyStop, setKeyStop] = useState(0);
1038
+ const [keyFocus, setKeyFocus] = useState<number | undefined>(undefined);
854
1039
 
855
1040
  // A number is pixels, set inline; a string is a class. Either way the one value
856
1041
  // sizes both the chart and its empty state.
@@ -995,6 +1180,19 @@ function SeriesPlot({
995
1180
  series.some(
996
1181
  (entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id && (entry.type ?? "line") !== "line",
997
1182
  );
1183
+ // Whole-number ticks on an axis whose values are all whole — see
1184
+ // `SeriesChartAxis.integerTicks`. A stack of whole layers sums to a whole number, so
1185
+ // the layers are enough to look at.
1186
+ const wholeOnly = (axis: SeriesChartAxis) =>
1187
+ axis.integerTicks ??
1188
+ series
1189
+ .filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id)
1190
+ .every((entry) =>
1191
+ plotted.every((row) => {
1192
+ const value = finite(row[entry.key]);
1193
+ return value === undefined || Number.isInteger(value);
1194
+ }),
1195
+ );
998
1196
  const onAxis = (axisId: string) => (ref: { axis?: string }) => (ref.axis ?? DEFAULT_Y_AXIS) === axisId;
999
1197
  const fittedY = (axis: SeriesChartAxis): [number, number] | undefined => {
1000
1198
  const own = [
@@ -1024,6 +1222,48 @@ function SeriesPlot({
1024
1222
  return row ? { index, row, x: xOf(index), key } : undefined;
1025
1223
  };
1026
1224
 
1225
+ // The keyboard stops of a clickable chart — every bar with a value, slot by slot; or,
1226
+ // with no bars, every slot with any value. Only what is inside the window on show.
1227
+ const stops: PointStop[] = [];
1228
+ if (onPointClick) {
1229
+ const valueText = valueFormat ?? number;
1230
+ const nameOf = (entry: SeriesChartSeries) =>
1231
+ typeof entry.label === "string" || typeof entry.label === "number" ? String(entry.label) : entry.key;
1232
+ const said = (entry: SeriesChartSeries, value: number) => {
1233
+ const name = nameOf(entry);
1234
+ return name ? `${name}: ${valueText(value)}` : valueText(value);
1235
+ };
1236
+ const bars = series.filter((entry) => entry.type === "bar");
1237
+ const [low, high] = fittedX ?? [-Infinity, Infinity];
1238
+ const placed = plotted
1239
+ .map((row, index) => ({ index, at: model.kind === "category" ? index : finite(row[xKey]) }))
1240
+ .filter((slot): slot is { index: number; at: number } => slot.at !== undefined && slot.at >= low && slot.at <= high)
1241
+ .sort((a, b) => a.at - b.at);
1242
+ placed.forEach(({ index, at }, k) => {
1243
+ const from = k > 0 ? (placed[k - 1].at + at) / 2 : low;
1244
+ const to = k < placed.length - 1 ? (placed[k + 1].at + at) / 2 : high;
1245
+ // The heading the tooltip would give it, when that is text; else the tick.
1246
+ const heading = xLabel(at);
1247
+ const where = typeof heading === "string" && heading ? heading : xFormat(at);
1248
+ const valued = (bars.length ? bars : series)
1249
+ .map((entry) => ({ entry, value: finite(rows[index]?.[entry.key]) }))
1250
+ .filter((cell): cell is { entry: SeriesChartSeries; value: number } => cell.value !== undefined);
1251
+ if (bars.length) {
1252
+ for (const { entry, value } of valued) {
1253
+ stops.push({ index, key: entry.key, from, to, name: `${where} — ${said(entry, value)}` });
1254
+ }
1255
+ } else if (valued.length) {
1256
+ stops.push({
1257
+ index,
1258
+ from,
1259
+ to,
1260
+ name: `${where} — ${valued.map(({ entry, value }) => said(entry, value)).join(", ")}`,
1261
+ });
1262
+ }
1263
+ });
1264
+ }
1265
+ const focusedStop = keyFocus === undefined ? undefined : stops[keyFocus];
1266
+
1027
1267
  const dotFor = (entry: SeriesChartSeries) => {
1028
1268
  const want = entry.dot;
1029
1269
  if (!want) return false;
@@ -1080,6 +1320,9 @@ function SeriesPlot({
1080
1320
  // Positive and negative layers stacked apart, as `axisExtent` fits them — a
1081
1321
  // month's expenses hang below the axis instead of eating into its income.
1082
1322
  stackOffset="sign"
1323
+ // recharts' own keyboard layer makes the whole surface a tab stop whose arrows
1324
+ // move the tooltip — a second stop beside `PointKeys`, and one ↵ does nothing on.
1325
+ accessibilityLayer={!onPointClick}
1083
1326
  onClick={
1084
1327
  onPointClick
1085
1328
  ? (state) => {
@@ -1136,7 +1379,10 @@ function SeriesPlot({
1136
1379
  yAxisId={axis.id}
1137
1380
  hide={axis.hide}
1138
1381
  domain={domain}
1139
- ticks={resolveTicks(axis.tickValues, domain, finite) ?? niceTicks(domain)}
1382
+ ticks={
1383
+ resolveTicks(axis.tickValues, domain, finite) ??
1384
+ (wholeOnly(axis) ? (integerTicks(domain) ?? niceTicks(domain)) : niceTicks(domain))
1385
+ }
1140
1386
  allowDataOverflow={zoomed}
1141
1387
  orientation={axis.orientation ?? "left"}
1142
1388
  width={axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide)}
@@ -1170,6 +1416,8 @@ function SeriesPlot({
1170
1416
  // The content shifts itself 12 px off the cursor (or flips) against the
1171
1417
  // boundary; recharts' own offset on top would double it.
1172
1418
  {...(tooltip?.boundary ? { offset: 0 } : {})}
1419
+ // The keyboard's stop shows its tooltip as the pointer's would.
1420
+ defaultIndex={focusedStop?.index}
1173
1421
  content={
1174
1422
  <ChartTooltipContent
1175
1423
  labelFormatter={(value) => xLabel(Number(value))}
@@ -1187,6 +1435,7 @@ function SeriesPlot({
1187
1435
  const curve = entry.step ? "stepAfter" : (entry.curve ?? "monotone");
1188
1436
  const width = entry.strokeWidth ?? (entry.step ? STEP_WIDTH : LINE_WIDTH);
1189
1437
  if (type === "bar") {
1438
+ const stub = entry.minBarLength ?? minBarLength;
1190
1439
  return (
1191
1440
  <Bar
1192
1441
  key={entry.key}
@@ -1196,6 +1445,12 @@ function SeriesPlot({
1196
1445
  fill={color}
1197
1446
  fillOpacity={entry.fillOpacity}
1198
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) } : {})}
1451
+ // Only the focused stop's series swaps its shape, and only for as long
1452
+ // as it has focus: every other bar keeps recharts' own.
1453
+ {...(focusedStop?.key === entry.key ? { shape: focusedBarShape(focusedStop.index) } : {})}
1199
1454
  onClick={
1200
1455
  onPointClick
1201
1456
  ? (_bar, index, event) => {
@@ -1320,6 +1575,22 @@ function SeriesPlot({
1320
1575
  />
1321
1576
  );
1322
1577
  })}
1578
+ {stops.length > 0 && (
1579
+ <PointKeys
1580
+ stops={stops}
1581
+ current={Math.min(keyStop, stops.length - 1)}
1582
+ label={labels.points}
1583
+ onFocusStop={(at) => {
1584
+ setKeyStop(at);
1585
+ setKeyFocus(at);
1586
+ }}
1587
+ onBlurStops={() => setKeyFocus(undefined)}
1588
+ onPick={(stop) => {
1589
+ const hit = hitAt(stop.index, stop.key);
1590
+ if (hit) onPointClick?.(hit);
1591
+ }}
1592
+ />
1593
+ )}
1323
1594
  {zoom?.layer}
1324
1595
  </ComposedChart>
1325
1596
  </ChartContainer>
@@ -0,0 +1,107 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+ import type { ProgressBarTone } from "./progress-bar";
4
+ import type { ChipHue } from "./chip";
5
+
6
+ /**
7
+ * The tones are `ProgressBar`'s, name for name — which are `Chip`'s, less nothing — so a
8
+ * dot in a legend, the bar it explains and the badge beside it can be given the same
9
+ * word and agree. The money pair is here for the same reason it is there: a legend
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.
13
+ */
14
+ export type StatusDotTone = ProgressBarTone | ChipHue;
15
+
16
+ /** `sm` 8px (keksdose's unread dot on the support trigger), `md` 10px (the avatar's
17
+ * unread dot), `lg` 12px (kastlan's occupancy legend swatch). */
18
+ export type StatusDotSize = "sm" | "md" | "lg";
19
+
20
+ const FILL: Record<StatusDotTone, string> = {
21
+ brand: "bg-[var(--brand)]",
22
+ neutral: "bg-[var(--text-muted)]",
23
+ success: "bg-[var(--success)]",
24
+ warning: "bg-[var(--warning)]",
25
+ danger: "bg-[var(--danger)]",
26
+ info: "bg-[var(--info)]",
27
+ income: "bg-[var(--money-income)]",
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)]",
34
+ };
35
+
36
+ const SIZE: Record<StatusDotSize, string> = { sm: "size-2", md: "size-2.5", lg: "size-3" };
37
+
38
+ export interface StatusDotProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
39
+ tone?: StatusDotTone;
40
+ size?: StatusDotSize;
41
+ /**
42
+ * Visible text beside the dot — a legend entry ("Rented") or a status ("Online"). It
43
+ * is also what a screen reader reads: the dot itself is decorative whenever there is
44
+ * a label.
45
+ *
46
+ * Without a label the dot is hidden from assistive technology, because a coloured
47
+ * circle says nothing to someone who cannot see it. Say the state where it IS read —
48
+ * keksdose's avatar trigger puts "3 unread" in its own `aria-label` — or give the dot
49
+ * an `aria-label` of its own, which makes it an image with that name.
50
+ */
51
+ label?: ReactNode;
52
+ /**
53
+ * A ring in the surface colour round the dot, so a dot laid over the corner of an
54
+ * icon or an avatar reads as a separate mark and not a smudge (keksdose top-bar.tsx
55
+ * :152, account-menu.tsx ~385, both hand-drawing `ring-2 ring-white
56
+ * dark:ring-slate-900`). Position it with `className` (`absolute -end-0.5 -top-0.5`).
57
+ */
58
+ ring?: boolean;
59
+ /** Extra classes for the dot itself; `className` goes on the outer element. */
60
+ dotClassName?: string;
61
+ }
62
+
63
+ /**
64
+ * A small token-coloured dot, with an optional label.
65
+ *
66
+ * kastlan's reporting dashboard (dashboard-tab.tsx:79) paints its occupancy legend
67
+ * with raw `bg-green-500` / `bg-red-500` / `bg-yellow-500`, which ignores the palette
68
+ * and the dark theme; keksdose draws two unread dots by hand in `rose-500`. Both are
69
+ * this.
70
+ */
71
+ export function StatusDot({
72
+ tone = "neutral",
73
+ size = "sm",
74
+ label,
75
+ ring = false,
76
+ className,
77
+ dotClassName,
78
+ "aria-label": ariaLabel,
79
+ ...rest
80
+ }: StatusDotProps) {
81
+ const dot = (
82
+ <span
83
+ className={cn(
84
+ "inline-block shrink-0 rounded-full",
85
+ SIZE[size],
86
+ FILL[tone],
87
+ ring && "ring-2 ring-[var(--bg-surface)]",
88
+ label == null && className,
89
+ dotClassName,
90
+ )}
91
+ {...(label == null
92
+ ? ariaLabel
93
+ ? { role: "img", "aria-label": ariaLabel, ...rest }
94
+ : { "aria-hidden": true, ...rest }
95
+ : { "aria-hidden": true })}
96
+ />
97
+ );
98
+ if (label == null) return dot;
99
+ return (
100
+ // No `aria-label` here: the visible label is the text, and a name on a role-less
101
+ // span is one ARIA forbids and screen readers ignore.
102
+ <span {...rest} className={cn("inline-flex min-w-0 items-center gap-1.5 text-sm", className)}>
103
+ {dot}
104
+ <span className="min-w-0 truncate">{label}</span>
105
+ </span>
106
+ );
107
+ }