@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,183 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { AnimatePresence, motion, useInView, useMotionValue, useReducedMotion, useSpring } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import styles from "./slope-chart.module.css";
|
|
7
|
+
const { spring } = motionTokens;
|
|
8
|
+
const follow = { stiffness: 480, damping: 42, restDelta: .01 };
|
|
9
|
+
const LABEL_GAP = 20, PAD = 14, HEAD = 28;
|
|
10
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
11
|
+
const grouped = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 });
|
|
12
|
+
const subscribeNothing = () => () => { };
|
|
13
|
+
function useReducedMotionSafe() {
|
|
14
|
+
const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);
|
|
15
|
+
return !!useReducedMotion() && hydrated;
|
|
16
|
+
}
|
|
17
|
+
/** Moves labels apart just enough to stop overlaps: touching labels form a group centred on where its members want to be. */
|
|
18
|
+
function spread(wanted, gap, low, high) {
|
|
19
|
+
const order = wanted.map((y, index) => ({ y, index })).sort((a, b) => a.y - b.y);
|
|
20
|
+
let groups = order.map(entry => ({ members: [entry], top: entry.y }));
|
|
21
|
+
for (let changed = true; changed;) {
|
|
22
|
+
changed = false;
|
|
23
|
+
const next = [];
|
|
24
|
+
for (const group of groups) {
|
|
25
|
+
const prev = next[next.length - 1];
|
|
26
|
+
if (prev && prev.top + prev.members.length * gap > group.top) {
|
|
27
|
+
const members = [...prev.members, ...group.members];
|
|
28
|
+
next[next.length - 1] = { members, top: members.reduce((sum, member, k) => sum + member.y - k * gap, 0) / members.length };
|
|
29
|
+
changed = true;
|
|
30
|
+
}
|
|
31
|
+
else
|
|
32
|
+
next.push(group);
|
|
33
|
+
}
|
|
34
|
+
groups = next.map(group => ({ ...group, top: clamp(group.top, low, Math.max(low, high - (group.members.length - 1) * gap)) }));
|
|
35
|
+
}
|
|
36
|
+
const out = new Array(wanted.length);
|
|
37
|
+
groups.forEach(group => group.members.forEach((member, k) => { out[member.index] = group.top + k * gap; }));
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** Rank change arrow: up, down, or level. The number rolls in when it changes. */
|
|
41
|
+
function Rank({ move, reduced }) {
|
|
42
|
+
const text = move === 0 ? "=" : `${Math.abs(move)}`;
|
|
43
|
+
return _jsxs("span", { className: styles.rank, "data-move": move > 0 ? "up" : move < 0 ? "down" : "level", "aria-hidden": "true", children: [move !== 0 && _jsx("svg", { width: "8", height: "8", viewBox: "0 0 8 8", children: _jsx("path", { d: move > 0 ? "M4 7V1M1.5 3.5 4 1l2.5 2.5" : "M4 1v6M1.5 4.5 4 7l2.5-2.5", fill: "none", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round", strokeLinejoin: "round" }) }), _jsx("span", { className: styles.rankSwap, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(motion.span, { initial: reduced ? { opacity: 0 } : { opacity: 0, y: move >= 0 ? 6 : -6 }, animate: { opacity: 1, y: 0 }, exit: reduced ? { opacity: 0 } : { opacity: 0, y: move >= 0 ? -6 : 6 }, transition: reduced ? { duration: 0 } : spring.snappy, children: text }, text) }) })] });
|
|
44
|
+
}
|
|
45
|
+
export function SlopeChart({ data, label, startLabel, endLabel, formatValue, formatChange, height, highlightKey = null, activeKey, onActiveChange, ranks = true, emptyLabel = "No data", ref, className }) {
|
|
46
|
+
const reduced = useReducedMotionSafe();
|
|
47
|
+
const figure = useRef(null);
|
|
48
|
+
const stage = useRef(null);
|
|
49
|
+
const tip = useRef(null);
|
|
50
|
+
useImperativeHandle(ref, () => figure.current);
|
|
51
|
+
const inView = useInView(figure, { once: true, amount: .3 });
|
|
52
|
+
const empty = data.length === 0;
|
|
53
|
+
const plotHeight = height ?? Math.max(200, data.length * 44);
|
|
54
|
+
const [width, setWidth] = useState(0);
|
|
55
|
+
useLayoutEffect(() => {
|
|
56
|
+
const node = stage.current;
|
|
57
|
+
if (!node)
|
|
58
|
+
return;
|
|
59
|
+
const read = () => setWidth(node.clientWidth);
|
|
60
|
+
read();
|
|
61
|
+
if (typeof ResizeObserver === "undefined")
|
|
62
|
+
return;
|
|
63
|
+
const observer = new ResizeObserver(read);
|
|
64
|
+
observer.observe(node);
|
|
65
|
+
return () => observer.disconnect();
|
|
66
|
+
}, []);
|
|
67
|
+
const narrow = width < 440;
|
|
68
|
+
const leftRoom = narrow ? (width < 300 ? 116 : 132) : 168, rightRoom = ranks ? (narrow ? 76 : 96) : (narrow ? 52 : 64);
|
|
69
|
+
const x0 = leftRoom, x1 = Math.max(x0 + 40, width - rightRoom);
|
|
70
|
+
// One shared value axis for both columns, padded so dots never touch the edges.
|
|
71
|
+
const values = data.flatMap(item => [item.start, item.end]);
|
|
72
|
+
let lo = Math.min(...values), hi = Math.max(...values);
|
|
73
|
+
if (!Number.isFinite(lo)) {
|
|
74
|
+
lo = 0;
|
|
75
|
+
hi = 1;
|
|
76
|
+
}
|
|
77
|
+
if (hi - lo < 1e-9) {
|
|
78
|
+
lo -= 1;
|
|
79
|
+
hi += 1;
|
|
80
|
+
}
|
|
81
|
+
const yOf = (value) => PAD + (1 - (value - lo) / (hi - lo)) * (plotHeight - PAD * 2);
|
|
82
|
+
const rankOf = (field) => { const sorted = [...data].sort((a, b) => b[field] - a[field]); return (item) => sorted.indexOf(item) + 1; };
|
|
83
|
+
const r0 = rankOf("start"), r1 = rankOf("end");
|
|
84
|
+
const l0 = spread(data.map(item => yOf(item.start)), LABEL_GAP, 8, plotHeight - 8);
|
|
85
|
+
const l1 = spread(data.map(item => yOf(item.end)), LABEL_GAP, 8, plotHeight - 8);
|
|
86
|
+
const rows = data.map((item, i) => ({ item, y0: yOf(item.start), y1: yOf(item.end), l0: l0[i], l1: l1[i], rank0: r0(item), rank1: r1(item), order: 0 }));
|
|
87
|
+
const byEnd = [...rows].sort((a, b) => a.rank1 - b.rank1);
|
|
88
|
+
byEnd.forEach((row, k) => { row.order = k; });
|
|
89
|
+
const [ownActive, setOwnActive] = useState(null);
|
|
90
|
+
const wanted = activeKey !== undefined ? activeKey : ownActive;
|
|
91
|
+
const active = wanted && data.some(item => item.key === wanted) ? wanted : null;
|
|
92
|
+
const setActive = (key) => { if (activeKey === undefined)
|
|
93
|
+
setOwnActive(key); if (key !== active)
|
|
94
|
+
onActiveChange?.(key); };
|
|
95
|
+
// The tooltip glides to the middle of the line in focus and stays inside the chart.
|
|
96
|
+
const tipX = useMotionValue(0), tipY = useMotionValue(0);
|
|
97
|
+
const tipSpringX = useSpring(tipX, follow), tipSpringY = useSpring(tipY, follow);
|
|
98
|
+
const wasShown = useRef(false);
|
|
99
|
+
const activeRow = rows.find(row => row.item.key === active) ?? null;
|
|
100
|
+
// The tooltip keeps the last item's content while it fades out, so it never collapses to an empty box mid-fade.
|
|
101
|
+
const [shownKey, setShownKey] = useState(null);
|
|
102
|
+
if (active && active !== shownKey)
|
|
103
|
+
setShownKey(active);
|
|
104
|
+
const tipRow = activeRow ?? rows.find(row => row.item.key === shownKey) ?? null;
|
|
105
|
+
useLayoutEffect(() => {
|
|
106
|
+
const bubble = tip.current;
|
|
107
|
+
if (!activeRow || !bubble || !width) {
|
|
108
|
+
wasShown.current = false;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const tw = bubble.offsetWidth, th = bubble.offsetHeight;
|
|
112
|
+
const mid = (activeRow.y0 + activeRow.y1) / 2, cx = (x0 + x1) / 2;
|
|
113
|
+
const above = mid - th - 16 >= 0;
|
|
114
|
+
const left = clamp(cx - tw / 2, 0, Math.max(0, width - tw));
|
|
115
|
+
const top = clamp(HEAD + (above ? mid - th - 16 : mid + 16), 0, Math.max(0, plotHeight + HEAD - th));
|
|
116
|
+
tipX.set(left);
|
|
117
|
+
tipY.set(top);
|
|
118
|
+
if (!wasShown.current || reduced) {
|
|
119
|
+
tipSpringX.jump(left);
|
|
120
|
+
tipSpringY.jump(top);
|
|
121
|
+
}
|
|
122
|
+
wasShown.current = true;
|
|
123
|
+
});
|
|
124
|
+
// The line nearest the pointer, measured at the pointer's position along the plot.
|
|
125
|
+
const nearest = (clientX, clientY) => {
|
|
126
|
+
const rect = stage.current?.getBoundingClientRect();
|
|
127
|
+
if (!rect || empty)
|
|
128
|
+
return null;
|
|
129
|
+
const x = clamp(clientX - rect.left, x0, x1), y = clientY - rect.top - HEAD;
|
|
130
|
+
const t = (x - x0) / Math.max(1, x1 - x0);
|
|
131
|
+
let best = null, distance = Infinity;
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
const inLine = Math.abs(row.y0 + (row.y1 - row.y0) * t - y);
|
|
134
|
+
const onLabel = clientX - rect.left < x0 ? Math.abs(row.l0 - y) : clientX - rect.left > x1 ? Math.abs(row.l1 - y) : Infinity;
|
|
135
|
+
const d = Math.min(inLine, onLabel);
|
|
136
|
+
if (d < distance) {
|
|
137
|
+
distance = d;
|
|
138
|
+
best = row;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return distance <= 22 ? best?.item.key ?? null : null;
|
|
142
|
+
};
|
|
143
|
+
const onPointerMove = (event) => { if (event.pointerType === "mouse" || event.buttons)
|
|
144
|
+
setActive(nearest(event.clientX, event.clientY)); };
|
|
145
|
+
const onKeyDown = (event) => {
|
|
146
|
+
if (empty)
|
|
147
|
+
return;
|
|
148
|
+
if (event.key === "Escape" && active) {
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
setActive(null);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const at = activeRow ? activeRow.order : -1;
|
|
154
|
+
const moves = { ArrowDown: at + 1, ArrowRight: at + 1, ArrowUp: at < 0 ? 0 : at - 1, ArrowLeft: at < 0 ? 0 : at - 1, Home: 0, End: byEnd.length - 1 };
|
|
155
|
+
if (!(event.key in moves))
|
|
156
|
+
return;
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
setActive(byEnd[clamp(moves[event.key], 0, byEnd.length - 1)].item.key);
|
|
159
|
+
};
|
|
160
|
+
const format = (value) => formatValue ? formatValue(value) : grouped.format(value);
|
|
161
|
+
const change = (item) => formatChange ? formatChange(item.end - item.start, item) : `${item.end >= item.start ? "+" : "−"}${format(Math.abs(item.end - item.start))}`;
|
|
162
|
+
const rankText = (row) => row.rank0 === row.rank1 ? "same rank" : row.rank1 < row.rank0 ? `up ${row.rank0 - row.rank1} to rank ${row.rank1}` : `down ${row.rank1 - row.rank0} to rank ${row.rank1}`;
|
|
163
|
+
const announce = activeRow ? `${activeRow.item.label}, ${startLabel} ${format(activeRow.item.start)}, ${endLabel} ${format(activeRow.item.end)}, ${change(activeRow.item)}, ${rankText(activeRow)}` : "";
|
|
164
|
+
const summary = empty ? `${label}. ${emptyLabel}.` : `${label}, ${startLabel} to ${endLabel}. ${byEnd.map(row => `${row.item.label} ${format(row.item.start)} to ${format(row.item.end)}`).join(", ")}.`;
|
|
165
|
+
const settle = reduced ? { duration: 0 } : spring.morph;
|
|
166
|
+
const drawn = inView || reduced;
|
|
167
|
+
return _jsxs("figure", { ref: figure, className: [styles.figure, className].filter(Boolean).join(" "), "aria-label": label, children: [_jsxs("div", { ref: stage, className: styles.stage, "data-narrow": narrow || undefined, "data-active": active ? true : undefined, role: "group", tabIndex: empty ? -1 : 0, "aria-roledescription": "slope chart", "aria-label": `${label}. Use up and down arrows to move between items in ${endLabel} order.`, onPointerMove: onPointerMove, onPointerDown: event => { if (event.pointerType !== "mouse") {
|
|
168
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
169
|
+
setActive(nearest(event.clientX, event.clientY));
|
|
170
|
+
} }, onPointerUp: event => { if (event.pointerType !== "mouse")
|
|
171
|
+
setActive(null); }, onPointerCancel: () => setActive(null), onPointerLeave: event => { if (event.pointerType === "mouse")
|
|
172
|
+
setActive(null); }, onKeyDown: onKeyDown, onBlur: () => setActive(null), onFocus: event => { if (event.currentTarget.matches(":focus-visible") && !empty && !active)
|
|
173
|
+
setActive(byEnd[0].item.key); }, children: [width > 0 && _jsxs(_Fragment, { children: [_jsxs("div", { className: styles.columns, "aria-hidden": "true", children: [_jsx("span", { className: styles.column, style: { left: x0, translate: "-50% 0" }, children: startLabel }), _jsx("span", { className: styles.column, style: { left: x1, translate: "-50% 0" }, children: endLabel })] }), _jsxs("div", { className: styles.plot, style: { height: plotHeight }, children: [_jsxs("svg", { className: styles.svg, width: width, height: plotHeight, "aria-hidden": "true", focusable: "false", children: [_jsx("line", { className: styles.axis, x1: x0, x2: x0, y1: 0, y2: plotHeight }), _jsx("line", { className: styles.axis, x1: x1, x2: x1, y1: 0, y2: plotHeight }), rows.map((row, i) => {
|
|
174
|
+
const key = row.item.key, on = active === key, accent = highlightKey === key;
|
|
175
|
+
const delay = reduced ? 0 : i * .05;
|
|
176
|
+
return _jsxs("g", { className: styles.item, "data-on": on || undefined, "data-accent": accent || undefined, "data-dim": (active !== null && !on) || undefined, children: [_jsx(motion.path, { className: styles.leader, initial: false, animate: { d: `M${x0 - 6},${row.l0}L${x0 - 1},${row.y0}` }, transition: settle }), _jsx(motion.path, { className: styles.leader, initial: false, animate: { d: `M${x1 + 6},${row.l1}L${x1 + 1},${row.y1}` }, transition: settle }), _jsx(motion.line, { className: styles.line, x1: x0, x2: x1, initial: reduced ? false : { y1: row.y0, y2: row.y1, pathLength: 0 }, animate: { y1: row.y0, y2: row.y1, pathLength: drawn ? 1 : 0 }, transition: { y1: settle, y2: settle, pathLength: reduced ? { duration: 0 } : { duration: .7, ease: motionTokens.ease.inOut, delay: .1 + delay } } }), _jsx(motion.circle, { className: styles.dot, cx: x0, r: 4, initial: reduced ? false : { cy: row.y0, scale: 0 }, animate: { cy: row.y0, scale: drawn ? 1 : 0 }, transition: { cy: settle, scale: reduced ? { duration: 0 } : { ...spring.snappy, delay } } }), _jsx(motion.circle, { className: styles.dot, cx: x1, r: 4, initial: reduced ? false : { cy: row.y1, scale: 0 }, animate: { cy: row.y1, scale: drawn ? 1 : 0 }, transition: { cy: settle, scale: reduced ? { duration: 0 } : { ...spring.snappy, delay: .7 + delay } } })] }, key);
|
|
177
|
+
})] }), rows.map((row, i) => {
|
|
178
|
+
const key = row.item.key, on = active === key, accent = highlightKey === key;
|
|
179
|
+
const delay = reduced ? 0 : i * .05;
|
|
180
|
+
return _jsxs("div", { className: styles.labels, "data-on": on || undefined, "data-accent": accent || undefined, "data-dim": (active !== null && !on) || undefined, "aria-hidden": "true", children: [_jsxs(motion.span, { className: styles.start, style: { right: width - x0 + 10, maxWidth: x0 - 10 }, initial: reduced ? false : { y: row.l0, opacity: 0 }, animate: { y: row.l0, opacity: drawn ? 1 : 0 }, transition: { y: settle, opacity: { duration: .3, delay } }, children: [_jsx("span", { className: styles.name, children: row.item.label }), _jsx("span", { className: styles.value, children: format(row.item.start) })] }), _jsxs(motion.span, { className: styles.end, style: { left: x1 + 10 }, initial: reduced ? false : { y: row.l1, opacity: 0 }, animate: { y: row.l1, opacity: drawn ? 1 : 0 }, transition: { y: settle, opacity: { duration: .3, delay: .7 + delay } }, children: [_jsx("span", { className: styles.value, children: format(row.item.end) }), ranks && _jsx(Rank, { move: row.rank0 - row.rank1, reduced: reduced })] })] }, key);
|
|
181
|
+
})] })] }), _jsxs(motion.div, { ref: tip, className: styles.tooltip, style: { x: reduced ? tipX : tipSpringX, y: reduced ? tipY : tipSpringY }, "aria-hidden": "true", children: [_jsx("p", { className: styles.tipTitle, children: tipRow?.item.label ?? "" }), _jsxs("p", { className: styles.tipRow, children: [_jsx("span", { className: styles.tipName, children: startLabel }), _jsx("span", { className: styles.tipValue, children: tipRow ? format(tipRow.item.start) : "" })] }), _jsxs("p", { className: styles.tipRow, children: [_jsx("span", { className: styles.tipName, children: endLabel }), _jsx("span", { className: styles.tipValue, children: tipRow ? format(tipRow.item.end) : "" })] }), _jsx("p", { className: styles.tipMeta, children: tipRow ? `${change(tipRow.item)}, ${rankText(tipRow)}` : "" })] }), empty && _jsx("p", { className: styles.message, children: emptyLabel })] }), _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 }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Item" }), _jsx("th", { scope: "col", children: startLabel }), _jsx("th", { scope: "col", children: endLabel }), _jsx("th", { scope: "col", children: "Change" }), _jsx("th", { scope: "col", children: "Rank" })] }) }), _jsx("tbody", { children: byEnd.map(row => _jsxs("tr", { children: [_jsx("th", { scope: "row", children: row.item.label }), _jsx("td", { children: format(row.item.start) }), _jsx("td", { children: format(row.item.end) }), _jsx("td", { children: change(row.item) }), _jsxs("td", { children: [row.rank0, " to ", row.rank1] })] }, row.item.key)) })] }) })] });
|
|
182
|
+
}
|
|
183
|
+
export default SlopeChart;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
.figure { position: relative; 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
|
+
.stage { position: relative; min-width: 0; outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
|
|
6
|
+
.columns { position: relative; height: 28px; }
|
|
7
|
+
.column { position: absolute; top: 0; color: var(--text-muted); font-size: var(--text-sm); line-height: 20px; white-space: nowrap; }
|
|
8
|
+
|
|
9
|
+
.plot { position: relative; }
|
|
10
|
+
.svg { display: block; overflow: visible; }
|
|
11
|
+
.axis { stroke: var(--border); stroke-width: 1; shape-rendering: crispEdges; }
|
|
12
|
+
|
|
13
|
+
/* Every line is one neutral ink; the story item takes the first series (derived from the accent) and the item in focus comes forward. */
|
|
14
|
+
.item { --ink: color-mix(in oklab, var(--foreground) 36%, var(--surface)); transition: opacity var(--duration-standard) var(--ease-standard); }
|
|
15
|
+
.item[data-accent] { --ink: var(--series-1); }
|
|
16
|
+
.item[data-on]:not([data-accent]) { --ink: var(--foreground); }
|
|
17
|
+
.item[data-dim] { opacity: .14; }
|
|
18
|
+
.line { stroke: var(--ink); stroke-width: 2; stroke-linecap: round; fill: none; transition: stroke var(--duration-fast) var(--ease-standard); }
|
|
19
|
+
.dot { fill: var(--ink); stroke: var(--surface); stroke-width: 2; transform-box: fill-box; transform-origin: center; transition: fill var(--duration-fast) var(--ease-standard); }
|
|
20
|
+
.leader { fill: none; stroke: var(--border-strong); stroke-width: 1; }
|
|
21
|
+
|
|
22
|
+
.labels { transition: opacity var(--duration-standard) var(--ease-standard); }
|
|
23
|
+
.labels[data-dim] { opacity: .3; }
|
|
24
|
+
.start, .end { position: absolute; top: 0; display: flex; height: 20px; align-items: center; gap: 8px; margin-top: -10px; font-size: var(--text-sm); line-height: 20px; white-space: nowrap; pointer-events: none; }
|
|
25
|
+
.start { justify-content: flex-end; max-width: 100%; }
|
|
26
|
+
/* On a phone the labels step down a size so the longest names fit beside the first column instead of running off the edge. */
|
|
27
|
+
.stage[data-narrow] .start, .stage[data-narrow] .end { gap: 6px; font-size: var(--text-xs); }
|
|
28
|
+
.name { min-width: 0; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; }
|
|
29
|
+
.value { color: var(--foreground); font-variant-numeric: tabular-nums; }
|
|
30
|
+
.labels[data-on] .name, .labels[data-accent] .name { color: var(--foreground); }
|
|
31
|
+
/* Only the story item is medium; the item in focus changes color, not weight, so its label never shifts sideways. */
|
|
32
|
+
.labels[data-accent] .value { font-weight: 500; }
|
|
33
|
+
|
|
34
|
+
.rank { display: inline-flex; min-width: 30px; align-items: center; gap: 2px; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
|
35
|
+
.rank[data-move="up"] { color: var(--text-secondary); }
|
|
36
|
+
.rankSwap { position: relative; display: inline-flex; overflow: hidden; }
|
|
37
|
+
|
|
38
|
+
.tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 168px; max-width: min(240px, 100%); gap: 3px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-control); 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); }
|
|
39
|
+
.stage[data-active] .tooltip { opacity: 1; scale: 1; }
|
|
40
|
+
.tipTitle { margin: 0 0 2px; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; }
|
|
41
|
+
.tipRow { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0; font-size: var(--text-sm); line-height: 1.3; }
|
|
42
|
+
.tipName { color: var(--text-secondary); }
|
|
43
|
+
.tipValue { color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
44
|
+
.tipMeta { margin: 2px 0 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
|
|
45
|
+
|
|
46
|
+
.message { display: grid; min-height: 160px; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); }
|
|
47
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
48
|
+
|
|
49
|
+
@media (prefers-reduced-motion: reduce) {
|
|
50
|
+
.item, .line, .dot, .labels, .tooltip { transition: none; }
|
|
51
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Text and numbers that spin into their new value like slot machine reels. Every character is a reel; digits count through
|
|
4
|
+
* the wheel in the direction the number moved, letters shuffle through the alphabet, and reels stop one after another from
|
|
5
|
+
* left to right with a soft, overshoot-free landing. Use it for prices, stats, and launch moments where a change deserves a beat.
|
|
6
|
+
*/
|
|
7
|
+
export interface SlotTextProps {
|
|
8
|
+
/** The value to show. Numbers pass through format; strings render as they are. */
|
|
9
|
+
value: string | number;
|
|
10
|
+
/** Formats a number value. Defaults to en-US grouping, so 12480 reads 12,480. */
|
|
11
|
+
format?: (value: number) => string;
|
|
12
|
+
/** Seconds the first reel spins. Later reels add stagger. */
|
|
13
|
+
duration?: number;
|
|
14
|
+
/** Seconds between reels stopping, left to right. */
|
|
15
|
+
stagger?: number;
|
|
16
|
+
/** Extra full turns a digit makes before it lands. 0 rolls straight to the new digit. */
|
|
17
|
+
spins?: number;
|
|
18
|
+
/** Which end reels are matched from when the length changes. Numbers default to end, so 999 to 1,000 grows on the left. */
|
|
19
|
+
align?: "start" | "end";
|
|
20
|
+
/** Announce new values politely to screen readers. */
|
|
21
|
+
announce?: boolean;
|
|
22
|
+
className?: string;
|
|
23
|
+
style?: CSSProperties;
|
|
24
|
+
}
|
|
25
|
+
export declare function SlotText({ value, format, duration, stagger, spins, align, announce, className, style }: SlotTextProps): import("react").JSX.Element;
|
|
26
|
+
export default SlotText;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useMotionValue, usePresence, useReducedMotion, useTransform, useVelocity } from "motion/react";
|
|
5
|
+
import styles from "./slot-text.module.css";
|
|
6
|
+
const DIGITS = "0123456789";
|
|
7
|
+
const LOWER = "abcdefghijklmnopqrstuvwxyz";
|
|
8
|
+
const UPPER = LOWER.toUpperCase();
|
|
9
|
+
/** Fast out, long soft landing, never past the target. */
|
|
10
|
+
const LANDING = [.12, .8, .16, 1];
|
|
11
|
+
const subscribe = () => () => { };
|
|
12
|
+
/** Cell pitch in em. Keep in sync with .cell in the stylesheet. */
|
|
13
|
+
const CELL = 1.6;
|
|
14
|
+
function useReducedFlag() {
|
|
15
|
+
const hydrated = useSyncExternalStore(subscribe, () => true, () => false);
|
|
16
|
+
return !!useReducedMotion() && hydrated;
|
|
17
|
+
}
|
|
18
|
+
const classOf = (char) => DIGITS.includes(char) ? DIGITS : LOWER.includes(char) ? LOWER : UPPER.includes(char) ? UPPER : null;
|
|
19
|
+
/** Deterministic shuffle so the same change always spins the same letters. */
|
|
20
|
+
function seeded(seed) {
|
|
21
|
+
let state = seed >>> 0 || 1;
|
|
22
|
+
return () => { state = (state * 1664525 + 1013904223) >>> 0; return state / 4294967296; };
|
|
23
|
+
}
|
|
24
|
+
/** The reel's cells, from what is visible now to the target. */
|
|
25
|
+
function buildStrip(from, to, spins, rising, seed) {
|
|
26
|
+
if (from === to)
|
|
27
|
+
return [to];
|
|
28
|
+
const set = classOf(to);
|
|
29
|
+
if (!set)
|
|
30
|
+
return [from, to];
|
|
31
|
+
if (set === DIGITS && classOf(from) === DIGITS) {
|
|
32
|
+
const a = Number(from), b = Number(to);
|
|
33
|
+
const steps = spins * 10 + (rising ? (b - a + 10) % 10 : (a - b + 10) % 10);
|
|
34
|
+
return Array.from({ length: steps + 1 }, (_, i) => String((a + (rising ? i : -i) + 100) % 10));
|
|
35
|
+
}
|
|
36
|
+
const random = seeded(seed);
|
|
37
|
+
const fillers = Array.from({ length: Math.max(2, spins * 5 + 2) }, () => set[Math.floor(random() * set.length)]);
|
|
38
|
+
return [from, ...fillers, to];
|
|
39
|
+
}
|
|
40
|
+
function Reel({ char, order, entering, rising, duration, stagger, spins, reduced }) {
|
|
41
|
+
const pos = useMotionValue(0);
|
|
42
|
+
const width = useMotionValue("auto");
|
|
43
|
+
const sizer = useRef(null);
|
|
44
|
+
const [isPresent, safeToRemove] = usePresence();
|
|
45
|
+
const [reel, setReel] = useState(() => ({
|
|
46
|
+
char,
|
|
47
|
+
strip: entering && !reduced ? buildStrip("", char, spins, rising, char.charCodeAt(0) + order) : [char],
|
|
48
|
+
from: 0,
|
|
49
|
+
grow: entering,
|
|
50
|
+
time: duration + order * stagger,
|
|
51
|
+
}));
|
|
52
|
+
// A new target restarts the reel from the cell that is on screen right now, keeping its sub-cell offset, so nothing jumps.
|
|
53
|
+
if (reel.char !== char) {
|
|
54
|
+
const current = pos.get();
|
|
55
|
+
const index = Math.max(0, Math.min(reel.strip.length - 1, Math.round(current)));
|
|
56
|
+
const visible = reel.strip[index];
|
|
57
|
+
setReel({
|
|
58
|
+
char,
|
|
59
|
+
strip: reduced ? [char] : buildStrip(visible, char, spins, rising, char.charCodeAt(0) * 31 + visible.charCodeAt(0) * 7 + order + reel.strip.length),
|
|
60
|
+
from: reduced ? 0 : current - index,
|
|
61
|
+
grow: false,
|
|
62
|
+
time: duration + order * stagger,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const blur = useTransform(useVelocity(pos), velocity => {
|
|
66
|
+
const amount = Math.min(Math.abs(velocity) * .05, 2.4);
|
|
67
|
+
return amount < .15 ? "none" : `blur(${amount.toFixed(2)}px)`;
|
|
68
|
+
});
|
|
69
|
+
// Cells are taller than the line, so the neighbours of a landed glyph sit fully outside the window.
|
|
70
|
+
const y = useTransform(pos, value => `translate3d(0, ${(-value * CELL).toFixed(4)}em, 0)`);
|
|
71
|
+
useLayoutEffect(() => {
|
|
72
|
+
const measured = sizer.current?.getBoundingClientRect().width ?? 0;
|
|
73
|
+
const last = reel.strip.length - 1;
|
|
74
|
+
if (reduced) {
|
|
75
|
+
pos.jump(last);
|
|
76
|
+
width.jump(measured);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
pos.jump(reel.from);
|
|
80
|
+
const spin = animate(pos, last, { duration: last === 0 ? .2 : reel.time, ease: [...LANDING] });
|
|
81
|
+
if (reel.grow)
|
|
82
|
+
width.jump(0);
|
|
83
|
+
if (width.get() === "auto") {
|
|
84
|
+
width.jump(measured);
|
|
85
|
+
return () => spin.stop();
|
|
86
|
+
}
|
|
87
|
+
const size = animate(width, measured, { type: "spring", visualDuration: Math.min(reel.time, .6), bounce: 0 });
|
|
88
|
+
return () => { spin.stop(); size.stop(); };
|
|
89
|
+
}, [reel, pos, width, reduced]);
|
|
90
|
+
useLayoutEffect(() => {
|
|
91
|
+
if (isPresent)
|
|
92
|
+
return;
|
|
93
|
+
if (reduced) {
|
|
94
|
+
safeToRemove();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const exit = animate(width, 0, { type: "spring", visualDuration: .35, bounce: 0, onComplete: safeToRemove });
|
|
98
|
+
return () => exit.stop();
|
|
99
|
+
}, [isPresent, safeToRemove, width, reduced]);
|
|
100
|
+
return _jsxs(motion.span, { className: styles.slot, style: { width }, "data-exiting": isPresent ? undefined : "", children: [_jsx("span", { ref: sizer, className: styles.sizer, children: char === " " ? "\u00a0" : char }), _jsx("span", { className: styles.window, children: _jsx(motion.span, { className: styles.strip, style: { transform: y, filter: blur }, children: reel.strip.map((cell, i) => _jsx("span", { className: styles.cell, children: cell === " " ? "\u00a0" : cell }, i)) }) })] });
|
|
101
|
+
}
|
|
102
|
+
export function SlotText({ value, format, duration = .9, stagger = .07, spins = 1, align, announce = false, className, style }) {
|
|
103
|
+
const reduced = useReducedFlag();
|
|
104
|
+
const text = typeof value === "number" ? (format ? format(value) : value.toLocaleString("en-US")) : value;
|
|
105
|
+
const fromEnd = (align ?? (typeof value === "number" ? "end" : "start")) === "end";
|
|
106
|
+
const chars = Array.from(text);
|
|
107
|
+
// A leading run of symbols (a currency sign, a plus) is keyed from the start and never spins into a digit; the rest is
|
|
108
|
+
// keyed from the aligned end, so separators and suffixes stay on their own reels when the number grows.
|
|
109
|
+
const lead = fromEnd ? chars.findIndex(char => /[\p{L}\p{N}]/u.test(char)) : 0;
|
|
110
|
+
const prefix = lead < 0 ? chars.length : lead;
|
|
111
|
+
const keyOf = (i) => i < prefix ? `p${i}` : fromEnd ? `e${chars.length - 1 - i}` : `s${i}`;
|
|
112
|
+
const [initialKeys] = useState(() => new Set(chars.map((_, i) => keyOf(i))));
|
|
113
|
+
const [trend, setTrend] = useState({ value, rising: true });
|
|
114
|
+
if (trend.value !== value) {
|
|
115
|
+
setTrend({ value, rising: typeof value === "number" && typeof trend.value === "number" ? value >= trend.value : true });
|
|
116
|
+
}
|
|
117
|
+
return _jsxs("span", { className: [styles.root, className].filter(Boolean).join(" "), style: style, children: [_jsx("span", { className: styles.srOnly, "aria-live": announce ? "polite" : undefined, children: text }), _jsx("span", { className: styles.reels, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, children: chars.map((char, i) => _jsx(Reel, { char: char, order: i, entering: !initialKeys.has(keyOf(i)), rising: trend.rising, duration: duration, stagger: stagger, spins: spins, reduced: reduced }, keyOf(i))) }) })] });
|
|
118
|
+
}
|
|
119
|
+
export default SlotText;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.root { position: relative; display: inline-block; font-variant-numeric: tabular-nums; line-height: 1.15; white-space: nowrap; vertical-align: bottom; }
|
|
2
|
+
.reels { display: inline-flex; height: 1.15em; align-items: flex-start; }
|
|
3
|
+
|
|
4
|
+
/* Each slot holds one character. Its width follows the character on a spring, so the word never resizes in one frame. */
|
|
5
|
+
.slot { position: relative; display: inline-block; height: 1.15em; flex: none; overflow: visible; }
|
|
6
|
+
.sizer { display: inline-block; visibility: hidden; white-space: pre; pointer-events: none; }
|
|
7
|
+
|
|
8
|
+
/* The window reaches a little past the line so glyphs fade at the edges instead of being cut. */
|
|
9
|
+
.window {
|
|
10
|
+
position: absolute;
|
|
11
|
+
inset: -.22em -.08em;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 .24em, #000 calc(100% - .24em), transparent);
|
|
14
|
+
mask-image: linear-gradient(to bottom, transparent, #000 .24em, #000 calc(100% - .24em), transparent);
|
|
15
|
+
}
|
|
16
|
+
.strip { position: absolute; top: calc(.22em - .225em); right: .08em; left: .08em; display: flex; flex-direction: column; align-items: center; will-change: transform; }
|
|
17
|
+
.cell { display: block; height: 1.6em; padding-top: .225em; box-sizing: border-box; line-height: 1.15; white-space: pre; }
|
|
18
|
+
|
|
19
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export type SortDirection = "asc" | "desc";
|
|
3
|
+
export type SortState = {
|
|
4
|
+
key: string;
|
|
5
|
+
direction: SortDirection;
|
|
6
|
+
};
|
|
7
|
+
export type DataColumn<T> = {
|
|
8
|
+
key: string;
|
|
9
|
+
label: string;
|
|
10
|
+
sortable?: boolean;
|
|
11
|
+
render?: (value: unknown, row: T) => ReactNode;
|
|
12
|
+
/** Right-aligns the column with tabular numerals. Detected when every value is a number. */
|
|
13
|
+
numeric?: boolean;
|
|
14
|
+
/** Fixed width such as 120 or "20%". Other columns are measured once and held, so sorting never reflows them. */
|
|
15
|
+
width?: number | string;
|
|
16
|
+
};
|
|
17
|
+
export type SortableDataTableProps<T extends Record<string, unknown>> = {
|
|
18
|
+
rows: T[];
|
|
19
|
+
columns: DataColumn<T>[];
|
|
20
|
+
rowKey: keyof T | ((row: T) => string);
|
|
21
|
+
caption?: string;
|
|
22
|
+
emptyMessage?: string;
|
|
23
|
+
/** Sort applied on the first render. */
|
|
24
|
+
defaultSort?: SortState;
|
|
25
|
+
onSortChange?: (sort: SortState) => void;
|
|
26
|
+
/** Adds a checkbox column, row click selection, and a count line with a clear action. */
|
|
27
|
+
selectable?: boolean;
|
|
28
|
+
selectedKeys?: string[];
|
|
29
|
+
defaultSelectedKeys?: string[];
|
|
30
|
+
onSelectionChange?: (keys: string[]) => void;
|
|
31
|
+
/** Noun for the count line, as in "6 projects". */
|
|
32
|
+
itemName?: {
|
|
33
|
+
one: string;
|
|
34
|
+
other: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export declare function SortableDataTable<T extends Record<string, unknown>>({ rows, columns, rowKey, caption, emptyMessage, defaultSort, onSortChange, selectable, selectedKeys, defaultSelectedKeys, onSelectionChange, itemName }: SortableDataTableProps<T>): import("react").JSX.Element;
|
|
38
|
+
export default SortableDataTable;
|