@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,327 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { AnimatePresence, animate, cancelFrame, frame, motion, useInView, useMotionValue, usePresence, useReducedMotion, useTransform } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import styles from "./bar-chart.module.css";
|
|
7
|
+
const { spring, duration, ease, blur, stagger } = motionTokens;
|
|
8
|
+
/** Motion drops inherited velocity on time-defined springs, so values that retarget mid-flight run the same springs written as stiffness and damping. */
|
|
9
|
+
const physical = ({ visualDuration, bounce }, restDelta = .01) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };
|
|
10
|
+
const settle = physical(spring.smooth);
|
|
11
|
+
const grow = physical(spring.morph);
|
|
12
|
+
const glide = physical(spring.snappy);
|
|
13
|
+
/** The zoom runs in fractions of the plot, so it rests far below a pixel. */
|
|
14
|
+
const zoom = physical(spring.smooth, .00001);
|
|
15
|
+
const collapse = physical({ visualDuration: duration.standard, bounce: 0 });
|
|
16
|
+
const fadeFast = { duration: duration.instant, ease: [...ease.standard] };
|
|
17
|
+
/** Starts animations on the next frame, so the render that caused them never swallows a spring's first frames. Returns a cleanup. */
|
|
18
|
+
function soon(start) {
|
|
19
|
+
let controls = null;
|
|
20
|
+
const run = () => { controls = start(); };
|
|
21
|
+
frame.update(run);
|
|
22
|
+
return () => { cancelFrame(run); controls?.stop(); };
|
|
23
|
+
}
|
|
24
|
+
/** Room above the top gridline, the share of each slot a bar fills, its widest size, and the radius of its data end. */
|
|
25
|
+
const TOP = 12, FILL = .58, MAX_BAR = 28, RADIUS = 4;
|
|
26
|
+
const grouped = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
|
|
27
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
28
|
+
/** Clean gridlines: the smallest step of 1, 2, 3, or 5 that covers the data in four rows or fewer, so shared steps survive a range change. */
|
|
29
|
+
function scaleFor(max) {
|
|
30
|
+
const safe = Math.max(max, 1);
|
|
31
|
+
const magnitude = 10 ** Math.floor(Math.log10(safe / 4));
|
|
32
|
+
for (const factor of [1, 2, 3, 5, 10]) {
|
|
33
|
+
const step = factor * magnitude, rows = Math.ceil(safe / step);
|
|
34
|
+
if (rows <= 4)
|
|
35
|
+
return { top: rows * step, ticks: Array.from({ length: rows + 1 }, (_, row) => row * step) };
|
|
36
|
+
}
|
|
37
|
+
return { top: safe, ticks: [0, safe] };
|
|
38
|
+
}
|
|
39
|
+
/** A column that grows from a square baseline to a softly rounded data end. */
|
|
40
|
+
function barPath(x, w, h, base) {
|
|
41
|
+
if (w <= 0 || h <= .01)
|
|
42
|
+
return `M${x.toFixed(2)} ${base}H${(x + Math.max(w, 0)).toFixed(2)}Z`;
|
|
43
|
+
const r = Math.min(RADIUS, w / 2, h), top = base - h, f = (value) => value.toFixed(2);
|
|
44
|
+
return `M${f(x)} ${base}V${f(top + r)}A${f(r)} ${f(r)} 0 0 1 ${f(x + r)} ${f(top)}H${f(x + w - r)}A${f(r)} ${f(r)} 0 0 1 ${f(x + w)} ${f(top + r)}V${base}Z`;
|
|
45
|
+
}
|
|
46
|
+
/** Slots count back from the latest bar, so the newest day stays pinned right while a longer range zooms out to the left. */
|
|
47
|
+
function usePlace(place, reduced) {
|
|
48
|
+
const value = useMotionValue(place);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (value.get() === place)
|
|
51
|
+
return;
|
|
52
|
+
if (reduced) {
|
|
53
|
+
value.jump(place);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
return soon(() => animate(value, place, settle));
|
|
57
|
+
}, [place, reduced, value]);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
const centerOf = ({ width, unit }, place) => width.get() - (place + .5) * width.get() * unit.get();
|
|
61
|
+
function Bar({ id, place, value, delay, shown, active, frame, scale, height, reduced }) {
|
|
62
|
+
const [isPresent, safeToRemove] = usePresence();
|
|
63
|
+
const amount = useMotionValue(0);
|
|
64
|
+
const slot = usePlace(place, reduced);
|
|
65
|
+
const [firstDelay] = useState(delay);
|
|
66
|
+
const grown = useRef(false);
|
|
67
|
+
const remove = useRef(safeToRemove);
|
|
68
|
+
useEffect(() => { remove.current = safeToRemove; }, [safeToRemove]);
|
|
69
|
+
// A bar grows once it is seen, each a beat after its neighbour; later values settle without a delay.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!isPresent)
|
|
72
|
+
return;
|
|
73
|
+
if (reduced) {
|
|
74
|
+
amount.jump(value);
|
|
75
|
+
grown.current = true;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!shown)
|
|
79
|
+
return;
|
|
80
|
+
const first = !grown.current;
|
|
81
|
+
grown.current = true;
|
|
82
|
+
return soon(() => animate(amount, value, first ? { ...grow, delay: firstDelay } : settle));
|
|
83
|
+
}, [amount, firstDelay, isPresent, reduced, shown, value]);
|
|
84
|
+
// A leaving bar sinks into the baseline, faster than a bar grows, while the zoom carries it out of the plot.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (isPresent)
|
|
87
|
+
return;
|
|
88
|
+
if (reduced) {
|
|
89
|
+
remove.current?.();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
return soon(() => animate(amount, 0, { ...collapse, onComplete: () => remove.current?.() }));
|
|
93
|
+
}, [amount, isPresent, reduced]);
|
|
94
|
+
const d = useTransform(() => {
|
|
95
|
+
const w = frame.width.get() * frame.unit.get(), size = Math.min(w * FILL, MAX_BAR), center = centerOf(frame, slot.get());
|
|
96
|
+
return barPath(center - size / 2, size, (amount.get() / scale.get()) * (height - TOP), height);
|
|
97
|
+
});
|
|
98
|
+
return _jsx(motion.path, { className: styles.bar, "data-key": id, "data-active": active || undefined, d: d });
|
|
99
|
+
}
|
|
100
|
+
/** A value on the axis: its gridline and label ride the scale, so a taller range slides them down while new rows fade in above. */
|
|
101
|
+
function useRowY(value, scale, height) {
|
|
102
|
+
return useTransform(() => Math.round(height - (value / scale.get()) * (height - TOP)) + .5);
|
|
103
|
+
}
|
|
104
|
+
function Gridline({ value, scale, height }) {
|
|
105
|
+
const y = useRowY(value, scale, height);
|
|
106
|
+
if (value === 0)
|
|
107
|
+
return null;
|
|
108
|
+
return _jsx(motion.line, { className: styles.grid, x1: 0, x2: "100%", y1: y, y2: y, initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: fadeFast }, transition: { duration: duration.standard, ease: [...ease.standard] } });
|
|
109
|
+
}
|
|
110
|
+
function TickLabel({ value, scale, height, avoid, format }) {
|
|
111
|
+
const y = useRowY(value, scale, height);
|
|
112
|
+
// A tick label makes way where the average label sits beside it.
|
|
113
|
+
const opacity = useTransform(() => avoid ? clamp((Math.abs(y.get() - avoid.get()) - 10) / 6, 0, 1) : 1);
|
|
114
|
+
return _jsx(motion.span, { className: styles.tick, style: { y }, initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: fadeFast }, transition: { duration: duration.standard, ease: [...ease.standard] }, children: _jsx(motion.span, { className: styles.tickText, style: { opacity }, children: format(value) }) });
|
|
115
|
+
}
|
|
116
|
+
function AxisLabel({ text, size, shift, place, frame, reduced }) {
|
|
117
|
+
const slot = usePlace(place, reduced);
|
|
118
|
+
// Centred under its bar and carried by the zoom. A label at either end keeps the nudge that holds it inside the settled plot.
|
|
119
|
+
const x = useTransform(() => centerOf(frame, slot.get()) - size / 2 + shift);
|
|
120
|
+
return _jsx(motion.span, { className: styles.axisLabel, style: { x }, initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: fadeFast }, transition: { duration: duration.standard, ease: [...ease.standard] }, children: text });
|
|
121
|
+
}
|
|
122
|
+
/** Axis labels that fit once the range has settled: every label, else every second, third, and so on, counted back from the latest so the newest date always shows and spacing stays even. */
|
|
123
|
+
function fittingLabels(data, width, sizes) {
|
|
124
|
+
const fitting = new Map();
|
|
125
|
+
if (!width)
|
|
126
|
+
return fitting;
|
|
127
|
+
const count = Math.max(1, data.length);
|
|
128
|
+
const labels = data.flatMap((item, index) => {
|
|
129
|
+
const size = item.axisLabel ? sizes[item.axisLabel] : undefined;
|
|
130
|
+
if (size === undefined)
|
|
131
|
+
return [];
|
|
132
|
+
const natural = width - (data.length - 1 - index + .5) * (width / count) - size / 2;
|
|
133
|
+
return [{ key: item.key, size, natural, left: clamp(natural, 0, Math.max(0, width - size)) }];
|
|
134
|
+
}).reverse();
|
|
135
|
+
for (let stride = 1; stride <= labels.length; stride++) {
|
|
136
|
+
const picked = labels.filter((_, rank) => rank % stride === 0);
|
|
137
|
+
if (stride === labels.length || picked.every((label, rank) => rank === 0 || label.left + label.size + 10 <= picked[rank - 1].left)) {
|
|
138
|
+
picked.forEach(label => fitting.set(label.key, label.left - label.natural));
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return fitting;
|
|
143
|
+
}
|
|
144
|
+
/** Changed text rises from a soft blur in the direction it moved, and leaves a little faster than it arrives. */
|
|
145
|
+
const rise = {
|
|
146
|
+
hidden: (direction) => ({ opacity: 0, y: `${.3 * direction}em`, filter: `blur(${blur.soft}px)` }),
|
|
147
|
+
shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.standard, ease: [...ease.enter] } },
|
|
148
|
+
gone: (direction) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: duration.instant, ease: [...ease.standard] } }),
|
|
149
|
+
};
|
|
150
|
+
const fade = { hidden: { opacity: 0, y: 0, filter: "blur(0px)" }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.fast } }, gone: { opacity: 0, y: 0, filter: "blur(0px)", transition: { duration: duration.instant } } };
|
|
151
|
+
function Swap({ text, direction, reduced }) {
|
|
152
|
+
return _jsx("span", { className: styles.swap, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: direction, children: _jsx(motion.span, { className: styles.swapText, custom: direction, variants: reduced ? fade : rise, initial: "hidden", animate: "shown", exit: "gone", children: text }, text) }) });
|
|
153
|
+
}
|
|
154
|
+
/** A number rolls digit by digit, keyed by place from the right, so unchanged digits hold still. Its width springs to fit, so the unit beside it glides instead of jumping. */
|
|
155
|
+
function Roll({ text, direction, reduced }) {
|
|
156
|
+
const sizer = useRef(null);
|
|
157
|
+
const width = useMotionValue("auto");
|
|
158
|
+
useLayoutEffect(() => {
|
|
159
|
+
const node = sizer.current;
|
|
160
|
+
if (!node || typeof ResizeObserver === "undefined")
|
|
161
|
+
return;
|
|
162
|
+
let measured = null;
|
|
163
|
+
// Only a new value springs; a late web font or a resize jumps straight to fit.
|
|
164
|
+
const observer = new ResizeObserver(() => {
|
|
165
|
+
const next = node.offsetWidth;
|
|
166
|
+
if (measured !== null && measured !== node.textContent && !reduced)
|
|
167
|
+
animate(width, next, grow);
|
|
168
|
+
else
|
|
169
|
+
width.jump(next);
|
|
170
|
+
measured = node.textContent;
|
|
171
|
+
});
|
|
172
|
+
observer.observe(node);
|
|
173
|
+
return () => observer.disconnect();
|
|
174
|
+
}, [reduced, width]);
|
|
175
|
+
const chars = [...text];
|
|
176
|
+
const variants = reduced ? fade : rise;
|
|
177
|
+
return _jsxs(motion.span, { className: styles.roll, style: { width }, children: [_jsx("span", { ref: sizer, className: styles.sizer, "aria-hidden": "true", children: text }), _jsx("span", { className: styles.digits, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: direction, anchorX: "right", children: chars.map((char, index) => _jsx(motion.span, { className: styles.digit, custom: direction, variants: variants, initial: "hidden", animate: "shown", exit: "gone", children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: direction, anchorX: "right", children: _jsx(motion.span, { className: styles.digit, custom: direction, variants: variants, initial: "hidden", animate: "shown", exit: "gone", children: char }, char) }) }, `p${chars.length - index}`)) }) })] });
|
|
178
|
+
}
|
|
179
|
+
const subscribeNothing = () => () => { };
|
|
180
|
+
/** Reduced motion is only known in the browser, so the first client render matches the server before it takes effect. */
|
|
181
|
+
function useReducedMotionSafe() {
|
|
182
|
+
const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);
|
|
183
|
+
return !!useReducedMotion() && hydrated;
|
|
184
|
+
}
|
|
185
|
+
export function BarChart({ data, label, period, unit = "", averageLabel = "Daily average", valueLabel = "Total", categoryLabel = "Day", showAverage = true, height = 176, formatValue = value => grouped.format(value) }) {
|
|
186
|
+
const reduced = useReducedMotionSafe();
|
|
187
|
+
const figure = useRef(null);
|
|
188
|
+
const plot = useRef(null);
|
|
189
|
+
const inView = useInView(figure, { once: true, amount: .35 });
|
|
190
|
+
const titleId = useId();
|
|
191
|
+
const count = Math.max(data.length, 1), last = data.length - 1;
|
|
192
|
+
const average = data.length ? data.reduce((sum, item) => sum + item.value, 0) / data.length : 0;
|
|
193
|
+
const peak = data.reduce((max, item) => Math.max(max, item.value), 0);
|
|
194
|
+
const { top, ticks } = useMemo(() => scaleFor(peak), [peak]);
|
|
195
|
+
const signature = data.map(item => item.key).join("|");
|
|
196
|
+
const suffix = unit ? ` ${unit}` : "";
|
|
197
|
+
// Bars that join an existing chart grow outward from the ones that stayed; the first reveal runs left to right.
|
|
198
|
+
const [known, setKnown] = useState(() => ({ signature, keys: new Set(data.map(item => item.key)), fresh: new Map(), changes: 0 }));
|
|
199
|
+
const [active, setActive] = useState(null);
|
|
200
|
+
if (known.signature !== signature) {
|
|
201
|
+
const incoming = data.map((item, index) => ({ key: item.key, place: last - index })).filter(item => !known.keys.has(item.key)).sort((a, b) => a.place - b.place);
|
|
202
|
+
const step = Math.min(stagger.item, .32 / Math.max(1, incoming.length));
|
|
203
|
+
setKnown({ signature, keys: new Set(data.map(item => item.key)), fresh: new Map(incoming.map((item, rank) => [item.key, rank * step])), changes: known.changes + 1 });
|
|
204
|
+
setActive(null);
|
|
205
|
+
}
|
|
206
|
+
const revealStep = Math.min(stagger.item, .36 / count);
|
|
207
|
+
const index = active === null ? null : Math.min(active, last);
|
|
208
|
+
const scrubbing = index !== null && last >= 0;
|
|
209
|
+
const shownValue = scrubbing ? data[index].value : average;
|
|
210
|
+
// Headline copy moves the way the data did: a larger value rises from below, a later day arrives from below too.
|
|
211
|
+
const [trend, setTrend] = useState({ value: shownValue, index, valueWay: 1, indexWay: 1 });
|
|
212
|
+
if (trend.value !== shownValue || trend.index !== index)
|
|
213
|
+
setTrend({ value: shownValue, index, valueWay: shownValue === trend.value ? trend.valueWay : shownValue > trend.value ? 1 : -1, indexWay: index === null || trend.index === null ? 1 : Math.sign(index - trend.index) || 1 });
|
|
214
|
+
const width = useMotionValue(0);
|
|
215
|
+
const unitShare = useMotionValue(1 / count);
|
|
216
|
+
const scale = useMotionValue(top);
|
|
217
|
+
const mean = useMotionValue(0);
|
|
218
|
+
const cursor = useMotionValue(0);
|
|
219
|
+
const frame = useMemo(() => ({ width, unit: unitShare }), [width, unitShare]);
|
|
220
|
+
// The chart draws in its own pixels; the width is read before paint and followed on resize. Axis labels wait for it, then thin out where they would collide.
|
|
221
|
+
const [plotWidth, setPlotWidth] = useState(0);
|
|
222
|
+
const [labelSizes, setLabelSizes] = useState({});
|
|
223
|
+
const measure = useRef(null);
|
|
224
|
+
const labelTexts = [...new Set(data.map(item => item.axisLabel).filter((text) => !!text))].join("\n");
|
|
225
|
+
useLayoutEffect(() => {
|
|
226
|
+
const node = plot.current, ruler = measure.current;
|
|
227
|
+
if (!node || !ruler)
|
|
228
|
+
return;
|
|
229
|
+
const read = () => {
|
|
230
|
+
width.set(node.clientWidth);
|
|
231
|
+
setPlotWidth(node.clientWidth);
|
|
232
|
+
const sizes = {};
|
|
233
|
+
ruler.querySelectorAll("[data-text]").forEach(item => { sizes[item.dataset.text ?? ""] = item.offsetWidth; });
|
|
234
|
+
setLabelSizes(current => Object.keys(sizes).every(text => current[text] === sizes[text]) ? current : sizes);
|
|
235
|
+
};
|
|
236
|
+
read();
|
|
237
|
+
if (typeof ResizeObserver === "undefined")
|
|
238
|
+
return;
|
|
239
|
+
const observer = new ResizeObserver(read);
|
|
240
|
+
observer.observe(node);
|
|
241
|
+
observer.observe(ruler);
|
|
242
|
+
return () => observer.disconnect();
|
|
243
|
+
}, [labelTexts, width]);
|
|
244
|
+
const shownLabels = useMemo(() => fittingLabels(data, plotWidth, labelSizes), [data, plotWidth, labelSizes]);
|
|
245
|
+
// A new range zooms the time axis around the latest bar, rescales the gridlines, and moves the average, all on springs that can be retargeted mid-flight.
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
if (reduced) {
|
|
248
|
+
unitShare.jump(1 / count);
|
|
249
|
+
scale.jump(top);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
return soon(() => { const controls = [animate(unitShare, 1 / count, zoom), animate(scale, top, settle)]; return { stop: () => controls.forEach(control => control.stop()) }; });
|
|
253
|
+
}, [count, reduced, scale, top, unitShare]);
|
|
254
|
+
// The average rises with the first bars, then springs to each new range.
|
|
255
|
+
const meanStarted = useRef(false);
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (reduced) {
|
|
258
|
+
mean.jump(average);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (!inView)
|
|
262
|
+
return;
|
|
263
|
+
const first = !meanStarted.current;
|
|
264
|
+
meanStarted.current = true;
|
|
265
|
+
return soon(() => animate(mean, average, first ? { ...settle, delay: .12 } : settle));
|
|
266
|
+
}, [average, inView, mean, reduced]);
|
|
267
|
+
// The cursor appears on the first scrubbed bar and glides between the ones after it.
|
|
268
|
+
const wasScrubbing = useRef(false);
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
if (index === null) {
|
|
271
|
+
wasScrubbing.current = false;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const place = last - index;
|
|
275
|
+
if (reduced || !wasScrubbing.current)
|
|
276
|
+
cursor.jump(place);
|
|
277
|
+
wasScrubbing.current = true;
|
|
278
|
+
if (reduced)
|
|
279
|
+
return;
|
|
280
|
+
const controls = animate(cursor, place, glide);
|
|
281
|
+
return () => controls.stop();
|
|
282
|
+
}, [cursor, index, last, reduced]);
|
|
283
|
+
const cursorX = useTransform(() => Math.round(centerOf(frame, cursor.get())) + .5);
|
|
284
|
+
const meanY = useTransform(() => Math.round(height - (mean.get() / scale.get()) * (height - TOP)) + .5);
|
|
285
|
+
const meanText = useTransform(() => `Avg ${formatValue(Math.round(mean.get()))}`);
|
|
286
|
+
const pointAt = (clientX) => {
|
|
287
|
+
const rect = plot.current?.getBoundingClientRect();
|
|
288
|
+
if (!rect?.width)
|
|
289
|
+
return last;
|
|
290
|
+
const place = Math.floor(((rect.right - clientX) / rect.width) * count);
|
|
291
|
+
return clamp(last - place, 0, last);
|
|
292
|
+
};
|
|
293
|
+
const onKeyDown = (event) => {
|
|
294
|
+
if (event.key === "Escape" && scrubbing) {
|
|
295
|
+
event.preventDefault();
|
|
296
|
+
setActive(null);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const from = index ?? last + 1, page = Math.min(7, count);
|
|
300
|
+
const next = { ArrowLeft: from - 1, ArrowDown: from - 1, ArrowRight: index === null ? last : from + 1, ArrowUp: index === null ? last : from + 1, PageDown: from - page, PageUp: from + page, Home: 0, End: last }[event.key];
|
|
301
|
+
if (next === undefined || last < 0)
|
|
302
|
+
return;
|
|
303
|
+
event.preventDefault();
|
|
304
|
+
setActive(clamp(next, 0, last));
|
|
305
|
+
};
|
|
306
|
+
const onPointerDown = (event) => {
|
|
307
|
+
if (event.pointerType !== "mouse")
|
|
308
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
309
|
+
setActive(pointAt(event.clientX));
|
|
310
|
+
};
|
|
311
|
+
const onPointerMove = (event) => { if (event.pointerType === "mouse" || event.buttons)
|
|
312
|
+
setActive(pointAt(event.clientX)); };
|
|
313
|
+
const release = (event) => { if (event.pointerType !== "mouse")
|
|
314
|
+
setActive(null); };
|
|
315
|
+
const scrubbed = scrubbing ? data[index] : null;
|
|
316
|
+
const reading = scrubbed ?? data[last];
|
|
317
|
+
const valueText = reading ? `${reading.label}: ${formatValue(reading.value)}${suffix}` : "No data";
|
|
318
|
+
const highest = data.reduce((best, item) => !best || item.value > best.value ? item : best, null);
|
|
319
|
+
const lowest = data.reduce((best, item) => !best || item.value < best.value ? item : best, null);
|
|
320
|
+
const summary = `${label}, ${period}. ${averageLabel} ${formatValue(Math.round(average))}${suffix}.${highest ? ` Highest ${highest.label}, ${formatValue(highest.value)}${suffix}.` : ""}${lowest && lowest !== highest ? ` Lowest ${lowest.label}, ${formatValue(lowest.value)}${suffix}.` : ""}`;
|
|
321
|
+
const shown = inView || reduced;
|
|
322
|
+
const avoid = showAverage ? meanY : null;
|
|
323
|
+
return _jsxs("figure", { ref: figure, className: styles.figure, "aria-labelledby": titleId, children: [_jsxs("figcaption", { id: titleId, className: styles.srOnly, children: [label, ", ", period] }), _jsxs("div", { className: styles.header, "aria-hidden": "true", children: [_jsx("span", { className: styles.kind, children: _jsx(Swap, { text: scrubbing ? valueLabel : averageLabel, direction: 1, reduced: reduced }) }), _jsxs("span", { className: styles.value, children: [_jsx(Roll, { text: formatValue(scrubbing ? data[index].value : Math.round(average)), direction: trend.valueWay, reduced: reduced }), unit && _jsx("span", { className: styles.unit, children: unit })] }), _jsx("span", { className: styles.when, children: _jsx(Swap, { text: scrubbed ? scrubbed.label : period, direction: scrubbing ? trend.indexWay : 1, reduced: reduced }) })] }), _jsxs("div", { className: styles.chart, "data-scrubbing": scrubbing || undefined, children: [_jsx("div", { ref: plot, className: styles.plot, style: { height }, children: _jsxs("svg", { className: styles.svg, width: "100%", height: height, role: "img", "aria-label": summary, children: [_jsx(AnimatePresence, { initial: false, children: ticks.map(value => _jsx(Gridline, { value: value, scale: scale, height: height }, value)) }), _jsx(motion.line, { className: styles.cursor, x1: cursorX, x2: cursorX, y1: TOP, y2: height, initial: false, animate: { opacity: scrubbing ? 1 : 0 }, transition: reduced ? { duration: 0 } : fadeFast }), _jsx(AnimatePresence, { initial: false, children: data.map((item, at) => _jsx(Bar, { id: item.key, place: last - at, value: item.value, delay: known.fresh.get(item.key) ?? at * revealStep, shown: shown, active: index === at, frame: frame, scale: scale, height: height, reduced: reduced }, item.key)) }), _jsx("line", { className: styles.baseline, x1: 0, x2: "100%", y1: height - .5, y2: height - .5 }), showAverage && _jsx(motion.line, { className: styles.mean, x1: 0, x2: "100%", y1: meanY, y2: meanY, initial: false, animate: { opacity: shown ? 1 : 0 }, transition: reduced ? { duration: 0 } : { duration: duration.standard, delay: .12 } })] }) }), _jsxs("div", { className: styles.gutter, "aria-hidden": "true", children: [_jsx(AnimatePresence, { initial: false, children: ticks.map(value => _jsx(TickLabel, { value: value, scale: scale, height: height, avoid: avoid, format: formatValue }, value)) }), showAverage && _jsx(motion.span, { className: styles.meanLabel, style: { y: meanY }, initial: false, animate: { opacity: shown ? 1 : 0 }, transition: reduced ? { duration: 0 } : { duration: duration.standard, delay: .12 }, children: _jsx(motion.span, { className: styles.tickText, children: meanText }) })] }), _jsxs("div", { className: styles.axis, "aria-hidden": "true", children: [_jsx(AnimatePresence, { initial: false, children: data.map((item, at) => item.axisLabel && shownLabels.has(item.key) ? _jsx(AxisLabel, { text: item.axisLabel, size: labelSizes[item.axisLabel] ?? 0, shift: shownLabels.get(item.key) ?? 0, place: last - at, frame: frame, reduced: reduced }, `${item.key}:${item.axisLabel}`) : null) }), _jsx("span", { ref: measure, className: styles.measure, children: labelTexts.split("\n").filter(Boolean).map(text => _jsx("span", { "data-text": text, children: text }, text)) })] }), _jsx("div", { className: styles.scrubber, role: "slider", tabIndex: 0, "aria-label": `${label}, explore by ${categoryLabel.toLowerCase()}`, "aria-orientation": "horizontal", "aria-valuemin": 1, "aria-valuemax": Math.max(1, data.length), "aria-valuenow": (index ?? last) + 1, "aria-valuetext": valueText, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: release, onPointerCancel: () => setActive(null), onPointerLeave: event => { if (event.pointerType === "mouse")
|
|
324
|
+
setActive(null); }, onKeyDown: onKeyDown, onBlur: () => setActive(null), onFocus: event => { if (event.currentTarget.matches(":focus-visible") && last >= 0)
|
|
325
|
+
setActive(current => current ?? last); } })] }), _jsxs("table", { className: styles.srOnly, children: [_jsxs("caption", { children: [label, ", ", period] }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: categoryLabel }), _jsx("th", { scope: "col", children: label })] }) }), _jsx("tbody", { children: data.map(item => _jsxs("tr", { children: [_jsx("th", { scope: "row", children: item.label }), _jsxs("td", { children: [formatValue(item.value), suffix] })] }, item.key)) })] }), _jsx("p", { className: styles.srOnly, "aria-live": "polite", "aria-atomic": "true", children: known.changes ? `${period}. ${averageLabel} ${formatValue(Math.round(average))}${suffix}.` : "" })] });
|
|
326
|
+
}
|
|
327
|
+
export default BarChart;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
.figure { display: grid; min-width: 0; gap: var(--space-comfort); margin: 0; color: var(--foreground); }
|
|
2
|
+
|
|
3
|
+
/* The headline is the readout: the average at rest, the scrubbed bar while exploring. Each line keeps its height, so swaps never move the plot. */
|
|
4
|
+
.header { display: grid; min-width: 0; gap: 2px; }
|
|
5
|
+
.kind, .when { display: block; min-height: calc(1em * var(--leading-body)); font-size: var(--text-sm); line-height: var(--leading-body); }
|
|
6
|
+
.kind { color: var(--text-secondary); }
|
|
7
|
+
.when { color: var(--text-muted); }
|
|
8
|
+
.value { display: flex; min-width: 0; align-items: baseline; gap: 6px; font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); font-variant-numeric: tabular-nums; }
|
|
9
|
+
.unit { color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-base); font-weight: 400; letter-spacing: var(--tracking-body); }
|
|
10
|
+
.swap { position: relative; display: block; min-width: 0; }
|
|
11
|
+
.swapText { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
12
|
+
/* A rolling number: outgoing digits are popped out of flow and anchored right, and the clip hides a digit still rising into a new place. */
|
|
13
|
+
.roll { position: relative; display: inline-flex; justify-content: flex-end; overflow-x: clip; overflow-y: visible; white-space: pre; }
|
|
14
|
+
.digits { position: relative; display: inline-flex; flex: none; }
|
|
15
|
+
.digit { position: relative; display: inline-block; white-space: pre; }
|
|
16
|
+
.sizer { position: absolute; top: 0; left: 0; visibility: hidden; white-space: pre; pointer-events: none; }
|
|
17
|
+
|
|
18
|
+
/* Plot, right-hand value axis, and the date axis beneath. */
|
|
19
|
+
.chart { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 52px; grid-template-rows: auto 26px; min-width: 0; }
|
|
20
|
+
.plot { position: relative; grid-area: 1 / 1; min-width: 0; }
|
|
21
|
+
.svg { display: block; overflow: hidden; }
|
|
22
|
+
.grid { stroke: var(--border); stroke-width: 1; shape-rendering: crispEdges; }
|
|
23
|
+
.baseline { stroke: var(--border-strong); stroke-width: 1; shape-rendering: crispEdges; }
|
|
24
|
+
.cursor { stroke: var(--border-strong); stroke-width: 1; shape-rendering: crispEdges; }
|
|
25
|
+
.mean { stroke: var(--text-secondary); stroke-width: 1; stroke-dasharray: 3 3; shape-rendering: crispEdges; }
|
|
26
|
+
/* The series is drawn in a soft step of the accent; the scrubbed bar takes the full accent while its neighbours step back. */
|
|
27
|
+
.bar { fill: color-mix(in oklab, var(--accent) 62%, var(--surface)); transition: fill var(--duration-fast) var(--ease-standard); }
|
|
28
|
+
.chart[data-scrubbing] .bar { fill: color-mix(in oklab, var(--accent) 24%, var(--surface)); }
|
|
29
|
+
/* On a dark surface a lighter mix keeps the hue clear instead of turning muddy. */
|
|
30
|
+
:global([data-theme="dark"]) .bar { fill: color-mix(in oklab, var(--accent) 76%, var(--surface)); }
|
|
31
|
+
:global([data-theme="dark"]) .chart[data-scrubbing] .bar { fill: color-mix(in oklab, var(--accent) 34%, var(--surface)); }
|
|
32
|
+
:global([data-theme="dark"]) .chart .bar[data-active] { fill: var(--accent); }
|
|
33
|
+
.chart .bar[data-active] { fill: var(--accent); }
|
|
34
|
+
|
|
35
|
+
.gutter { position: relative; grid-area: 1 / 2; min-width: 0; }
|
|
36
|
+
.tick, .meanLabel { position: absolute; top: 0; left: 10px; display: block; height: 0; white-space: nowrap; pointer-events: none; }
|
|
37
|
+
.tickText { display: block; transform: translateY(-50%); color: var(--text-muted); font-size: var(--text-xs); line-height: 1; font-variant-numeric: tabular-nums; }
|
|
38
|
+
.meanLabel .tickText { color: var(--foreground); font-weight: 500; }
|
|
39
|
+
|
|
40
|
+
.axis { position: relative; grid-area: 2 / 1; min-width: 0; overflow: hidden; }
|
|
41
|
+
.axisLabel { position: absolute; top: 8px; left: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
42
|
+
|
|
43
|
+
/* The scrubber covers the plot and its axes. Horizontal drags scrub, vertical swipes still scroll the page. */
|
|
44
|
+
.scrubber { position: absolute; inset: 0; border-radius: calc(var(--radius-control) / 2); outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
|
|
45
|
+
|
|
46
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
47
|
+
@media (prefers-reduced-motion: reduce) { .bar { transition: none; } }
|
|
48
|
+
.measure { position: absolute; top: 0; left: 0; display: flex; visibility: hidden; pointer-events: none; }
|
|
49
|
+
.measure > span { flex: none; font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface BillingToggleOption {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
/** Short savings note, e.g. "Save 20%". */
|
|
5
|
+
badge?: string;
|
|
6
|
+
/** Badge text once this option is selected, e.g. "You save $48". Defaults to `badge`. */
|
|
7
|
+
activeBadge?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface BillingToggleProps {
|
|
10
|
+
value: string;
|
|
11
|
+
onValueChange: (value: string) => void;
|
|
12
|
+
options?: BillingToggleOption[];
|
|
13
|
+
/** Accessible name of the group. */
|
|
14
|
+
label?: string;
|
|
15
|
+
size?: "md" | "lg";
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A billing period switch. One thumb glides between the options on a critically damped spring, and
|
|
20
|
+
* the savings note on the cheaper period tints and rewrites itself in place once it is chosen.
|
|
21
|
+
*/
|
|
22
|
+
export declare function BillingToggle({ value, onValueChange, options, label, size, className }: BillingToggleProps): import("react").JSX.Element;
|
|
23
|
+
export interface BillingPriceProps {
|
|
24
|
+
amount: number;
|
|
25
|
+
currency?: string;
|
|
26
|
+
/** Period after the price, e.g. "per month". Swaps in place when it changes. */
|
|
27
|
+
period?: string;
|
|
28
|
+
/** Previous price, shown struck through when it is higher than `amount`. */
|
|
29
|
+
was?: number;
|
|
30
|
+
decimals?: number;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
/** A price that rolls to its new amount. The old price and the period ease their width, so nothing beside them jumps. */
|
|
34
|
+
export declare function BillingPrice({ amount, currency, period, was, decimals, className }: BillingPriceProps): import("react").JSX.Element;
|
|
35
|
+
export default BillingToggle;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
5
|
+
import { AnimatedCounter } from "../animated-counter/animated-counter.js";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import styles from "./billing-toggle.module.css";
|
|
8
|
+
const { duration, ease, blur } = motionTokens;
|
|
9
|
+
/** Critically damped: the thumb glides and lands without overshoot. */
|
|
10
|
+
const glide = { type: "spring", visualDuration: 0.34, bounce: 0 };
|
|
11
|
+
const DEFAULT_OPTIONS = [
|
|
12
|
+
{ value: "monthly", label: "Monthly" },
|
|
13
|
+
{ value: "yearly", label: "Yearly", badge: "Save 20%" },
|
|
14
|
+
];
|
|
15
|
+
const swap = {
|
|
16
|
+
hidden: { opacity: 0, y: "0.45em", filter: `blur(${blur.subtle}px)` },
|
|
17
|
+
shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.standard, ease: [...ease.enter] } },
|
|
18
|
+
gone: { opacity: 0, y: "-0.45em", filter: `blur(${blur.subtle}px)`, transition: { duration: duration.fast, ease: [...ease.standard] } },
|
|
19
|
+
};
|
|
20
|
+
const still = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: duration.fast } }, gone: { opacity: 0, transition: { duration: 0 } } };
|
|
21
|
+
/**
|
|
22
|
+
* Text that swaps in place. Every candidate sits in the same grid cell, so the box always has the
|
|
23
|
+
* width of the longest one and nothing around it moves when the text changes.
|
|
24
|
+
*/
|
|
25
|
+
function StableSwap({ text, candidates, reduced, className }) {
|
|
26
|
+
return (_jsxs("span", { className: [styles.swap, className].filter(Boolean).join(" "), children: [[...new Set(candidates)].map(candidate => _jsx("span", { className: styles.swapSizer, "aria-hidden": "true", children: candidate }, candidate)), _jsx(AnimatePresence, { initial: false, children: _jsx(motion.span, { className: styles.swapText, variants: reduced ? still : swap, initial: "hidden", animate: "shown", exit: "gone", children: text }, text) })] }));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A billing period switch. One thumb glides between the options on a critically damped spring, and
|
|
30
|
+
* the savings note on the cheaper period tints and rewrites itself in place once it is chosen.
|
|
31
|
+
*/
|
|
32
|
+
export function BillingToggle({ value, onValueChange, options = DEFAULT_OPTIONS, label = "Billing period", size = "md", className }) {
|
|
33
|
+
const reduced = !!useReducedMotion();
|
|
34
|
+
const rootRef = useRef(null);
|
|
35
|
+
const refs = useRef([]);
|
|
36
|
+
const [thumb, setThumb] = useState(null);
|
|
37
|
+
const selectedIndex = Math.max(0, options.findIndex(option => option.value === value));
|
|
38
|
+
const measure = useCallback(() => {
|
|
39
|
+
const node = refs.current[selectedIndex];
|
|
40
|
+
if (!node)
|
|
41
|
+
return;
|
|
42
|
+
setThumb(current => current && current.x === node.offsetLeft && current.width === node.offsetWidth ? current : { x: node.offsetLeft, width: node.offsetWidth });
|
|
43
|
+
}, [selectedIndex]);
|
|
44
|
+
useLayoutEffect(() => {
|
|
45
|
+
measure();
|
|
46
|
+
const root = rootRef.current;
|
|
47
|
+
if (!root || typeof ResizeObserver === "undefined")
|
|
48
|
+
return;
|
|
49
|
+
const observer = new ResizeObserver(measure);
|
|
50
|
+
observer.observe(root);
|
|
51
|
+
return () => observer.disconnect();
|
|
52
|
+
}, [measure]);
|
|
53
|
+
const onKeyDown = (event, index) => {
|
|
54
|
+
const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
|
|
55
|
+
const target = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : step ? (index + step + options.length) % options.length : -1;
|
|
56
|
+
if (target < 0)
|
|
57
|
+
return;
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
onValueChange(options[target].value);
|
|
60
|
+
refs.current[target]?.focus();
|
|
61
|
+
};
|
|
62
|
+
return (_jsxs("div", { ref: rootRef, role: "radiogroup", "aria-label": label, className: [styles.root, className].filter(Boolean).join(" "), "data-size": size, children: [thumb ? (_jsx(motion.span, { className: styles.thumb, "aria-hidden": "true", initial: false, animate: { x: thumb.x, width: thumb.width }, transition: reduced ? { duration: 0 } : glide })) : null, options.map((option, index) => {
|
|
63
|
+
const selected = index === selectedIndex;
|
|
64
|
+
const badgeText = selected ? option.activeBadge ?? option.badge : option.badge;
|
|
65
|
+
const candidates = [option.badge, option.activeBadge].filter((text) => !!text);
|
|
66
|
+
return (_jsxs("button", { ref: node => { refs.current[index] = node; }, type: "button", role: "radio", "aria-checked": selected, tabIndex: selected ? 0 : -1, className: styles.option, "data-selected": selected || undefined, onClick: () => onValueChange(option.value), onKeyDown: event => onKeyDown(event, index), children: [selected && !thumb ? _jsx("span", { className: styles.thumbStatic, "aria-hidden": "true" }) : null, _jsxs("span", { className: styles.content, children: [_jsx("span", { className: styles.label, children: option.label }), badgeText ? (_jsx("span", { className: styles.badge, "data-active": selected || undefined, children: _jsx(StableSwap, { text: badgeText, candidates: candidates, reduced: reduced }) })) : null] })] }, option.value));
|
|
67
|
+
})] }));
|
|
68
|
+
}
|
|
69
|
+
const clip = { type: "spring", visualDuration: 0.36, bounce: 0 };
|
|
70
|
+
/** A price that rolls to its new amount. The old price and the period ease their width, so nothing beside them jumps. */
|
|
71
|
+
export function BillingPrice({ amount, currency = "$", period, was, decimals = 0, className }) {
|
|
72
|
+
const reduced = !!useReducedMotion();
|
|
73
|
+
const showWas = was !== undefined && was > amount;
|
|
74
|
+
const periodRef = useRef(null);
|
|
75
|
+
const [periodWidth, setPeriodWidth] = useState(null);
|
|
76
|
+
useLayoutEffect(() => {
|
|
77
|
+
const sizer = periodRef.current;
|
|
78
|
+
if (sizer)
|
|
79
|
+
setPeriodWidth(sizer.offsetWidth);
|
|
80
|
+
}, [period]);
|
|
81
|
+
return (_jsxs("span", { className: [styles.price, className].filter(Boolean).join(" "), children: [_jsxs("span", { className: styles.amount, children: [_jsx("span", { className: styles.currency, children: currency }), _jsx("span", { className: styles.counter, children: _jsx(AnimatedCounter, { value: amount, decimals: decimals }) })] }), _jsxs("span", { className: styles.aside, children: [_jsx(AnimatePresence, { initial: false, children: showWas ? (_jsx(motion.span, { className: styles.wasSlot, initial: reduced ? { opacity: 0 } : { opacity: 0, width: 0 }, animate: { opacity: 1, width: "auto" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, width: 0, transition: { ...clip, opacity: { duration: duration.fast } } }, transition: reduced ? { duration: duration.fast } : { ...clip, opacity: { duration: duration.standard, ease: [...ease.enter] } }, children: _jsxs("del", { className: styles.was, children: [currency, was.toFixed(decimals)] }) }, "was")) : null }), period ? (_jsxs(motion.span, { className: styles.period, initial: false, animate: periodWidth === null ? undefined : { width: periodWidth }, transition: reduced ? { duration: 0 } : clip, children: [_jsx("span", { ref: periodRef, className: styles.periodSizer, "aria-hidden": "true", children: period }), _jsx(AnimatePresence, { initial: false, children: _jsx(motion.span, { className: styles.periodText, variants: reduced ? still : swap, initial: "hidden", animate: "shown", exit: "gone", children: period }, period) })] })) : null] })] }));
|
|
82
|
+
}
|
|
83
|
+
export default BillingToggle;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/* Override --billing-accent on any ancestor to recolor the savings note. */
|
|
2
|
+
.root {
|
|
3
|
+
--_accent: var(--billing-accent, var(--success));
|
|
4
|
+
--_h: 34px;
|
|
5
|
+
--_pad: 3px;
|
|
6
|
+
position: relative;
|
|
7
|
+
isolation: isolate;
|
|
8
|
+
display: inline-flex;
|
|
9
|
+
max-width: 100%;
|
|
10
|
+
align-items: center;
|
|
11
|
+
padding: var(--_pad);
|
|
12
|
+
border: 1px solid var(--border-subtle);
|
|
13
|
+
border-radius: var(--radius-full); corner-shape: round;
|
|
14
|
+
background: var(--surface-muted);
|
|
15
|
+
font-family: var(--font-body);
|
|
16
|
+
font-size: var(--text-sm);
|
|
17
|
+
letter-spacing: var(--tracking-body);
|
|
18
|
+
line-height: 1;
|
|
19
|
+
}
|
|
20
|
+
.root[data-size="lg"] { --_h: 42px; --_pad: 4px; font-size: var(--text-base); }
|
|
21
|
+
|
|
22
|
+
/* One thumb for the whole track. Its position and width are measured from the selected option. */
|
|
23
|
+
.thumb,
|
|
24
|
+
.thumbStatic {
|
|
25
|
+
position: absolute;
|
|
26
|
+
z-index: -1;
|
|
27
|
+
border-radius: var(--radius-full); corner-shape: round;
|
|
28
|
+
background: var(--surface-raised);
|
|
29
|
+
box-shadow: 0 0 0 1px var(--border-subtle), 0 1px 2px oklch(0% 0 0 / .06), 0 2px 6px oklch(0% 0 0 / .04);
|
|
30
|
+
pointer-events: none;
|
|
31
|
+
}
|
|
32
|
+
.thumb { top: var(--_pad); bottom: var(--_pad); left: 0; will-change: transform, width; }
|
|
33
|
+
.thumbStatic { inset: 0; }
|
|
34
|
+
:global(:root[data-theme="dark"]) .thumb,
|
|
35
|
+
:global(:root[data-theme="dark"]) .thumbStatic {
|
|
36
|
+
box-shadow: 0 0 0 1px var(--border), inset 0 1px 0 oklch(100% 0 0 / .05), 0 1px 2px oklch(0% 0 0 / .3);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.option {
|
|
40
|
+
position: relative;
|
|
41
|
+
display: inline-flex;
|
|
42
|
+
min-width: 0;
|
|
43
|
+
height: var(--_h);
|
|
44
|
+
align-items: center;
|
|
45
|
+
padding: 0 14px;
|
|
46
|
+
border: 0;
|
|
47
|
+
border-radius: var(--radius-full); corner-shape: round;
|
|
48
|
+
background: transparent;
|
|
49
|
+
color: var(--text-muted);
|
|
50
|
+
font: inherit;
|
|
51
|
+
letter-spacing: inherit;
|
|
52
|
+
white-space: nowrap;
|
|
53
|
+
cursor: pointer;
|
|
54
|
+
transition: color var(--duration-fast) var(--ease-standard);
|
|
55
|
+
-webkit-tap-highlight-color: transparent;
|
|
56
|
+
}
|
|
57
|
+
.option[data-selected] { color: var(--foreground); cursor: default; }
|
|
58
|
+
.option:has(.badge) { padding-right: 5px; }
|
|
59
|
+
.root[data-size="lg"] .option { padding-inline: 18px; }
|
|
60
|
+
.root[data-size="lg"] .option:has(.badge) { padding-right: 6px; }
|
|
61
|
+
|
|
62
|
+
@media (hover: hover) and (pointer: fine) {
|
|
63
|
+
.option:not([data-selected]):hover { color: var(--text-secondary); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.content {
|
|
67
|
+
position: relative;
|
|
68
|
+
display: inline-flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 8px;
|
|
71
|
+
transition: transform var(--duration-fast) var(--ease-standard);
|
|
72
|
+
}
|
|
73
|
+
.option:not([data-selected]):active .content { transform: scale(.97); }
|
|
74
|
+
|
|
75
|
+
.label { font-weight: 500; }
|
|
76
|
+
|
|
77
|
+
/* The savings note: quiet until its option is chosen, then it takes the accent tint. */
|
|
78
|
+
.badge {
|
|
79
|
+
display: inline-flex;
|
|
80
|
+
height: calc(var(--_h) - 10px);
|
|
81
|
+
align-items: center;
|
|
82
|
+
padding: 0 8px;
|
|
83
|
+
border-radius: var(--radius-full); corner-shape: round;
|
|
84
|
+
background: color-mix(in oklab, var(--foreground) 6%, transparent);
|
|
85
|
+
color: var(--text-secondary);
|
|
86
|
+
font-size: var(--text-xs);
|
|
87
|
+
font-weight: 500;
|
|
88
|
+
font-variant-numeric: tabular-nums;
|
|
89
|
+
transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard);
|
|
90
|
+
}
|
|
91
|
+
.badge[data-active] {
|
|
92
|
+
background: color-mix(in oklab, var(--_accent) 13%, transparent);
|
|
93
|
+
color: color-mix(in oklab, var(--_accent) 88%, var(--foreground));
|
|
94
|
+
}
|
|
95
|
+
:global(:root[data-theme="dark"]) .badge[data-active] {
|
|
96
|
+
background: color-mix(in oklab, var(--_accent) 18%, transparent);
|
|
97
|
+
color: var(--_accent);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Stacked swap: every candidate reserves the cell, the visible one crossfades on top. */
|
|
101
|
+
.swap { position: relative; display: inline-grid; justify-items: center; overflow-y: clip; overflow-clip-margin: 2px; }
|
|
102
|
+
.swapSizer { grid-area: 1 / 1; visibility: hidden; }
|
|
103
|
+
.swapText { grid-area: 1 / 1; display: inline-block; }
|
|
104
|
+
|
|
105
|
+
@media (prefers-reduced-motion: reduce) {
|
|
106
|
+
.content, .badge { transition: none; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ------------------------------------------------------------------------ Price */
|
|
110
|
+
|
|
111
|
+
.price { display: inline-flex; min-width: 0; flex-wrap: wrap; align-items: flex-end; gap: 4px 10px; }
|
|
112
|
+
.amount {
|
|
113
|
+
display: inline-flex;
|
|
114
|
+
align-items: flex-start;
|
|
115
|
+
color: var(--foreground);
|
|
116
|
+
font-family: var(--font-display);
|
|
117
|
+
font-size: var(--text-3xl);
|
|
118
|
+
font-weight: 400;
|
|
119
|
+
letter-spacing: var(--tracking-display);
|
|
120
|
+
line-height: 1;
|
|
121
|
+
font-variant-numeric: tabular-nums;
|
|
122
|
+
}
|
|
123
|
+
.currency { margin-top: .12em; margin-right: .04em; color: var(--text-secondary); font-size: .55em; }
|
|
124
|
+
.counter { display: inline-flex; }
|
|
125
|
+
.counter > span { font: inherit !important; letter-spacing: inherit !important; }
|
|
126
|
+
.aside {
|
|
127
|
+
display: inline-flex;
|
|
128
|
+
align-items: baseline;
|
|
129
|
+
padding-bottom: .3em;
|
|
130
|
+
color: var(--text-muted);
|
|
131
|
+
font-size: var(--text-sm);
|
|
132
|
+
line-height: var(--leading-body);
|
|
133
|
+
white-space: nowrap;
|
|
134
|
+
}
|
|
135
|
+
.wasSlot { display: inline-block; overflow-x: clip; }
|
|
136
|
+
.was { display: inline-block; padding-right: 6px; color: var(--text-muted); font-variant-numeric: tabular-nums; text-decoration-thickness: 1px; }
|
|
137
|
+
.period { position: relative; display: inline-block; overflow-x: clip; overflow-clip-margin: 2px; }
|
|
138
|
+
.periodSizer { display: inline-block; visibility: hidden; }
|
|
139
|
+
.periodText { position: absolute; top: 0; left: 0; display: inline-block; }
|