@eifi1/ui-kit 0.7.0 → 0.8.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.
- package/README.md +32 -17
- package/dist/chart.d.ts +3 -2
- package/dist/components/alert-banner.d.ts +59 -10
- package/dist/components/alert-banner.js +114 -8
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +5 -0
- package/dist/components/button-group.d.ts +26 -0
- package/dist/components/button-group.js +49 -0
- package/dist/components/button-group.js.map +1 -0
- package/dist/components/calculator.d.ts +5 -0
- package/dist/components/chart-zoom.d.ts +63 -14
- package/dist/components/chart-zoom.js +82 -16
- package/dist/components/chart-zoom.js.map +1 -1
- package/dist/components/chip.d.ts +67 -2
- package/dist/components/chip.js +73 -8
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +120 -0
- package/dist/components/confirm-dialog.js +96 -0
- package/dist/components/confirm-dialog.js.map +1 -0
- package/dist/components/copy-button.d.ts +62 -0
- package/dist/components/copy-button.js +95 -0
- package/dist/components/copy-button.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +5 -1
- package/dist/components/data-table-pagination.js +71 -58
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.d.ts +28 -5
- package/dist/components/data-table-sort.js +11 -7
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +544 -423
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +51 -5
- package/dist/components/date-picker.js +152 -40
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +60 -0
- package/dist/components/description-list.js +112 -0
- package/dist/components/description-list.js.map +1 -0
- package/dist/components/disclosure.d.ts +37 -2
- package/dist/components/disclosure.js +14 -5
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/facing-pair.d.ts +2 -0
- package/dist/components/file-button.d.ts +19 -1
- package/dist/components/file-button.js +8 -1
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +56 -9
- package/dist/components/file-dropzone.js +100 -70
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +107 -0
- package/dist/components/floating-panel.js +206 -0
- package/dist/components/floating-panel.js.map +1 -0
- package/dist/components/full-bleed-dialog.d.ts +12 -1
- package/dist/components/full-bleed-dialog.js +26 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/modal.d.ts +6 -2
- package/dist/components/modal.js +2 -1
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-field.d.ts +5 -0
- package/dist/components/number-input.d.ts +5 -0
- package/dist/components/numpad-sheet.d.ts +5 -0
- package/dist/components/progress-bar.d.ts +54 -0
- package/dist/components/progress-bar.js +94 -0
- package/dist/components/progress-bar.js.map +1 -0
- package/dist/components/scroll-area.d.ts +53 -0
- package/dist/components/scroll-area.js +86 -0
- package/dist/components/scroll-area.js.map +1 -0
- package/dist/components/separator.d.ts +23 -0
- package/dist/components/separator.js +24 -0
- package/dist/components/separator.js.map +1 -0
- package/dist/components/series-chart-ticks.d.ts +34 -1
- package/dist/components/series-chart-ticks.js +83 -1
- package/dist/components/series-chart-ticks.js.map +1 -1
- package/dist/components/series-chart.d.ts +314 -19
- package/dist/components/series-chart.js +510 -120
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +2 -0
- package/dist/components/skeleton.d.ts +36 -0
- package/dist/components/skeleton.js +35 -0
- package/dist/components/skeleton.js.map +1 -0
- package/dist/components/stat-tile.d.ts +60 -6
- package/dist/components/stat-tile.js +60 -26
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/table.d.ts +83 -0
- package/dist/components/table.js +166 -0
- package/dist/components/table.js.map +1 -0
- package/dist/components/time-input.d.ts +2 -0
- package/dist/components/toggle-legend.d.ts +3 -2
- package/dist/components/toggle-legend.js +1 -1
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.d.ts +10 -4
- package/dist/components/tooltip.js +23 -6
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/tree-view.d.ts +129 -0
- package/dist/components/tree-view.js +376 -0
- package/dist/components/tree-view.js.map +1 -0
- package/dist/components/treemap.d.ts +9 -2
- package/dist/components/treemap.js +2 -1
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +92 -14
- package/dist/components/ui.js +112 -30
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
- package/dist/data-table.d.ts +2 -2
- package/dist/data-table.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
- package/dist/hooks/use-copy-to-clipboard.js +81 -0
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
- package/dist/hooks/use-debounce.d.ts +43 -0
- package/dist/hooks/use-debounce.js +56 -0
- package/dist/hooks/use-debounce.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +72 -0
- package/dist/hooks/use-file-drop.js +57 -0
- package/dist/hooks/use-file-drop.js.map +1 -0
- package/dist/i18n/defaults.d.ts +5 -0
- package/dist/i18n/defaults.js +7 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +17 -0
- package/dist/i18n/kit-labels.js +6 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
- package/dist/i18n/locales/de-CH-informal.js +8 -0
- package/dist/i18n/locales/de-CH-informal.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +5 -0
- package/dist/i18n/locales/de-CH.js +1 -14
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/de-informal.d.ts +64 -0
- package/dist/i18n/locales/de-informal.js +37 -0
- package/dist/i18n/locales/de-informal.js.map +1 -0
- package/dist/i18n/locales/de.d.ts +5 -0
- package/dist/i18n/locales/de.js +25 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +5 -0
- package/dist/i18n/locales/es.js +25 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +5 -0
- package/dist/i18n/locales/fr.js +25 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +5 -0
- package/dist/i18n/locales/hu.js +25 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +5 -0
- package/dist/i18n/locales/it.js +25 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +5 -0
- package/dist/i18n/locales/zh.js +25 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +13 -0
- package/dist/i18n/swiss.js +19 -0
- package/dist/i18n/swiss.js.map +1 -0
- package/dist/index.d.ts +26 -11
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/dates.d.ts +37 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/rhf/form.d.ts +2 -0
- package/dist/rhf.d.ts +2 -0
- package/dist/search/command-palette.d.ts +15 -1
- package/dist/search/command-palette.js +10 -2
- package/dist/search/command-palette.js.map +1 -1
- package/dist/shell/app-shell.js +2 -2
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +35 -2
- package/dist/wizard/stepper-nav.js +54 -22
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +107 -5
- package/dist/wizard/types.js +2 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +141 -21
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.d.ts +10 -2
- package/dist/wizard/wizard-summary.js +3 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +4 -1
- package/dist/wizard.js.map +1 -1
- package/package.json +1 -1
- package/src/components/alert-banner.tsx +211 -20
- package/src/components/button-group.tsx +75 -0
- package/src/components/chart-zoom.tsx +158 -20
- package/src/components/chip.tsx +160 -9
- package/src/components/confirm-dialog.tsx +242 -0
- package/src/components/copy-button.tsx +158 -0
- package/src/components/data-table-pagination.tsx +19 -6
- package/src/components/data-table-sort.ts +49 -10
- package/src/components/data-table.tsx +293 -24
- package/src/components/date-picker.tsx +262 -57
- package/src/components/description-list.tsx +174 -0
- package/src/components/disclosure.tsx +58 -5
- package/src/components/file-button.tsx +22 -2
- package/src/components/file-dropzone.tsx +198 -100
- package/src/components/floating-panel.tsx +341 -0
- package/src/components/full-bleed-dialog.tsx +48 -5
- package/src/components/modal.tsx +9 -2
- package/src/components/progress-bar.tsx +163 -0
- package/src/components/scroll-area.tsx +129 -0
- package/src/components/separator.tsx +37 -0
- package/src/components/series-chart-ticks.ts +135 -0
- package/src/components/series-chart.tsx +934 -67
- package/src/components/skeleton.tsx +65 -0
- package/src/components/stat-tile.tsx +160 -27
- package/src/components/table.tsx +263 -0
- package/src/components/toggle-legend.tsx +4 -3
- package/src/components/tooltip.tsx +40 -12
- package/src/components/tree-view.tsx +589 -0
- package/src/components/treemap.tsx +10 -2
- package/src/components/ui.tsx +268 -42
- package/src/data-table.ts +2 -0
- package/src/hooks/use-copy-to-clipboard.ts +122 -0
- package/src/hooks/use-debounce.ts +105 -0
- package/src/hooks/use-file-drop.ts +123 -0
- package/src/i18n/defaults.ts +6 -0
- package/src/i18n/kit-labels.tsx +19 -0
- package/src/i18n/locales/de-CH-informal.ts +10 -0
- package/src/i18n/locales/de-CH.ts +1 -16
- package/src/i18n/locales/de-informal.ts +61 -0
- package/src/i18n/locales/de.ts +25 -0
- package/src/i18n/locales/es.ts +25 -0
- package/src/i18n/locales/fr.ts +25 -0
- package/src/i18n/locales/hu.ts +25 -0
- package/src/i18n/locales/it.ts +25 -0
- package/src/i18n/locales/zh.ts +25 -0
- package/src/i18n/swiss.ts +25 -0
- package/src/index.ts +29 -0
- package/src/lib/dates.ts +48 -0
- package/src/search/command-palette.tsx +29 -3
- package/src/shell/app-shell.tsx +2 -2
- package/src/wizard/stepper-nav.tsx +102 -28
- package/src/wizard/types.ts +106 -4
- package/src/wizard/use-wizard.ts +211 -31
- package/src/wizard/wizard-summary.tsx +24 -12
- 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(
|
|
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:
|
|
135
|
-
series:
|
|
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:
|
|
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
|
|
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?:
|
|
285
|
-
series?: readonly
|
|
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(
|
|
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>
|
package/src/components/chip.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|