@teamofsilicons/silicon-ui 0.0.0-stage → 0.2.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 +81 -2
- package/check.mjs +236 -0
- 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/eslint.js +53 -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 +76 -4
- package/styles.css +218 -0
- package/styles.css.d.ts +1 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { ArrowUp } from "lucide-react";
|
|
5
|
+
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import styles from "./sortable-data-table.module.css";
|
|
8
|
+
const collator = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
|
|
9
|
+
const isEmpty = (value) => value == null || value === "";
|
|
10
|
+
const comparable = (value) => value instanceof Date ? value.getTime() : value;
|
|
11
|
+
const blur = (px) => `blur(${px}px)`;
|
|
12
|
+
const enter = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
|
|
13
|
+
const leave = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
|
|
14
|
+
const instant = { duration: motionTokens.duration.instant };
|
|
15
|
+
/** Changing text rises in from the side it is moving toward and lifts away on the other. */
|
|
16
|
+
const rise = {
|
|
17
|
+
enter: (direction) => ({ opacity: 0, y: `${.3 * direction}em`, filter: blur(motionTokens.blur.soft) }),
|
|
18
|
+
shown: { opacity: 1, y: "0em", filter: blur(0), transition: enter },
|
|
19
|
+
gone: (direction) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: blur(motionTokens.blur.subtle), transition: leave }),
|
|
20
|
+
};
|
|
21
|
+
/** Reduced motion keeps the same properties as rise (no travel, no blur) so the server and a reduced-motion client render identical styles. */
|
|
22
|
+
const fade = { enter: { opacity: 0, y: "0em", filter: blur(0) }, shown: { opacity: 1, y: "0em", filter: blur(0), transition: instant }, gone: { opacity: 0, y: "0em", filter: blur(0), transition: instant } };
|
|
23
|
+
/** Check and dash share three points, so the header box morphs between all and some. */
|
|
24
|
+
const checkPath = "M4.25 9.25 L7.25 12.25 L13.75 5.75";
|
|
25
|
+
const dashPath = "M4.75 9 L9 9 L13.25 9";
|
|
26
|
+
/** Text that changes in place. The slot eases to the new width, so the words beside it never jump. */
|
|
27
|
+
function Swap({ value, direction, reduced, className }) {
|
|
28
|
+
const inner = useRef(null);
|
|
29
|
+
const width = useMotionValue("auto");
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
const node = inner.current;
|
|
32
|
+
if (!node)
|
|
33
|
+
return;
|
|
34
|
+
let settled = false;
|
|
35
|
+
const measure = () => {
|
|
36
|
+
const next = node.getBoundingClientRect().width;
|
|
37
|
+
if (settled && !reduced)
|
|
38
|
+
animate(width, next, motionTokens.spring.morph);
|
|
39
|
+
else
|
|
40
|
+
width.jump(next);
|
|
41
|
+
settled = true;
|
|
42
|
+
};
|
|
43
|
+
measure();
|
|
44
|
+
const observer = new ResizeObserver(measure);
|
|
45
|
+
observer.observe(node);
|
|
46
|
+
return () => observer.disconnect();
|
|
47
|
+
}, [reduced, width]);
|
|
48
|
+
return _jsx(motion.span, { className: [styles.swap, className].filter(Boolean).join(" "), style: { width }, children: _jsx("span", { ref: inner, className: styles.swapInner, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: direction, children: _jsx(motion.span, { className: styles.swapText, custom: direction, variants: reduced ? fade : rise, initial: "enter", animate: "shown", exit: "gone", children: value }, value) }) }) });
|
|
49
|
+
}
|
|
50
|
+
/** The arrow fades in where a column becomes sorted and flips on a spring when the direction changes. */
|
|
51
|
+
function SortGlyph({ active, descending, reduced }) {
|
|
52
|
+
const rotate = useMotionValue(descending ? 180 : 0);
|
|
53
|
+
const seen = useRef({ active, hiddenAt: -Infinity });
|
|
54
|
+
useLayoutEffect(() => {
|
|
55
|
+
const state = seen.current;
|
|
56
|
+
if (active) {
|
|
57
|
+
// Turn only while the arrow is visible; a freshly shown arrow starts pointing the right way.
|
|
58
|
+
const visible = state.active || performance.now() - state.hiddenAt < 160;
|
|
59
|
+
if (visible && !reduced)
|
|
60
|
+
animate(rotate, descending ? 180 : 0, motionTokens.spring.snappy);
|
|
61
|
+
else
|
|
62
|
+
rotate.jump(descending ? 180 : 0);
|
|
63
|
+
}
|
|
64
|
+
else if (state.active)
|
|
65
|
+
state.hiddenAt = performance.now();
|
|
66
|
+
state.active = active;
|
|
67
|
+
}, [active, descending, reduced, rotate]);
|
|
68
|
+
const transition = reduced ? instant : active ? { opacity: enter, filter: enter, scale: motionTokens.spring.snappy } : { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] };
|
|
69
|
+
return _jsxs("span", { className: styles.sortIcon, "aria-hidden": "true", children: [_jsx(ArrowUp, { className: styles.sortHint, size: 16, strokeWidth: 1.75 }), _jsx(motion.span, { className: styles.sortArrow, style: { rotate }, initial: false, animate: active ? { opacity: 1, scale: 1, filter: blur(0) } : { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle) }, transition: transition, children: _jsx(ArrowUp, { size: 16, strokeWidth: 1.75 }) })] });
|
|
70
|
+
}
|
|
71
|
+
function SelectBox({ checked, mixed = false, label, nav, reduced, onToggle, inputRef }) {
|
|
72
|
+
const local = useRef(null);
|
|
73
|
+
const ref = inputRef ?? local;
|
|
74
|
+
const on = checked || mixed;
|
|
75
|
+
useLayoutEffect(() => { if (ref.current)
|
|
76
|
+
ref.current.indeterminate = mixed; }, [mixed, ref]);
|
|
77
|
+
const opacity = { duration: on ? motionTokens.duration.instant : motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
|
|
78
|
+
return _jsxs("label", { className: styles.selectHit, children: [_jsx("input", { ref: ref, type: "checkbox", className: styles.selectInput, checked: checked, "aria-label": label, "data-nav": nav, onChange: event => onToggle(event.nativeEvent.shiftKey === true) }), _jsxs("span", { className: styles.selectBox, "data-on": on || undefined, "aria-hidden": "true", children: [_jsx(motion.span, { className: styles.selectFill, initial: false, animate: { opacity: on ? 1 : 0, scale: on ? 1 : .6 }, transition: reduced ? { duration: 0 } : { scale: motionTokens.spring.snappy, opacity } }), _jsx("svg", { className: styles.selectMark, viewBox: "0 0 18 18", fill: "none", focusable: "false", children: _jsx(motion.path, { initial: false, animate: { d: mixed ? dashPath : checkPath, pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }, transition: reduced ? { duration: 0 } : { d: motionTokens.spring.morph, pathLength: motionTokens.spring.snappy, opacity }, stroke: "currentColor", strokeWidth: 1.75, strokeLinecap: "round", strokeLinejoin: "round" }) })] })] });
|
|
79
|
+
}
|
|
80
|
+
export function SortableDataTable({ rows, columns, rowKey, caption = "Data table", emptyMessage = "No rows to show", defaultSort, onSortChange, selectable = false, selectedKeys, defaultSelectedKeys, onSelectionChange, itemName = { one: "row", other: "rows" } }) {
|
|
81
|
+
const reduced = useReducedMotion() ?? false;
|
|
82
|
+
const tableRef = useRef(null);
|
|
83
|
+
const selectAllRef = useRef(null);
|
|
84
|
+
const anchor = useRef(null);
|
|
85
|
+
const [sort, setSort] = useState(defaultSort ?? null);
|
|
86
|
+
const [announcement, setAnnouncement] = useState("");
|
|
87
|
+
const [internalSelection, setInternalSelection] = useState(defaultSelectedKeys ?? []);
|
|
88
|
+
const selection = useMemo(() => new Set(selectedKeys ?? internalSelection), [internalSelection, selectedKeys]);
|
|
89
|
+
const sortedRows = useMemo(() => {
|
|
90
|
+
if (!sort)
|
|
91
|
+
return rows;
|
|
92
|
+
return rows.map((row, index) => ({ row, index })).sort((a, b) => {
|
|
93
|
+
const left = comparable(a.row[sort.key]);
|
|
94
|
+
const right = comparable(b.row[sort.key]);
|
|
95
|
+
// Empty values stay at the bottom in both directions.
|
|
96
|
+
if (isEmpty(left) || isEmpty(right))
|
|
97
|
+
return isEmpty(left) === isEmpty(right) ? a.index - b.index : isEmpty(left) ? 1 : -1;
|
|
98
|
+
const result = typeof left === "number" && typeof right === "number" ? left - right : collator.compare(String(left), String(right));
|
|
99
|
+
return (sort.direction === "asc" ? result : -result) || a.index - b.index;
|
|
100
|
+
}).map(entry => entry.row);
|
|
101
|
+
}, [rows, sort]);
|
|
102
|
+
const numeric = useMemo(() => new Set(columns.filter(column => column.numeric ?? (rows.some(row => typeof row[column.key] === "number") && rows.every(row => typeof row[column.key] === "number" || isEmpty(row[column.key])))).map(column => column.key)), [columns, rows]);
|
|
103
|
+
const getRowKey = (row) => String(typeof rowKey === "function" ? rowKey(row) : row[rowKey]);
|
|
104
|
+
const keys = sortedRows.map(getRowKey);
|
|
105
|
+
const order = keys.join("\u0000");
|
|
106
|
+
const selectedCount = keys.filter(key => selection.has(key)).length;
|
|
107
|
+
const allSelected = keys.length > 0 && selectedCount === keys.length;
|
|
108
|
+
// Measure the natural column widths once, then hold them with a fixed layout so nothing reflows while rows move.
|
|
109
|
+
const signature = [selectable ? "select" : "", ...columns.map(column => column.key)].join("\u0000");
|
|
110
|
+
const [locked, setLocked] = useState(null);
|
|
111
|
+
const widths = locked?.signature === signature ? locked.widths : null;
|
|
112
|
+
useLayoutEffect(() => {
|
|
113
|
+
const table = tableRef.current;
|
|
114
|
+
if (!table || widths)
|
|
115
|
+
return;
|
|
116
|
+
const measure = () => {
|
|
117
|
+
const total = table.getBoundingClientRect().width;
|
|
118
|
+
if (!total || getComputedStyle(table).display !== "table")
|
|
119
|
+
return false;
|
|
120
|
+
const next = {};
|
|
121
|
+
table.querySelectorAll("thead th[data-key]").forEach(cell => { next[cell.dataset.key ?? ""] = cell.getBoundingClientRect().width / total * 100; });
|
|
122
|
+
setLocked({ signature, widths: next });
|
|
123
|
+
return true;
|
|
124
|
+
};
|
|
125
|
+
if (measure())
|
|
126
|
+
return;
|
|
127
|
+
const observer = new ResizeObserver(() => { if (measure())
|
|
128
|
+
observer.disconnect(); });
|
|
129
|
+
observer.observe(table);
|
|
130
|
+
return () => observer.disconnect();
|
|
131
|
+
}, [signature, widths]);
|
|
132
|
+
// A still tint under the sorted column fills the gaps that open while rows pass each other.
|
|
133
|
+
const [band, setBand] = useState(null);
|
|
134
|
+
const seenSort = useRef(false);
|
|
135
|
+
useLayoutEffect(() => {
|
|
136
|
+
const table = tableRef.current;
|
|
137
|
+
const key = sort?.key;
|
|
138
|
+
const update = () => {
|
|
139
|
+
const cell = key ? table?.querySelector(`thead th[data-key="${CSS.escape(key)}"]`) : null;
|
|
140
|
+
setBand(current => !cell ? null : current?.left === cell.offsetLeft && current.width === cell.offsetWidth ? current : { left: cell.offsetLeft, width: cell.offsetWidth });
|
|
141
|
+
};
|
|
142
|
+
update();
|
|
143
|
+
if (!table || !key)
|
|
144
|
+
return;
|
|
145
|
+
// On a phone the header is a scrolling strip of sort controls; keep the active one in view.
|
|
146
|
+
const cell = table.querySelector(`thead th[data-key="${CSS.escape(key)}"]`);
|
|
147
|
+
const strip = cell?.parentElement;
|
|
148
|
+
if (cell && strip && strip.scrollWidth > strip.clientWidth && getComputedStyle(table).display !== "table") {
|
|
149
|
+
const bounds = strip.getBoundingClientRect(), target = cell.getBoundingClientRect(), inset = 8;
|
|
150
|
+
const start = Math.max(bounds.left, strip.querySelector("th:not([data-key])")?.getBoundingClientRect().right ?? bounds.left) + inset;
|
|
151
|
+
const shift = target.right > bounds.right - inset ? target.right - bounds.right + inset : target.left < start ? target.left - start : 0;
|
|
152
|
+
if (shift)
|
|
153
|
+
strip.scrollTo({ left: strip.scrollLeft + shift, behavior: reduced || !seenSort.current ? "auto" : "smooth" });
|
|
154
|
+
}
|
|
155
|
+
seenSort.current = true;
|
|
156
|
+
const observer = new ResizeObserver(update);
|
|
157
|
+
observer.observe(table);
|
|
158
|
+
return () => observer.disconnect();
|
|
159
|
+
}, [reduced, sort?.key, widths]);
|
|
160
|
+
const shownCount = selectedCount || keys.length;
|
|
161
|
+
const noun = selectedCount ? "selected" : keys.length === 1 ? itemName.one : itemName.other;
|
|
162
|
+
const [lastCount, setLastCount] = useState(shownCount);
|
|
163
|
+
const [countDirection, setCountDirection] = useState(1);
|
|
164
|
+
if (shownCount !== lastCount) {
|
|
165
|
+
setLastCount(shownCount);
|
|
166
|
+
setCountDirection(shownCount > lastCount ? 1 : -1);
|
|
167
|
+
}
|
|
168
|
+
function sortBy(column) {
|
|
169
|
+
const next = { key: column.key, direction: sort?.key === column.key && sort.direction === "asc" ? "desc" : "asc" };
|
|
170
|
+
setSort(next);
|
|
171
|
+
onSortChange?.(next);
|
|
172
|
+
setAnnouncement(`Sorted by ${column.label}, ${next.direction === "asc" ? "ascending" : "descending"}`);
|
|
173
|
+
}
|
|
174
|
+
function commit(next) {
|
|
175
|
+
const list = keys.filter(key => next.has(key));
|
|
176
|
+
if (selectedKeys === undefined)
|
|
177
|
+
setInternalSelection(list);
|
|
178
|
+
onSelectionChange?.(list);
|
|
179
|
+
setAnnouncement(list.length ? `${list.length} of ${keys.length} selected` : "Selection cleared");
|
|
180
|
+
}
|
|
181
|
+
function toggleRow(key, extend) {
|
|
182
|
+
const next = new Set(selection);
|
|
183
|
+
const checked = !selection.has(key);
|
|
184
|
+
const from = extend && anchor.current ? keys.indexOf(anchor.current) : -1;
|
|
185
|
+
const to = keys.indexOf(key);
|
|
186
|
+
(from < 0 ? [key] : keys.slice(Math.min(from, to), Math.max(from, to) + 1)).forEach(item => checked ? next.add(item) : next.delete(item));
|
|
187
|
+
anchor.current = key;
|
|
188
|
+
commit(next);
|
|
189
|
+
}
|
|
190
|
+
function clearSelection() {
|
|
191
|
+
commit(new Set());
|
|
192
|
+
selectAllRef.current?.focus();
|
|
193
|
+
}
|
|
194
|
+
function onRowClick(event, key) {
|
|
195
|
+
if (!selectable || event.target.closest("a, button, input, label, select, textarea, [role='button'], [contenteditable='true']") || window.getSelection()?.toString())
|
|
196
|
+
return;
|
|
197
|
+
toggleRow(key, event.shiftKey);
|
|
198
|
+
// Keep the keyboard path where the pointer left off, so arrows continue from this row.
|
|
199
|
+
event.currentTarget.querySelector("input[type='checkbox']")?.focus({ preventScroll: true });
|
|
200
|
+
}
|
|
201
|
+
/** Arrows walk the header controls and the row checkboxes; Escape clears the selection. */
|
|
202
|
+
function onKeyDown(event) {
|
|
203
|
+
const target = event.target;
|
|
204
|
+
if (event.key === "Escape" && selectedCount) {
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
event.stopPropagation();
|
|
207
|
+
if (target.dataset.clear !== undefined)
|
|
208
|
+
clearSelection();
|
|
209
|
+
else
|
|
210
|
+
commit(new Set());
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const nav = target.dataset.nav;
|
|
214
|
+
if (!nav)
|
|
215
|
+
return;
|
|
216
|
+
const list = (name) => [...event.currentTarget.querySelectorAll(`[data-nav="${name}"]`)];
|
|
217
|
+
const items = list(nav);
|
|
218
|
+
const index = items.indexOf(target);
|
|
219
|
+
const steps = nav === "head"
|
|
220
|
+
? { ArrowRight: [items, index + 1], ArrowLeft: [items, index - 1], Home: [items, 0], End: [items, items.length - 1], ...(target === selectAllRef.current ? { ArrowDown: [list("row"), 0] } : {}) }
|
|
221
|
+
: { ArrowDown: [items, index + 1], ArrowUp: index === 0 && selectAllRef.current ? [[selectAllRef.current], 0] : [items, index - 1], Home: [items, 0], End: [items, items.length - 1] };
|
|
222
|
+
const step = steps[event.key];
|
|
223
|
+
const next = step?.[0][step[1]];
|
|
224
|
+
if (!next)
|
|
225
|
+
return;
|
|
226
|
+
event.preventDefault();
|
|
227
|
+
next.focus();
|
|
228
|
+
}
|
|
229
|
+
return _jsxs("div", { className: styles.wrapper, onKeyDown: onKeyDown, children: [_jsxs("div", { className: styles.scroller, children: [band ? _jsx("span", { className: styles.band, style: { left: band.left, width: band.width }, "aria-hidden": "true" }) : null, _jsxs("table", { ref: tableRef, role: "table", className: styles.table, "data-fixed": widths ? "" : undefined, "data-selectable": selectable || undefined, children: [_jsx("caption", { children: caption }), _jsxs("colgroup", { children: [selectable ? _jsx("col", { className: styles.selectCol }) : null, columns.map((column, index) => _jsx("col", { style: { width: column.width !== undefined ? (typeof column.width === "number" ? `${column.width}px` : column.width) : widths && index > 0 ? `${widths[column.key]}%` : undefined } }, column.key))] }), _jsx("thead", { role: "rowgroup", children: _jsxs("tr", { role: "row", children: [selectable ? _jsx("th", { scope: "col", role: "columnheader", className: styles.selectCell, children: _jsx(SelectBox, { inputRef: selectAllRef, checked: allSelected, mixed: selectedCount > 0 && !allSelected, label: "Select all rows", nav: "head", reduced: reduced, onToggle: () => commit(allSelected ? new Set() : new Set(keys)) }) }) : null, columns.map((column, index) => {
|
|
230
|
+
const active = sort?.key === column.key;
|
|
231
|
+
const sortable = column.sortable !== false;
|
|
232
|
+
return _jsx("th", { scope: "col", role: "columnheader", "data-key": column.key, "data-primary": index === 0 || undefined, "data-sorted": active || undefined, "data-numeric": numeric.has(column.key) || undefined, "data-sortable": sortable || undefined, "aria-sort": !sortable ? undefined : active ? (sort.direction === "asc" ? "ascending" : "descending") : "none", children: sortable ? _jsx("button", { className: styles.sortButton, type: "button", "data-nav": "head", onClick: () => sortBy(column), "aria-label": `Sort by ${column.label}${active ? `, currently ${sort.direction === "asc" ? "ascending" : "descending"}` : ""}`, children: _jsxs("span", { className: styles.sortInner, children: [_jsx("span", { children: column.label }), _jsx(SortGlyph, { active: active, descending: active && sort.direction === "desc", reduced: reduced })] }) }) : column.label }, column.key);
|
|
233
|
+
})] }) }), _jsx("tbody", { role: "rowgroup", children: sortedRows.length ? sortedRows.map((row, index) => {
|
|
234
|
+
const key = keys[index];
|
|
235
|
+
const selected = selectable && selection.has(key);
|
|
236
|
+
return _jsxs(motion.tr, { role: "row", layout: reduced ? false : "position", layoutDependency: order, transition: motionTokens.spring.smooth, "data-selected": selected || undefined, onClick: event => onRowClick(event, key), onMouseDown: event => { if (selectable && event.shiftKey)
|
|
237
|
+
event.preventDefault(); }, children: [selectable ? _jsx("td", { role: "cell", className: styles.selectCell, children: _jsx(SelectBox, { checked: selected, label: `Select ${String(row[columns[0]?.key] ?? key)}`, nav: "row", reduced: reduced, onToggle: extend => toggleRow(key, extend) }) }) : null, columns.map((column, columnIndex) => _jsx("td", { role: "cell", "data-label": column.label, "data-primary": columnIndex === 0 || undefined, "data-sorted": sort?.key === column.key || undefined, "data-numeric": numeric.has(column.key) || undefined, children: column.render ? column.render(row[column.key], row) : String(row[column.key] ?? "–") }, column.key))] }, key);
|
|
238
|
+
}) : _jsx("tr", { role: "row", children: _jsx("td", { role: "cell", className: styles.empty, colSpan: columns.length + (selectable ? 1 : 0), children: emptyMessage }) }) })] })] }), selectable ? _jsxs("div", { className: styles.footer, children: [_jsxs("span", { className: styles.srOnly, children: [shownCount, " ", noun] }), _jsxs("span", { className: styles.count, "data-active": selectedCount > 0 || undefined, "aria-hidden": "true", children: [_jsx(Swap, { className: styles.number, value: String(shownCount), direction: countDirection, reduced: reduced }), _jsx(Swap, { value: noun, direction: selectedCount ? 1 : -1, reduced: reduced })] }), _jsx(AnimatePresence, { initial: false, children: selectedCount ? _jsx(motion.button, { type: "button", className: styles.clear, "data-clear": "", onClick: clearSelection, initial: reduced ? { opacity: 0 } : { opacity: 0, scale: .96, filter: blur(motionTokens.blur.soft) }, animate: { opacity: 1, scale: 1, filter: blur(0) }, exit: reduced ? { opacity: 0, transition: instant } : { opacity: 0, scale: .98, filter: blur(motionTokens.blur.subtle), transition: leave }, transition: reduced ? instant : enter, whileTap: reduced ? undefined : { scale: .97, transition: { duration: motionTokens.duration.instant } }, children: "Clear selection" }, "clear") : null })] }) : null, _jsx("p", { className: styles.srOnly, role: "status", children: announcement })] });
|
|
239
|
+
}
|
|
240
|
+
export default SortableDataTable;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* Cells are opaque so rows passing each other during a sort cover one another cleanly instead of blending text. */
|
|
2
|
+
.wrapper { --column-tint: var(--surface-muted); position: relative; width: 100%; min-width: 0; max-width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); font-family: var(--font-body); }
|
|
3
|
+
.scroller { position: relative; overflow-x: auto; overscroll-behavior-x: contain; }
|
|
4
|
+
.band { position: absolute; top: 0; bottom: 0; background: var(--column-tint); pointer-events: none; }
|
|
5
|
+
.table { position: relative; z-index: 1; width: 100%; min-width: 560px; border-collapse: separate; border-spacing: 0; font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); text-align: left; }
|
|
6
|
+
.table[data-fixed] { table-layout: fixed; }
|
|
7
|
+
.table caption, .srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
8
|
+
.selectCol { width: 44px; }
|
|
9
|
+
|
|
10
|
+
.table th, .table td { --cell-base: var(--surface); --cell-fill: var(--cell-base); background-color: var(--cell-fill); transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
|
|
11
|
+
.table th[data-sorted], .table td[data-sorted] { --cell-base: var(--column-tint); }
|
|
12
|
+
.table th { height: 44px; padding: 0 var(--space-inset); border-bottom: 1px solid var(--border); color: var(--text-muted); font-size: var(--text-sm); font-weight: 500; text-align: left; white-space: nowrap; }
|
|
13
|
+
.table th[data-sortable] { padding: 0; }
|
|
14
|
+
.table th[data-sorted] { color: var(--foreground); }
|
|
15
|
+
.table td { height: 52px; padding: 0 var(--space-inset); border-bottom: 1px solid var(--border-subtle); color: var(--foreground); font-weight: 400; vertical-align: middle; overflow-wrap: anywhere; }
|
|
16
|
+
.table tbody tr:last-child td { border-bottom: 0; }
|
|
17
|
+
.table [data-numeric] { text-align: right; font-variant-numeric: tabular-nums; }
|
|
18
|
+
.table[data-selectable] tbody tr { cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
19
|
+
.table[data-selectable] tbody tr[data-selected] td { --cell-fill: color-mix(in oklab, var(--accent) 8%, var(--cell-base)); }
|
|
20
|
+
.table[data-selectable] [data-primary]:not([data-sortable]), .table[data-selectable] th[data-primary] .sortButton { padding-left: var(--space-related); }
|
|
21
|
+
|
|
22
|
+
.sortButton { display: flex; width: 100%; height: 44px; align-items: center; justify-content: flex-start; border: 0; padding: 0 var(--space-inset); background: transparent; color: inherit; font: inherit; letter-spacing: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
23
|
+
.table th[data-numeric] .sortButton { justify-content: flex-end; }
|
|
24
|
+
.sortInner { display: inline-flex; align-items: center; gap: 6px; border-radius: var(--radius-control); transition: color var(--duration-fast) var(--ease-standard); }
|
|
25
|
+
/* Right-aligned columns lead with the arrow so the label lines up with the digits below it. */
|
|
26
|
+
.table th[data-numeric] .sortInner { flex-direction: row-reverse; }
|
|
27
|
+
.sortIcon { position: relative; display: grid; width: 16px; height: 16px; flex: none; place-items: center; }
|
|
28
|
+
.sortHint, .sortArrow { position: absolute; inset: 0; display: grid; place-items: center; }
|
|
29
|
+
.sortHint { color: var(--text-muted); opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard); }
|
|
30
|
+
.sortArrow { color: var(--foreground); }
|
|
31
|
+
.sortButton:focus-visible .sortHint { opacity: .6; }
|
|
32
|
+
.table th[data-sorted] .sortHint { opacity: 0; }
|
|
33
|
+
|
|
34
|
+
.selectCell { width: 44px; }
|
|
35
|
+
.table .selectCell { padding: 0 0 0 var(--space-inset); }
|
|
36
|
+
.selectHit { position: relative; display: flex; width: 100%; height: 44px; align-items: center; cursor: pointer; }
|
|
37
|
+
.selectInput { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
|
|
38
|
+
.selectBox { position: relative; display: block; width: 18px; height: 18px; flex: none; border: 1px solid var(--border-strong); border-radius: var(--radius-detail); background: var(--surface); color: var(--control-glyph); pointer-events: none; transition: border-color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
|
|
39
|
+
.selectBox[data-on] { border-color: var(--control-on); }
|
|
40
|
+
.selectFill { position: absolute; inset: -1px; border-radius: inherit; corner-shape: inherit; background: var(--control-on); }
|
|
41
|
+
.selectMark { position: absolute; inset: -1px; width: 18px; height: 18px; overflow: visible; }
|
|
42
|
+
/* The native checkbox is hidden, so its keyboard focus outline is drawn on the visible box. */
|
|
43
|
+
.selectInput:focus-visible + .selectBox { outline: var(--focus-outline-width, 2px) solid var(--focus-outline, color-mix(in oklab, var(--accent-strong) 72%, transparent)); outline-offset: 2px; }
|
|
44
|
+
.selectInput:active + .selectBox { transform: scale(.95); transition: border-color var(--duration-fast) var(--ease-standard), transform 110ms var(--ease-standard); }
|
|
45
|
+
|
|
46
|
+
.footer { display: flex; height: 44px; align-items: center; justify-content: space-between; gap: var(--space-group); padding: 0 var(--space-related) 0 var(--space-inset); border-top: 1px solid var(--border); color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); }
|
|
47
|
+
.count { display: inline-flex; min-width: 0; align-items: baseline; gap: .28em; white-space: nowrap; transition: color var(--duration-standard) var(--ease-standard); }
|
|
48
|
+
.count[data-active] { color: var(--foreground); }
|
|
49
|
+
.swap { display: inline-block; }
|
|
50
|
+
.swapInner { position: relative; display: inline-flex; width: max-content; }
|
|
51
|
+
.swapText { display: inline-block; white-space: nowrap; }
|
|
52
|
+
.number { font-variant-numeric: tabular-nums; }
|
|
53
|
+
.clear { height: 30px; flex: none; border: 0; border-radius: var(--radius-control); padding: 0 var(--space-group); background: transparent; color: var(--foreground); font: inherit; font-weight: 500; letter-spacing: inherit; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard); -webkit-tap-highlight-color: transparent; }
|
|
54
|
+
|
|
55
|
+
.empty { height: 120px !important; color: var(--text-muted) !important; text-align: center !important; }
|
|
56
|
+
|
|
57
|
+
@media (hover: hover) and (pointer: fine) {
|
|
58
|
+
.table tbody tr:hover td { --cell-fill: color-mix(in oklab, var(--foreground) 3%, var(--cell-base)); }
|
|
59
|
+
.table[data-selectable] tbody tr[data-selected]:hover td { --cell-fill: color-mix(in oklab, var(--accent) 11%, var(--cell-base)); }
|
|
60
|
+
.table tbody tr:has(.empty):hover td { --cell-fill: var(--cell-base); }
|
|
61
|
+
.sortButton:hover .sortInner { color: var(--foreground); }
|
|
62
|
+
.sortButton:hover .sortHint { opacity: .6; }
|
|
63
|
+
.table th[data-sorted] .sortButton:hover .sortHint { opacity: 0; }
|
|
64
|
+
.selectHit:hover .selectBox:not([data-on]) { border-color: var(--text-muted); }
|
|
65
|
+
.clear:hover { background: var(--surface-muted); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@media (max-width: 620px) {
|
|
69
|
+
.scroller { overflow: visible; }
|
|
70
|
+
.band { display: none; }
|
|
71
|
+
.table { display: block; width: 100%; min-width: 0; max-width: 100%; }
|
|
72
|
+
.table thead { display: block; }
|
|
73
|
+
/* The header becomes a strip of sort controls, so sorting stays one tap away on a phone. */
|
|
74
|
+
.table thead tr { display: flex; align-items: center; gap: var(--space-tight); padding: var(--space-tight) var(--space-related); overflow-x: auto; border-bottom: 1px solid var(--border); scrollbar-width: none; }
|
|
75
|
+
.table thead tr::-webkit-scrollbar { display: none; }
|
|
76
|
+
.table th { display: block; flex: none; height: auto; border: 0; padding: 0; background: transparent; }
|
|
77
|
+
/* Select all stays pinned while the sort controls scroll beneath it. */
|
|
78
|
+
.table th.selectCell { position: sticky; left: calc(-1 * var(--space-related)); z-index: 1; width: 40px; margin-left: calc(-1 * var(--space-related)); padding: 0 0 0 var(--space-group); background: var(--surface); }
|
|
79
|
+
.table th.selectCell .selectHit { height: 32px; }
|
|
80
|
+
/* Once the strip scrolls, a fade off the pinned cell hides the word sliding under it. */
|
|
81
|
+
.table th.selectCell::after { position: absolute; top: 0; bottom: 0; left: 100%; width: 36px; background: linear-gradient(to right, var(--surface) 45%, transparent); content: ""; opacity: 0; pointer-events: none; animation: stripFade linear both; animation-timeline: scroll(nearest inline); animation-range: 0 24px; }
|
|
82
|
+
.table th .sortButton, .table[data-selectable] th[data-primary] .sortButton { width: auto; height: 32px; border-radius: var(--radius-control); padding: 0 10px; transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
|
|
83
|
+
.table th[data-sorted] .sortButton { background: var(--surface-muted); }
|
|
84
|
+
.table th[data-numeric] .sortInner { flex-direction: row; }
|
|
85
|
+
.sortButton:focus-visible { --focus-outline-offset: 1px; }
|
|
86
|
+
.table tbody { display: block; width: 100%; }
|
|
87
|
+
/* Rows fold into two lines: the first column with its number, then the remaining details. */
|
|
88
|
+
.table tbody tr { --row-fill: var(--surface); position: relative; display: flex; width: 100%; min-height: 64px; box-sizing: border-box; flex-wrap: wrap; align-items: baseline; align-content: center; gap: 2px var(--space-related); padding: 10px var(--space-inset); border-bottom: 1px solid var(--border-subtle); background-color: var(--row-fill); transition: background-color var(--duration-standard) var(--ease-standard); }
|
|
89
|
+
.table tbody tr::after { order: 1; flex-basis: 100%; content: ""; }
|
|
90
|
+
.table[data-selectable] tbody tr { padding-left: 44px; }
|
|
91
|
+
.table[data-selectable] tbody tr[data-selected] { --row-fill: color-mix(in oklab, var(--accent) 8%, var(--surface)); }
|
|
92
|
+
.table tbody tr:last-child { border-bottom: 0; }
|
|
93
|
+
.table td { order: 2; display: block; min-width: 0; height: auto; border: 0; padding: 0; background: transparent; color: var(--text-secondary); font-size: var(--text-xs); text-align: left; }
|
|
94
|
+
.table[data-selectable] td[data-primary]:not([data-sortable]) { padding-left: 0; }
|
|
95
|
+
.table td[data-primary] { order: 0; flex: 1 1 0; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; }
|
|
96
|
+
.table td[data-numeric]:not([data-primary]) { order: 0; flex: none; margin-left: auto; padding-left: var(--space-related); color: var(--foreground); font-size: var(--text-sm); }
|
|
97
|
+
.table td[data-numeric] ~ td[data-numeric] { order: 2; margin-left: 0; padding-left: 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
|
98
|
+
/* The sorted value lifts to full contrast, echoing the tinted column on wider screens. */
|
|
99
|
+
.table td[data-sorted]:not([data-primary]) { color: var(--foreground); }
|
|
100
|
+
.table td.selectCell { position: absolute; top: 0; bottom: 0; left: var(--space-tight); display: flex; width: 36px; align-items: center; padding: 0; }
|
|
101
|
+
.table td.selectCell .selectHit { justify-content: center; }
|
|
102
|
+
.table .empty { flex: 1 0 100%; height: auto !important; padding: var(--space-section) 0; }
|
|
103
|
+
.table[data-selectable] tbody tr:has(.empty) { padding-left: var(--space-inset); }
|
|
104
|
+
}
|
|
105
|
+
@media (max-width: 620px) and (hover: hover) and (pointer: fine) {
|
|
106
|
+
.table tbody tr:hover { --row-fill: color-mix(in oklab, var(--foreground) 3%, var(--surface)); }
|
|
107
|
+
.table[data-selectable] tbody tr[data-selected]:hover { --row-fill: color-mix(in oklab, var(--accent) 11%, var(--surface)); }
|
|
108
|
+
.table tbody tr:hover td { background: transparent; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@media (prefers-reduced-motion: reduce) {
|
|
112
|
+
.table th, .table td, .table tbody tr, .sortInner, .sortHint, .selectBox, .count, .clear, .table th .sortButton { transition: none !important; }
|
|
113
|
+
.selectInput:active + .selectBox { transform: none; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@keyframes stripFade { to { opacity: 1; } }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface SparklineProps {
|
|
2
|
+
data: number[];
|
|
3
|
+
label: string;
|
|
4
|
+
value?: string;
|
|
5
|
+
change?: string;
|
|
6
|
+
tone?: "accent" | "success" | "warning" | "danger";
|
|
7
|
+
width?: number;
|
|
8
|
+
height?: number;
|
|
9
|
+
/** One label per point, such as a date. It replaces the change while that point is scrubbed. */
|
|
10
|
+
labels?: string[];
|
|
11
|
+
/** Formats a scrubbed point for the headline. Defaults to a grouped number. */
|
|
12
|
+
formatValue?: (value: number, index: number) => string;
|
|
13
|
+
/** A quiet neutral fill under the line. */
|
|
14
|
+
area?: boolean;
|
|
15
|
+
/** Pointer and keyboard scrubbing through the points. */
|
|
16
|
+
interactive?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function Sparkline({ data, label, value, change, tone, width, height, labels, formatValue, area, interactive }: SparklineProps): import("react").JSX.Element;
|
|
19
|
+
export default Sparkline;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import styles from "./sparkline.module.css";
|
|
7
|
+
/** Copy that holds a number enters from the side it moved toward: a larger value rises from below, a smaller one drops from above. */
|
|
8
|
+
const rise = { hidden: (direction) => ({ opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` }), shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: (direction) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }) };
|
|
9
|
+
const fade = { hidden: { opacity: 0, y: 0, filter: "blur(0px)" }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } } };
|
|
10
|
+
const amountIn = (text) => Number(text.replace(/,/g, "").replace(/−/g, "-").match(/-?\d+(?:\.\d+)?/)?.[0] ?? NaN);
|
|
11
|
+
const grouped = new Intl.NumberFormat("en-US");
|
|
12
|
+
/** The first draw: one smooth stroke, slow at both ends like a pen. */
|
|
13
|
+
const draw = { duration: motionTokens.duration.considered * 1.75, ease: [...motionTokens.ease.inOut] };
|
|
14
|
+
const quick = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
|
|
15
|
+
/** A number keeps its unchanged digits still, keyed by place from the right. Words key on everything up to them, so only the first changed word and what follows it roll. */
|
|
16
|
+
function chunksOf(text, whole) {
|
|
17
|
+
if (!text)
|
|
18
|
+
return [];
|
|
19
|
+
if (whole)
|
|
20
|
+
return [{ slot: "all", key: text, text }];
|
|
21
|
+
if (!/\p{L}/u.test(text))
|
|
22
|
+
return [...text].map((char, index, all) => ({ slot: `n${all.length - index}`, key: char, text: char }));
|
|
23
|
+
const words = text.split(/(?<=\s)/);
|
|
24
|
+
return words.map((word, index) => ({ slot: `w${index}`, key: words.slice(0, index + 1).join(""), text: word }));
|
|
25
|
+
}
|
|
26
|
+
/** Text that changes rolls in place, like a numeric content transition; `morph` springs the wrapper to the new width so nothing beside it jumps. */
|
|
27
|
+
function Roll({ text, direction, whole = false, morph = false, tone }) {
|
|
28
|
+
const reduceMotion = !!useReducedMotion();
|
|
29
|
+
const sizer = useRef(null);
|
|
30
|
+
const width = useMotionValue("auto");
|
|
31
|
+
const [shown, setShown] = useState({ text, direction: 1 });
|
|
32
|
+
if (shown.text !== text)
|
|
33
|
+
setShown({ text, direction: direction ?? (amountIn(text) < amountIn(shown.text) ? -1 : 1) });
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const node = sizer.current;
|
|
36
|
+
if (!node || typeof ResizeObserver === "undefined")
|
|
37
|
+
return;
|
|
38
|
+
let measured = null;
|
|
39
|
+
// Layout size, not the transformed rect, so a scaling parent never leaves the text clipped. Only a new text springs; font loads jump.
|
|
40
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
41
|
+
if (!entry)
|
|
42
|
+
return;
|
|
43
|
+
const next = entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;
|
|
44
|
+
if (measured !== null && measured !== node.textContent && !reduceMotion)
|
|
45
|
+
animate(width, next, motionTokens.spring.morph);
|
|
46
|
+
else
|
|
47
|
+
width.jump(next);
|
|
48
|
+
measured = node.textContent;
|
|
49
|
+
});
|
|
50
|
+
observer.observe(node);
|
|
51
|
+
return () => observer.disconnect();
|
|
52
|
+
}, [morph, reduceMotion, width]);
|
|
53
|
+
const numeric = !whole && !/\p{L}/u.test(text);
|
|
54
|
+
const variants = reduceMotion ? fade : rise;
|
|
55
|
+
const anchor = numeric ? "right" : "left";
|
|
56
|
+
return _jsxs(motion.span, { className: styles.roll, "data-end": numeric || undefined, "data-whole": whole || undefined, style: morph ? { width } : undefined, children: [morph && _jsx("span", { ref: sizer, className: styles.sizer, "aria-hidden": "true", children: text }), _jsx("span", { className: styles.track, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: shown.direction, anchorX: anchor, children: chunksOf(text, whole).map(chunk => _jsx(motion.span, { className: styles.slot, custom: shown.direction, variants: variants, initial: "hidden", animate: "shown", exit: "gone", children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: shown.direction, anchorX: anchor, children: _jsx(motion.span, { className: tone ? `${styles.glyph} ${tone}` : styles.glyph, custom: shown.direction, variants: variants, initial: "hidden", animate: "shown", exit: "gone", children: chunk.text }, `${tone ?? ""}${chunk.key}`) }) }, chunk.slot)) }) })] });
|
|
57
|
+
}
|
|
58
|
+
/** Space kept above the highest and below the lowest point, and half the line width. */
|
|
59
|
+
const inset = 5, cap = 1.25;
|
|
60
|
+
const grid = Array.from({ length: 161 }, (_, index) => index / 160);
|
|
61
|
+
/** A smooth line through every point, sampled densely in unit space (x and y both 0 to 1, y up). Monotone cubic tangents keep it from swinging past the data. */
|
|
62
|
+
function curveFor(data) {
|
|
63
|
+
const min = Math.min(...data), span = Math.max(...data) - min;
|
|
64
|
+
const ys = data.map(value => span ? (value - min) / span : .5);
|
|
65
|
+
const last = ys.length - 1, step = 1 / last;
|
|
66
|
+
const slopes = ys.slice(1).map((y, index) => (y - ys[index]) / step);
|
|
67
|
+
const tangents = ys.map((_, index) => index === 0 || index === last ? slopes[0] : (Math.sign(slopes[index - 1]) + Math.sign(slopes[index])) * Math.min(Math.abs(slopes[index - 1]), Math.abs(slopes[index]), Math.abs(slopes[index - 1] + slopes[index]) / 4) || 0);
|
|
68
|
+
if (last > 1) {
|
|
69
|
+
tangents[0] = (3 * slopes[0] - tangents[1]) / 2;
|
|
70
|
+
tangents[last] = (3 * slopes[last - 1] - tangents[last - 1]) / 2;
|
|
71
|
+
}
|
|
72
|
+
const samples = Math.max(1, Math.min(16, Math.round(128 / last)));
|
|
73
|
+
const points = [];
|
|
74
|
+
for (let index = 0; index < last; index++)
|
|
75
|
+
for (let sample = 0; sample < samples; sample++) {
|
|
76
|
+
const t = sample / samples, t2 = t * t, t3 = t2 * t;
|
|
77
|
+
const y = (2 * t3 - 3 * t2 + 1) * ys[index] + (t3 - 2 * t2 + t) * step * tangents[index] + (3 * t2 - 2 * t3) * ys[index + 1] + (t3 - t2) * step * tangents[index + 1];
|
|
78
|
+
points.push([(index + t) * step, Math.min(1, Math.max(0, y))]);
|
|
79
|
+
}
|
|
80
|
+
points.push([1, ys[last]]);
|
|
81
|
+
return points;
|
|
82
|
+
}
|
|
83
|
+
/** Height of the sampled line at x, straight between samples. */
|
|
84
|
+
function heightAt(shape, x) {
|
|
85
|
+
let low = 0, high = shape.length - 1;
|
|
86
|
+
if (x <= shape[low][0])
|
|
87
|
+
return shape[low][1];
|
|
88
|
+
if (x >= shape[high][0])
|
|
89
|
+
return shape[high][1];
|
|
90
|
+
while (high - low > 1) {
|
|
91
|
+
const middle = (low + high) >> 1;
|
|
92
|
+
if (shape[middle][0] < x)
|
|
93
|
+
low = middle;
|
|
94
|
+
else
|
|
95
|
+
high = middle;
|
|
96
|
+
}
|
|
97
|
+
const [x0, y0] = shape[low], [x1, y1] = shape[high];
|
|
98
|
+
return x1 === x0 ? y1 : y0 + ((y1 - y0) * (x - x0)) / (x1 - x0);
|
|
99
|
+
}
|
|
100
|
+
/** Every drawn string for one frame, in the chart's own pixels so strokes and dots stay round at any width. */
|
|
101
|
+
function geometryOf(shape, w, h, at) {
|
|
102
|
+
const toY = (y) => inset + (1 - y) * (h - inset * 2);
|
|
103
|
+
const points = shape.map(([x, y]) => [x * w, toY(y)]);
|
|
104
|
+
const pairs = points.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`);
|
|
105
|
+
const cx = at * w, cy = toY(heightAt(shape, at));
|
|
106
|
+
return { points, line: pairs.join(" "), trail: `M${pairs.join("L")}`, fill: `M${pairs.join("L")}L${w},${h}L0,${h}Z`, dot: `${cx},${cy} ${cx},${cy}`, cx };
|
|
107
|
+
}
|
|
108
|
+
/** Where the pen is along the line once `progress` of its length is drawn, so the fill follows the tip. */
|
|
109
|
+
function tipAt(points, progress) {
|
|
110
|
+
const steps = points.slice(1).map(([x, y], index) => Math.hypot(x - points[index][0], y - points[index][1]));
|
|
111
|
+
let left = progress * steps.reduce((sum, step) => sum + step, 0);
|
|
112
|
+
for (let index = 0; index < steps.length; index++) {
|
|
113
|
+
const step = steps[index], from = points[index][0];
|
|
114
|
+
if (left <= step)
|
|
115
|
+
return from + (points[index + 1][0] - from) * (step ? left / step : 1);
|
|
116
|
+
left -= step;
|
|
117
|
+
}
|
|
118
|
+
return points[points.length - 1][0];
|
|
119
|
+
}
|
|
120
|
+
export function Sparkline({ data, label, value, change, tone = "accent", width = 160, height = 52, labels, formatValue, area = true, interactive = true }) {
|
|
121
|
+
const safeData = data.length > 1 ? data : [data[0] ?? 0, data[0] ?? 0];
|
|
122
|
+
const last = safeData.length - 1;
|
|
123
|
+
const key = safeData.join(",");
|
|
124
|
+
const target = useMemo(() => curveFor(key.split(",").map(Number)), [key]);
|
|
125
|
+
const description = `${label}: ${value ?? safeData[last]}. ${change ?? ""}`.trim();
|
|
126
|
+
const uid = `sparkline${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
127
|
+
const ref = useRef(null);
|
|
128
|
+
const plot = useRef(null);
|
|
129
|
+
const inView = useInView(ref, { once: true, amount: .5 });
|
|
130
|
+
const reduceMotion = !!useReducedMotion();
|
|
131
|
+
const [box, setBox] = useState({ w: width, h: height });
|
|
132
|
+
const [active, setActive] = useState(null);
|
|
133
|
+
const [landed, setLanded] = useState(false);
|
|
134
|
+
const index = active === null ? null : Math.min(active, last);
|
|
135
|
+
const [moved, setMoved] = useState({ index, direction: 1 });
|
|
136
|
+
if (moved.index !== index)
|
|
137
|
+
setMoved({ index, direction: index === null || moved.index === null ? 1 : Math.sign(index - moved.index) || 1 });
|
|
138
|
+
const [first] = useState(() => geometryOf(target, width, height, 1));
|
|
139
|
+
const shape = useRef(target);
|
|
140
|
+
const size = useRef(box);
|
|
141
|
+
const drawn = useMotionValue(0), cursor = useMotionValue(1);
|
|
142
|
+
const line = useMotionValue(first.line), trail = useMotionValue(first.trail), fill = useMotionValue(first.fill), dot = useMotionValue(first.dot);
|
|
143
|
+
const cursorX = useMotionValue(first.cx), lit = useMotionValue(first.cx + cap + 16), dim = useMotionValue(first.cx + cap), reveal = useMotionValue(0);
|
|
144
|
+
const lineOpacity = useTransform(drawn, progress => progress > .001 ? 1 : 0);
|
|
145
|
+
// One writer for every drawn attribute: the morphing shape, the cursor, the pen and the measured size all repaint through here.
|
|
146
|
+
const paint = useCallback(() => {
|
|
147
|
+
const { w, h } = size.current, progress = drawn.get();
|
|
148
|
+
const next = geometryOf(shape.current, w, h, cursor.get());
|
|
149
|
+
line.set(next.line);
|
|
150
|
+
trail.set(next.trail);
|
|
151
|
+
fill.set(next.fill);
|
|
152
|
+
dot.set(next.dot);
|
|
153
|
+
cursorX.set(next.cx);
|
|
154
|
+
lit.set(next.cx + cap + 16);
|
|
155
|
+
dim.set(next.cx + cap);
|
|
156
|
+
reveal.set(progress >= 1 ? w + 32 : tipAt(next.points, progress) + 16);
|
|
157
|
+
}, [cursor, cursorX, dim, dot, drawn, fill, line, lit, reveal, trail]);
|
|
158
|
+
useMotionValueEvent(cursor, "change", paint);
|
|
159
|
+
useMotionValueEvent(drawn, "change", paint);
|
|
160
|
+
// The chart draws in its own pixels: measure the width, keep the height in proportion.
|
|
161
|
+
useLayoutEffect(() => {
|
|
162
|
+
const node = plot.current;
|
|
163
|
+
if (!node || typeof ResizeObserver === "undefined")
|
|
164
|
+
return;
|
|
165
|
+
const observer = new ResizeObserver(() => { const w = node.clientWidth; if (w)
|
|
166
|
+
setBox(current => Math.abs(current.w - w) < .5 ? current : { w, h: (w * height) / width }); });
|
|
167
|
+
observer.observe(node);
|
|
168
|
+
return () => observer.disconnect();
|
|
169
|
+
}, [height, width]);
|
|
170
|
+
useLayoutEffect(() => { size.current = box; paint(); }, [box, paint]);
|
|
171
|
+
// The line draws left to right once it is seen, the fill follows the pen, then the end dot lands.
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
// Reduced motion shows the finished line at once; the dot lands a frame later, after hydration has matched the server.
|
|
174
|
+
if (reduceMotion) {
|
|
175
|
+
drawn.jump(1);
|
|
176
|
+
const frame = requestAnimationFrame(() => setLanded(true));
|
|
177
|
+
return () => cancelAnimationFrame(frame);
|
|
178
|
+
}
|
|
179
|
+
if (!inView)
|
|
180
|
+
return;
|
|
181
|
+
const controls = animate(drawn, 1, { ...draw, onComplete: () => setLanded(true) });
|
|
182
|
+
return () => controls.stop();
|
|
183
|
+
}, [drawn, inView, reduceMotion]);
|
|
184
|
+
// New data morphs the line from the shape on screen, sampled on shared x positions so a series that gains or loses points still flows.
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
const from = shape.current;
|
|
187
|
+
if (from === target)
|
|
188
|
+
return;
|
|
189
|
+
if (reduceMotion) {
|
|
190
|
+
shape.current = target;
|
|
191
|
+
paint();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const xs = [...new Set([...grid, ...target.map(([x]) => x)])].sort((a, b) => a - b);
|
|
195
|
+
const start = xs.map(x => heightAt(from, x)), goal = xs.map(x => heightAt(target, x));
|
|
196
|
+
const controls = animate(0, 1, { ...motionTokens.spring.smooth, onUpdate: progress => { shape.current = xs.map((x, at) => [x, start[at] + (goal[at] - start[at]) * progress]); paint(); }, onComplete: () => { shape.current = target; paint(); } });
|
|
197
|
+
return () => controls.stop();
|
|
198
|
+
}, [paint, reduceMotion, target]);
|
|
199
|
+
// The cursor springs to the scrubbed point and rides the line between points; leaving sends it home to the latest value.
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
const to = index === null ? 1 : index / last;
|
|
202
|
+
if (reduceMotion) {
|
|
203
|
+
cursor.jump(to);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const controls = animate(cursor, to, motionTokens.spring.snappy);
|
|
207
|
+
return () => controls.stop();
|
|
208
|
+
}, [cursor, index, last, reduceMotion]);
|
|
209
|
+
const scrubbing = index !== null;
|
|
210
|
+
const format = (at) => formatValue ? formatValue(safeData[at], at) : grouped.format(safeData[at]);
|
|
211
|
+
const point = format(index ?? last), when = labels?.[index ?? last];
|
|
212
|
+
const headline = value === undefined ? undefined : scrubbing ? point : value;
|
|
213
|
+
const aside = index === null ? change ?? "" : value === undefined ? [point, when].filter(Boolean).join(" · ") : when ?? `${index + 1} of ${last + 1}`;
|
|
214
|
+
const pointAt = (clientX) => { const rect = plot.current?.getBoundingClientRect(); return rect?.width ? Math.max(0, Math.min(last, Math.round(((clientX - rect.left) / rect.width) * last))) : last; };
|
|
215
|
+
const onKeyDown = (event) => {
|
|
216
|
+
if (event.key === "Escape" && scrubbing) {
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
setActive(null);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const from = index ?? last, page = Math.max(1, Math.round((last + 1) / 6));
|
|
222
|
+
const next = { ArrowLeft: from - 1, ArrowDown: from - 1, ArrowRight: from + 1, ArrowUp: from + 1, PageDown: from - page, PageUp: from + page, Home: 0, End: last }[event.key];
|
|
223
|
+
if (next === undefined)
|
|
224
|
+
return;
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
setActive(Math.max(0, Math.min(last, next)));
|
|
227
|
+
};
|
|
228
|
+
const scrub = interactive ? {
|
|
229
|
+
role: "slider", tabIndex: 0, "aria-label": `${label}, explore values`, "aria-valuemin": 1, "aria-valuemax": last + 1, "aria-valuenow": (index ?? last) + 1, "aria-valuetext": `${when ?? (index === null ? "Latest" : `Point ${index + 1} of ${last + 1}`)}: ${point}`,
|
|
230
|
+
onPointerDown: (event) => setActive(pointAt(event.clientX)),
|
|
231
|
+
onPointerMove: (event) => { if (event.pointerType === "mouse" || event.buttons)
|
|
232
|
+
setActive(pointAt(event.clientX)); },
|
|
233
|
+
onPointerUp: (event) => { if (event.pointerType !== "mouse")
|
|
234
|
+
setActive(null); },
|
|
235
|
+
onPointerLeave: () => setActive(null), onPointerCancel: () => setActive(null), onBlur: () => setActive(null), onKeyDown,
|
|
236
|
+
} : {};
|
|
237
|
+
return _jsxs("figure", { ref: ref, className: styles.figure, "aria-label": description, children: [_jsxs("figcaption", { className: styles.caption, children: [_jsx("span", { className: styles.label, children: _jsx(Roll, { text: label, whole: true }) }), headline !== undefined && _jsx("strong", { children: _jsx(Roll, { text: headline, morph: true }) }), _jsx("small", { className: styles[tone], children: _jsx(Roll, { text: aside, direction: scrubbing ? moved.direction : undefined, tone: scrubbing ? styles.point : undefined, morph: true }) })] }), _jsx("div", { ref: plot, className: styles.plot, "data-interactive": interactive || undefined, ...scrub, children: _jsxs("svg", { className: styles.chart, viewBox: `0 0 ${box.w} ${box.h}`, "aria-hidden": "true", focusable: "false", children: [_jsxs("defs", { children: [_jsx("clipPath", { id: `${uid}-lit`, clipPathUnits: "userSpaceOnUse", children: _jsx(motion.rect, { x: -16, y: -16, width: lit, height: box.h + 32 }) }), _jsx("clipPath", { id: `${uid}-dim`, clipPathUnits: "userSpaceOnUse", children: _jsx(motion.rect, { x: dim, y: -16, width: box.w + 32, height: box.h + 32 }) }), _jsx("clipPath", { id: `${uid}-fill`, clipPathUnits: "userSpaceOnUse", children: _jsx(motion.rect, { x: -16, y: -16, width: reveal, height: box.h + 32 }) })] }), area && _jsx(motion.path, { className: `${styles.area} ${styles[tone]}`, d: fill, clipPath: `url(#${uid}-fill)` }), _jsx("line", { className: styles.baseline, x1: "0", y1: box.h - .5, x2: box.w, y2: box.h - .5 }), _jsx(motion.path, { className: styles.trail, d: trail, clipPath: `url(#${uid}-dim)`, initial: false, animate: { opacity: scrubbing && landed ? 1 : 0 }, transition: reduceMotion ? { duration: 0 } : quick }), _jsx(motion.polyline, { className: [styles.line, styles[tone]].join(" "), points: line, clipPath: `url(#${uid}-lit)`, style: { pathLength: drawn, opacity: lineOpacity } }), _jsx(motion.line, { className: styles.cursor, x1: cursorX, x2: cursorX, y1: 0, y2: box.h, initial: false, animate: { opacity: scrubbing ? 1 : 0 }, transition: reduceMotion ? { duration: 0 } : quick }), _jsx(motion.polyline, { className: [styles.end, styles[tone]].join(" "), points: dot, initial: false, animate: { opacity: landed ? 1 : 0, strokeWidth: landed ? scrubbing ? 10 : 7 : 0 }, transition: reduceMotion ? { duration: 0 } : { opacity: quick, strokeWidth: motionTokens.spring.snappy }, fill: "none", strokeLinecap: "round" })] }) })] });
|
|
238
|
+
}
|
|
239
|
+
export default Sparkline;
|