@eifi1/ui-kit 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -11
- package/dist/components/alert-banner.d.ts +32 -5
- package/dist/components/alert-banner.js +57 -14
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +4 -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 +66 -0
- package/dist/components/bulk-action-bar.js +149 -0
- package/dist/components/bulk-action-bar.js.map +1 -0
- package/dist/components/calculator.d.ts +4 -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/copy-button.d.ts +13 -4
- package/dist/components/copy-button.js +17 -4
- package/dist/components/copy-button.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/file-dropzone.d.ts +3 -4
- package/dist/components/file-dropzone.js +2 -1
- package/dist/components/file-dropzone.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 +174 -0
- package/dist/components/list.js +215 -0
- package/dist/components/list.js.map +1 -0
- package/dist/components/menu-item.d.ts +99 -0
- package/dist/components/menu-item.js +83 -0
- package/dist/components/menu-item.js.map +1 -0
- 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/number-field.d.ts +4 -0
- package/dist/components/number-input.d.ts +4 -0
- package/dist/components/numpad-sheet.d.ts +4 -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/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 +16 -0
- package/dist/components/series-chart.js +137 -3
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/status-dot.d.ts +49 -0
- package/dist/components/status-dot.js +52 -0
- package/dist/components/status-dot.js.map +1 -0
- package/dist/components/table.d.ts +39 -6
- package/dist/components/table.js +43 -4
- package/dist/components/table.js.map +1 -1
- package/dist/components/text.d.ts +63 -0
- package/dist/components/text.js +23 -0
- package/dist/components/text.js.map +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 +29 -1
- package/dist/components/toggle-group.js +52 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/tooltip.d.ts +38 -15
- package/dist/components/tooltip.js +85 -47
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +104 -4
- package/dist/components/ui.js +112 -8
- package/dist/components/ui.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/i18n/defaults.d.ts +4 -0
- package/dist/i18n/defaults.js +9 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +8 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +4 -0
- package/dist/i18n/locales/de-CH.d.ts +4 -0
- package/dist/i18n/locales/de-informal.d.ts +4 -0
- package/dist/i18n/locales/de.d.ts +4 -0
- package/dist/i18n/locales/de.js +21 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +4 -0
- package/dist/i18n/locales/es.js +21 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +4 -0
- package/dist/i18n/locales/fr.js +21 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +4 -0
- package/dist/i18n/locales/hu.js +21 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +4 -0
- package/dist/i18n/locales/it.js +21 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +4 -0
- package/dist/i18n/locales/zh.js +21 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +15 -7
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/clipping.d.ts +9 -0
- package/dist/lib/clipping.js +14 -0
- package/dist/lib/clipping.js.map +1 -0
- 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/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-summary.js +3 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/package.json +16 -12
- package/src/components/alert-banner.tsx +117 -18
- package/src/components/breadcrumbs.tsx +168 -0
- package/src/components/bulk-action-bar.tsx +256 -0
- package/src/components/chip.tsx +128 -7
- package/src/components/copy-button.tsx +29 -7
- 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/file-dropzone.tsx +9 -10
- package/src/components/full-bleed-dialog.tsx +42 -5
- package/src/components/list.tsx +417 -0
- package/src/components/menu-item.tsx +214 -0
- package/src/components/modal.tsx +80 -17
- package/src/components/page-contents.tsx +4 -4
- package/src/components/page-header.tsx +68 -0
- package/src/components/series-chart-labels.ts +4 -0
- package/src/components/series-chart.tsx +253 -3
- package/src/components/status-dot.tsx +99 -0
- package/src/components/table.tsx +91 -6
- package/src/components/text.tsx +89 -0
- package/src/components/toast.tsx +441 -0
- package/src/components/toggle-group.tsx +82 -3
- package/src/components/tooltip.tsx +186 -100
- package/src/components/ui.tsx +237 -8
- package/src/hooks/use-close-transition.ts +14 -5
- package/src/hooks/use-copy-to-clipboard.ts +1 -1
- package/src/i18n/defaults.ts +8 -0
- package/src/i18n/kit-labels.tsx +8 -0
- package/src/i18n/locales/de.ts +20 -0
- package/src/i18n/locales/es.ts +20 -0
- package/src/i18n/locales/fr.ts +20 -0
- package/src/i18n/locales/hu.ts +20 -0
- package/src/i18n/locales/it.ts +20 -0
- package/src/i18n/locales/zh.ts +20 -0
- package/src/index.ts +26 -0
- package/src/lib/clipping.ts +16 -0
- package/src/search/command-palette.tsx +42 -5
- package/src/search/global-search.tsx +59 -18
- 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-summary.tsx +14 -10
- package/tokens.css +97 -0
package/src/components/modal.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContext, useRef, useState } from "react";
|
|
1
|
+
import { createContext, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import type {
|
|
4
4
|
ComponentPropsWithoutRef,
|
|
@@ -11,7 +11,7 @@ import { cn } from "../lib/cn";
|
|
|
11
11
|
import { useFocusTrap } from "../hooks/use-focus-trap";
|
|
12
12
|
import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
|
|
13
13
|
import { useOverlayHistory } from "../hooks/use-overlay-history";
|
|
14
|
-
import { useCloseTransition } from "../hooks/use-close-transition";
|
|
14
|
+
import { OVERLAY_EXIT_MS, prefersReducedMotion, useCloseTransition } from "../hooks/use-close-transition";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Drag the panel by its top strip, in pointer space (dev#460).
|
|
@@ -40,6 +40,9 @@ function useDragOffset(enabled: boolean) {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
return {
|
|
43
|
+
/** Back to where the panel opens. A `Modal` with an `open` prop outlives its own
|
|
44
|
+
* closes, so "the position resets when the dialog closes" needs saying. */
|
|
45
|
+
reset: () => setOffset(null),
|
|
43
46
|
style: offset ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : undefined,
|
|
44
47
|
handlers: !enabled
|
|
45
48
|
? undefined
|
|
@@ -120,6 +123,28 @@ export function useBackdropClose(onClose: () => void) {
|
|
|
120
123
|
export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"> {
|
|
121
124
|
/** Invoked on backdrop click and on Escape. */
|
|
122
125
|
onClose: () => void;
|
|
126
|
+
/**
|
|
127
|
+
* Who decides whether the dialog is up, when the caller wants to keep it MOUNTED.
|
|
128
|
+
*
|
|
129
|
+
* Left out, the dialog is open for as long as it is mounted — `{open && <Modal/>}`,
|
|
130
|
+
* which is every caller before this prop and stays exactly as it was. That idiom has
|
|
131
|
+
* one blind spot: a close the CALLER decides on (a save that succeeded, a
|
|
132
|
+
* `setOpen(false)` from outside) unmounts the panel on the spot, with no exit,
|
|
133
|
+
* because there is nothing left to animate. kastlan's `FormModal` was nothing but an
|
|
134
|
+
* `if (!open) return null` gate in front of this.
|
|
135
|
+
*
|
|
136
|
+
* Passed, the component stays mounted while closed and renders nothing, and a flip
|
|
137
|
+
* to `false` plays the same exit a dismissal does before the panel goes. The focus
|
|
138
|
+
* trap, the scroll lock and the Back-gesture history entry are held only while
|
|
139
|
+
* `open` is true — they are released the moment it turns false, so focus is back on
|
|
140
|
+
* the trigger while the panel is still lowering. A dismissal (Escape, the backdrop,
|
|
141
|
+
* Back, the frame's X) animates first and then calls `onClose`; the `false` that
|
|
142
|
+
* comes back from it is not animated a second time.
|
|
143
|
+
*
|
|
144
|
+
* The body is unmounted once the exit has finished, as it is today, so a form inside
|
|
145
|
+
* still starts fresh on the next open.
|
|
146
|
+
*/
|
|
147
|
+
open?: boolean;
|
|
123
148
|
/** `"alertdialog"` for a dialog that interrupts to ask a question the user must
|
|
124
149
|
* answer (a confirmation) — screen readers announce it with more urgency. Only the
|
|
125
150
|
* two dialog roles are allowed: the panel stays modal either way. */
|
|
@@ -183,6 +208,7 @@ export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"
|
|
|
183
208
|
*/
|
|
184
209
|
export function Modal({
|
|
185
210
|
onClose,
|
|
211
|
+
open,
|
|
186
212
|
children,
|
|
187
213
|
size = "md",
|
|
188
214
|
className,
|
|
@@ -196,37 +222,68 @@ export function Modal({
|
|
|
196
222
|
}: ModalProps) {
|
|
197
223
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
198
224
|
const drag = useDragOffset(Boolean(draggable));
|
|
225
|
+
// Without `open`, mounted means open — the constant `true` every hook below used to
|
|
226
|
+
// be given. With it, `open` itself.
|
|
227
|
+
const active = open ?? true;
|
|
228
|
+
|
|
229
|
+
// Whether the last close came through `requestClose`, i.e. has ALREADY been
|
|
230
|
+
// animated. Set in the same batch as the caller's `onClose`, so the render that sees
|
|
231
|
+
// `open` turn false also sees this and does not lower the panel a second time.
|
|
232
|
+
const [dismissed, setDismissed] = useState(false);
|
|
233
|
+
// The exit of a close the caller decided on (only reachable with `open`). Adjusted
|
|
234
|
+
// during render when `open` flips — `Collapse`'s pattern — so the very render that
|
|
235
|
+
// closes it already paints the `-out` classes rather than a frame of nothing.
|
|
236
|
+
const [exiting, setExiting] = useState(false);
|
|
237
|
+
const [prevOpen, setPrevOpen] = useState(open);
|
|
238
|
+
if (open !== prevOpen) {
|
|
239
|
+
setPrevOpen(open);
|
|
240
|
+
setDismissed(false);
|
|
241
|
+
setExiting(open === false && prevOpen === true && !dismissed && !prefersReducedMotion());
|
|
242
|
+
if (open) drag.reset();
|
|
243
|
+
}
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!exiting) return;
|
|
246
|
+
const timer = setTimeout(() => setExiting(false), OVERLAY_EXIT_MS);
|
|
247
|
+
return () => clearTimeout(timer);
|
|
248
|
+
}, [exiting]);
|
|
249
|
+
|
|
199
250
|
// The panel lowers itself before the caller unmounts it (live #320 rework). Every
|
|
200
251
|
// dismissal below goes through `requestClose`; `onClose` still does the closing, one
|
|
201
252
|
// animation later. A caller that closes the dialog ITSELF — after a save, say —
|
|
202
|
-
// unmounts with no exit
|
|
203
|
-
const { closing, requestClose } = useCloseTransition(
|
|
204
|
-
|
|
253
|
+
// unmounts with no exit unless it passes `open`, which is what that prop is for.
|
|
254
|
+
const { closing, requestClose } = useCloseTransition(() => {
|
|
255
|
+
setDismissed(true);
|
|
256
|
+
onClose();
|
|
257
|
+
});
|
|
258
|
+
// A panel on its way out after `open` turned false is already closed: a press on
|
|
259
|
+
// its fading backdrop must not report a second close.
|
|
260
|
+
const backdropClose = useBackdropClose(active ? requestClose : () => {});
|
|
205
261
|
// Back means the same as Escape here. On a phone Escape doesn't exist, so without
|
|
206
262
|
// this the only way out of a dialog is finding its close button — and Back, the
|
|
207
263
|
// gesture everyone reaches for, navigated the page underneath instead (#172).
|
|
208
|
-
//
|
|
209
|
-
useOverlayHistory(
|
|
264
|
+
// Held only while open: a closed `open={false}` dialog must not own a history entry.
|
|
265
|
+
useOverlayHistory(active, requestClose);
|
|
210
266
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
useBodyScrollLock(true);
|
|
267
|
+
// Through the shared hook and not by hand: this component's own save/restore copy
|
|
268
|
+
// was one half of the pair that left the page permanently unscrollable when a dialog
|
|
269
|
+
// containing an open sheet was closed — see the note in use-body-scroll-lock.ts.
|
|
270
|
+
useBodyScrollLock(active);
|
|
216
271
|
|
|
217
272
|
// Focus the panel itself (not the first field) so opening doesn't pop the mobile
|
|
218
273
|
// keyboard, while still moving focus into the dialog for keyboard and screen-reader
|
|
219
274
|
// users. Through the shared hook since this component is where it came from — the
|
|
220
275
|
// hook adds per-keystroke recomputation, a guard on restoring to a detached node,
|
|
221
|
-
// and nesting, none of which this copy had.
|
|
222
|
-
|
|
276
|
+
// and nesting, none of which this copy had. Released when `open` turns false, which
|
|
277
|
+
// hands focus back to the trigger while the panel is still on its way out.
|
|
278
|
+
useFocusTrap(panelRef, { active, initialFocus: "container" });
|
|
279
|
+
const leaving = closing || exiting;
|
|
223
280
|
|
|
224
281
|
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
225
282
|
onKeyDown?.(e);
|
|
226
283
|
if (e.defaultPrevented) return;
|
|
227
284
|
if (e.key === "Escape") {
|
|
228
285
|
e.stopPropagation();
|
|
229
|
-
requestClose();
|
|
286
|
+
if (active) requestClose();
|
|
230
287
|
return;
|
|
231
288
|
}
|
|
232
289
|
// Tab containment lives in useFocusTrap, which listens on the panel itself.
|
|
@@ -237,13 +294,19 @@ export function Modal({
|
|
|
237
294
|
// bar (a z-30 context) could paint BELOW a sibling z-30 sticky element like the
|
|
238
295
|
// expanded transaction editor (feedback #320). The tour/command-palette overlays
|
|
239
296
|
// already portal for the same reason.
|
|
297
|
+
if (!active && !exiting) return null;
|
|
240
298
|
return createPortal(
|
|
241
299
|
<div
|
|
300
|
+
// Out of the accessibility tree for the length of a caller-driven exit: focus is
|
|
301
|
+
// already back on the page, and an `aria-modal` panel that is leaving would hide
|
|
302
|
+
// that page from a screen reader for 220ms. Not `inert` — that would let a tap
|
|
303
|
+
// fall through the fading backdrop to the row underneath.
|
|
304
|
+
aria-hidden={exiting || undefined}
|
|
242
305
|
className={cn(
|
|
243
306
|
// Live #320. The panel below rises only where it is bottom-anchored: from md up
|
|
244
307
|
// it is centred, and a centred box sliding up from off-screen reads as a
|
|
245
308
|
// different component arriving rather than as the same one settling.
|
|
246
|
-
|
|
309
|
+
leaving ? "animate-overlay-out" : "animate-overlay",
|
|
247
310
|
"fixed inset-0 z-50 flex items-end justify-center bg-black/40 md:items-center",
|
|
248
311
|
fullBleed ? "p-0 md:p-4" : "p-4",
|
|
249
312
|
)}
|
|
@@ -270,7 +333,7 @@ export function Modal({
|
|
|
270
333
|
// the ones where the panel is being dragged.
|
|
271
334
|
style={{ ...style, ...drag.style }}
|
|
272
335
|
className={cn(
|
|
273
|
-
|
|
336
|
+
leaving ? "animate-sheet-out md:animate-none" : "animate-sheet md:animate-none",
|
|
274
337
|
"w-full rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm outline-none",
|
|
275
338
|
{ md: "max-w-md", lg: "max-w-lg", xl: "max-w-3xl" }[size],
|
|
276
339
|
// A panel taller than the screen has to scroll ITSELF. The backdrop is
|
|
@@ -176,14 +176,14 @@ export function PageContents({
|
|
|
176
176
|
<li key={item.id} data-entry={item.id}>
|
|
177
177
|
<a
|
|
178
178
|
href={hrefFor(item.id)}
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
179
|
+
// Entries WRAP in the rail rather than truncate: a cut-off heading needed a
|
|
180
|
+
// native `title` to be read at all, which a keyboard never sees and which
|
|
181
|
+
// broke keksdose's one-tooltip rule (dev#523).
|
|
182
182
|
aria-current={current ? "location" : undefined}
|
|
183
183
|
onClick={() => setClicked(item.id)}
|
|
184
184
|
className={cn(
|
|
185
185
|
"-ms-px block border-s-2 py-1 pe-1 text-[13px] leading-snug transition-colors duration-150",
|
|
186
|
-
variant === "rail" && "
|
|
186
|
+
variant === "rail" && "[overflow-wrap:anywhere]",
|
|
187
187
|
item.level === 2 ? "ps-6" : "ps-3",
|
|
188
188
|
current
|
|
189
189
|
? "border-[var(--brand)] font-medium text-[var(--brand)]"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { SECTION_LABEL_CLASS } from "./text";
|
|
4
|
+
|
|
5
|
+
/** `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a
|
|
6
|
+
* phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx). */
|
|
7
|
+
export type PageHeaderSize = "sm" | "md";
|
|
8
|
+
|
|
9
|
+
const TITLE: Record<PageHeaderSize, string> = {
|
|
10
|
+
sm: "text-lg font-semibold",
|
|
11
|
+
md: "text-xl font-semibold tracking-tight sm:text-2xl",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
|
|
15
|
+
title: ReactNode;
|
|
16
|
+
/** A sentence under the title. */
|
|
17
|
+
description?: ReactNode;
|
|
18
|
+
/** Buttons for the page — "New", "Export". At the title's end from `sm` up; under it,
|
|
19
|
+
* wrapping, on a phone. */
|
|
20
|
+
actions?: ReactNode;
|
|
21
|
+
/** A `Breadcrumbs` trail, drawn above everything else. */
|
|
22
|
+
breadcrumbs?: ReactNode;
|
|
23
|
+
/** A small uppercase line above the title — the section or the kind of record
|
|
24
|
+
* ("Building", "Settings"). The section-label type. */
|
|
25
|
+
eyebrow?: ReactNode;
|
|
26
|
+
/** The title's element. Default `h1`: a page has one, and this is it. `h2` for a
|
|
27
|
+
* header inside a page that already has its `h1` (a tab's own header). */
|
|
28
|
+
as?: "h1" | "h2" | "h3";
|
|
29
|
+
size?: PageHeaderSize;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the
|
|
34
|
+
* page's actions.
|
|
35
|
+
*
|
|
36
|
+
* kastlan's (shared/components/display/page-header.tsx) is the model — title and
|
|
37
|
+
* description on one side, actions on the other, stacked on a phone — and this adds
|
|
38
|
+
* what its pages kept writing around it: a trail above, an eyebrow, a `ReactNode`
|
|
39
|
+
* title (a status chip beside the name), and actions that WRAP when there are more
|
|
40
|
+
* than fit a phone's width, instead of running off it. No outer margin: the page's
|
|
41
|
+
* own rhythm (`space-y-*`, a grid gap) places it.
|
|
42
|
+
*/
|
|
43
|
+
export function PageHeader({
|
|
44
|
+
title,
|
|
45
|
+
description,
|
|
46
|
+
actions,
|
|
47
|
+
breadcrumbs,
|
|
48
|
+
eyebrow,
|
|
49
|
+
as = "h1",
|
|
50
|
+
size = "md",
|
|
51
|
+
className,
|
|
52
|
+
...rest
|
|
53
|
+
}: PageHeaderProps) {
|
|
54
|
+
const Heading = as as ElementType;
|
|
55
|
+
return (
|
|
56
|
+
<div {...rest} className={cn("flex min-w-0 flex-col gap-2", className)}>
|
|
57
|
+
{breadcrumbs}
|
|
58
|
+
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
|
59
|
+
<div className="min-w-0 flex-1">
|
|
60
|
+
{eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, "mb-1")}>{eyebrow}</p>}
|
|
61
|
+
<Heading className={cn("break-words text-[var(--text-primary)]", TITLE[size])}>{title}</Heading>
|
|
62
|
+
{description != null && <p className="mt-1 text-sm text-[var(--text-muted)]">{description}</p>}
|
|
63
|
+
</div>
|
|
64
|
+
{actions != null && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -15,6 +15,9 @@ export interface SeriesChartLabels {
|
|
|
15
15
|
empty: string;
|
|
16
16
|
/** Accessible name of a `ToggleLegend` — a group of switches, one per series. */
|
|
17
17
|
legend: string;
|
|
18
|
+
/** Accessible name of the group of keyboard stops a clickable chart (`onPointClick`)
|
|
19
|
+
* lays over its bars or periods — one tab stop, arrow keys between them. */
|
|
20
|
+
points: string;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export const DEFAULT_SERIES_CHART_LABELS: SeriesChartLabels = {
|
|
@@ -23,4 +26,5 @@ export const DEFAULT_SERIES_CHART_LABELS: SeriesChartLabels = {
|
|
|
23
26
|
"Drag to zoom: a roughly square selection zooms both axes, a long thin one only its own. Double-click to reset.",
|
|
24
27
|
empty: "No data",
|
|
25
28
|
legend: "Series",
|
|
29
|
+
points: "Chart values",
|
|
26
30
|
};
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
// "category"`) or real dates (`x.type: "time"`) instead of a number the app had to
|
|
27
27
|
// invent. What each of those does to the zoom is decided in one place,
|
|
28
28
|
// `defaultZoomAxes` in `chart-zoom.tsx`.
|
|
29
|
-
import { useMemo } from "react";
|
|
30
|
-
import type { ReactNode, RefObject } from "react";
|
|
29
|
+
import { useMemo, useRef, useState } from "react";
|
|
30
|
+
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
|
31
31
|
import {
|
|
32
32
|
Area,
|
|
33
33
|
Bar,
|
|
@@ -37,8 +37,12 @@ import {
|
|
|
37
37
|
Line,
|
|
38
38
|
ReferenceDot,
|
|
39
39
|
ReferenceLine,
|
|
40
|
+
Rectangle,
|
|
40
41
|
XAxis,
|
|
41
42
|
YAxis,
|
|
43
|
+
usePlotArea,
|
|
44
|
+
useXAxisScale,
|
|
45
|
+
type BarShapeProps,
|
|
42
46
|
} from "recharts";
|
|
43
47
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "./chart";
|
|
44
48
|
import {
|
|
@@ -202,6 +206,22 @@ interface SeriesChartAxisShape {
|
|
|
202
206
|
* a hairline). A pinned `domain` is left alone either way.
|
|
203
207
|
*/
|
|
204
208
|
includeZero?: boolean;
|
|
209
|
+
/**
|
|
210
|
+
* Tick only on whole numbers — the y counterpart of `SeriesChartX.integerTicks`, and
|
|
211
|
+
* what keksdose's user-count axis (`metrics-panel.tsx`, `wholeTicks`) and its runway
|
|
212
|
+
* in days (`cash-buffer-chart.tsx`, `wholeDayTicks`) each wrote by hand: a band of
|
|
213
|
+
* [0, 3.1] users is ticked 0 1 2 3, not 0 0.5 1 … 3, and a short runway is not ticked
|
|
214
|
+
* 0 / 0.5 / 1 under a rounding formatter that prints "1" twice. A (zoom) window that
|
|
215
|
+
* holds no whole number falls back to the ordinary ticks rather than to none.
|
|
216
|
+
*
|
|
217
|
+
* AUTOMATIC by default — on when every value this axis carries is a whole number
|
|
218
|
+
* (counts), because that is where a half tick is a value no sample can have, and it
|
|
219
|
+
* changes nothing on a span wider than about eight, which already ticks on whole
|
|
220
|
+
* steps. `true` forces it over fractional data (days of runway computed as 43.7);
|
|
221
|
+
* `false` keeps fractional ticks for a continuous quantity that merely happens to be
|
|
222
|
+
* whole in this sample. `tickValues` wins over either.
|
|
223
|
+
*/
|
|
224
|
+
integerTicks?: boolean;
|
|
205
225
|
}
|
|
206
226
|
|
|
207
227
|
/**
|
|
@@ -818,6 +838,150 @@ const STEP_WIDTH = 1.5;
|
|
|
818
838
|
* which recharts cannot tell apart per cell, so stacks stay square. */
|
|
819
839
|
const BAR_RADIUS: [number, number, number, number] = [3, 3, 0, 0];
|
|
820
840
|
|
|
841
|
+
/**
|
|
842
|
+
* One keyboard stop of a clickable chart: a bar, or (a chart with no bars) a whole slot.
|
|
843
|
+
* `from`/`to` bound its slot on the plotted number line — half way to each neighbour,
|
|
844
|
+
* or to the edge of the window on show.
|
|
845
|
+
*/
|
|
846
|
+
interface PointStop {
|
|
847
|
+
index: number;
|
|
848
|
+
/** The bar's series; absent for a slot. */
|
|
849
|
+
key?: string;
|
|
850
|
+
from: number;
|
|
851
|
+
to: number;
|
|
852
|
+
/** The accessible name: where it is and what it says. */
|
|
853
|
+
name: string;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* The shape of a series whose bar has keyboard focus: recharts' own rectangle, with an
|
|
858
|
+
* outline on the one bar at `index` — drawn at the bar's exact geometry, which an
|
|
859
|
+
* overlay could only guess at. Not `activeBar`: that follows the tooltip's active index,
|
|
860
|
+
* which recharts stops taking from `defaultIndex` once a pointer has hovered the chart.
|
|
861
|
+
*/
|
|
862
|
+
function focusedBarShape(index: number) {
|
|
863
|
+
return function FocusedBar(props: BarShapeProps) {
|
|
864
|
+
const focused = props.originalDataIndex === index;
|
|
865
|
+
return (
|
|
866
|
+
<Rectangle {...props} {...(focused ? { stroke: "var(--text-primary)", strokeWidth: 2 } : {})} />
|
|
867
|
+
);
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* The keyboard's way onto a clickable chart (`onPointClick`): one focusable `<rect>` per
|
|
873
|
+
* bar, or per slot when there are no bars, laid over the plot INSIDE the recharts chart,
|
|
874
|
+
* where the x scale answers.
|
|
875
|
+
*
|
|
876
|
+
* keksdose drew its weekday pattern as seven real `<button>`s (spending-tab.tsx) because
|
|
877
|
+
* a recharts bar is reachable by nothing but the pointer. This is that, for every chart:
|
|
878
|
+
*
|
|
879
|
+
* - ONE tab stop (roving `tabIndex`): Tab moves past the chart, not through 30 bars.
|
|
880
|
+
* - ←/→ step through the stops in order, ↑/↓ between the bars of one slot, Home/End.
|
|
881
|
+
* - ↵ and Space report the stop as a click would — with the series for a bar.
|
|
882
|
+
* - Each is a `role="button"` named "period — series: value".
|
|
883
|
+
*
|
|
884
|
+
* The stops take NO pointer events, so the drag-to-zoom layer, the tooltip and the
|
|
885
|
+
* pointer's own clicks underneath behave exactly as they did. Focus is shown by the
|
|
886
|
+
* slot tinted and, for a bar, its own outline (`focusedBarShape`) — and by the tooltip,
|
|
887
|
+
* moved to the stop through `defaultIndex`, which recharts honours until a pointer has
|
|
888
|
+
* hovered the chart.
|
|
889
|
+
*/
|
|
890
|
+
function PointKeys({
|
|
891
|
+
stops,
|
|
892
|
+
current,
|
|
893
|
+
label,
|
|
894
|
+
onFocusStop,
|
|
895
|
+
onBlurStops,
|
|
896
|
+
onPick,
|
|
897
|
+
}: {
|
|
898
|
+
stops: readonly PointStop[];
|
|
899
|
+
current: number;
|
|
900
|
+
label: string;
|
|
901
|
+
onFocusStop: (at: number) => void;
|
|
902
|
+
onBlurStops: () => void;
|
|
903
|
+
onPick: (stop: PointStop) => void;
|
|
904
|
+
}) {
|
|
905
|
+
const plot = usePlotArea();
|
|
906
|
+
const scale = useXAxisScale();
|
|
907
|
+
const refs = useRef<(SVGRectElement | null)[]>([]);
|
|
908
|
+
if (!plot || !scale || plot.width <= 0 || plot.height <= 0) return null;
|
|
909
|
+
|
|
910
|
+
const move = (at: number) => {
|
|
911
|
+
const next = Math.max(0, Math.min(stops.length - 1, at));
|
|
912
|
+
refs.current[next]?.focus();
|
|
913
|
+
};
|
|
914
|
+
const onKeyDown = (at: number) => (event: KeyboardEvent<SVGRectElement>) => {
|
|
915
|
+
const stop = stops[at];
|
|
916
|
+
// ↑/↓ stay in the slot: the next or previous bar of the same period. The stops are
|
|
917
|
+
// in slot order, so a slot's bars are neighbours.
|
|
918
|
+
const inSlot = (step: 1 | -1) => (stops[at + step]?.index === stop.index ? at + step : at);
|
|
919
|
+
const target =
|
|
920
|
+
event.key === "ArrowRight"
|
|
921
|
+
? at + 1
|
|
922
|
+
: event.key === "ArrowLeft"
|
|
923
|
+
? at - 1
|
|
924
|
+
: event.key === "ArrowDown"
|
|
925
|
+
? inSlot(1)
|
|
926
|
+
: event.key === "ArrowUp"
|
|
927
|
+
? inSlot(-1)
|
|
928
|
+
: event.key === "Home"
|
|
929
|
+
? 0
|
|
930
|
+
: event.key === "End"
|
|
931
|
+
? stops.length - 1
|
|
932
|
+
: undefined;
|
|
933
|
+
if (target !== undefined) {
|
|
934
|
+
event.preventDefault();
|
|
935
|
+
move(target);
|
|
936
|
+
} else if (event.key === "Enter" || event.key === " ") {
|
|
937
|
+
event.preventDefault();
|
|
938
|
+
onPick(stop);
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
const left = plot.x;
|
|
943
|
+
const right = plot.x + plot.width;
|
|
944
|
+
return (
|
|
945
|
+
<g role="group" aria-label={label}>
|
|
946
|
+
{stops.map((stop, at) => {
|
|
947
|
+
// An edge with no neighbour runs to the plot's edge.
|
|
948
|
+
const a = finite(scale(stop.from)) ?? left;
|
|
949
|
+
const b = finite(scale(stop.to)) ?? right;
|
|
950
|
+
const x0 = Math.max(left, Math.min(a, b));
|
|
951
|
+
const x1 = Math.min(right, Math.max(a, b));
|
|
952
|
+
return (
|
|
953
|
+
<rect
|
|
954
|
+
key={`${stop.index}:${stop.key ?? ""}`}
|
|
955
|
+
ref={(node) => {
|
|
956
|
+
refs.current[at] = node;
|
|
957
|
+
}}
|
|
958
|
+
role="button"
|
|
959
|
+
tabIndex={at === current ? 0 : -1}
|
|
960
|
+
aria-label={stop.name}
|
|
961
|
+
data-point-key={stop.key}
|
|
962
|
+
x={x0}
|
|
963
|
+
y={plot.y}
|
|
964
|
+
width={Math.max(0, x1 - x0)}
|
|
965
|
+
height={plot.height}
|
|
966
|
+
fill="transparent"
|
|
967
|
+
// Focus only: the pointer's drag, hover and click belong to what is below.
|
|
968
|
+
pointerEvents="none"
|
|
969
|
+
strokeWidth={2}
|
|
970
|
+
className={cn(
|
|
971
|
+
"outline-none focus-visible:fill-[var(--brand)]/10",
|
|
972
|
+
// A bar outlines ITSELF (see `FOCUSED_BAR`); a slot has nothing else to.
|
|
973
|
+
stop.key === undefined && "focus-visible:stroke-[var(--brand)]",
|
|
974
|
+
)}
|
|
975
|
+
onFocus={() => onFocusStop(at)}
|
|
976
|
+
onBlur={onBlurStops}
|
|
977
|
+
onKeyDown={onKeyDown(at)}
|
|
978
|
+
/>
|
|
979
|
+
);
|
|
980
|
+
})}
|
|
981
|
+
</g>
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
821
985
|
interface PlotProps extends Omit<SeriesChartProps, "rows" | "x"> {
|
|
822
986
|
/** The rows AS PLOTTED — what the zoom fits on. The caller's are `model.source`. */
|
|
823
987
|
rows: readonly SeriesChartRow[];
|
|
@@ -851,6 +1015,10 @@ function SeriesPlot({
|
|
|
851
1015
|
const labels = useKitLabels("seriesChart", DEFAULT_SERIES_CHART_LABELS, labelsProp);
|
|
852
1016
|
const locale = useKitLocale(localeProp);
|
|
853
1017
|
const number = useDefaultFormat(localeProp);
|
|
1018
|
+
// The keyboard stops' roving tab stop, and the one that has focus (see `PointKeys`).
|
|
1019
|
+
// Up here, above the empty state's early return, like every hook.
|
|
1020
|
+
const [keyStop, setKeyStop] = useState(0);
|
|
1021
|
+
const [keyFocus, setKeyFocus] = useState<number | undefined>(undefined);
|
|
854
1022
|
|
|
855
1023
|
// A number is pixels, set inline; a string is a class. Either way the one value
|
|
856
1024
|
// sizes both the chart and its empty state.
|
|
@@ -995,6 +1163,19 @@ function SeriesPlot({
|
|
|
995
1163
|
series.some(
|
|
996
1164
|
(entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id && (entry.type ?? "line") !== "line",
|
|
997
1165
|
);
|
|
1166
|
+
// Whole-number ticks on an axis whose values are all whole — see
|
|
1167
|
+
// `SeriesChartAxis.integerTicks`. A stack of whole layers sums to a whole number, so
|
|
1168
|
+
// the layers are enough to look at.
|
|
1169
|
+
const wholeOnly = (axis: SeriesChartAxis) =>
|
|
1170
|
+
axis.integerTicks ??
|
|
1171
|
+
series
|
|
1172
|
+
.filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id)
|
|
1173
|
+
.every((entry) =>
|
|
1174
|
+
plotted.every((row) => {
|
|
1175
|
+
const value = finite(row[entry.key]);
|
|
1176
|
+
return value === undefined || Number.isInteger(value);
|
|
1177
|
+
}),
|
|
1178
|
+
);
|
|
998
1179
|
const onAxis = (axisId: string) => (ref: { axis?: string }) => (ref.axis ?? DEFAULT_Y_AXIS) === axisId;
|
|
999
1180
|
const fittedY = (axis: SeriesChartAxis): [number, number] | undefined => {
|
|
1000
1181
|
const own = [
|
|
@@ -1024,6 +1205,48 @@ function SeriesPlot({
|
|
|
1024
1205
|
return row ? { index, row, x: xOf(index), key } : undefined;
|
|
1025
1206
|
};
|
|
1026
1207
|
|
|
1208
|
+
// The keyboard stops of a clickable chart — every bar with a value, slot by slot; or,
|
|
1209
|
+
// with no bars, every slot with any value. Only what is inside the window on show.
|
|
1210
|
+
const stops: PointStop[] = [];
|
|
1211
|
+
if (onPointClick) {
|
|
1212
|
+
const valueText = valueFormat ?? number;
|
|
1213
|
+
const nameOf = (entry: SeriesChartSeries) =>
|
|
1214
|
+
typeof entry.label === "string" || typeof entry.label === "number" ? String(entry.label) : entry.key;
|
|
1215
|
+
const said = (entry: SeriesChartSeries, value: number) => {
|
|
1216
|
+
const name = nameOf(entry);
|
|
1217
|
+
return name ? `${name}: ${valueText(value)}` : valueText(value);
|
|
1218
|
+
};
|
|
1219
|
+
const bars = series.filter((entry) => entry.type === "bar");
|
|
1220
|
+
const [low, high] = fittedX ?? [-Infinity, Infinity];
|
|
1221
|
+
const placed = plotted
|
|
1222
|
+
.map((row, index) => ({ index, at: model.kind === "category" ? index : finite(row[xKey]) }))
|
|
1223
|
+
.filter((slot): slot is { index: number; at: number } => slot.at !== undefined && slot.at >= low && slot.at <= high)
|
|
1224
|
+
.sort((a, b) => a.at - b.at);
|
|
1225
|
+
placed.forEach(({ index, at }, k) => {
|
|
1226
|
+
const from = k > 0 ? (placed[k - 1].at + at) / 2 : low;
|
|
1227
|
+
const to = k < placed.length - 1 ? (placed[k + 1].at + at) / 2 : high;
|
|
1228
|
+
// The heading the tooltip would give it, when that is text; else the tick.
|
|
1229
|
+
const heading = xLabel(at);
|
|
1230
|
+
const where = typeof heading === "string" && heading ? heading : xFormat(at);
|
|
1231
|
+
const valued = (bars.length ? bars : series)
|
|
1232
|
+
.map((entry) => ({ entry, value: finite(rows[index]?.[entry.key]) }))
|
|
1233
|
+
.filter((cell): cell is { entry: SeriesChartSeries; value: number } => cell.value !== undefined);
|
|
1234
|
+
if (bars.length) {
|
|
1235
|
+
for (const { entry, value } of valued) {
|
|
1236
|
+
stops.push({ index, key: entry.key, from, to, name: `${where} — ${said(entry, value)}` });
|
|
1237
|
+
}
|
|
1238
|
+
} else if (valued.length) {
|
|
1239
|
+
stops.push({
|
|
1240
|
+
index,
|
|
1241
|
+
from,
|
|
1242
|
+
to,
|
|
1243
|
+
name: `${where} — ${valued.map(({ entry, value }) => said(entry, value)).join(", ")}`,
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
const focusedStop = keyFocus === undefined ? undefined : stops[keyFocus];
|
|
1249
|
+
|
|
1027
1250
|
const dotFor = (entry: SeriesChartSeries) => {
|
|
1028
1251
|
const want = entry.dot;
|
|
1029
1252
|
if (!want) return false;
|
|
@@ -1080,6 +1303,9 @@ function SeriesPlot({
|
|
|
1080
1303
|
// Positive and negative layers stacked apart, as `axisExtent` fits them — a
|
|
1081
1304
|
// month's expenses hang below the axis instead of eating into its income.
|
|
1082
1305
|
stackOffset="sign"
|
|
1306
|
+
// recharts' own keyboard layer makes the whole surface a tab stop whose arrows
|
|
1307
|
+
// move the tooltip — a second stop beside `PointKeys`, and one ↵ does nothing on.
|
|
1308
|
+
accessibilityLayer={!onPointClick}
|
|
1083
1309
|
onClick={
|
|
1084
1310
|
onPointClick
|
|
1085
1311
|
? (state) => {
|
|
@@ -1136,7 +1362,10 @@ function SeriesPlot({
|
|
|
1136
1362
|
yAxisId={axis.id}
|
|
1137
1363
|
hide={axis.hide}
|
|
1138
1364
|
domain={domain}
|
|
1139
|
-
ticks={
|
|
1365
|
+
ticks={
|
|
1366
|
+
resolveTicks(axis.tickValues, domain, finite) ??
|
|
1367
|
+
(wholeOnly(axis) ? (integerTicks(domain) ?? niceTicks(domain)) : niceTicks(domain))
|
|
1368
|
+
}
|
|
1140
1369
|
allowDataOverflow={zoomed}
|
|
1141
1370
|
orientation={axis.orientation ?? "left"}
|
|
1142
1371
|
width={axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide)}
|
|
@@ -1170,6 +1399,8 @@ function SeriesPlot({
|
|
|
1170
1399
|
// The content shifts itself 12 px off the cursor (or flips) against the
|
|
1171
1400
|
// boundary; recharts' own offset on top would double it.
|
|
1172
1401
|
{...(tooltip?.boundary ? { offset: 0 } : {})}
|
|
1402
|
+
// The keyboard's stop shows its tooltip as the pointer's would.
|
|
1403
|
+
defaultIndex={focusedStop?.index}
|
|
1173
1404
|
content={
|
|
1174
1405
|
<ChartTooltipContent
|
|
1175
1406
|
labelFormatter={(value) => xLabel(Number(value))}
|
|
@@ -1196,6 +1427,9 @@ function SeriesPlot({
|
|
|
1196
1427
|
fill={color}
|
|
1197
1428
|
fillOpacity={entry.fillOpacity}
|
|
1198
1429
|
radius={entry.stack === undefined ? BAR_RADIUS : 0}
|
|
1430
|
+
// Only the focused stop's series swaps its shape, and only for as long
|
|
1431
|
+
// as it has focus: every other bar keeps recharts' own.
|
|
1432
|
+
{...(focusedStop?.key === entry.key ? { shape: focusedBarShape(focusedStop.index) } : {})}
|
|
1199
1433
|
onClick={
|
|
1200
1434
|
onPointClick
|
|
1201
1435
|
? (_bar, index, event) => {
|
|
@@ -1320,6 +1554,22 @@ function SeriesPlot({
|
|
|
1320
1554
|
/>
|
|
1321
1555
|
);
|
|
1322
1556
|
})}
|
|
1557
|
+
{stops.length > 0 && (
|
|
1558
|
+
<PointKeys
|
|
1559
|
+
stops={stops}
|
|
1560
|
+
current={Math.min(keyStop, stops.length - 1)}
|
|
1561
|
+
label={labels.points}
|
|
1562
|
+
onFocusStop={(at) => {
|
|
1563
|
+
setKeyStop(at);
|
|
1564
|
+
setKeyFocus(at);
|
|
1565
|
+
}}
|
|
1566
|
+
onBlurStops={() => setKeyFocus(undefined)}
|
|
1567
|
+
onPick={(stop) => {
|
|
1568
|
+
const hit = hitAt(stop.index, stop.key);
|
|
1569
|
+
if (hit) onPointClick?.(hit);
|
|
1570
|
+
}}
|
|
1571
|
+
/>
|
|
1572
|
+
)}
|
|
1323
1573
|
{zoom?.layer}
|
|
1324
1574
|
</ComposedChart>
|
|
1325
1575
|
</ChartContainer>
|