@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.
- package/README.md +37 -23
- package/dist/components/alert-banner.d.ts +57 -5
- package/dist/components/alert-banner.js +63 -17
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +6 -0
- package/dist/components/breadcrumbs.d.ts +60 -0
- package/dist/components/breadcrumbs.js +88 -0
- package/dist/components/breadcrumbs.js.map +1 -0
- package/dist/components/bulk-action-bar.d.ts +112 -0
- package/dist/components/bulk-action-bar.js +196 -0
- package/dist/components/bulk-action-bar.js.map +1 -0
- package/dist/components/button-group.d.ts +58 -3
- package/dist/components/button-group.js +56 -5
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +6 -0
- package/dist/components/calendar-heatmap.d.ts +124 -0
- package/dist/components/calendar-heatmap.js +295 -0
- package/dist/components/calendar-heatmap.js.map +1 -0
- package/dist/components/chip.d.ts +31 -5
- package/dist/components/chip.js +85 -5
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +34 -2
- package/dist/components/choice-card.js +49 -0
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/copy-button.d.ts +14 -4
- package/dist/components/copy-button.js +17 -4
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/data-table.js +2 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +22 -2
- package/dist/components/date-picker.js +11 -3
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +43 -6
- package/dist/components/description-list.js +91 -11
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +7 -0
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/disclosure.d.ts +57 -2
- package/dist/components/disclosure.js +24 -12
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/field.d.ts +43 -0
- package/dist/components/field.js +52 -0
- package/dist/components/field.js.map +1 -0
- package/dist/components/file-button.d.ts +1 -0
- package/dist/components/file-dropzone.d.ts +4 -4
- package/dist/components/file-dropzone.js +2 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +147 -4
- package/dist/components/floating-panel.js +188 -28
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +23 -4
- package/dist/components/full-bleed-dialog.js +11 -2
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/list.d.ts +187 -0
- package/dist/components/list.js +221 -0
- package/dist/components/list.js.map +1 -0
- package/dist/components/menu-item.d.ts +109 -0
- package/dist/components/menu-item.js +88 -0
- package/dist/components/menu-item.js.map +1 -0
- package/dist/components/mini-calendar.d.ts +70 -5
- package/dist/components/mini-calendar.js +166 -62
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +23 -1
- package/dist/components/modal.js +36 -10
- package/dist/components/modal.js.map +1 -1
- package/dist/components/nav-pills.d.ts +66 -0
- package/dist/components/nav-pills.js +65 -0
- package/dist/components/nav-pills.js.map +1 -0
- package/dist/components/number-field.d.ts +6 -0
- package/dist/components/number-input.d.ts +6 -0
- package/dist/components/numpad-sheet.d.ts +6 -0
- package/dist/components/page-contents.js +1 -2
- package/dist/components/page-contents.js.map +1 -1
- package/dist/components/page-header.d.ts +37 -0
- package/dist/components/page-header.js +36 -0
- package/dist/components/page-header.js.map +1 -0
- package/dist/components/progress-bar.d.ts +31 -2
- package/dist/components/progress-bar.js +73 -7
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +3 -0
- package/dist/components/series-chart-labels.js +2 -1
- package/dist/components/series-chart-labels.js.map +1 -1
- package/dist/components/series-chart.d.ts +32 -0
- package/dist/components/series-chart.js +141 -4
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +1 -0
- package/dist/components/status-dot.d.ts +53 -0
- package/dist/components/status-dot.js +57 -0
- package/dist/components/status-dot.js.map +1 -0
- package/dist/components/table.d.ts +78 -13
- package/dist/components/table.js +84 -11
- package/dist/components/table.js.map +1 -1
- package/dist/components/text.d.ts +70 -0
- package/dist/components/text.js +24 -0
- package/dist/components/text.js.map +1 -0
- package/dist/components/time-input.d.ts +1 -0
- package/dist/components/toast.d.ts +167 -0
- package/dist/components/toast.js +226 -0
- package/dist/components/toast.js.map +1 -0
- package/dist/components/toggle-group.d.ts +44 -1
- package/dist/components/toggle-group.js +73 -4
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/tooltip.d.ts +46 -15
- package/dist/components/tooltip.js +87 -47
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +158 -7
- package/dist/components/ui.js +191 -10
- package/dist/components/ui.js.map +1 -1
- package/dist/components/user-avatar.d.ts +25 -3
- package/dist/components/user-avatar.js +32 -3
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/hooks/use-close-transition.d.ts +7 -1
- package/dist/hooks/use-close-transition.js +5 -2
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.js +1 -1
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -0
- package/dist/i18n/defaults.d.ts +6 -0
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +11 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
- package/dist/i18n/locales/de-CH.d.ts +6 -0
- package/dist/i18n/locales/de-informal.d.ts +6 -0
- package/dist/i18n/locales/de.d.ts +6 -0
- package/dist/i18n/locales/de.js +30 -2
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +6 -0
- package/dist/i18n/locales/es.js +30 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +6 -0
- package/dist/i18n/locales/fr.js +30 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +6 -0
- package/dist/i18n/locales/hu.js +30 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +6 -0
- package/dist/i18n/locales/it.js +30 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +6 -0
- package/dist/i18n/locales/zh.js +30 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +27 -15
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/clipping.d.ts +25 -0
- package/dist/lib/clipping.js +17 -0
- package/dist/lib/clipping.js.map +1 -0
- package/dist/lib/dates.d.ts +17 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/rhf/form.d.ts +1 -0
- package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
- package/dist/rhf/use-rhf-wizard-step.js +38 -0
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
- package/dist/rhf.d.ts +2 -0
- package/dist/rhf.js +1 -0
- package/dist/rhf.js.map +1 -1
- package/dist/search/command-palette.d.ts +19 -2
- package/dist/search/command-palette.js +16 -5
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.d.ts +26 -1
- package/dist/search/global-search.js +29 -13
- package/dist/search/global-search.js.map +1 -1
- package/dist/search.d.ts +1 -1
- package/dist/shell/topbar-action-menu.d.ts +73 -3
- package/dist/shell/topbar-action-menu.js +100 -27
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +2 -1
- package/dist/wizard/stepper-nav.d.ts +1 -0
- package/dist/wizard/stepper-nav.js +1 -1
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +2 -2
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +1 -1
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-context.d.ts +7 -1
- package/dist/wizard/wizard-context.js +4 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-summary.js +3 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +2 -1
- package/package.json +16 -12
- package/src/components/alert-banner.tsx +149 -21
- package/src/components/breadcrumbs.tsx +168 -0
- package/src/components/bulk-action-bar.tsx +362 -0
- package/src/components/button-group.tsx +124 -4
- package/src/components/calendar-heatmap.tsx +504 -0
- package/src/components/chip.tsx +128 -7
- package/src/components/choice-card.tsx +97 -1
- package/src/components/copy-button.tsx +29 -7
- package/src/components/data-table.tsx +7 -0
- package/src/components/date-picker.tsx +49 -1
- package/src/components/description-list.tsx +168 -15
- package/src/components/dialog-frame.tsx +7 -0
- package/src/components/disclosure.tsx +93 -21
- package/src/components/field.tsx +137 -0
- package/src/components/file-dropzone.tsx +9 -10
- package/src/components/floating-panel.tsx +366 -15
- package/src/components/full-bleed-dialog.tsx +42 -5
- package/src/components/list.tsx +443 -0
- package/src/components/menu-item.tsx +235 -0
- package/src/components/mini-calendar.tsx +245 -54
- package/src/components/modal.tsx +80 -17
- package/src/components/nav-pills.tsx +145 -0
- package/src/components/page-contents.tsx +4 -4
- package/src/components/page-header.tsx +68 -0
- package/src/components/progress-bar.tsx +129 -11
- package/src/components/series-chart-labels.ts +4 -0
- package/src/components/series-chart.tsx +274 -3
- package/src/components/status-dot.tsx +107 -0
- package/src/components/table.tsx +176 -17
- package/src/components/text.tsx +97 -0
- package/src/components/toast.tsx +441 -0
- package/src/components/toggle-group.tsx +128 -4
- package/src/components/tooltip.tsx +200 -100
- package/src/components/ui.tsx +381 -14
- package/src/components/user-avatar.tsx +57 -3
- package/src/hooks/use-close-transition.ts +14 -5
- package/src/hooks/use-copy-to-clipboard.ts +1 -1
- package/src/i18n/defaults.ts +10 -0
- package/src/i18n/kit-labels.tsx +10 -0
- package/src/i18n/locales/de.ts +29 -0
- package/src/i18n/locales/es.ts +29 -0
- package/src/i18n/locales/fr.ts +29 -0
- package/src/i18n/locales/hu.ts +29 -0
- package/src/i18n/locales/it.ts +29 -0
- package/src/i18n/locales/zh.ts +29 -0
- package/src/index.ts +31 -0
- package/src/lib/clipping.ts +34 -0
- package/src/lib/dates.ts +37 -0
- package/src/rhf/use-rhf-wizard-step.ts +113 -0
- package/src/rhf.ts +2 -0
- package/src/search/command-palette.tsx +42 -5
- package/src/search/global-search.tsx +59 -18
- package/src/shell/topbar-action-menu.tsx +239 -49
- package/src/wizard/stepper-nav.tsx +2 -1
- package/src/wizard/types.ts +2 -2
- package/src/wizard/use-wizard.ts +3 -3
- package/src/wizard/wizard-context.tsx +9 -0
- package/src/wizard/wizard-summary.tsx +14 -10
- 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={
|
|
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
|
+
}
|