@teamofsilicons/silicon-ui 0.0.0-stage → 0.1.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/LICENSE +22 -0
- package/README.md +63 -2
- package/components/accordion/accordion.d.ts +13 -0
- package/components/accordion/accordion.js +35 -0
- package/components/accordion/accordion.module.css +15 -0
- package/components/action-button/action-button.d.ts +11 -0
- package/components/action-button/action-button.js +57 -0
- package/components/action-button/action-button.module.css +15 -0
- package/components/activity-heatmap/activity-heatmap.d.ts +37 -0
- package/components/activity-heatmap/activity-heatmap.js +303 -0
- package/components/activity-heatmap/activity-heatmap.module.css +83 -0
- package/components/alert/alert.d.ts +13 -0
- package/components/alert/alert.js +79 -0
- package/components/alert/alert.module.css +15 -0
- package/components/animated-counter/animated-counter.d.ts +13 -0
- package/components/animated-counter/animated-counter.js +73 -0
- package/components/animated-counter/animated-counter.module.css +12 -0
- package/components/announcement-bar/announcement-bar.d.ts +53 -0
- package/components/announcement-bar/announcement-bar.js +187 -0
- package/components/announcement-bar/announcement-bar.module.css +60 -0
- package/components/avatar/avatar.d.ts +8 -0
- package/components/avatar/avatar.js +18 -0
- package/components/avatar/avatar.module.css +60 -0
- package/components/avatar-group/avatar-group.d.ts +13 -0
- package/components/avatar-group/avatar-group.js +24 -0
- package/components/avatar-group/avatar-group.module.css +27 -0
- package/components/badge/badge.d.ts +10 -0
- package/components/badge/badge.js +66 -0
- package/components/badge/badge.module.css +52 -0
- package/components/bar-chart/bar-chart.d.ts +32 -0
- package/components/bar-chart/bar-chart.js +327 -0
- package/components/bar-chart/bar-chart.module.css +49 -0
- package/components/billing-toggle/billing-toggle.d.ts +35 -0
- package/components/billing-toggle/billing-toggle.js +83 -0
- package/components/billing-toggle/billing-toggle.module.css +139 -0
- package/components/bottom-sheet/bottom-sheet.d.ts +27 -0
- package/components/bottom-sheet/bottom-sheet.js +386 -0
- package/components/bottom-sheet/bottom-sheet.module.css +69 -0
- package/components/breadcrumb/breadcrumb.d.ts +13 -0
- package/components/breadcrumb/breadcrumb.js +19 -0
- package/components/breadcrumb/breadcrumb.module.css +12 -0
- package/components/brush-chart/brush-chart.d.ts +45 -0
- package/components/brush-chart/brush-chart.js +426 -0
- package/components/brush-chart/brush-chart.module.css +69 -0
- package/components/button/button.d.ts +10 -0
- package/components/button/button.js +97 -0
- package/components/button/button.module.css +39 -0
- package/components/button-group/button-group.d.ts +59 -0
- package/components/button-group/button-group.js +280 -0
- package/components/button-group/button-group.module.css +87 -0
- package/components/button-link/button-link.d.ts +24 -0
- package/components/button-link/button-link.js +11 -0
- package/components/button-link/button-link.module.css +7 -0
- package/components/calendar/calendar.d.ts +22 -0
- package/components/calendar/calendar.js +253 -0
- package/components/calendar/calendar.module.css +51 -0
- package/components/card/card.d.ts +19 -0
- package/components/card/card.js +108 -0
- package/components/card/card.module.css +51 -0
- package/components/card-stack/card-stack.d.ts +34 -0
- package/components/card-stack/card-stack.js +291 -0
- package/components/card-stack/card-stack.module.css +63 -0
- package/components/carousel/carousel.d.ts +28 -0
- package/components/carousel/carousel.js +332 -0
- package/components/carousel/carousel.module.css +42 -0
- package/components/chat-thread/chat-thread.d.ts +135 -0
- package/components/chat-thread/chat-thread.js +406 -0
- package/components/chat-thread/chat-thread.module.css +242 -0
- package/components/checkbox/checkbox.d.ts +7 -0
- package/components/checkbox/checkbox.js +23 -0
- package/components/checkbox/checkbox.module.css +15 -0
- package/components/chip-group/chip-group.d.ts +24 -0
- package/components/chip-group/chip-group.js +196 -0
- package/components/chip-group/chip-group.module.css +38 -0
- package/components/code-block/code-block.d.ts +11 -0
- package/components/code-block/code-block.js +133 -0
- package/components/code-block/code-block.module.css +43 -0
- package/components/color-picker/color-picker.d.ts +40 -0
- package/components/color-picker/color-picker.js +449 -0
- package/components/color-picker/color-picker.module.css +111 -0
- package/components/combobox/combobox.d.ts +19 -0
- package/components/combobox/combobox.js +167 -0
- package/components/combobox/combobox.module.css +28 -0
- package/components/command-palette/command-palette.d.ts +20 -0
- package/components/command-palette/command-palette.js +151 -0
- package/components/command-palette/command-palette.module.css +94 -0
- package/components/comment-thread/comment-thread.d.ts +120 -0
- package/components/comment-thread/comment-thread.js +343 -0
- package/components/comment-thread/comment-thread.module.css +195 -0
- package/components/confirm-morph/confirm-morph.d.ts +65 -0
- package/components/confirm-morph/confirm-morph.js +355 -0
- package/components/confirm-morph/confirm-morph.module.css +146 -0
- package/components/context-menu/context-menu.d.ts +18 -0
- package/components/context-menu/context-menu.js +148 -0
- package/components/context-menu/context-menu.module.css +17 -0
- package/components/copy-button/copy-button.d.ts +10 -0
- package/components/copy-button/copy-button.js +69 -0
- package/components/copy-button/copy-button.module.css +21 -0
- package/components/countdown/countdown.d.ts +31 -0
- package/components/countdown/countdown.js +177 -0
- package/components/countdown/countdown.module.css +49 -0
- package/components/date-picker/date-picker.d.ts +17 -0
- package/components/date-picker/date-picker.js +100 -0
- package/components/date-picker/date-picker.module.css +34 -0
- package/components/date-range-picker/date-range-picker.d.ts +44 -0
- package/components/date-range-picker/date-range-picker.js +534 -0
- package/components/date-range-picker/date-range-picker.module.css +214 -0
- package/components/dialog/dialog.d.ts +11 -0
- package/components/dialog/dialog.js +52 -0
- package/components/dialog/dialog.module.css +36 -0
- package/components/donut-chart/donut-chart.d.ts +46 -0
- package/components/donut-chart/donut-chart.js +382 -0
- package/components/donut-chart/donut-chart.module.css +48 -0
- package/components/drawer/drawer.d.ts +20 -0
- package/components/drawer/drawer.js +111 -0
- package/components/drawer/drawer.module.css +97 -0
- package/components/dropdown-menu/dropdown-menu.d.ts +16 -0
- package/components/dropdown-menu/dropdown-menu.js +39 -0
- package/components/dropdown-menu/dropdown-menu.module.css +38 -0
- package/components/elastic-slider/elastic-slider.d.ts +39 -0
- package/components/elastic-slider/elastic-slider.js +261 -0
- package/components/elastic-slider/elastic-slider.module.css +40 -0
- package/components/empty-state/empty-state.d.ts +12 -0
- package/components/empty-state/empty-state.js +69 -0
- package/components/empty-state/empty-state.module.css +13 -0
- package/components/expandable-card/expandable-card.d.ts +13 -0
- package/components/expandable-card/expandable-card.js +89 -0
- package/components/expandable-card/expandable-card.module.css +22 -0
- package/components/expanding-button-group/expanding-button-group.d.ts +41 -0
- package/components/expanding-button-group/expanding-button-group.js +258 -0
- package/components/expanding-button-group/expanding-button-group.module.css +44 -0
- package/components/expanding-search/expanding-search.d.ts +38 -0
- package/components/expanding-search/expanding-search.js +242 -0
- package/components/expanding-search/expanding-search.module.css +52 -0
- package/components/file-dropzone/file-dropzone.d.ts +44 -0
- package/components/file-dropzone/file-dropzone.js +379 -0
- package/components/file-dropzone/file-dropzone.module.css +115 -0
- package/components/file-upload/file-upload.d.ts +22 -0
- package/components/file-upload/file-upload.js +192 -0
- package/components/file-upload/file-upload.module.css +49 -0
- package/components/filter-toolbar/filter-toolbar.d.ts +48 -0
- package/components/filter-toolbar/filter-toolbar.js +446 -0
- package/components/filter-toolbar/filter-toolbar.module.css +70 -0
- package/components/floating-button-group/floating-button-group.d.ts +51 -0
- package/components/floating-button-group/floating-button-group.js +215 -0
- package/components/floating-button-group/floating-button-group.module.css +74 -0
- package/components/gauge/gauge.d.ts +18 -0
- package/components/gauge/gauge.js +101 -0
- package/components/gauge/gauge.module.css +22 -0
- package/components/hold-to-confirm/hold-to-confirm.d.ts +25 -0
- package/components/hold-to-confirm/hold-to-confirm.js +165 -0
- package/components/hold-to-confirm/hold-to-confirm.module.css +20 -0
- package/components/hover-card/hover-card.d.ts +38 -0
- package/components/hover-card/hover-card.js +145 -0
- package/components/hover-card/hover-card.module.css +20 -0
- package/components/image-compare/image-compare.d.ts +27 -0
- package/components/image-compare/image-compare.js +267 -0
- package/components/image-compare/image-compare.module.css +25 -0
- package/components/in-view-title/in-view-title.d.ts +17 -0
- package/components/in-view-title/in-view-title.js +70 -0
- package/components/in-view-title/in-view-title.module.css +13 -0
- package/components/inline-edit/inline-edit.d.ts +27 -0
- package/components/inline-edit/inline-edit.js +315 -0
- package/components/inline-edit/inline-edit.module.css +73 -0
- package/components/input/input.d.ts +7 -0
- package/components/input/input.js +60 -0
- package/components/input/input.module.css +22 -0
- package/components/json-viewer/json-viewer.d.ts +48 -0
- package/components/json-viewer/json-viewer.js +463 -0
- package/components/json-viewer/json-viewer.module.css +92 -0
- package/components/line-chart/line-chart.d.ts +58 -0
- package/components/line-chart/line-chart.js +383 -0
- package/components/line-chart/line-chart.module.css +60 -0
- package/components/mention-input/mention-input.d.ts +72 -0
- package/components/mention-input/mention-input.js +417 -0
- package/components/mention-input/mention-input.module.css +134 -0
- package/components/metric-card/metric-card.d.ts +8 -0
- package/components/metric-card/metric-card.js +44 -0
- package/components/metric-card/metric-card.module.css +18 -0
- package/components/money-input/money-input.d.ts +48 -0
- package/components/money-input/money-input.js +503 -0
- package/components/money-input/money-input.module.css +77 -0
- package/components/morph-select/morph-select.d.ts +50 -0
- package/components/morph-select/morph-select.js +395 -0
- package/components/morph-select/morph-select.module.css +88 -0
- package/components/multi-select/multi-select.d.ts +18 -0
- package/components/multi-select/multi-select.js +105 -0
- package/components/multi-select/multi-select.module.css +37 -0
- package/components/notification-center/notification-center.d.ts +24 -0
- package/components/notification-center/notification-center.js +146 -0
- package/components/notification-center/notification-center.module.css +79 -0
- package/components/number-field/number-field.d.ts +41 -0
- package/components/number-field/number-field.js +455 -0
- package/components/number-field/number-field.module.css +71 -0
- package/components/otp-input/otp-input.d.ts +13 -0
- package/components/otp-input/otp-input.js +129 -0
- package/components/otp-input/otp-input.module.css +28 -0
- package/components/pagination/pagination.d.ts +7 -0
- package/components/pagination/pagination.js +60 -0
- package/components/pagination/pagination.module.css +16 -0
- package/components/password-field/password-field.d.ts +14 -0
- package/components/password-field/password-field.js +51 -0
- package/components/password-field/password-field.module.css +27 -0
- package/components/password-strength/password-strength.d.ts +107 -0
- package/components/password-strength/password-strength.js +161 -0
- package/components/password-strength/password-strength.module.css +57 -0
- package/components/phone-input/phone-input.d.ts +73 -0
- package/components/phone-input/phone-input.js +612 -0
- package/components/phone-input/phone-input.module.css +98 -0
- package/components/popover/popover.d.ts +6 -0
- package/components/popover/popover.js +19 -0
- package/components/popover/popover.module.css +32 -0
- package/components/progress/progress.d.ts +8 -0
- package/components/progress/progress.js +42 -0
- package/components/progress/progress.module.css +1 -0
- package/components/radio-cards/radio-cards.d.ts +35 -0
- package/components/radio-cards/radio-cards.js +110 -0
- package/components/radio-cards/radio-cards.module.css +84 -0
- package/components/radio-group/radio-group.d.ts +13 -0
- package/components/radio-group/radio-group.js +55 -0
- package/components/radio-group/radio-group.module.css +30 -0
- package/components/resizable-panels/resizable-panels.d.ts +37 -0
- package/components/resizable-panels/resizable-panels.js +338 -0
- package/components/resizable-panels/resizable-panels.module.css +40 -0
- package/components/rich-text-editor/rich-text-editor.d.ts +55 -0
- package/components/rich-text-editor/rich-text-editor.js +1733 -0
- package/components/rich-text-editor/rich-text-editor.module.css +201 -0
- package/components/ridgeline/ridgeline.d.ts +37 -0
- package/components/ridgeline/ridgeline.js +346 -0
- package/components/ridgeline/ridgeline.module.css +58 -0
- package/components/scroll-area/scroll-area.d.ts +40 -0
- package/components/scroll-area/scroll-area.js +153 -0
- package/components/scroll-area/scroll-area.module.css +79 -0
- package/components/search-field/search-field.d.ts +7 -0
- package/components/search-field/search-field.js +20 -0
- package/components/search-field/search-field.module.css +17 -0
- package/components/segmented-control/segmented-control.d.ts +16 -0
- package/components/segmented-control/segmented-control.js +56 -0
- package/components/segmented-control/segmented-control.module.css +15 -0
- package/components/select/select.d.ts +15 -0
- package/components/select/select.js +36 -0
- package/components/select/select.module.css +35 -0
- package/components/shortcut-recorder/shortcut-recorder.d.ts +108 -0
- package/components/shortcut-recorder/shortcut-recorder.js +380 -0
- package/components/shortcut-recorder/shortcut-recorder.module.css +85 -0
- package/components/signature-pad/signature-pad.d.ts +72 -0
- package/components/signature-pad/signature-pad.js +450 -0
- package/components/signature-pad/signature-pad.module.css +84 -0
- package/components/skeleton/skeleton.d.ts +13 -0
- package/components/skeleton/skeleton.js +53 -0
- package/components/skeleton/skeleton.module.css +11 -0
- package/components/slider/slider.d.ts +45 -0
- package/components/slider/slider.js +347 -0
- package/components/slider/slider.module.css +45 -0
- package/components/slope-chart/slope-chart.d.ts +38 -0
- package/components/slope-chart/slope-chart.js +183 -0
- package/components/slope-chart/slope-chart.module.css +51 -0
- package/components/slot-text/slot-text.d.ts +26 -0
- package/components/slot-text/slot-text.js +119 -0
- package/components/slot-text/slot-text.module.css +19 -0
- package/components/sortable-data-table/sortable-data-table.d.ts +38 -0
- package/components/sortable-data-table/sortable-data-table.js +240 -0
- package/components/sortable-data-table/sortable-data-table.module.css +116 -0
- package/components/sparkline/sparkline.d.ts +19 -0
- package/components/sparkline/sparkline.js +239 -0
- package/components/sparkline/sparkline.module.css +26 -0
- package/components/split-button/split-button.d.ts +19 -0
- package/components/split-button/split-button.js +33 -0
- package/components/split-button/split-button.module.css +51 -0
- package/components/stepper/stepper.d.ts +36 -0
- package/components/stepper/stepper.js +108 -0
- package/components/stepper/stepper.module.css +74 -0
- package/components/streamgraph/streamgraph.d.ts +51 -0
- package/components/streamgraph/streamgraph.js +376 -0
- package/components/streamgraph/streamgraph.module.css +55 -0
- package/components/swipe-actions/swipe-actions.d.ts +35 -0
- package/components/swipe-actions/swipe-actions.js +330 -0
- package/components/swipe-actions/swipe-actions.module.css +64 -0
- package/components/switch/switch.d.ts +7 -0
- package/components/switch/switch.js +45 -0
- package/components/switch/switch.module.css +12 -0
- package/components/tabs/tabs.d.ts +8 -0
- package/components/tabs/tabs.js +138 -0
- package/components/tabs/tabs.module.css +27 -0
- package/components/tag-input/tag-input.d.ts +15 -0
- package/components/tag-input/tag-input.js +150 -0
- package/components/tag-input/tag-input.module.css +34 -0
- package/components/text-morph/text-morph.d.ts +13 -0
- package/components/text-morph/text-morph.js +69 -0
- package/components/text-morph/text-morph.module.css +8 -0
- package/components/text-reveal/text-reveal.d.ts +14 -0
- package/components/text-reveal/text-reveal.js +20 -0
- package/components/text-reveal/text-reveal.module.css +13 -0
- package/components/text-shimmer/text-shimmer.d.ts +18 -0
- package/components/text-shimmer/text-shimmer.js +90 -0
- package/components/text-shimmer/text-shimmer.module.css +9 -0
- package/components/textarea/textarea.d.ts +7 -0
- package/components/textarea/textarea.js +56 -0
- package/components/textarea/textarea.module.css +22 -0
- package/components/theme-switch/theme-switch.d.ts +11 -0
- package/components/theme-switch/theme-switch.js +37 -0
- package/components/theme-switch/theme-switch.module.css +31 -0
- package/components/time-picker/time-picker.d.ts +13 -0
- package/components/time-picker/time-picker.js +95 -0
- package/components/time-picker/time-picker.module.css +23 -0
- package/components/timeline/timeline.d.ts +47 -0
- package/components/timeline/timeline.js +171 -0
- package/components/timeline/timeline.module.css +51 -0
- package/components/toast/toast.d.ts +8 -0
- package/components/toast/toast.js +118 -0
- package/components/toast/toast.module.css +100 -0
- package/components/toast-stack/toast-stack.d.ts +57 -0
- package/components/toast-stack/toast-stack.js +399 -0
- package/components/toast-stack/toast-stack.module.css +61 -0
- package/components/tooltip/tooltip.d.ts +8 -0
- package/components/tooltip/tooltip.js +62 -0
- package/components/tooltip/tooltip.module.css +18 -0
- package/components/tree-view/tree-view.d.ts +17 -0
- package/components/tree-view/tree-view.js +147 -0
- package/components/tree-view/tree-view.module.css +22 -0
- package/components/treemap/treemap.d.ts +35 -0
- package/components/treemap/treemap.js +375 -0
- package/components/treemap/treemap.module.css +59 -0
- package/components/usage-meter/usage-meter.d.ts +24 -0
- package/components/usage-meter/usage-meter.js +296 -0
- package/components/usage-meter/usage-meter.module.css +61 -0
- package/components/user-menu/user-menu.d.ts +62 -0
- package/components/user-menu/user-menu.js +316 -0
- package/components/user-menu/user-menu.module.css +97 -0
- package/components/waffle-chart/waffle-chart.d.ts +39 -0
- package/components/waffle-chart/waffle-chart.js +244 -0
- package/components/waffle-chart/waffle-chart.module.css +52 -0
- package/components.json +1330 -0
- package/lib/escape.d.ts +19 -0
- package/lib/escape.js +50 -0
- package/lib/media.d.ts +353 -0
- package/lib/media.js +65 -0
- package/lib/menu-highlight.d.ts +28 -0
- package/lib/menu-highlight.js +43 -0
- package/lib/motion-tokens.d.ts +60 -0
- package/lib/motion-tokens.js +25 -0
- package/lib/return-focus.d.ts +9 -0
- package/lib/return-focus.js +53 -0
- package/lib/silicon-provider.d.ts +60 -0
- package/lib/silicon-provider.js +47 -0
- package/lib/use-copy-feedback.d.ts +8 -0
- package/lib/use-copy-feedback.js +36 -0
- package/lib/use-today.d.ts +2 -0
- package/lib/use-today.js +29 -0
- package/package.json +66 -4
- package/styles.css +218 -0
- package/styles.css.d.ts +1 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { animate, motion, useInView, useMotionValue, useReducedMotion, useSpring } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import styles from "./brush-chart.module.css";
|
|
7
|
+
const DAY = 86400000;
|
|
8
|
+
const { spring } = motionTokens;
|
|
9
|
+
const physical = ({ visualDuration, bounce }, restDelta = .001) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };
|
|
10
|
+
const settle = physical(spring.smooth);
|
|
11
|
+
const glide = physical(spring.snappy, .01);
|
|
12
|
+
const follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: .01 };
|
|
13
|
+
const draw = physical({ visualDuration: .9, bounce: 0 });
|
|
14
|
+
const TOP = 28, GAP = 14, GRIP = 14;
|
|
15
|
+
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
|
|
16
|
+
const grouped = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 });
|
|
17
|
+
const utc = (options) => new Intl.DateTimeFormat("en-US", { timeZone: "UTC", ...options });
|
|
18
|
+
const fullDate = utc({ weekday: "short", month: "short", day: "numeric", year: "numeric" });
|
|
19
|
+
const dayTick = utc({ month: "short", day: "numeric" });
|
|
20
|
+
const monthTick = utc({ month: "short" });
|
|
21
|
+
const yearTick = utc({ year: "numeric" });
|
|
22
|
+
const shortDate = utc({ month: "short", day: "numeric", year: "numeric" });
|
|
23
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
24
|
+
const time = (date) => typeof date === "number" ? date : date.getTime();
|
|
25
|
+
const clean = (value) => Number(value.toPrecision(12));
|
|
26
|
+
/** Clean gridlines from zero: the smallest step of 1, 2, 2.5, or 5 that covers the data in four rows or fewer. */
|
|
27
|
+
function niceTop(high) {
|
|
28
|
+
const hi = Math.max(high, 1), magnitude = 10 ** Math.floor(Math.log10(hi / 4));
|
|
29
|
+
for (const factor of [1, 2, 2.5, 5, 10]) {
|
|
30
|
+
const step = factor * magnitude, top = Math.ceil(hi / step) * step;
|
|
31
|
+
if (top / step <= 4 + 1e-9)
|
|
32
|
+
return { top: clean(top), step: clean(step) };
|
|
33
|
+
}
|
|
34
|
+
return { top: hi, step: hi / 4 };
|
|
35
|
+
}
|
|
36
|
+
/** Calendar ticks that fit the width: days, weeks, then months, quarters, and years, each landing on a real boundary. */
|
|
37
|
+
function timeTicks(start, end, width) {
|
|
38
|
+
const room = Math.max(2, Math.floor(width / 76)), span = end - start;
|
|
39
|
+
for (const days of [1, 2, 7, 14]) {
|
|
40
|
+
if (span / (days * DAY) > room)
|
|
41
|
+
continue;
|
|
42
|
+
const ticks = [];
|
|
43
|
+
let t = Math.ceil(start / DAY) * DAY;
|
|
44
|
+
if (days >= 7)
|
|
45
|
+
while (new Date(t).getUTCDay() !== 1)
|
|
46
|
+
t += DAY;
|
|
47
|
+
for (; t <= end; t += days * DAY)
|
|
48
|
+
ticks.push({ t, label: dayTick.format(t) });
|
|
49
|
+
return ticks;
|
|
50
|
+
}
|
|
51
|
+
for (const months of [1, 2, 3, 6, 12]) {
|
|
52
|
+
if (span / (months * 30.44 * DAY) > room)
|
|
53
|
+
continue;
|
|
54
|
+
const ticks = [];
|
|
55
|
+
const first = new Date(start);
|
|
56
|
+
let y = first.getUTCFullYear(), m = first.getUTCMonth() + (first.getUTCDate() > 1 || first.getUTCHours() > 0 ? 1 : 0);
|
|
57
|
+
for (;;) {
|
|
58
|
+
y += Math.floor(m / 12);
|
|
59
|
+
m %= 12;
|
|
60
|
+
const t = Date.UTC(y, m, 1);
|
|
61
|
+
if (t > end)
|
|
62
|
+
break;
|
|
63
|
+
if (m % months === 0)
|
|
64
|
+
ticks.push({ t, label: m === 0 ? yearTick.format(t) : monthTick.format(t) });
|
|
65
|
+
m += 1;
|
|
66
|
+
}
|
|
67
|
+
return ticks;
|
|
68
|
+
}
|
|
69
|
+
const ticks = [];
|
|
70
|
+
for (let y = new Date(start).getUTCFullYear() + 1; Date.UTC(y, 0, 1) <= end; y++)
|
|
71
|
+
ticks.push({ t: Date.UTC(y, 0, 1), label: String(y) });
|
|
72
|
+
return ticks;
|
|
73
|
+
}
|
|
74
|
+
/** First index at or after t. */
|
|
75
|
+
function lowerBound(points, t) {
|
|
76
|
+
let low = 0, high = points.length;
|
|
77
|
+
while (low < high) {
|
|
78
|
+
const middle = (low + high) >> 1;
|
|
79
|
+
if (points[middle].t < t)
|
|
80
|
+
low = middle + 1;
|
|
81
|
+
else
|
|
82
|
+
high = middle;
|
|
83
|
+
}
|
|
84
|
+
return low;
|
|
85
|
+
}
|
|
86
|
+
/** A line through the points in view. Past two points per pixel, each pixel column keeps only its low and high so spikes survive. */
|
|
87
|
+
function linePath(points, from, to, x, y, width) {
|
|
88
|
+
const slice = points.slice(Math.max(0, from), Math.min(points.length, to));
|
|
89
|
+
if (!slice.length)
|
|
90
|
+
return "";
|
|
91
|
+
const parts = [];
|
|
92
|
+
if (slice.length <= width * 2) {
|
|
93
|
+
for (const point of slice)
|
|
94
|
+
parts.push(`${x(point.t).toFixed(1)},${y(point.value).toFixed(1)}`);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
let column = -1, low = null, high = null;
|
|
98
|
+
const flush = () => { if (!low || !high)
|
|
99
|
+
return; const pair = low.t < high.t ? [low, high] : [high, low]; for (const point of pair)
|
|
100
|
+
parts.push(`${x(point.t).toFixed(1)},${y(point.value).toFixed(1)}`); };
|
|
101
|
+
for (const point of slice) {
|
|
102
|
+
const at = Math.floor(x(point.t));
|
|
103
|
+
if (at !== column) {
|
|
104
|
+
flush();
|
|
105
|
+
column = at;
|
|
106
|
+
low = high = point;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (point.value < low.value)
|
|
110
|
+
low = point;
|
|
111
|
+
if (point.value > high.value)
|
|
112
|
+
high = point;
|
|
113
|
+
}
|
|
114
|
+
flush();
|
|
115
|
+
}
|
|
116
|
+
return `M${parts.join("L")}`;
|
|
117
|
+
}
|
|
118
|
+
const subscribeNothing = () => () => { };
|
|
119
|
+
function useReducedMotionSafe() {
|
|
120
|
+
const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);
|
|
121
|
+
return !!useReducedMotion() && hydrated;
|
|
122
|
+
}
|
|
123
|
+
function useWidth() {
|
|
124
|
+
const node = useRef(null);
|
|
125
|
+
const [width, setWidth] = useState(0);
|
|
126
|
+
useLayoutEffect(() => {
|
|
127
|
+
const element = node.current;
|
|
128
|
+
if (!element)
|
|
129
|
+
return;
|
|
130
|
+
setWidth(element.clientWidth);
|
|
131
|
+
if (typeof ResizeObserver === "undefined")
|
|
132
|
+
return;
|
|
133
|
+
const observer = new ResizeObserver(() => setWidth(element.clientWidth));
|
|
134
|
+
observer.observe(element);
|
|
135
|
+
return () => observer.disconnect();
|
|
136
|
+
}, []);
|
|
137
|
+
return [node, width];
|
|
138
|
+
}
|
|
139
|
+
export function BrushChart({ data, label, unit = "", formatValue = value => grouped.format(value), formatTick = value => compact.format(value), formatDate = date => fullDate.format(date), annotations = [], range, defaultRange, onRangeChange, minSpan = 7 * DAY, height = 240, overviewHeight = 52, emptyLabel = "No data yet", ref, className }) {
|
|
140
|
+
const reduced = useReducedMotionSafe();
|
|
141
|
+
const uid = `brush${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
142
|
+
const figure = useRef(null);
|
|
143
|
+
const tip = useRef(null);
|
|
144
|
+
useImperativeHandle(ref, () => figure.current);
|
|
145
|
+
const inView = useInView(figure, { once: true, amount: .3 });
|
|
146
|
+
const [plot, width] = useWidth();
|
|
147
|
+
const [strip, stripWidth] = useWidth();
|
|
148
|
+
const points = useMemo(() => data.map(item => ({ t: time(item.date), value: item.value })), [data]);
|
|
149
|
+
// Zoomed out, a centred seven point average and its low to high band replace the raw line, so weekly noise reads as texture instead of a solid block.
|
|
150
|
+
const smooth = useMemo(() => {
|
|
151
|
+
const around = (index, read) => { const out = []; for (let at = Math.max(0, index - 3); at <= Math.min(points.length - 1, index + 3); at++)
|
|
152
|
+
out.push(read(at)); return out; };
|
|
153
|
+
const raw = points.map((_, index) => { const values = around(index, (at) => [points[at].value]).map(([value]) => value); return { value: values.reduce((sum, value) => sum + value, 0) / values.length, low: Math.min(...values), high: Math.max(...values) }; });
|
|
154
|
+
// The band is averaged once more so its edges ease instead of stepping.
|
|
155
|
+
return points.map((point, index) => { const near = around(index, (at) => [raw[at].low, raw[at].high]); return { t: point.t, value: raw[index].value, low: near.reduce((sum, [low]) => sum + low, 0) / near.length, high: near.reduce((sum, [, high]) => sum + high, 0) / near.length }; });
|
|
156
|
+
}, [points]);
|
|
157
|
+
const events = useMemo(() => annotations.map(item => ({ ...item, t: time(item.date) })).sort((a, b) => a.t - b.t), [annotations]);
|
|
158
|
+
const empty = points.length < 2;
|
|
159
|
+
const first = points[0]?.t ?? 0, lastT = points[points.length - 1]?.t ?? 1;
|
|
160
|
+
const whole = [first, lastT];
|
|
161
|
+
const fit = ([start, end]) => {
|
|
162
|
+
const span = clamp(end - start, Math.min(minSpan, lastT - first), lastT - first);
|
|
163
|
+
const s = clamp(start, first, lastT - span);
|
|
164
|
+
return [s, s + span];
|
|
165
|
+
};
|
|
166
|
+
// The window: follows the finger 1:1 while dragged, glides on a spring when set from outside or reset.
|
|
167
|
+
const [view, setView] = useState(() => range ?? defaultRange ?? whole);
|
|
168
|
+
const viewRef = useRef(view);
|
|
169
|
+
const flight = useRef(null);
|
|
170
|
+
const place = (next) => { viewRef.current = next; setView(next); };
|
|
171
|
+
const glideTo = (next) => {
|
|
172
|
+
flight.current?.stop();
|
|
173
|
+
const from = viewRef.current;
|
|
174
|
+
if (reduced) {
|
|
175
|
+
place(next);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
flight.current = animate(0, 1, { ...settle, onUpdate: t => place([from[0] + (next[0] - from[0]) * t, from[1] + (next[1] - from[1]) * t]) });
|
|
179
|
+
};
|
|
180
|
+
const commit = (next, smooth) => {
|
|
181
|
+
const target = fit(next);
|
|
182
|
+
if (smooth)
|
|
183
|
+
glideTo(target);
|
|
184
|
+
else {
|
|
185
|
+
flight.current?.stop();
|
|
186
|
+
place(target);
|
|
187
|
+
}
|
|
188
|
+
onRangeChange?.(target);
|
|
189
|
+
};
|
|
190
|
+
const rangeKey = range ? `${range[0]}:${range[1]}` : "";
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (!range || empty)
|
|
193
|
+
return;
|
|
194
|
+
const target = fit(range), now = viewRef.current;
|
|
195
|
+
if (Math.abs(target[0] - now[0]) + Math.abs(target[1] - now[1]) > 1000)
|
|
196
|
+
glideTo(target);
|
|
197
|
+
}, [rangeKey, empty]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
198
|
+
// Data that no longer covers the window pulls it back inside.
|
|
199
|
+
useEffect(() => { if (!empty) {
|
|
200
|
+
const target = fit(viewRef.current);
|
|
201
|
+
if (target[0] !== viewRef.current[0] || target[1] !== viewRef.current[1])
|
|
202
|
+
place(target);
|
|
203
|
+
} }, [first, lastT]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
204
|
+
const [start, end] = view;
|
|
205
|
+
const span = Math.max(1, end - start);
|
|
206
|
+
const from = Math.max(0, lowerBound(points, start) - 1), to = Math.min(points.length, lowerBound(points, end) + 1);
|
|
207
|
+
let peak = 0;
|
|
208
|
+
for (let index = from; index < to; index++)
|
|
209
|
+
peak = Math.max(peak, points[index].value);
|
|
210
|
+
const scale = niceTop(peak * 1.04);
|
|
211
|
+
// The value axis springs to the tallest point in view, so zooming into a quiet stretch fills the plot.
|
|
212
|
+
const [top, setTop] = useState(scale.top);
|
|
213
|
+
const topFlight = useRef(null);
|
|
214
|
+
const aimed = useRef(scale.top);
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (aimed.current === scale.top)
|
|
217
|
+
return;
|
|
218
|
+
aimed.current = scale.top;
|
|
219
|
+
topFlight.current?.stop();
|
|
220
|
+
if (reduced)
|
|
221
|
+
return;
|
|
222
|
+
topFlight.current = animate(top, scale.top, { ...settle, onUpdate: setTop });
|
|
223
|
+
}, [reduced, scale.top]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
224
|
+
useEffect(() => () => { flight.current?.stop(); topFlight.current?.stop(); }, []);
|
|
225
|
+
const plotH = height;
|
|
226
|
+
const x = (t) => ((t - start) / span) * width;
|
|
227
|
+
const topNow = reduced ? scale.top : top;
|
|
228
|
+
const y = (value) => TOP + (1 - value / (topNow || 1)) * (plotH - TOP);
|
|
229
|
+
const line = width && !empty ? linePath(points, from, to, x, y, width) : "";
|
|
230
|
+
const area = line ? `${line}L${x(points[to - 1].t).toFixed(1)},${plotH}L${x(points[from].t).toFixed(1)},${plotH}Z` : "";
|
|
231
|
+
const trend = width && !empty ? linePath(smooth, from, to, x, y, width * 4) : "";
|
|
232
|
+
const band = trend ? `${linePath(smooth.map(point => ({ t: point.t, value: point.high })), from, to, x, y, width * 4)}L${linePath(smooth.map(point => ({ t: point.t, value: point.low })), from, to, x, y, width * 4).slice(1).split("L").reverse().join("L")}Z` : "";
|
|
233
|
+
// How much of the raw line shows: all of it past about four pixels a point, none below about three.
|
|
234
|
+
const pxPerPoint = empty ? width : (width * (lastT - first)) / (points.length - 1) / span;
|
|
235
|
+
const detail = clamp((pxPerPoint - 2.75) / 1.5, 0, 1);
|
|
236
|
+
const ticks = scale.top > 0 ? Array.from({ length: Math.round(scale.top / scale.step) + 1 }, (_, index) => clean(index * scale.step)) : [];
|
|
237
|
+
const xTicks = width && !empty ? timeTicks(start, end, width) : [];
|
|
238
|
+
// Overview: the whole series, drawn once per width.
|
|
239
|
+
const ox = (t) => ((t - first) / (lastT - first || 1)) * stripWidth;
|
|
240
|
+
const overview = useMemo(() => {
|
|
241
|
+
if (!stripWidth || empty)
|
|
242
|
+
return { line: "", area: "", marks: [] };
|
|
243
|
+
const max = niceTop(smooth.reduce((m, point) => Math.max(m, point.value), 0)).top || 1;
|
|
244
|
+
const oy = (value) => 4 + (1 - value / max) * (overviewHeight - 4);
|
|
245
|
+
const path = linePath(smooth, 0, smooth.length, ox, oy, stripWidth * 4);
|
|
246
|
+
const marks = events.map(event => { const at = clamp(lowerBound(smooth, event.t), 0, smooth.length - 1); return { t: event.t, x: ox(event.t), y: oy(smooth[at].value) }; });
|
|
247
|
+
return { line: path, area: `${path}L${stripWidth},${overviewHeight}L0,${overviewHeight}Z`, marks };
|
|
248
|
+
}, [smooth, events, stripWidth, overviewHeight, empty]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
249
|
+
const wx0 = ox(start), wx1 = ox(end);
|
|
250
|
+
// The line draws in from the left once, the first time it is seen.
|
|
251
|
+
const drawn = useMotionValue(0);
|
|
252
|
+
const [reveal, setReveal] = useState(0);
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (empty)
|
|
255
|
+
return;
|
|
256
|
+
if (reduced) {
|
|
257
|
+
drawn.jump(1);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (!inView || drawn.get() >= 1)
|
|
261
|
+
return;
|
|
262
|
+
const controls = animate(drawn, 1, { ...draw, onUpdate: setReveal });
|
|
263
|
+
return () => controls.stop();
|
|
264
|
+
}, [drawn, empty, inView, reduced]);
|
|
265
|
+
// Crosshair on the nearest point in view; the tooltip glides beside it and never leaves the plot.
|
|
266
|
+
const [active, setActive] = useState(null);
|
|
267
|
+
const [hoverEvent, setHoverEvent] = useState(null);
|
|
268
|
+
const index = active !== null && active >= from && active < to ? active : null;
|
|
269
|
+
const nearest = (clientX) => {
|
|
270
|
+
const rect = plot.current?.getBoundingClientRect();
|
|
271
|
+
if (!rect?.width || empty)
|
|
272
|
+
return null;
|
|
273
|
+
const t = start + ((clientX - rect.left) / rect.width) * span;
|
|
274
|
+
const at = clamp(lowerBound(points, t), 1, points.length - 1);
|
|
275
|
+
return clamp(t - points[at - 1].t < points[at].t - t ? at - 1 : at, Math.max(0, from), to - 1);
|
|
276
|
+
};
|
|
277
|
+
const cx = index === null ? 0 : x(points[index].t), cy = index === null ? 0 : y(smooth[index].value + (points[index].value - smooth[index].value) * detail);
|
|
278
|
+
const eventAt = index === null ? null : events.find(event => Math.abs(event.t - points[index].t) < DAY / 2) ?? null;
|
|
279
|
+
const shownEvent = hoverEvent !== null ? events[hoverEvent] : null;
|
|
280
|
+
const tipX = useMotionValue(0), tipY = useMotionValue(0);
|
|
281
|
+
const tipSpringX = useSpring(tipX, follow), tipSpringY = useSpring(tipY, follow);
|
|
282
|
+
const tipOn = index !== null || shownEvent !== null;
|
|
283
|
+
const wasOn = useRef(false);
|
|
284
|
+
useLayoutEffect(() => {
|
|
285
|
+
const bubble = tip.current;
|
|
286
|
+
if (!tipOn || !bubble || !width) {
|
|
287
|
+
wasOn.current = false;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const ax = shownEvent ? x(shownEvent.t) : cx, ay = shownEvent ? TOP : cy;
|
|
291
|
+
const tw = bubble.offsetWidth, th = bubble.offsetHeight;
|
|
292
|
+
let left = ax + GAP;
|
|
293
|
+
if (left + tw > width)
|
|
294
|
+
left = ax - GAP - tw;
|
|
295
|
+
let topY = ay - th - GAP;
|
|
296
|
+
if (topY < 0)
|
|
297
|
+
topY = ay + GAP;
|
|
298
|
+
tipX.set(clamp(left, 0, Math.max(0, width - tw)));
|
|
299
|
+
tipY.set(clamp(topY, 0, Math.max(0, plotH - th)));
|
|
300
|
+
if (!wasOn.current || reduced) {
|
|
301
|
+
tipSpringX.jump(tipX.get());
|
|
302
|
+
tipSpringY.jump(tipY.get());
|
|
303
|
+
}
|
|
304
|
+
wasOn.current = true;
|
|
305
|
+
});
|
|
306
|
+
const onPlotMove = (event) => { if (event.pointerType === "mouse" || event.buttons)
|
|
307
|
+
setActive(nearest(event.clientX)); };
|
|
308
|
+
const onPlotKey = (event) => {
|
|
309
|
+
if (empty)
|
|
310
|
+
return;
|
|
311
|
+
if (event.key === "Escape" && index !== null) {
|
|
312
|
+
event.preventDefault();
|
|
313
|
+
setActive(null);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const current = index ?? to - 1, page = Math.max(1, Math.round((to - from) / 8));
|
|
317
|
+
const next = { ArrowLeft: current - 1, ArrowRight: index === null ? to - 1 : current + 1, PageDown: current - page, PageUp: current + page, Home: from, End: to - 1 }[event.key];
|
|
318
|
+
if (next === undefined)
|
|
319
|
+
return;
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
setActive(clamp(next, Math.max(0, from), to - 1));
|
|
322
|
+
};
|
|
323
|
+
// Overview gestures: grab an edge to resize, the window to pan, or empty track to draw a new window. A tap recentres; a double tap resets.
|
|
324
|
+
const drag = useRef(null);
|
|
325
|
+
const toTime = (clientX) => { const rect = strip.current?.getBoundingClientRect(); return rect?.width ? first + clamp((clientX - rect.left) / rect.width, 0, 1) * (lastT - first) : first; };
|
|
326
|
+
const onStripDown = (event) => {
|
|
327
|
+
if (empty || event.button > 0)
|
|
328
|
+
return;
|
|
329
|
+
const rect = strip.current.getBoundingClientRect(), px = event.clientX - rect.left;
|
|
330
|
+
const mode = Math.abs(px - wx0) <= GRIP && px <= (wx0 + wx1) / 2 ? "start" : Math.abs(px - wx1) <= GRIP ? "end" : px > wx0 && px < wx1 ? "pan" : "new";
|
|
331
|
+
drag.current = { mode, pointerX: event.clientX, from: viewRef.current, anchor: toTime(event.clientX), moved: false };
|
|
332
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
333
|
+
flight.current?.stop();
|
|
334
|
+
};
|
|
335
|
+
const onStripMove = (event) => {
|
|
336
|
+
const current = drag.current;
|
|
337
|
+
if (!current)
|
|
338
|
+
return;
|
|
339
|
+
if (!current.moved && Math.abs(event.clientX - current.pointerX) < 3)
|
|
340
|
+
return;
|
|
341
|
+
current.moved = true;
|
|
342
|
+
const t = toTime(event.clientX), shift = t - current.anchor;
|
|
343
|
+
const [s, e] = current.from, least = Math.min(minSpan, lastT - first);
|
|
344
|
+
const next = current.mode === "pan" ? [s + shift, e + shift]
|
|
345
|
+
: current.mode === "start" ? [clamp(t, first, e - least), e]
|
|
346
|
+
: current.mode === "end" ? [s, clamp(t, s + least, lastT)]
|
|
347
|
+
: [Math.min(current.anchor, t), Math.max(current.anchor, t)];
|
|
348
|
+
if (current.mode === "new" && next[1] - next[0] < least) {
|
|
349
|
+
if (t >= current.anchor)
|
|
350
|
+
next[1] = next[0] + least;
|
|
351
|
+
else
|
|
352
|
+
next[0] = next[1] - least;
|
|
353
|
+
}
|
|
354
|
+
commit(next, false);
|
|
355
|
+
};
|
|
356
|
+
const onStripUp = () => {
|
|
357
|
+
const current = drag.current;
|
|
358
|
+
drag.current = null;
|
|
359
|
+
if (!current || current.moved || current.mode !== "new")
|
|
360
|
+
return;
|
|
361
|
+
const half = (viewRef.current[1] - viewRef.current[0]) / 2;
|
|
362
|
+
commit([current.anchor - half, current.anchor + half], true);
|
|
363
|
+
};
|
|
364
|
+
const reset = () => commit(whole, true);
|
|
365
|
+
const stepSize = Math.max(DAY, (lastT - first) / Math.max(1, points.length - 1));
|
|
366
|
+
const onHandleKey = (event, edge) => {
|
|
367
|
+
const [s, e] = viewRef.current, win = e - s;
|
|
368
|
+
const big = event.shiftKey || event.key.startsWith("Page");
|
|
369
|
+
const delta = { ArrowLeft: -1, ArrowDown: -1, PageDown: -1, ArrowRight: 1, ArrowUp: 1, PageUp: 1 }[event.key];
|
|
370
|
+
let next = null;
|
|
371
|
+
if (event.key === "Escape" || event.key === "0")
|
|
372
|
+
next = whole;
|
|
373
|
+
else if (edge === "window" && (event.key === "+" || event.key === "="))
|
|
374
|
+
next = [s + win / 4, e - win / 4];
|
|
375
|
+
else if (edge === "window" && (event.key === "-" || event.key === "_"))
|
|
376
|
+
next = [s - win / 2, e + win / 2];
|
|
377
|
+
else if (event.key === "Home")
|
|
378
|
+
next = edge === "end" ? [s, s + Math.min(minSpan, lastT - first)] : [first, edge === "window" ? first + win : e];
|
|
379
|
+
else if (event.key === "End")
|
|
380
|
+
next = edge === "start" ? [e - Math.min(minSpan, lastT - first), e] : [edge === "window" ? lastT - win : s, lastT];
|
|
381
|
+
else if (delta) {
|
|
382
|
+
const move = delta * (edge === "window" ? win * (big ? .5 : .1) : stepSize * (big ? 10 : 1));
|
|
383
|
+
next = edge === "window" ? [s + move, e + move] : edge === "start" ? [Math.min(s + move, e - minSpan), e] : [s, Math.max(e + move, s + minSpan)];
|
|
384
|
+
}
|
|
385
|
+
if (!next)
|
|
386
|
+
return;
|
|
387
|
+
event.preventDefault();
|
|
388
|
+
commit(next, false);
|
|
389
|
+
};
|
|
390
|
+
const format = (value) => `${formatValue(value)}${unit ? ` ${unit}` : ""}`;
|
|
391
|
+
const reading = index === null ? null : points[index];
|
|
392
|
+
const previous = index !== null && index >= 7 ? points[index - 7] : null;
|
|
393
|
+
const change = reading && previous && previous.value ? (reading.value - previous.value) / previous.value : null;
|
|
394
|
+
const windowText = `${shortDate.format(start)} to ${shortDate.format(end)}`;
|
|
395
|
+
const inWindow = events.filter(event => event.t >= start && event.t <= end);
|
|
396
|
+
// Event labels are placed left to right and skipped when they would collide with the one before.
|
|
397
|
+
const labelled = new Map();
|
|
398
|
+
let edge = -Infinity;
|
|
399
|
+
inWindow.forEach((event, at) => {
|
|
400
|
+
const px = x(event.t), w = event.label.length * 6.2 + 4, next = at + 1 < inWindow.length ? x(inWindow[at + 1].t) - 10 : width;
|
|
401
|
+
if (px + 14 >= edge && px + 14 + w <= next) {
|
|
402
|
+
labelled.set(event.t, false);
|
|
403
|
+
edge = px + 14 + w + 8;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (px - 14 - w >= Math.max(0, edge) && (at === 0 || px - 14 - w >= x(inWindow[at - 1].t) + 12)) {
|
|
407
|
+
labelled.set(event.t, true);
|
|
408
|
+
edge = px + 12;
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
edge = Math.max(edge, px + 12);
|
|
412
|
+
});
|
|
413
|
+
const announce = reading ? `${formatDate(new Date(reading.t))}, ${format(reading.value)}${eventAt ? `. ${eventAt.label}` : ""}` : "";
|
|
414
|
+
const summary = empty ? `${label}. ${emptyLabel}.` : `${label}, ${shortDate.format(first)} to ${shortDate.format(lastT)}. Showing ${windowText}.`;
|
|
415
|
+
return _jsxs("figure", { ref: figure, className: [styles.figure, className].filter(Boolean).join(" "), "aria-label": label, children: [_jsxs("div", { className: styles.chart, "data-active": tipOn || undefined, children: [_jsxs("div", { ref: plot, className: styles.plot, style: { height: plotH }, role: "group", tabIndex: empty ? -1 : 0, "aria-roledescription": "chart", "aria-label": `${label}, ${windowText}. Use left and right arrows to read values.`, onPointerMove: onPlotMove, onPointerDown: event => { if (event.pointerType !== "mouse") {
|
|
416
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
417
|
+
setActive(nearest(event.clientX));
|
|
418
|
+
} }, onPointerUp: event => { if (event.pointerType !== "mouse")
|
|
419
|
+
setActive(null); }, onPointerCancel: () => setActive(null), onPointerLeave: event => { if (event.pointerType === "mouse")
|
|
420
|
+
setActive(null); }, onKeyDown: onPlotKey, onBlur: () => setActive(null), onDoubleClick: reset, onFocus: event => { if (event.currentTarget.matches(":focus-visible") && !empty)
|
|
421
|
+
setActive(current => current ?? to - 1); }, children: [_jsxs("svg", { className: styles.svg, width: "100%", height: plotH, "aria-hidden": "true", focusable: "false", children: [_jsx("defs", { children: _jsx("clipPath", { id: `${uid}-plot`, children: _jsx("rect", { x: 0, y: -8, width: Math.max(0, width * (reduced ? 1 : reveal)), height: plotH + 16 }) }) }), ticks.slice(1).map(value => _jsx("line", { className: styles.grid, x1: 0, x2: "100%", y1: Math.round(y(value)) + .5, y2: Math.round(y(value)) + .5 }, value)), _jsx("line", { className: styles.baseline, x1: 0, x2: "100%", y1: plotH - .5, y2: plotH - .5 }), inWindow.map(event => _jsx("line", { className: styles.eventRule, x1: Math.round(x(event.t)) + .5, x2: Math.round(x(event.t)) + .5, y1: TOP - 10, y2: plotH, "data-active": shownEvent?.t === event.t || eventAt?.t === event.t || undefined }, event.t)), _jsxs("g", { clipPath: `url(#${uid}-plot)`, children: [_jsx("path", { className: styles.band, d: band, style: { opacity: 1 - detail } }), _jsx("path", { className: styles.area, d: area, style: { opacity: detail } }), _jsx("path", { className: styles.trend, d: trend, style: { opacity: 1 - detail } }), _jsx("path", { className: styles.line, d: line, style: { opacity: detail } })] }), _jsx("line", { className: styles.crosshair, x1: Math.round(cx) + .5, x2: Math.round(cx) + .5, y1: TOP - 10, y2: plotH }), _jsx("circle", { className: styles.dot, cx: cx, cy: cy, r: 4 })] }), inWindow.map(event => {
|
|
422
|
+
const px = x(event.t), i = events.indexOf(event);
|
|
423
|
+
return _jsxs("span", { className: styles.event, style: { left: px }, "data-labelled": labelled.has(event.t) || undefined, "data-flip": labelled.get(event.t) || undefined, "data-active": shownEvent?.t === event.t || eventAt?.t === event.t || undefined, "aria-hidden": "true", onPointerEnter: () => setHoverEvent(i), onPointerLeave: () => setHoverEvent(null), onPointerDown: event => event.stopPropagation(), children: [_jsx("span", { className: styles.eventDot }), _jsx("span", { className: styles.eventLabel, children: event.label })] }, event.t);
|
|
424
|
+
}), _jsx(motion.div, { ref: tip, className: styles.tooltip, style: { x: reduced ? tipX : tipSpringX, y: reduced ? tipY : tipSpringY }, "aria-hidden": "true", children: shownEvent ? _jsxs(_Fragment, { children: [_jsx("p", { className: styles.tipTitle, children: formatDate(new Date(shownEvent.t)) }), _jsx("p", { className: styles.tipValue, children: shownEvent.label }), shownEvent.description && _jsx("p", { className: styles.tipNote, children: shownEvent.description })] }) : reading ? _jsxs(_Fragment, { children: [_jsx("p", { className: styles.tipTitle, children: formatDate(new Date(reading.t)) }), _jsx("p", { className: styles.tipValue, children: format(reading.value) }), detail < 1 && index !== null && _jsx("p", { className: styles.tipNote, children: `${formatValue(Math.round(smooth[index].value))} seven day average` }), change !== null && _jsx("p", { className: styles.tipNote, children: `${change >= 0 ? "+" : "\u2212"}${Math.abs(change * 100).toFixed(1)}% vs a week earlier` }), eventAt && _jsxs("p", { className: styles.tipEvent, children: [_jsx("span", { className: styles.tipEventDot }), eventAt.label] })] }) : null }), empty && _jsx("p", { className: styles.message, children: emptyLabel })] }), _jsx("div", { className: styles.gutter, "aria-hidden": "true", children: ticks.map(value => _jsx("span", { className: styles.tick, style: { top: y(value) }, children: formatTick(value) }, value)) }), _jsx("div", { className: styles.axis, "aria-hidden": "true", children: xTicks.map(tick => { const px = x(tick.t); return _jsx("span", { className: styles.axisLabel, style: { left: px, translate: `${px < 24 ? 0 : px > width - 24 ? -100 : -50}% 0` }, children: tick.label }, tick.t); }) }), _jsxs("div", { ref: strip, className: styles.strip, style: { height: overviewHeight }, onPointerDown: onStripDown, onPointerMove: onStripMove, onPointerUp: onStripUp, onPointerCancel: () => { drag.current = null; }, onDoubleClick: reset, children: [_jsxs("svg", { className: styles.svg, width: "100%", height: overviewHeight, "aria-hidden": "true", focusable: "false", children: [_jsx("defs", { children: _jsx("clipPath", { id: `${uid}-window`, children: _jsx("rect", { x: wx0, y: -2, width: Math.max(0, wx1 - wx0), height: overviewHeight + 4 }) }) }), _jsx("path", { className: styles.overviewArea, d: overview.area }), _jsx("path", { className: styles.overviewLine, d: overview.line }), _jsxs("g", { clipPath: `url(#${uid}-window)`, children: [_jsx("path", { className: styles.overviewAreaIn, d: overview.area }), _jsx("path", { className: styles.overviewLineIn, d: overview.line })] }), overview.marks.map(mark => _jsx("circle", { className: styles.overviewEvent, cx: mark.x, cy: mark.y, r: 2.5 }, mark.t))] }), !empty && _jsxs(_Fragment, { children: [_jsx("div", { className: styles.shade, "data-edge": "start", style: { left: 0, width: Math.max(0, wx0) } }), _jsx("div", { className: styles.shade, "data-edge": "end", style: { left: wx1, right: 0 } }), _jsx("div", { className: styles.window, style: { left: wx0, width: Math.max(0, wx1 - wx0) }, role: "slider", tabIndex: 0, "aria-label": `${label} window`, "aria-roledescription": "range window", "aria-valuemin": first, "aria-valuemax": lastT, "aria-valuenow": Math.round(start), "aria-valuetext": windowText, onKeyDown: event => onHandleKey(event, "window") }), _jsx("div", { className: styles.handle, style: { left: wx0 }, role: "slider", tabIndex: 0, "aria-label": "Window start", "aria-valuemin": first, "aria-valuemax": Math.round(end), "aria-valuenow": Math.round(start), "aria-valuetext": shortDate.format(start), onKeyDown: event => onHandleKey(event, "start"), children: _jsx("span", { className: styles.grip }) }), _jsx("div", { className: styles.handle, style: { left: wx1 }, role: "slider", tabIndex: 0, "aria-label": "Window end", "aria-valuemin": Math.round(start), "aria-valuemax": lastT, "aria-valuenow": Math.round(end), "aria-valuetext": shortDate.format(end), onKeyDown: event => onHandleKey(event, "end"), children: _jsx("span", { className: styles.grip }) })] })] })] }), _jsx("p", { className: styles.srOnly, "aria-live": "polite", "aria-atomic": "true", children: announce }), _jsx("p", { className: styles.srOnly, children: summary }), !empty && _jsx("div", { className: styles.srOnly, children: _jsxs("table", { children: [_jsx("caption", { children: `${label}, ${windowText}` }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Date" }), _jsx("th", { scope: "col", children: label }), _jsx("th", { scope: "col", children: "Event" })] }) }), _jsx("tbody", { children: points.slice(lowerBound(points, start), lowerBound(points, end + 1)).map(point => { const event = events.find(item => Math.abs(item.t - point.t) < DAY / 2); return _jsxs("tr", { children: [_jsx("th", { scope: "row", children: shortDate.format(point.t) }), _jsx("td", { children: format(point.value) }), _jsx("td", { children: event ? `${event.label}${event.description ? `. ${event.description}` : ""}` : "" })] }, point.t); }) })] }) })] });
|
|
425
|
+
}
|
|
426
|
+
export default BrushChart;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
.figure { display: grid; min-width: 0; margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
|
|
2
|
+
|
|
3
|
+
/* Series colors (--series-1 to --series-4) come from registry/foundation.css. */
|
|
4
|
+
|
|
5
|
+
/* Plot with a right-hand value axis, the date axis beneath, and the overview strip under both. */
|
|
6
|
+
.chart { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; grid-template-rows: auto 28px auto; row-gap: 0; min-width: 0; }
|
|
7
|
+
.plot { position: relative; grid-area: 1 / 1; min-width: 0; outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; cursor: crosshair; }
|
|
8
|
+
.svg { display: block; overflow: visible; }
|
|
9
|
+
.grid { stroke: var(--border); stroke-width: 1; shape-rendering: crispEdges; }
|
|
10
|
+
.baseline { stroke: var(--border-strong); stroke-width: 1; shape-rendering: crispEdges; }
|
|
11
|
+
.line { fill: none; stroke: var(--series-1); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
|
|
12
|
+
.area { fill: color-mix(in oklab, var(--series-1) 10%, transparent); stroke: none; }
|
|
13
|
+
.trend { fill: none; stroke: var(--series-1); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
|
|
14
|
+
.band { fill: color-mix(in oklab, var(--series-1) 14%, transparent); stroke: none; }
|
|
15
|
+
.crosshair { stroke: var(--foreground); stroke-opacity: .45; stroke-width: 1; shape-rendering: crispEdges; opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); pointer-events: none; }
|
|
16
|
+
.dot { fill: var(--series-1); stroke: var(--surface); stroke-width: 2; opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); pointer-events: none; }
|
|
17
|
+
.chart[data-active] .crosshair, .chart[data-active] .dot { opacity: 1; }
|
|
18
|
+
|
|
19
|
+
/* Events: a hairline through the plot, a dot in the second chart series on top, and a label when there is room for it. */
|
|
20
|
+
.eventRule { stroke: var(--border-strong); stroke-width: 1; shape-rendering: crispEdges; transition: stroke var(--duration-fast) var(--ease-standard); }
|
|
21
|
+
.eventRule[data-active] { stroke: var(--series-2); }
|
|
22
|
+
.event { position: absolute; top: 6px; display: flex; box-sizing: border-box; min-height: 24px; align-items: center; gap: 6px; padding: 0 6px 0 0; margin-left: -12px; border: 0; background: transparent; color: var(--text-secondary); cursor: default; font: inherit; font-size: var(--text-xs); line-height: 1; white-space: nowrap; -webkit-tap-highlight-color: transparent; }
|
|
23
|
+
.event[data-flip] { flex-direction: row-reverse; padding: 0 0 0 6px; margin-left: 0; translate: calc(-100% + 12px) 0; }
|
|
24
|
+
.eventDot { display: grid; width: 24px; height: 24px; flex: none; place-items: center; }
|
|
25
|
+
.eventDot::before { width: 7px; height: 7px; border-radius: 50%; corner-shape: round; background: var(--series-2); box-shadow: 0 0 0 2px var(--surface); content: ""; transition: transform var(--duration-fast) var(--ease-standard); }
|
|
26
|
+
.event[data-active] .eventDot::before { transform: scale(1.3); }
|
|
27
|
+
.eventLabel { opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
|
|
28
|
+
.event[data-labelled] .eventLabel { opacity: 1; }
|
|
29
|
+
.event[data-active] .eventLabel { color: var(--foreground); }
|
|
30
|
+
|
|
31
|
+
.tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 132px; max-width: min(220px, 100%); gap: 2px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }
|
|
32
|
+
.chart[data-active] .tooltip { opacity: 1; scale: 1; }
|
|
33
|
+
.tipTitle { margin: 0 0 2px; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
34
|
+
.tipValue { margin: 0; color: var(--foreground); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
|
|
35
|
+
.tipNote { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
|
|
36
|
+
.tipEvent { display: flex; align-items: center; gap: 6px; margin: 4px 0 0; color: var(--foreground); font-size: var(--text-xs); line-height: var(--leading-body); }
|
|
37
|
+
.tipEventDot { width: 7px; height: 7px; flex: none; border-radius: 50%; corner-shape: round; background: var(--series-2); }
|
|
38
|
+
|
|
39
|
+
.message { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); pointer-events: none; }
|
|
40
|
+
|
|
41
|
+
.gutter { position: relative; grid-area: 1 / 2; min-width: 0; }
|
|
42
|
+
.tick { position: absolute; left: 10px; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; translate: 0 -50%; pointer-events: none; }
|
|
43
|
+
.axis { position: relative; grid-area: 2 / 1; min-width: 0; }
|
|
44
|
+
.axisLabel { position: absolute; top: 9px; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
45
|
+
|
|
46
|
+
/* Overview: the whole history, with everything outside the window stepped back. */
|
|
47
|
+
.strip { --strip-radius: var(--radius-detail); position: relative; grid-area: 3 / 1; min-width: 0; margin-top: 8px; border-radius: var(--strip-radius); background: color-mix(in oklab, var(--foreground) 2.5%, transparent); cursor: crosshair; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
|
|
48
|
+
/* The overview drawing is clipped to the strip's corners; the handles stay outside the clip so they can overhang the ends. */
|
|
49
|
+
.strip > .svg { clip-path: inset(0 round var(--strip-radius)); }
|
|
50
|
+
.overviewLine { fill: none; stroke: color-mix(in oklab, var(--foreground) 28%, var(--surface)); stroke-width: 1; stroke-linejoin: round; }
|
|
51
|
+
.overviewArea { fill: color-mix(in oklab, var(--foreground) 4%, transparent); }
|
|
52
|
+
.overviewLineIn { fill: none; stroke: var(--series-1); stroke-width: 1; stroke-linejoin: round; }
|
|
53
|
+
.overviewAreaIn { fill: color-mix(in oklab, var(--series-1) 12%, transparent); }
|
|
54
|
+
.overviewEvent { fill: var(--series-2); stroke: var(--surface); stroke-width: 1.5; }
|
|
55
|
+
.shade { position: absolute; top: 0; bottom: 0; background: color-mix(in oklab, var(--surface) 45%, transparent); pointer-events: none; }
|
|
56
|
+
.shade[data-edge="start"] { border-radius: var(--strip-radius) 0 0 var(--strip-radius); }
|
|
57
|
+
.shade[data-edge="end"] { border-radius: 0 var(--strip-radius) var(--strip-radius) 0; }
|
|
58
|
+
.window { position: absolute; top: 0; bottom: 0; border-radius: var(--strip-radius); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--series-1) 45%, transparent); cursor: grab; outline: none; transition: box-shadow var(--duration-fast) var(--ease-standard); }
|
|
59
|
+
.window:active { cursor: grabbing; }
|
|
60
|
+
.handle { position: absolute; top: 50%; display: grid; width: 28px; height: 44px; place-items: center; margin-left: -14px; translate: 0 -50%; cursor: ew-resize; outline: none; touch-action: none; }
|
|
61
|
+
.grip { width: 6px; height: 22px; border-radius: var(--radius-full); corner-shape: round; background: var(--surface-raised); box-shadow: 0 0 0 1px color-mix(in oklab, var(--foreground) 26%, transparent), var(--shadow-resting); transition: transform var(--duration-instant) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
|
|
62
|
+
.handle:active .grip { transform: scaleY(1.12); }
|
|
63
|
+
@media (hover: hover) and (pointer: fine) { .handle:hover .grip { transform: scaleY(1.12); } }
|
|
64
|
+
|
|
65
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
66
|
+
|
|
67
|
+
@media (prefers-reduced-motion: reduce) {
|
|
68
|
+
.crosshair, .dot, .tooltip, .eventRule, .eventLabel, .eventDot::before, .grip, .window { transition: none; }
|
|
69
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes } from "react";
|
|
2
|
+
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
|
3
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
4
|
+
export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onDrag" | "onDragEnd" | "onDragStart" | "onAnimationStart"> {
|
|
5
|
+
variant?: ButtonVariant;
|
|
6
|
+
size?: ButtonSize;
|
|
7
|
+
loading?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare const Button: import("react").ForwardRefExoticComponent<ButtonProps & import("react").RefAttributes<HTMLButtonElement>>;
|
|
10
|
+
export default Button;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { forwardRef, isValidElement, useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import styles from "./button.module.css";
|
|
7
|
+
/** Icon buttons press a little deeper, wide buttons a little less, so every size reads as the same push. */
|
|
8
|
+
const pressVariants = {
|
|
9
|
+
pressed: (button) => {
|
|
10
|
+
const width = button.current?.offsetWidth ?? 0;
|
|
11
|
+
return { scale: width > 220 ? .985 : width && width <= 48 ? .96 : .97, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } };
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
const rest = { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" };
|
|
15
|
+
/** Text rises about .3em out of a soft blur; the outgoing label lifts away a little faster. */
|
|
16
|
+
const textIn = { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` };
|
|
17
|
+
const textOut = { opacity: 0, y: -3, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };
|
|
18
|
+
const iconIn = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };
|
|
19
|
+
const iconOut = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };
|
|
20
|
+
const fadeIn = { ...rest, opacity: 0 };
|
|
21
|
+
const fadeOut = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
|
|
22
|
+
/** Scale rides the spring; opacity and blur tween so blur never overshoots below zero. */
|
|
23
|
+
const iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } };
|
|
24
|
+
/** A key for the label content: text plus element names, so a new label or icon crossfades while prop-only updates stay in place. */
|
|
25
|
+
function labelKey(node) {
|
|
26
|
+
if (node == null || typeof node === "boolean")
|
|
27
|
+
return "";
|
|
28
|
+
if (typeof node === "string" || typeof node === "number" || typeof node === "bigint")
|
|
29
|
+
return String(node);
|
|
30
|
+
if (Array.isArray(node))
|
|
31
|
+
return node.map(labelKey).join("");
|
|
32
|
+
if (!isValidElement(node))
|
|
33
|
+
return "";
|
|
34
|
+
const type = node.type;
|
|
35
|
+
return `<${typeof type === "string" ? type : type?.displayName ?? type?.name ?? ""}>${labelKey(node.props.children)}`;
|
|
36
|
+
}
|
|
37
|
+
/** Springs the slot to the natural width of the incoming label when it changes, so a new label never snaps the button's size.
|
|
38
|
+
* The outgoing label is popped out of flow at once, so it never holds the old width. Other resizes (a late web font, a parent reflow) jump straight to the new width, so nothing wobbles on first paint. */
|
|
39
|
+
function useMorphWidth(content, key, reduced) {
|
|
40
|
+
const width = useMotionValue("auto");
|
|
41
|
+
const lastKey = useRef(key), armedUntil = useRef(0);
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
if (lastKey.current === key)
|
|
44
|
+
return;
|
|
45
|
+
lastKey.current = key;
|
|
46
|
+
armedUntil.current = performance.now() + 700;
|
|
47
|
+
}, [key]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const node = content.current, slot = node?.parentElement;
|
|
50
|
+
if (!node || !slot || typeof ResizeObserver === "undefined")
|
|
51
|
+
return;
|
|
52
|
+
let measured = false;
|
|
53
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
54
|
+
if (!entry)
|
|
55
|
+
return;
|
|
56
|
+
const next = entry.contentRect.width;
|
|
57
|
+
if (!next || !measured || reduced || performance.now() > armedUntil.current) {
|
|
58
|
+
measured = next > 0;
|
|
59
|
+
width.jump(next || "auto");
|
|
60
|
+
delete slot.dataset.morphing;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
slot.dataset.morphing = "";
|
|
64
|
+
animate(width, next, { ...motionTokens.spring.morph, onComplete: () => { delete slot.dataset.morphing; } });
|
|
65
|
+
});
|
|
66
|
+
observer.observe(node);
|
|
67
|
+
return () => observer.disconnect();
|
|
68
|
+
}, [content, reduced, width]);
|
|
69
|
+
return width;
|
|
70
|
+
}
|
|
71
|
+
function LabelPhase({ children, icon, reduced, ref }) {
|
|
72
|
+
const present = useIsPresent();
|
|
73
|
+
return _jsx(motion.span, { ref: ref, className: styles.labelPhase, "aria-hidden": present ? undefined : true, initial: reduced ? fadeIn : icon ? iconIn : textIn, animate: rest, exit: reduced ? fadeOut : icon ? iconOut : textOut, transition: reduced ? { duration: motionTokens.duration.instant } : icon ? iconEnter : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: children });
|
|
74
|
+
}
|
|
75
|
+
export const Button = forwardRef(function Button({ className, variant = "primary", size = "md", loading = false, disabled, children, onClick, ...props }, ref) {
|
|
76
|
+
const reduceMotion = useReducedMotion() ?? false;
|
|
77
|
+
const buttonRef = useRef(null);
|
|
78
|
+
const contentRef = useRef(null);
|
|
79
|
+
const key = labelKey(children);
|
|
80
|
+
const width = useMorphWidth(contentRef, key, reduceMotion);
|
|
81
|
+
const setRefs = useCallback((node) => {
|
|
82
|
+
buttonRef.current = node;
|
|
83
|
+
if (typeof ref === "function")
|
|
84
|
+
ref(node);
|
|
85
|
+
else if (ref)
|
|
86
|
+
ref.current = node;
|
|
87
|
+
}, [ref]);
|
|
88
|
+
// A trigger that anchors a menu, popover, or dialog keeps its rect still while pressed, so the layer never measures a scaled anchor.
|
|
89
|
+
// Radix triggers (asChild) pass aria-haspopup or data-state through; the stylesheet repeats the guard.
|
|
90
|
+
const popup = props["aria-haspopup"];
|
|
91
|
+
const anchorsLayer = (popup !== undefined && popup !== false && popup !== "false") || props.role === "combobox" || props["data-state"] !== undefined;
|
|
92
|
+
const inert = disabled || loading || props["aria-disabled"] === true || props["aria-disabled"] === "true";
|
|
93
|
+
const classes = [styles.button, styles[variant], styles[size], className].filter(Boolean).join(" ");
|
|
94
|
+
return (_jsxs(motion.button, { ref: setRefs, tabIndex: props.tabIndex ?? 0, className: classes, "data-variant": variant, disabled: disabled, "aria-busy": loading || undefined, custom: buttonRef, variants: pressVariants, whileTap: reduceMotion || anchorsLayer || inert ? undefined : "pressed", transition: motionTokens.spring.snappy, ...props, "aria-disabled": loading || props["aria-disabled"] || undefined, onClick: loading ? event => event.preventDefault() : onClick, children: [_jsx(AnimatePresence, { initial: false, children: loading ? _jsx(motion.span, { className: styles.loader, "aria-hidden": "true", initial: reduceMotion ? { opacity: 0 } : { opacity: 0, scale: .6 }, animate: { opacity: 1, scale: 1 }, exit: reduceMotion ? fadeOut : { ...iconOut, scale: .8 }, transition: reduceMotion ? { duration: motionTokens.duration.instant } : iconEnter, children: _jsx("span", { className: styles.spinner }) }, "loader") : null }), _jsx(motion.span, { className: [styles.labelSlot, loading ? styles.loadingLabel : ""].filter(Boolean).join(" "), style: { width }, children: _jsx("span", { ref: contentRef, className: styles.labelContent, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(LabelPhase, { icon: !/\S/.test(key.replace(/<[^>]*>/g, "")), reduced: reduceMotion, children: children }, key) }) }) })] }));
|
|
95
|
+
});
|
|
96
|
+
Button.displayName = "Button";
|
|
97
|
+
export default Button;
|