@eifi1/ui-kit 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +32 -17
  2. package/dist/chart.d.ts +3 -2
  3. package/dist/components/alert-banner.d.ts +59 -10
  4. package/dist/components/alert-banner.js +114 -8
  5. package/dist/components/alert-banner.js.map +1 -1
  6. package/dist/components/amount-input.d.ts +3 -0
  7. package/dist/components/button-group.d.ts +26 -0
  8. package/dist/components/button-group.js +49 -0
  9. package/dist/components/button-group.js.map +1 -0
  10. package/dist/components/calculator.d.ts +3 -0
  11. package/dist/components/chart-zoom.d.ts +63 -14
  12. package/dist/components/chart-zoom.js +82 -16
  13. package/dist/components/chart-zoom.js.map +1 -1
  14. package/dist/components/chip.d.ts +67 -2
  15. package/dist/components/chip.js +73 -8
  16. package/dist/components/chip.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +120 -0
  18. package/dist/components/confirm-dialog.js +96 -0
  19. package/dist/components/confirm-dialog.js.map +1 -0
  20. package/dist/components/copy-button.d.ts +62 -0
  21. package/dist/components/copy-button.js +95 -0
  22. package/dist/components/copy-button.js.map +1 -0
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table-pagination.d.ts +5 -1
  26. package/dist/components/data-table-pagination.js +71 -58
  27. package/dist/components/data-table-pagination.js.map +1 -1
  28. package/dist/components/data-table-sort.d.ts +28 -5
  29. package/dist/components/data-table-sort.js +11 -7
  30. package/dist/components/data-table-sort.js.map +1 -1
  31. package/dist/components/data-table.d.ts +1 -1
  32. package/dist/components/data-table.js +544 -423
  33. package/dist/components/data-table.js.map +1 -1
  34. package/dist/components/date-picker.d.ts +51 -5
  35. package/dist/components/date-picker.js +152 -40
  36. package/dist/components/date-picker.js.map +1 -1
  37. package/dist/components/description-list.d.ts +60 -0
  38. package/dist/components/description-list.js +112 -0
  39. package/dist/components/description-list.js.map +1 -0
  40. package/dist/components/disclosure.d.ts +37 -2
  41. package/dist/components/disclosure.js +14 -5
  42. package/dist/components/disclosure.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +2 -0
  44. package/dist/components/file-button.d.ts +17 -1
  45. package/dist/components/file-button.js +8 -1
  46. package/dist/components/file-button.js.map +1 -1
  47. package/dist/components/file-dropzone.d.ts +54 -9
  48. package/dist/components/file-dropzone.js +100 -70
  49. package/dist/components/file-dropzone.js.map +1 -1
  50. package/dist/components/floating-panel.d.ts +107 -0
  51. package/dist/components/floating-panel.js +206 -0
  52. package/dist/components/floating-panel.js.map +1 -0
  53. package/dist/components/full-bleed-dialog.d.ts +12 -1
  54. package/dist/components/full-bleed-dialog.js +28 -5
  55. package/dist/components/full-bleed-dialog.js.map +1 -1
  56. package/dist/components/modal.d.ts +6 -2
  57. package/dist/components/modal.js +2 -1
  58. package/dist/components/modal.js.map +1 -1
  59. package/dist/components/number-field.d.ts +3 -0
  60. package/dist/components/number-input.d.ts +3 -0
  61. package/dist/components/numpad-sheet.d.ts +3 -0
  62. package/dist/components/progress-bar.d.ts +54 -0
  63. package/dist/components/progress-bar.js +94 -0
  64. package/dist/components/progress-bar.js.map +1 -0
  65. package/dist/components/scroll-area.d.ts +53 -0
  66. package/dist/components/scroll-area.js +86 -0
  67. package/dist/components/scroll-area.js.map +1 -0
  68. package/dist/components/separator.d.ts +23 -0
  69. package/dist/components/separator.js +24 -0
  70. package/dist/components/separator.js.map +1 -0
  71. package/dist/components/series-chart-ticks.d.ts +34 -1
  72. package/dist/components/series-chart-ticks.js +83 -1
  73. package/dist/components/series-chart-ticks.js.map +1 -1
  74. package/dist/components/series-chart.d.ts +314 -19
  75. package/dist/components/series-chart.js +510 -120
  76. package/dist/components/series-chart.js.map +1 -1
  77. package/dist/components/skeleton.d.ts +36 -0
  78. package/dist/components/skeleton.js +35 -0
  79. package/dist/components/skeleton.js.map +1 -0
  80. package/dist/components/stat-tile.d.ts +60 -6
  81. package/dist/components/stat-tile.js +60 -26
  82. package/dist/components/stat-tile.js.map +1 -1
  83. package/dist/components/table.d.ts +83 -0
  84. package/dist/components/table.js +166 -0
  85. package/dist/components/table.js.map +1 -0
  86. package/dist/components/toggle-legend.d.ts +3 -2
  87. package/dist/components/toggle-legend.js +1 -1
  88. package/dist/components/toggle-legend.js.map +1 -1
  89. package/dist/components/tree-view.d.ts +129 -0
  90. package/dist/components/tree-view.js +376 -0
  91. package/dist/components/tree-view.js.map +1 -0
  92. package/dist/components/treemap.d.ts +9 -2
  93. package/dist/components/treemap.js +6 -2
  94. package/dist/components/treemap.js.map +1 -1
  95. package/dist/components/ui.d.ts +94 -13
  96. package/dist/components/ui.js +111 -29
  97. package/dist/components/ui.js.map +1 -1
  98. package/dist/components/use-table-state.d.ts +1 -1
  99. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  100. package/dist/data-table.d.ts +2 -2
  101. package/dist/data-table.js.map +1 -1
  102. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  103. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  104. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  105. package/dist/hooks/use-debounce.d.ts +43 -0
  106. package/dist/hooks/use-debounce.js +56 -0
  107. package/dist/hooks/use-debounce.js.map +1 -0
  108. package/dist/hooks/use-file-drop.d.ts +72 -0
  109. package/dist/hooks/use-file-drop.js +57 -0
  110. package/dist/hooks/use-file-drop.js.map +1 -0
  111. package/dist/i18n/defaults.d.ts +3 -0
  112. package/dist/i18n/defaults.js +7 -1
  113. package/dist/i18n/defaults.js.map +1 -1
  114. package/dist/i18n/kit-labels.d.ts +15 -0
  115. package/dist/i18n/kit-labels.js +6 -2
  116. package/dist/i18n/kit-labels.js.map +1 -1
  117. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  118. package/dist/i18n/locales/de-CH-informal.js +8 -0
  119. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  120. package/dist/i18n/locales/de-CH.d.ts +3 -0
  121. package/dist/i18n/locales/de-CH.js +1 -14
  122. package/dist/i18n/locales/de-CH.js.map +1 -1
  123. package/dist/i18n/locales/de-informal.d.ts +64 -0
  124. package/dist/i18n/locales/de-informal.js +37 -0
  125. package/dist/i18n/locales/de-informal.js.map +1 -0
  126. package/dist/i18n/locales/de.d.ts +3 -0
  127. package/dist/i18n/locales/de.js +25 -0
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +3 -0
  130. package/dist/i18n/locales/es.js +25 -0
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +3 -0
  133. package/dist/i18n/locales/fr.js +25 -0
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +3 -0
  136. package/dist/i18n/locales/hu.js +25 -0
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +3 -0
  139. package/dist/i18n/locales/it.js +25 -0
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +3 -0
  142. package/dist/i18n/locales/zh.js +25 -0
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/i18n/swiss.d.ts +13 -0
  145. package/dist/i18n/swiss.js +19 -0
  146. package/dist/i18n/swiss.js.map +1 -0
  147. package/dist/index.d.ts +25 -10
  148. package/dist/index.js +22 -0
  149. package/dist/index.js.map +1 -1
  150. package/dist/lib/dates.d.ts +37 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/search/command-palette.d.ts +15 -1
  154. package/dist/search/command-palette.js +10 -2
  155. package/dist/search/command-palette.js.map +1 -1
  156. package/dist/wizard/stepper-nav.d.ts +35 -2
  157. package/dist/wizard/stepper-nav.js +54 -22
  158. package/dist/wizard/stepper-nav.js.map +1 -1
  159. package/dist/wizard/types.d.ts +107 -5
  160. package/dist/wizard/types.js +2 -1
  161. package/dist/wizard/types.js.map +1 -1
  162. package/dist/wizard/use-wizard.js +141 -21
  163. package/dist/wizard/use-wizard.js.map +1 -1
  164. package/dist/wizard/wizard-summary.d.ts +10 -2
  165. package/dist/wizard/wizard-summary.js +3 -1
  166. package/dist/wizard/wizard-summary.js.map +1 -1
  167. package/dist/wizard.d.ts +4 -1
  168. package/dist/wizard.js.map +1 -1
  169. package/package.json +1 -1
  170. package/src/components/alert-banner.tsx +211 -20
  171. package/src/components/button-group.tsx +75 -0
  172. package/src/components/chart-zoom.tsx +158 -20
  173. package/src/components/chip.tsx +160 -9
  174. package/src/components/confirm-dialog.tsx +242 -0
  175. package/src/components/copy-button.tsx +158 -0
  176. package/src/components/data-table-pagination.tsx +19 -6
  177. package/src/components/data-table-sort.ts +49 -10
  178. package/src/components/data-table.tsx +293 -24
  179. package/src/components/date-picker.tsx +262 -57
  180. package/src/components/description-list.tsx +174 -0
  181. package/src/components/disclosure.tsx +58 -5
  182. package/src/components/file-button.tsx +22 -2
  183. package/src/components/file-dropzone.tsx +198 -100
  184. package/src/components/floating-panel.tsx +341 -0
  185. package/src/components/full-bleed-dialog.tsx +53 -5
  186. package/src/components/modal.tsx +9 -2
  187. package/src/components/progress-bar.tsx +163 -0
  188. package/src/components/scroll-area.tsx +129 -0
  189. package/src/components/separator.tsx +37 -0
  190. package/src/components/series-chart-ticks.ts +135 -0
  191. package/src/components/series-chart.tsx +934 -67
  192. package/src/components/skeleton.tsx +65 -0
  193. package/src/components/stat-tile.tsx +160 -27
  194. package/src/components/table.tsx +263 -0
  195. package/src/components/toggle-legend.tsx +4 -3
  196. package/src/components/tree-view.tsx +589 -0
  197. package/src/components/treemap.tsx +13 -2
  198. package/src/components/ui.tsx +270 -39
  199. package/src/data-table.ts +2 -0
  200. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  201. package/src/hooks/use-debounce.ts +105 -0
  202. package/src/hooks/use-file-drop.ts +123 -0
  203. package/src/i18n/defaults.ts +6 -0
  204. package/src/i18n/kit-labels.tsx +19 -0
  205. package/src/i18n/locales/de-CH-informal.ts +10 -0
  206. package/src/i18n/locales/de-CH.ts +1 -16
  207. package/src/i18n/locales/de-informal.ts +61 -0
  208. package/src/i18n/locales/de.ts +25 -0
  209. package/src/i18n/locales/es.ts +25 -0
  210. package/src/i18n/locales/fr.ts +25 -0
  211. package/src/i18n/locales/hu.ts +25 -0
  212. package/src/i18n/locales/it.ts +25 -0
  213. package/src/i18n/locales/zh.ts +25 -0
  214. package/src/i18n/swiss.ts +25 -0
  215. package/src/index.ts +29 -0
  216. package/src/lib/dates.ts +48 -0
  217. package/src/search/command-palette.tsx +29 -3
  218. package/src/wizard/stepper-nav.tsx +102 -28
  219. package/src/wizard/types.ts +106 -4
  220. package/src/wizard/use-wizard.ts +211 -31
  221. package/src/wizard/wizard-summary.tsx +24 -12
  222. package/src/wizard.ts +3 -2
@@ -20,7 +20,7 @@
20
20
  // Everything with a decision in it is a pure exported function below (`zoomAxesFor`,
21
21
  // `selectionFromDrag`, `zoomAfter`, `zoomDomains`, `fitYToX`, `fitXToY`); the
22
22
  // components are plumbing between a pointer and those.
23
- import { createContext, useContext, useEffect, useState } from "react";
23
+ import { createContext, useContext, useEffect, useRef, useState } from "react";
24
24
  import type { ComponentType, Dispatch, ReactNode, SetStateAction } from "react";
25
25
  import {
26
26
  usePlotArea,
@@ -59,10 +59,16 @@ export function zoomAxesFor(
59
59
  width: number,
60
60
  height: number,
61
61
  threshold: number = ZOOM_SQUARE_ENOUGH,
62
+ /** A chart that zooms ONE axis only (see {@link ZoomTarget.zoomAxes}): every drag is
63
+ * a band along it, however it was drawn, and a drag with no extent along it is
64
+ * nothing — a tall narrow drag over a bar chart names no stretch of the abscissa. */
65
+ only?: "x" | "y",
62
66
  ): ZoomAxes | null {
63
67
  if (!(width > 0) || !(height > 0)) return null;
64
68
  const across = Math.abs(dx) / width;
65
69
  const down = Math.abs(dy) / height;
70
+ if (only === "x") return across < ZOOM_MIN_DRAG ? null : "x";
71
+ if (only === "y") return down < ZOOM_MIN_DRAG ? null : "y";
66
72
  if (across < ZOOM_MIN_DRAG && down < ZOOM_MIN_DRAG) return null;
67
73
  const longer = Math.max(across, down);
68
74
  const shorter = Math.min(across, down);
@@ -107,8 +113,16 @@ export function selectionFromDrag(
107
113
  xInverse: InverseScaleFunction | undefined,
108
114
  yInverse: (axisId: string) => InverseScaleFunction | undefined,
109
115
  axisIds: readonly string[],
116
+ only?: "x" | "y",
110
117
  ): ZoomSelection | null {
111
- const which = zoomAxesFor(drag.toX - drag.fromX, drag.toY - drag.fromY, plot.width, plot.height);
118
+ const which = zoomAxesFor(
119
+ drag.toX - drag.fromX,
120
+ drag.toY - drag.fromY,
121
+ plot.width,
122
+ plot.height,
123
+ ZOOM_SQUARE_ENOUGH,
124
+ only,
125
+ );
112
126
  if (!which) return null;
113
127
  const next: ZoomSelection = { which, y: {} };
114
128
  if (which !== "y" && xInverse) {
@@ -128,12 +142,28 @@ export function selectionFromDrag(
128
142
  return next.x || Object.keys(next.y).length ? next : null;
129
143
  }
130
144
 
145
+ /** One row of a zoomable chart. Loose on purpose: a row also carries what the chart
146
+ * does not plot — keksdose's `rawPeriod` for a drilldown, a label for a category axis —
147
+ * and only the finite NUMBERS in it are ever read as samples. */
148
+ export type ZoomRow = Readonly<Record<string, unknown>>;
149
+
150
+ /** A series as the fit sees it. `stack` groups bars/areas drawn on top of each other
151
+ * (recharts' `stackId`), whose extent is their SUM, not any one of them. */
152
+ export interface ZoomFitSeries {
153
+ key: string;
154
+ axis?: string;
155
+ stack?: string;
156
+ /** Only `"line"` matters here: recharts cannot stack a line, so a line's `stack` is
157
+ * ignored rather than summed into a height nothing on the chart reaches. */
158
+ type?: string;
159
+ }
160
+
131
161
  /** What a band refit needs to know about the chart — a subset of `SeriesChart`'s own
132
162
  * props, so a chart hands its data straight over. */
133
163
  export interface ZoomFitSource {
134
- rows: Record<string, number>[];
135
- series: { key: string; axis?: string }[];
136
- axes: { id: string }[];
164
+ rows: readonly ZoomRow[];
165
+ series: readonly ZoomFitSeries[];
166
+ axes: readonly { id: string }[];
137
167
  xKey: string;
138
168
  }
139
169
 
@@ -156,8 +186,8 @@ type Span = [number, number] | undefined;
156
186
 
157
187
  /** A pair widened by one value. A non-finite one is not a value — one NaN would
158
188
  * otherwise take the whole window with it. */
159
- function widen(span: Span, value: number): Span {
160
- if (!Number.isFinite(value)) return span;
189
+ function widen(span: Span, value: unknown): Span {
190
+ if (typeof value !== "number" || !Number.isFinite(value)) return span;
161
191
  if (!span) return [value, value];
162
192
  if (value < span[0]) span[0] = value;
163
193
  else if (value > span[1]) span[1] = value;
@@ -171,6 +201,63 @@ function keysOn(source: ZoomFitSource, axisId: string): string[] {
171
201
  .map((entry) => entry.key);
172
202
  }
173
203
 
204
+ /** A row's value as a sample, or 0 for a stack's missing layer — a stacked bar with no
205
+ * value for one series draws the rest of the stack from where that one would end. */
206
+ const layer = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
207
+
208
+ /**
209
+ * The lowest and highest value an axis DRAWS over the rows whose abscissa is inside
210
+ * `window` (every row when there is none) — `undefined` when it draws nothing finite.
211
+ *
212
+ * Stack-aware, and that is why it exists: a stack of three bars reaches the SUM of
213
+ * them, and fitting the axis to the tallest single layer (what fitting on values did)
214
+ * cuts the top two thirds of every stack off. Positive and negative layers are summed
215
+ * apart, as recharts draws them under `stackOffset="sign"` — a month with 900 income
216
+ * and −300 expense stacks up to 900 and down to −300, not to 600. A stacked extent
217
+ * therefore always contains the zero its layers stand on.
218
+ *
219
+ * `SeriesChart` fits its axes with this and the zoom refits them with it, so the two
220
+ * cannot disagree about how tall a stack is.
221
+ */
222
+ export function axisExtent(
223
+ source: ZoomFitSource,
224
+ axisId: string,
225
+ window?: readonly [number, number],
226
+ ): [number, number] | undefined {
227
+ const mine = source.series.filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axisId);
228
+ const loose: string[] = [];
229
+ const stacks = new Map<string, string[]>();
230
+ for (const entry of mine) {
231
+ if (entry.stack === undefined || entry.type === "line" || entry.type === undefined) {
232
+ loose.push(entry.key);
233
+ } else {
234
+ stacks.set(entry.stack, [...(stacks.get(entry.stack) ?? []), entry.key]);
235
+ }
236
+ }
237
+ let span: Span;
238
+ for (const row of source.rows) {
239
+ if (window) {
240
+ const at = row[source.xKey];
241
+ if (typeof at !== "number" || !(at >= window[0] && at <= window[1])) continue;
242
+ }
243
+ for (const key of loose) span = widen(span, row[key]);
244
+ for (const keys of stacks.values()) {
245
+ let up = 0;
246
+ let down = 0;
247
+ let any = false;
248
+ for (const key of keys) {
249
+ const value = layer(row[key]);
250
+ any ||= typeof row[key] === "number" && Number.isFinite(row[key]);
251
+ if (value >= 0) up += value;
252
+ else down += value;
253
+ }
254
+ if (!any) continue;
255
+ span = widen(widen(span, down), up);
256
+ }
257
+ }
258
+ return span;
259
+ }
260
+
174
261
  /**
175
262
  * Each y axis fitted to the samples inside an x window — what a band across x leaves
176
263
  * the axes nobody dragged showing. Zoom into a tenth of a sweep and a y axis still
@@ -185,16 +272,9 @@ export function fitYToX(
185
272
  source: ZoomFitSource,
186
273
  window: readonly [number, number],
187
274
  ): Record<string, [number, number]> {
188
- const [from, to] = window;
189
275
  const fitted: Record<string, [number, number]> = {};
190
276
  for (const axis of source.axes) {
191
- const keys = keysOn(source, axis.id);
192
- let span: Span;
193
- for (const row of source.rows) {
194
- const at = row[source.xKey];
195
- if (!(at >= from && at <= to)) continue;
196
- for (const key of keys) span = widen(span, row[key]);
197
- }
277
+ const span = axisExtent(source, axis.id, window);
198
278
  if (span) fitted[axis.id] = airy(span[0], span[1]);
199
279
  }
200
280
  return fitted;
@@ -215,7 +295,7 @@ export function fitXToY(
215
295
  for (const key of keysOn(source, axisId)) {
216
296
  for (const row of source.rows) {
217
297
  const value = row[key];
218
- if (Number.isFinite(value) && value >= low && value <= high) {
298
+ if (typeof value === "number" && Number.isFinite(value) && value >= low && value <= high) {
219
299
  hits = widen(hits, row[source.xKey]);
220
300
  }
221
301
  }
@@ -275,17 +355,43 @@ export interface ZoomBinding {
275
355
  layer: ReactNode;
276
356
  }
277
357
 
358
+ /**
359
+ * Which axes a chart lets a drag zoom. `"none"` draws no drag layer and no reset.
360
+ * A drag on a one-axis chart is always a band along that axis (see {@link zoomAxesFor}).
361
+ */
362
+ export type ZoomAxesSetting = ZoomAxes | "none";
363
+
364
+ /**
365
+ * The zoom a chart gets when it does not say, read off what its series ARE.
366
+ *
367
+ * - Only lines: `"both"` — lenkbank's measurement plots, unchanged.
368
+ * - Any area: `"x"`. An area's height is a length from zero, and a y window that cuts
369
+ * the baseline off draws a band of 40 as three times one of 20 — so its y axis refits
370
+ * to the x window (still from zero), and a drag never names a y range.
371
+ * - Any bar: `"none"`. A bar chart is a handful of periods read one bar at a time, and
372
+ * in keksdose every one of them is CLICKED — to drill into a payee, a month, a budget
373
+ * line — which a drag layer over the plot would swallow. A chart of many bars opts
374
+ * back in with `zoomAxes: "x"`; a click then reports the period, not the bar.
375
+ */
376
+ export function defaultZoomAxes(series: readonly { type?: string }[] | undefined): ZoomAxesSetting {
377
+ if (series?.some((entry) => entry.type === "bar")) return "none";
378
+ if (series?.some((entry) => entry.type === "area")) return "x";
379
+ return "both";
380
+ }
381
+
278
382
  /** The props `withChartZoom` reads off the chart it wraps. */
279
383
  export interface ZoomTarget {
280
384
  /** Default: the single axis {@link DEFAULT_Y_AXIS}. */
281
385
  axes?: readonly { id: string; hide?: boolean }[];
282
386
  /** The chart's own data, so a band can refit the axis it did not name. Optional: a
283
387
  * chart with nothing plotted has nothing to fit to. */
284
- rows?: Record<string, number>[];
285
- series?: readonly { key: string; axis?: string }[];
388
+ rows?: readonly ZoomRow[];
389
+ series?: readonly ZoomFitSeries[];
286
390
  x?: { key?: string };
287
391
  zoom?: ZoomBinding;
288
392
  labels?: Partial<SeriesChartLabels>;
393
+ /** Which axes a drag may zoom. Default: {@link defaultZoomAxes} of `series`. */
394
+ zoomAxes?: ZoomAxesSetting;
289
395
  }
290
396
 
291
397
  const SharedX = createContext<{
@@ -326,6 +432,8 @@ export function withChartZoom<P extends ZoomTarget>(Chart: ComponentType<P>) {
326
432
  const xWindow = group ? group.window : ownX;
327
433
  const setXWindow = group ? group.set : setOwnX;
328
434
  const axes = props.axes ?? [{ id: DEFAULT_Y_AXIS }];
435
+ const setting = props.zoomAxes ?? defaultZoomAxes(props.series);
436
+ const only = setting === "x" || setting === "y" ? setting : undefined;
329
437
 
330
438
  // Every axis, hidden ones included: a hidden axis still scales its lines, and a
331
439
  // line whose axis did not refit is drawn as a flat streak inside the window.
@@ -350,11 +458,22 @@ export function withChartZoom<P extends ZoomTarget>(Chart: ComponentType<P>) {
350
458
  setOwnY(null);
351
459
  };
352
460
 
461
+ // Switched off, the chart still gets a binding — with no window and no layer — so a
462
+ // chart whose series change type mid-life is not remounted by a different tree.
463
+ if (setting === "none") {
464
+ return (
465
+ <div className="relative">
466
+ <Chart {...props} zoom={{ yDomains: {}, layer: null }} />
467
+ </div>
468
+ );
469
+ }
470
+
353
471
  const binding: ZoomBinding = {
354
472
  ...zoomDomains(state, source),
355
473
  layer: (
356
474
  <ZoomLayer
357
475
  axisIds={axes.map((axis) => axis.id)}
476
+ only={only}
358
477
  label={labels.zoomHint}
359
478
  onZoom={(selection) => {
360
479
  const next = zoomAfter(state, selection, source);
@@ -395,11 +514,13 @@ export function withChartZoom<P extends ZoomTarget>(Chart: ComponentType<P>) {
395
514
  */
396
515
  function ZoomLayer({
397
516
  axisIds,
517
+ only,
398
518
  label,
399
519
  onZoom,
400
520
  onReset,
401
521
  }: {
402
522
  axisIds: string[];
523
+ only?: "x" | "y";
403
524
  label: string;
404
525
  onZoom: (selection: ZoomSelection) => void;
405
526
  onReset: () => void;
@@ -411,6 +532,9 @@ function ZoomLayer({
411
532
  // one thing React will not forgive.
412
533
  const [scales] = useState(() => new Map<string, InverseScaleFunction | undefined>());
413
534
  const [drag, setDrag] = useState<ZoomDrag | null>(null);
535
+ // A finished drag is followed by a click on the same element, and a chart with an
536
+ // `onClick` (keksdose opens a drilldown on one) would read the zoom as a pick.
537
+ const draggedRef = useRef(false);
414
538
 
415
539
  if (!plot || plot.width <= 0 || plot.height <= 0) return null;
416
540
 
@@ -425,14 +549,22 @@ function ZoomLayer({
425
549
  const finish = () => {
426
550
  if (!drag) return;
427
551
  setDrag(null);
428
- const selection = selectionFromDrag(drag, plot, xInverse, (id) => scales.get(id), axisIds);
552
+ const selection = selectionFromDrag(drag, plot, xInverse, (id) => scales.get(id), axisIds, only);
553
+ draggedRef.current = selection !== null;
429
554
  if (selection) onZoom(selection);
430
555
  };
431
556
 
432
557
  // What the drag would do, shown while it is being made: a band paints the full width
433
558
  // or height, so the reader sees it is about to let the other axis refit.
434
559
  const which = drag
435
- ? zoomAxesFor(drag.toX - drag.fromX, drag.toY - drag.fromY, plot.width, plot.height)
560
+ ? zoomAxesFor(
561
+ drag.toX - drag.fromX,
562
+ drag.toY - drag.fromY,
563
+ plot.width,
564
+ plot.height,
565
+ ZOOM_SQUARE_ENOUGH,
566
+ only,
567
+ )
436
568
  : null;
437
569
 
438
570
  return (
@@ -463,6 +595,7 @@ function ZoomLayer({
463
595
  style={{ cursor: "crosshair" }}
464
596
  onPointerDown={(event) => {
465
597
  if (event.button !== 0) return;
598
+ draggedRef.current = false;
466
599
  const point = at(event);
467
600
  // Best effort: capture keeps a drag alive past the plot's edge, but a
468
601
  // pointer id the environment does not know throws — jsdom refuses them all —
@@ -481,6 +614,11 @@ function ZoomLayer({
481
614
  }}
482
615
  onPointerUp={finish}
483
616
  onPointerCancel={() => setDrag(null)}
617
+ onClick={(event) => {
618
+ if (!draggedRef.current) return;
619
+ draggedRef.current = false;
620
+ event.stopPropagation();
621
+ }}
484
622
  onDoubleClick={onReset}
485
623
  />
486
624
  </g>
@@ -1,5 +1,5 @@
1
1
  import { forwardRef, useId, useRef, useState } from "react";
2
- import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactNode } from "react";
2
+ import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from "react";
3
3
  import { X } from "lucide-react";
4
4
  import type { LucideIcon } from "lucide-react";
5
5
  import { cn } from "../lib/cn";
@@ -33,6 +33,21 @@ export type ChipTone =
33
33
  | "expense";
34
34
  /** `lg` is the 44px touch target (`min-h-11`) a phone surface wants; `md` is ~28px. */
35
35
  export type ChipSize = "sm" | "md" | "lg";
36
+ /**
37
+ * How much of the tone the chip wears.
38
+ *
39
+ * - `soft` (default): the tinted surface every chip has had.
40
+ * - `outline`: the tone's border and text on NO surface — keksdose's DEV badge in the
41
+ * account menu and kastlan's 27 shadcn `variant="outline"` badges, which sit on
42
+ * coloured rows where a second tint would muddy the row.
43
+ * - `solid`: the tone as a fill under contrasting text — the "count" pill (unread
44
+ * messages, open tasks). It centres its content and sets figures tabular, so a
45
+ * one-digit count is a round dot and "9" → "10" does not jiggle the row.
46
+ */
47
+ export type ChipVariant = "soft" | "outline" | "solid";
48
+ /** `pill` (default) is fully rounded; `square` has the small radius of a field or a
49
+ * button — for a chip that sits in a table cell or beside square controls. */
50
+ export type ChipShape = "pill" | "square";
36
51
 
37
52
  const TONE: Record<ChipTone, { idle: string; selected: string }> = {
38
53
  neutral: {
@@ -73,6 +88,72 @@ const TONE: Record<ChipTone, { idle: string; selected: string }> = {
73
88
  },
74
89
  };
75
90
 
91
+ // `outline` idle looks: the tone's border and text, the surface left to whatever the
92
+ // chip sits on. Selected still takes the tone's `selected` look (below), because an
93
+ // outline toggle that stays an outline when on has no visible state.
94
+ const OUTLINE: Record<ChipTone, string> = {
95
+ neutral: "border-[var(--border-strong)] bg-transparent text-[var(--text-secondary)]",
96
+ brand: "border-[var(--brand)] bg-transparent text-[var(--brand)]",
97
+ danger: "border-[var(--danger-border)] bg-transparent text-[var(--danger)]",
98
+ warning: "border-[var(--warning-border)] bg-transparent text-[var(--warning)]",
99
+ success: "border-[var(--success-border)] bg-transparent text-[var(--success)]",
100
+ info: "border-[var(--info-border)] bg-transparent text-[var(--info)]",
101
+ income: "border-current/60 bg-transparent text-[var(--money-income)]",
102
+ expense: "border-current/60 bg-transparent text-[var(--money-expense)]",
103
+ };
104
+
105
+ // `solid`: the fill under its contrast pair. Only brand and danger have a declared
106
+ // `-contrast` token; the other hues are 700s in the light theme and 300s in the dark
107
+ // one, so `--text-inverse` (the surface colour) is the side of the pair that contrasts
108
+ // with them in both — which is what the inverse token is for. The border goes
109
+ // transparent rather than away, so a solid chip is exactly as tall as a soft one.
110
+ const SOLID: Record<ChipTone, string> = {
111
+ neutral: "border-transparent bg-[var(--bg-inverse)] text-[var(--text-inverse)]",
112
+ brand: "border-transparent bg-[var(--brand)] text-[var(--brand-contrast)]",
113
+ danger: "border-transparent bg-[var(--danger)] text-[var(--danger-contrast)]",
114
+ warning: "border-transparent bg-[var(--warning)] text-[var(--text-inverse)]",
115
+ success: "border-transparent bg-[var(--success)] text-[var(--text-inverse)]",
116
+ info: "border-transparent bg-[var(--info)] text-[var(--text-inverse)]",
117
+ income: "border-transparent bg-[var(--money-income)] text-[var(--text-inverse)]",
118
+ expense: "border-transparent bg-[var(--money-expense)] text-[var(--text-inverse)]",
119
+ };
120
+
121
+ /** The status-badge type (`caps`): a size step down, heavier and tracked, because
122
+ * capitals at body size shout and capitals untracked run together. Per size, so it
123
+ * replaces the size's own `text-*` through tailwind-merge. */
124
+ const CAPS: Record<ChipSize, string> = {
125
+ sm: "text-[10px] font-semibold uppercase tracking-wider",
126
+ md: "text-[11px] font-semibold uppercase tracking-wider",
127
+ lg: "text-xs font-semibold uppercase tracking-wider",
128
+ };
129
+
130
+ function surfaceOf(tone: ChipTone, variant: ChipVariant, selected: boolean | undefined): string {
131
+ const palette = TONE[tone];
132
+ if (variant === "solid") {
133
+ // A solid chip is already the strongest look there is, so "on" is a ring round it.
134
+ return cn(SOLID[tone], selected && "ring-2 ring-[var(--border-strong)] ring-offset-1 ring-offset-[var(--bg-surface)]");
135
+ }
136
+ if (selected) return palette.selected;
137
+ return variant === "outline" ? OUTLINE[tone] : palette.idle;
138
+ }
139
+
140
+ /** What {@link ChipProps.renderLink} is handed: everything the chip would have put on
141
+ * its own `<a>`. Spread it onto your router's link, mapping `href` to what the link
142
+ * calls it — `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */
143
+ export interface ChipLinkProps {
144
+ href: string;
145
+ /** The chip's whole look — keep it, or the pill is a bare link. */
146
+ className: string;
147
+ children: ReactNode;
148
+ /** The chip's forwarded ref. React 19 passes it to a function component as a prop. */
149
+ ref?: Ref<HTMLAnchorElement>;
150
+ "aria-current"?: "true";
151
+ id?: string;
152
+ title?: string;
153
+ [key: `aria-${string}`]: string | boolean | number | undefined;
154
+ [key: `data-${string}`]: unknown;
155
+ }
156
+
76
157
  /**
77
158
  * `body` is the whole pill when the chip is ONE element. When a link or a button also has
78
159
  * a dismiss control, the pill is a wrapper holding two siblings (see the note on
@@ -118,6 +199,19 @@ interface ChipBaseProps {
118
199
  children: ReactNode;
119
200
  tone?: ChipTone;
120
201
  size?: ChipSize;
202
+ /** See {@link ChipVariant}. Default `soft`. */
203
+ variant?: ChipVariant;
204
+ /** See {@link ChipShape}. Default `pill`. */
205
+ shape?: ChipShape;
206
+ /**
207
+ * The uppercase, tracked "status badge" type — keksdose's ~25 PAID / DRAFT /
208
+ * OVERDUE / ADMIN badges, each spelling `uppercase tracking-wide text-[10px]` by
209
+ * hand. A flag rather than a fourth `variant` because it is TYPE, not surface: a
210
+ * status badge is soft, outlined or solid as the row needs, and a `variant="tag"`
211
+ * would have had to pick one. Screen readers read the text as written, not as
212
+ * drawn, so write it in normal case.
213
+ */
214
+ caps?: boolean;
121
215
  icon?: LucideIcon;
122
216
  /**
123
217
  * Marks the chip as the current one — `aria-current` on a link, `aria-pressed` on a
@@ -141,6 +235,11 @@ interface ChipBaseProps {
141
235
  /** Accessible name for the dismiss button. Default: `common.remove` from the
142
236
  * {@link UiKitProvider}, else "Remove". */
143
237
  removeLabel?: string;
238
+ /** Disables the × ONLY — the chip itself stays a working link or toggle. For the
239
+ * value that may not be removed right now (the last filter of a required set, a
240
+ * tag the user may read but not edit) where `disabled` would also kill the chip's
241
+ * own action. */
242
+ removeDisabled?: boolean;
144
243
  disabled?: boolean;
145
244
  className?: string;
146
245
  /**
@@ -176,9 +275,25 @@ export type ChipProps = ChipBaseProps &
176
275
  /** Renders the chip as a link. Mutually exclusive with `onClick`. */
177
276
  href?: string;
178
277
  onClick?: never;
278
+ /**
279
+ * Renders the link for `href` — pass your router's `<Link>` here, since a
280
+ * plain `<a>` reloads a single-page app (keksdose's account-menu admin pill).
281
+ * Default: `<a>`. Ignored without `href`.
282
+ *
283
+ * A render function rather than an `as={Link}`, for three reasons. It is the
284
+ * API `StatTile` already has (`renderLink`), so a consumer learns it once.
285
+ * Router links do not take `href` — react-router's takes `to` — so an `as`
286
+ * would either have to pass props through untyped or teach the kit about one
287
+ * router; here the caller maps `href` onto its own link in one line, fully
288
+ * typed. And it keeps `href` as THE link prop, which is what the `href` /
289
+ * `onClick` exclusion below is typed on: the pill is a link because it has an
290
+ * href, whichever element draws it.
291
+ */
292
+ renderLink?: (props: ChipLinkProps) => ReactElement;
179
293
  }
180
294
  | {
181
295
  href?: never;
296
+ renderLink?: never;
182
297
  /** Renders the chip as a toggle button. Mutually exclusive with `href`. Receives
183
298
  * the click, so a chip inside a clickable row can `stopPropagation()`. */
184
299
  onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
@@ -198,12 +313,17 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
198
313
  children,
199
314
  tone = "neutral",
200
315
  size = "md",
316
+ variant = "soft",
317
+ shape = "pill",
318
+ caps = false,
201
319
  icon: Icon,
202
320
  selected,
203
321
  href,
322
+ renderLink,
204
323
  onClick,
205
324
  onRemove,
206
325
  removeLabel,
326
+ removeDisabled = false,
207
327
  disabled = false,
208
328
  className,
209
329
  tabIndex,
@@ -216,15 +336,20 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
216
336
  ) {
217
337
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS, { remove: removeLabel });
218
338
  const s = SIZE[size];
219
- const palette = TONE[tone];
220
339
  const interactive = !!href || !!onClick;
340
+ // One radius for every rounded piece — the pill, the body inside a split pill, the ×
341
+ // — so a square chip has no round focus ring inside it.
342
+ const radius = shape === "square" ? (size === "sm" ? "rounded" : "rounded-md") : "rounded-full";
343
+ const type = cn(caps && CAPS[size], variant === "solid" && "justify-center tabular-nums");
221
344
  const surface = cn(
222
- selected ? palette.selected : palette.idle,
345
+ surfaceOf(tone, variant, selected),
223
346
  disabled && "cursor-default opacity-50",
224
347
  );
225
348
  const look = cn(
226
349
  CHIP_BASE,
227
350
  s.body,
351
+ type,
352
+ radius,
228
353
  surface,
229
354
  interactive && !disabled && "cursor-pointer hover:brightness-[0.97] dark:hover:brightness-110",
230
355
  className,
@@ -257,12 +382,16 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
257
382
  e.stopPropagation();
258
383
  onRemove();
259
384
  }}
260
- disabled={disabled}
385
+ disabled={disabled || removeDisabled}
261
386
  className={cn(
262
387
  // Logical margins: the × sits at the END of the pill, which is the left in RTL.
263
- "-me-0.5 ms-0.5 shrink-0 rounded-full p-0.5 transition-colors",
388
+ "-me-0.5 ms-0.5 shrink-0 p-0.5 transition-colors",
389
+ shape === "square" ? "rounded-sm" : "rounded-full",
264
390
  "hover:bg-[var(--bg-active)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
265
- disabled && "pointer-events-none",
391
+ (disabled || removeDisabled) && "pointer-events-none",
392
+ // A disabled chip is already dimmed as a whole; dimming the × again on top
393
+ // would take it to a quarter. `removeDisabled` alone dims just the ×.
394
+ removeDisabled && !disabled && "opacity-40",
266
395
  )}
267
396
  >
268
397
  <X className={s.remove} aria-hidden />
@@ -271,7 +400,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
271
400
 
272
401
  // ── The combined shape: one pill, two interactive siblings inside it.
273
402
  const pill = (inner: ReactNode) => (
274
- <span className={cn(CHIP_PILL, s.tail, surface, className)}>
403
+ <span className={cn(CHIP_PILL, s.tail, radius, surface, className)}>
275
404
  {inner}
276
405
  {remove}
277
406
  </span>
@@ -279,16 +408,30 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
279
408
  // The body inside a pill: no border or surface of its own (the pill has them), a
280
409
  // hover wash in the text's own hue so it works on every tone.
281
410
  const inner = cn(
282
- "inline-flex min-w-0 items-center self-stretch rounded-full transition-colors",
411
+ "inline-flex min-w-0 items-center self-stretch transition-colors",
412
+ radius,
283
413
  CHIP_RING,
284
414
  s.split,
415
+ type,
285
416
  !disabled && "cursor-pointer hover:bg-current/10",
286
417
  disabled && "cursor-default",
287
418
  );
288
419
 
289
420
  // ── The three shapes.
290
421
  if (href && !disabled) {
291
- const link = (
422
+ const linkProps: ChipLinkProps = {
423
+ ...rest,
424
+ ref: ref as Ref<HTMLAnchorElement>,
425
+ href,
426
+ "aria-current": selected ? "true" : undefined,
427
+ className: remove ? inner : look,
428
+ children: body,
429
+ };
430
+ const link = renderLink ? (
431
+ // Through a component rather than called here, so the forwarded ref arrives as
432
+ // an ordinary prop of an element and is never handed to a function mid-render.
433
+ <RenderedLink render={renderLink} {...linkProps} />
434
+ ) : (
292
435
  <a
293
436
  ref={ref as React.Ref<HTMLAnchorElement>}
294
437
  href={href}
@@ -338,6 +481,14 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
338
481
  );
339
482
  });
340
483
 
484
+ /** Calls a chip's `renderLink` with the props it was given — see its call site. */
485
+ function RenderedLink({
486
+ render,
487
+ ...props
488
+ }: ChipLinkProps & { render: (props: ChipLinkProps) => ReactElement }) {
489
+ return render(props);
490
+ }
491
+
341
492
  /* ── ChipInput ────────────────────────────────────────────────────────────── */
342
493
 
343
494
  export interface ChipInputLabels {