@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,303 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import styles from "./activity-heatmap.module.css";
|
|
7
|
+
const DAY = 86400000;
|
|
8
|
+
const LEVELS = [0, 1, 2, 3, 4];
|
|
9
|
+
const enter = [...motionTokens.ease.enter];
|
|
10
|
+
const standard = [...motionTokens.ease.standard];
|
|
11
|
+
/** Total wave travel in ms, kept under the system's stagger budget however many weeks the range spans. */
|
|
12
|
+
const WAVE = 420;
|
|
13
|
+
const toUtc = (iso) => Date.parse(`${iso}T00:00:00Z`);
|
|
14
|
+
const toIso = (time) => new Date(time).toISOString().slice(0, 10);
|
|
15
|
+
function buildModel(days, weekStartsOn, thresholds, locale) {
|
|
16
|
+
const dates = days.map(day => toUtc(day.date)).filter(Number.isFinite);
|
|
17
|
+
const start = dates.length ? Math.min(...dates) : toUtc("2025-01-01");
|
|
18
|
+
const length = dates.length ? Math.round((Math.max(...dates) - start) / DAY) + 1 : 0;
|
|
19
|
+
const counts = new Array(length).fill(0);
|
|
20
|
+
days.forEach(day => { const index = Math.round((toUtc(day.date) - start) / DAY); if (index >= 0 && index < length)
|
|
21
|
+
counts[index] += Math.max(0, day.count); });
|
|
22
|
+
const max = counts.reduce((a, b) => Math.max(a, b), 0);
|
|
23
|
+
const bounds = thresholds ?? [Math.max(1, Math.ceil(max * .25)), Math.max(2, Math.ceil(max * .5)), Math.max(3, Math.ceil(max * .75))];
|
|
24
|
+
const levels = counts.map(count => count <= 0 ? 0 : count <= bounds[0] ? 1 : count <= bounds[1] ? 2 : count <= bounds[2] ? 3 : 4);
|
|
25
|
+
const lead = (new Date(start).getUTCDay() - weekStartsOn + 7) % 7;
|
|
26
|
+
const weeks = Math.ceil((lead + length) / 7);
|
|
27
|
+
const monthName = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
|
|
28
|
+
const months = [];
|
|
29
|
+
for (let index = 0; index < length; index++) {
|
|
30
|
+
const date = new Date(start + index * DAY);
|
|
31
|
+
if (index === 0 || date.getUTCDate() === 1)
|
|
32
|
+
months.push({ month: date.getUTCFullYear() * 12 + date.getUTCMonth(), col: Math.floor((lead + index) / 7), label: monthName.format(date) });
|
|
33
|
+
}
|
|
34
|
+
// A partial first month keeps its label only when there is room before the next one.
|
|
35
|
+
if (months.length > 1 && months[1].col - months[0].col < 3)
|
|
36
|
+
months.shift();
|
|
37
|
+
const perLevel = [0, 0, 0, 0, 0];
|
|
38
|
+
levels.forEach(level => perLevel[level]++);
|
|
39
|
+
return { start, length, lead, weeks, total: counts.reduce((a, b) => a + b, 0), counts, levels, thresholds: bounds, months, perLevel };
|
|
40
|
+
}
|
|
41
|
+
const CONTRIBUTIONS = { one: "contribution", other: "contributions" };
|
|
42
|
+
const noun = (count, unit) => count === 1 ? unit.one : unit.other;
|
|
43
|
+
const noopSubscribe = () => () => { };
|
|
44
|
+
/** Reduced motion only after hydration, so the server and the first client render agree. */
|
|
45
|
+
function useReducedMotionSafe() {
|
|
46
|
+
const hydrated = useSyncExternalStore(noopSubscribe, () => true, () => false);
|
|
47
|
+
const reduced = useReducedMotion();
|
|
48
|
+
return hydrated && !!reduced;
|
|
49
|
+
}
|
|
50
|
+
const tipText = (reduced) => ({
|
|
51
|
+
from: (change) => change === "instant" ? { opacity: 1, y: "0em", filter: "blur(0px)" } : reduced ? { opacity: 0 } : { opacity: 0, y: `${.3 * (change || 1)}em`, filter: `blur(${change ? motionTokens.blur.soft : motionTokens.blur.subtle}px)` },
|
|
52
|
+
to: { opacity: 1, y: "0em", filter: "blur(0px)" },
|
|
53
|
+
gone: (change) => change === "instant" || reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: `${-.3 * (change || 1)}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },
|
|
54
|
+
});
|
|
55
|
+
/** A total that rolls digit by digit in the direction it moved. Places keep their identity, so only changed digits turn,
|
|
56
|
+
* and when the number gains or loses a digit the width follows on a spring instead of shifting the sentence in one frame. */
|
|
57
|
+
function RollingNumber({ value, locale, reduced }) {
|
|
58
|
+
const [state, setState] = useState({ value, direction: 1 });
|
|
59
|
+
if (state.value !== value)
|
|
60
|
+
setState({ value, direction: value > state.value ? 1 : -1 });
|
|
61
|
+
const inner = useRef(null);
|
|
62
|
+
const width = useMotionValue("auto");
|
|
63
|
+
const armedUntil = useRef(0), lastValue = useRef(value);
|
|
64
|
+
useLayoutEffect(() => {
|
|
65
|
+
if (lastValue.current === value)
|
|
66
|
+
return;
|
|
67
|
+
lastValue.current = value;
|
|
68
|
+
armedUntil.current = performance.now() + 600;
|
|
69
|
+
}, [value]);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const node = inner.current;
|
|
72
|
+
if (!node || typeof ResizeObserver === "undefined")
|
|
73
|
+
return;
|
|
74
|
+
const observer = new ResizeObserver(() => {
|
|
75
|
+
const next = node.getBoundingClientRect().width;
|
|
76
|
+
if (reduced || width.get() === "auto" || performance.now() > armedUntil.current)
|
|
77
|
+
width.jump(next);
|
|
78
|
+
else
|
|
79
|
+
animate(width, next, motionTokens.spring.morph);
|
|
80
|
+
});
|
|
81
|
+
observer.observe(node);
|
|
82
|
+
return () => observer.disconnect();
|
|
83
|
+
}, [reduced, width]);
|
|
84
|
+
const chars = [...new Intl.NumberFormat(locale).format(value)];
|
|
85
|
+
const variants = {
|
|
86
|
+
from: (direction) => reduced ? { opacity: 0 } : { opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` },
|
|
87
|
+
to: { opacity: 1, y: "0em", filter: "blur(0px)" },
|
|
88
|
+
gone: (direction) => reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },
|
|
89
|
+
};
|
|
90
|
+
return _jsx(motion.span, { className: styles.rollingFrame, style: { width }, "aria-hidden": "true", children: _jsx("span", { ref: inner, className: styles.rolling, children: chars.map((char, index) => {
|
|
91
|
+
const place = chars.length - index;
|
|
92
|
+
return _jsx("span", { className: styles.place, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: state.direction, children: _jsx(motion.span, { className: styles.placeChar, custom: state.direction, variants: variants, initial: "from", animate: "to", exit: "gone", transition: reduced ? { duration: motionTokens.duration.fast } : { duration: motionTokens.duration.standard, ease: enter, delay: Math.min(place * .018, .09) }, children: char }, char) }) }, place);
|
|
93
|
+
}) }) });
|
|
94
|
+
}
|
|
95
|
+
/** A short word that changes rises in from a soft blur while the old one lifts away a little faster. */
|
|
96
|
+
function RiseText({ text, reduced, children }) {
|
|
97
|
+
return _jsx("span", { className: styles.rise, "aria-hidden": "true", children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(motion.span, { className: styles.riseLine, initial: reduced ? { opacity: 0 } : { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: "0em", filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } }, transition: { duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard, ease: enter }, children: children ?? text }, text) }) });
|
|
98
|
+
}
|
|
99
|
+
export function ActivityHeatmap({ days, label, period, unit: unitProp = CONTRIBUTIONS, thresholds, weekStartsOn = 0, selectedDate = null, onSelectDate, actions, locale = "en-US", className }) {
|
|
100
|
+
const reduced = useReducedMotionSafe();
|
|
101
|
+
const id = useId();
|
|
102
|
+
// Keyed by its words, so an inline unit object does not rebuild every cell on each render.
|
|
103
|
+
const unit = useMemo(() => ({ one: unitProp.one, other: unitProp.other }), [unitProp.one, unitProp.other]);
|
|
104
|
+
const rootRef = useRef(null);
|
|
105
|
+
const plotRef = useRef(null);
|
|
106
|
+
const gridRef = useRef(null);
|
|
107
|
+
const scrollerRef = useRef(null);
|
|
108
|
+
const ringRef = useRef(null);
|
|
109
|
+
const measureRef = useRef(null);
|
|
110
|
+
const inView = useInView(plotRef, { once: true, amount: .35 });
|
|
111
|
+
const model = useMemo(() => buildModel(days, weekStartsOn, thresholds, locale), [days, weekStartsOn, thresholds, locale]);
|
|
112
|
+
const formats = useMemo(() => ({
|
|
113
|
+
long: new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }),
|
|
114
|
+
short: new Intl.DateTimeFormat(locale, { weekday: "short", month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }),
|
|
115
|
+
weekday: new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }),
|
|
116
|
+
number: new Intl.NumberFormat(locale),
|
|
117
|
+
}), [locale]);
|
|
118
|
+
// A new range sweeps forward when it is later than the last one and back when it is earlier.
|
|
119
|
+
const [range, setRange] = useState({ start: model.start, direction: 1 });
|
|
120
|
+
if (range.start !== model.start)
|
|
121
|
+
setRange({ start: model.start, direction: model.start > range.start ? 1 : -1 });
|
|
122
|
+
const [reveal, setReveal] = useState("hidden");
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (reveal !== "hidden" || !(inView || reduced))
|
|
125
|
+
return;
|
|
126
|
+
const frame = requestAnimationFrame(() => setReveal(reduced ? "done" : "revealing"));
|
|
127
|
+
return () => cancelAnimationFrame(frame);
|
|
128
|
+
}, [inView, reduced, reveal]);
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (reveal !== "revealing")
|
|
131
|
+
return;
|
|
132
|
+
const timer = window.setTimeout(() => setReveal("done"), WAVE + 700);
|
|
133
|
+
return () => window.clearTimeout(timer);
|
|
134
|
+
}, [reveal]);
|
|
135
|
+
const selectedIndex = selectedDate ? Math.round((toUtc(selectedDate) - model.start) / DAY) : -1;
|
|
136
|
+
const hasSelection = selectedIndex >= 0 && selectedIndex < model.length;
|
|
137
|
+
const [focusIndex, setFocusIndex] = useState(null);
|
|
138
|
+
const tabIndexDay = focusIndex !== null && focusIndex < model.length ? focusIndex : hasSelection ? selectedIndex : model.length - 1;
|
|
139
|
+
// Legend: hovering or focusing a level previews it on the grid, clicking pins it. The caption says how many days sit at that level.
|
|
140
|
+
const [preview, setPreview] = useState(null);
|
|
141
|
+
const [pinned, setPinned] = useState(null);
|
|
142
|
+
const [legendFocus, setLegendFocus] = useState(0);
|
|
143
|
+
const previewTimer = useRef(0);
|
|
144
|
+
const highlight = preview ?? pinned;
|
|
145
|
+
const previewLevel = (level) => { window.clearTimeout(previewTimer.current); if (level !== null)
|
|
146
|
+
setPreview(level);
|
|
147
|
+
else
|
|
148
|
+
previewTimer.current = window.setTimeout(() => setPreview(null), 90); };
|
|
149
|
+
useEffect(() => () => window.clearTimeout(previewTimer.current), []);
|
|
150
|
+
// Tooltip: one bubble for the whole grid. It jumps into place when it opens and glides on a spring between cells after that.
|
|
151
|
+
const [tip, setTip] = useState(null);
|
|
152
|
+
const [open, setOpen] = useState(false);
|
|
153
|
+
const [change, setChange] = useState("instant");
|
|
154
|
+
const openRef = useRef(false);
|
|
155
|
+
const hideTimer = useRef(0);
|
|
156
|
+
const tipX = useMotionValue(0), tipY = useMotionValue(0), tipWidth = useMotionValue("auto");
|
|
157
|
+
function contentFor(index, anchor) {
|
|
158
|
+
const count = model.counts[index];
|
|
159
|
+
const date = new Date(model.start + index * DAY);
|
|
160
|
+
return { key: `day-${toIso(date.getTime())}`, primary: count ? `${formats.number.format(count)} ${noun(count, unit)}` : `No ${unit.other}`, secondary: formats.short.format(date), value: count, anchor };
|
|
161
|
+
}
|
|
162
|
+
const [a, b, c] = model.thresholds;
|
|
163
|
+
const ranges = [`no ${unit.other}`, a === 1 ? `1 ${unit.one}` : `1 to ${a} ${unit.other}`, `${a + 1} to ${b} ${unit.other}`, `${b + 1} to ${c} ${unit.other}`, `${c + 1} or more ${unit.other}`];
|
|
164
|
+
const shortRanges = [`no ${unit.other}`, a === 1 ? `1 ${unit.one}` : `1–${a} ${unit.other}`, `${a + 1}–${b} ${unit.other}`, `${b + 1}–${c} ${unit.other}`, `${c + 1}+ ${unit.other}`];
|
|
165
|
+
const dayCount = highlight === null ? "" : `${formats.number.format(model.perLevel[highlight])} ${model.perLevel[highlight] === 1 ? "day" : "days"}`;
|
|
166
|
+
const caption = highlight === null ? "" : `${dayCount} with ${shortRanges[highlight]}`;
|
|
167
|
+
function show(next) {
|
|
168
|
+
window.clearTimeout(hideTimer.current);
|
|
169
|
+
setChange(tip && openRef.current ? Math.sign(next.value - tip.value) : "instant");
|
|
170
|
+
setTip(next);
|
|
171
|
+
setOpen(true);
|
|
172
|
+
}
|
|
173
|
+
function hideSoon(delay = 110) {
|
|
174
|
+
window.clearTimeout(hideTimer.current);
|
|
175
|
+
hideTimer.current = window.setTimeout(() => setOpen(false), delay);
|
|
176
|
+
}
|
|
177
|
+
useEffect(() => () => window.clearTimeout(hideTimer.current), []);
|
|
178
|
+
useLayoutEffect(() => {
|
|
179
|
+
const root = rootRef.current, measure = measureRef.current;
|
|
180
|
+
if (!tip || !root || !measure)
|
|
181
|
+
return;
|
|
182
|
+
const place = (glide) => {
|
|
183
|
+
const box = root.getBoundingClientRect(), cell = tip.anchor.getBoundingClientRect();
|
|
184
|
+
const width = Math.ceil(measure.getBoundingClientRect().width);
|
|
185
|
+
const half = width / 2 + 13;
|
|
186
|
+
const x = Math.min(Math.max(cell.left - box.left + cell.width / 2, half + 2), box.width - half - 2);
|
|
187
|
+
const y = cell.top - box.top;
|
|
188
|
+
if (glide) {
|
|
189
|
+
animate(tipX, x, motionTokens.spring.snappy);
|
|
190
|
+
animate(tipY, y, motionTokens.spring.snappy);
|
|
191
|
+
animate(tipWidth, width, motionTokens.spring.morph);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
tipX.jump(x);
|
|
195
|
+
tipY.jump(y);
|
|
196
|
+
tipWidth.jump(width);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
place(open && openRef.current && !reduced);
|
|
200
|
+
openRef.current = open;
|
|
201
|
+
if (!open)
|
|
202
|
+
return;
|
|
203
|
+
// The grid scrolls sideways on narrow screens; the bubble stays pinned to its cell while it does.
|
|
204
|
+
const scroller = scrollerRef.current;
|
|
205
|
+
const follow = () => place(false);
|
|
206
|
+
scroller?.addEventListener("scroll", follow, { passive: true });
|
|
207
|
+
return () => scroller?.removeEventListener("scroll", follow);
|
|
208
|
+
}, [tip, open, reduced, tipX, tipY, tipWidth]);
|
|
209
|
+
const indexOf = (element) => {
|
|
210
|
+
const cell = element?.closest("[data-index]");
|
|
211
|
+
return cell && gridRef.current?.contains(cell) ? { cell, index: Number(cell.dataset.index) } : null;
|
|
212
|
+
};
|
|
213
|
+
const focusDay = (index) => gridRef.current?.querySelector(`[data-index="${index}"]`)?.focus();
|
|
214
|
+
function onGridPointerOver(event) {
|
|
215
|
+
const hit = indexOf(event.target);
|
|
216
|
+
if (hit)
|
|
217
|
+
show(contentFor(hit.index, hit.cell));
|
|
218
|
+
}
|
|
219
|
+
function onGridPointerLeave(event) {
|
|
220
|
+
// A keyboard user keeps their bubble on the focused day. A tap focuses its day too, so the bubble stays until focus moves on;
|
|
221
|
+
// where a tap does not focus, it lingers long enough to read.
|
|
222
|
+
const focused = indexOf(document.activeElement);
|
|
223
|
+
if (focused?.cell.matches(":focus-visible"))
|
|
224
|
+
show(contentFor(focused.index, focused.cell));
|
|
225
|
+
else
|
|
226
|
+
hideSoon(event.pointerType === "touch" ? 2400 : 110);
|
|
227
|
+
}
|
|
228
|
+
function onGridFocus(event) {
|
|
229
|
+
const hit = indexOf(event.target);
|
|
230
|
+
if (!hit)
|
|
231
|
+
return;
|
|
232
|
+
setFocusIndex(hit.index);
|
|
233
|
+
show(contentFor(hit.index, hit.cell));
|
|
234
|
+
}
|
|
235
|
+
function onGridBlur(event) {
|
|
236
|
+
if (!gridRef.current?.contains(event.relatedTarget))
|
|
237
|
+
hideSoon(0);
|
|
238
|
+
}
|
|
239
|
+
function onGridKeyDown(event) {
|
|
240
|
+
const hit = indexOf(event.target);
|
|
241
|
+
if (!hit)
|
|
242
|
+
return;
|
|
243
|
+
const moves = { ArrowUp: hit.index - 1, ArrowDown: hit.index + 1, ArrowLeft: hit.index - 7, ArrowRight: hit.index + 7, Home: 0, End: model.length - 1 };
|
|
244
|
+
if (event.key in moves) {
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
focusDay(Math.min(Math.max(moves[event.key], 0), model.length - 1));
|
|
247
|
+
}
|
|
248
|
+
else if (event.key === "Enter" || event.key === " ") {
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
onSelectDate?.(toIso(model.start + hit.index * DAY));
|
|
251
|
+
}
|
|
252
|
+
else if (event.key === "Escape" && open) {
|
|
253
|
+
event.preventDefault();
|
|
254
|
+
setOpen(false);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function onLegendKeyDown(event) {
|
|
258
|
+
const moves = { ArrowLeft: legendFocus - 1, ArrowRight: legendFocus + 1, Home: 0, End: 4 };
|
|
259
|
+
if (event.key === "Escape") {
|
|
260
|
+
previewLevel(null);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (!(event.key in moves))
|
|
264
|
+
return;
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
const next = Math.min(Math.max(moves[event.key], 0), 4);
|
|
267
|
+
rootRef.current?.querySelector(`[data-level-key="${next}"]`)?.focus();
|
|
268
|
+
}
|
|
269
|
+
// The selection ring glides between cells on CSS. When it first appears it should fade in where it lands, not fly in from its last spot.
|
|
270
|
+
const ringShown = hasSelection && reveal !== "hidden";
|
|
271
|
+
const ringWasShown = useRef(ringShown);
|
|
272
|
+
useLayoutEffect(() => {
|
|
273
|
+
const ring = ringRef.current;
|
|
274
|
+
if (ring && ringShown && !ringWasShown.current) {
|
|
275
|
+
ring.style.transitionProperty = "opacity";
|
|
276
|
+
void ring.offsetWidth;
|
|
277
|
+
ring.style.transitionProperty = "";
|
|
278
|
+
}
|
|
279
|
+
ringWasShown.current = ringShown;
|
|
280
|
+
}, [ringShown]);
|
|
281
|
+
const maxDiagonal = Math.max(1, model.weeks - 1 + 6);
|
|
282
|
+
const step = Math.min(12, WAVE / maxDiagonal);
|
|
283
|
+
const cells = useMemo(() => Array.from({ length: 7 }, (_, row) => _jsx("div", { role: "row", className: styles.row, children: Array.from({ length: model.weeks }, (_, col) => {
|
|
284
|
+
const index = col * 7 + row - model.lead;
|
|
285
|
+
const inRange = index >= 0 && index < model.length;
|
|
286
|
+
const wave = { "--wave": `${Math.round((col + row) * step)}ms`, "--wave-back": `${Math.round((maxDiagonal - col - row) * step)}ms` };
|
|
287
|
+
if (!inRange)
|
|
288
|
+
return _jsx("span", { className: styles.cell, "data-empty": "", style: wave, "aria-hidden": "true" }, col);
|
|
289
|
+
const count = model.counts[index];
|
|
290
|
+
const date = new Date(model.start + index * DAY);
|
|
291
|
+
return _jsx("span", { role: "gridcell", className: styles.cell, style: wave, "data-index": index, "data-level": model.levels[index], tabIndex: index === tabIndexDay ? 0 : -1, "aria-selected": onSelectDate ? index === selectedIndex : undefined, "aria-label": `${count ? formats.number.format(count) : "No"} ${noun(count, unit)}, ${formats.long.format(date)}`, onClick: () => onSelectDate?.(toIso(date.getTime())) }, col);
|
|
292
|
+
}) }, row)), [model, step, maxDiagonal, tabIndexDay, selectedIndex, formats, unit, onSelectDate]);
|
|
293
|
+
const weekdayLabels = useMemo(() => Array.from({ length: 7 }, (_, row) => {
|
|
294
|
+
const weekday = (row + weekStartsOn) % 7;
|
|
295
|
+
// January 4, 1970 was a Sunday.
|
|
296
|
+
return weekday % 2 === 1 ? formats.weekday.format(new Date((3 + weekday) * DAY)) : "";
|
|
297
|
+
}), [formats, weekStartsOn]);
|
|
298
|
+
const selectedCol = hasSelection ? Math.floor((selectedIndex + model.lead) / 7) : 0;
|
|
299
|
+
const selectedRow = hasSelection ? (selectedIndex + model.lead) % 7 : 0;
|
|
300
|
+
const summary = `${formats.number.format(model.total)} ${noun(model.total, unit)} in ${period}`;
|
|
301
|
+
return _jsxs("div", { ref: rootRef, className: [styles.root, className].filter(Boolean).join(" "), children: [_jsxs("div", { className: styles.header, children: [_jsxs("p", { className: styles.summary, children: [_jsxs("span", { className: styles.total, children: [_jsx(RollingNumber, { value: model.total, locale: locale, reduced: reduced }), " ", noun(model.total, unit)] }), " ", _jsxs("span", { className: styles.period, children: ["in ", _jsx(RiseText, { text: period, reduced: reduced })] }), _jsx("span", { className: styles.srOnly, role: "status", children: summary })] }), actions && _jsx("div", { className: styles.actions, children: actions })] }), _jsx("div", { ref: scrollerRef, className: styles.scroller, children: _jsxs("div", { className: styles.canvas, style: { "--weeks": model.weeks }, children: [_jsx("div", { className: styles.months, "aria-hidden": "true", children: model.months.map((month, order) => _jsx("span", { className: styles.month, style: { "--col": month.col }, children: month.label }, `${month.month % 12}-${model.months.findIndex(item => item.month % 12 === month.month % 12) === order ? 0 : 1}`)) }), _jsx("div", { className: styles.weekdays, "aria-hidden": "true", children: weekdayLabels.map((text, row) => _jsx("span", { className: styles.weekday, children: text }, row)) }), _jsxs("div", { ref: plotRef, className: styles.plot, children: [_jsx("div", { ref: gridRef, role: "grid", "aria-label": label, "aria-readonly": "true", "aria-describedby": `${id}-legend`, className: styles.grid, "data-reveal": reveal, "data-direction": range.direction < 0 ? "back" : undefined, "data-highlight": highlight ?? undefined, onPointerOver: onGridPointerOver, onPointerLeave: onGridPointerLeave, onFocus: onGridFocus, onBlur: onGridBlur, onKeyDown: onGridKeyDown, children: cells }), _jsx("span", { ref: ringRef, className: styles.ring, "data-shown": ringShown || undefined, style: { "--col": selectedCol, "--row": selectedRow }, "aria-hidden": "true" })] })] }) }), _jsxs("div", { className: styles.legend, children: [_jsxs("span", { id: `${id}-legend`, className: styles.srOnly, children: ["Darker squares mean more ", unit.other, ". Levels: ", ranges.join(", "), "."] }), _jsxs("span", { className: styles.caption, "aria-live": "polite", children: [_jsx(RiseText, { text: caption, reduced: reduced, children: caption && _jsxs(_Fragment, { children: [_jsx("span", { className: styles.captionLong, children: caption }), _jsx("span", { className: styles.captionShort, children: dayCount })] }) }), _jsx("span", { className: styles.srOnly, children: caption })] }), _jsxs("span", { className: styles.scale, children: [_jsx("span", { className: styles.legendText, "aria-hidden": "true", children: "Less" }), _jsx("div", { className: styles.swatches, role: "group", "aria-label": "Highlight days by level", onKeyDown: onLegendKeyDown, children: LEVELS.map(level => _jsx("button", { type: "button", className: styles.swatch, "data-level-key": level, "data-level": level, tabIndex: level === legendFocus ? 0 : -1, "aria-pressed": pinned === level, "aria-label": `Highlight days with ${ranges[level]}`, onClick: () => setPinned(current => current === level ? null : level), onPointerEnter: () => previewLevel(level), onPointerLeave: () => previewLevel(null), onFocus: () => { setLegendFocus(level); previewLevel(level); }, onBlur: () => previewLevel(null) }, level)) }), _jsx("span", { className: styles.legendText, "aria-hidden": "true", children: "More" })] })] }), _jsx(motion.div, { className: styles.tip, style: { x: tipX, y: tipY }, "aria-hidden": "true", children: _jsx(motion.div, { className: styles.bubble, style: { x: "-50%" }, initial: false, animate: open && tip ? { opacity: 1, scale: 1 } : { opacity: 0, scale: reduced ? 1 : .96 }, transition: reduced ? { duration: open ? motionTokens.duration.fast : motionTokens.duration.instant } : open ? { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter } } : { duration: motionTokens.duration.instant, ease: standard }, children: _jsxs(motion.span, { className: styles.tipBody, style: { width: tipWidth }, children: [_jsxs("span", { ref: measureRef, className: styles.tipMeasure, children: [_jsx("span", { className: styles.tipPrimary, children: tip?.primary }), _jsx("span", { className: styles.tipSecondary, children: tip?.secondary })] }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: change, children: tip && _jsxs(motion.span, { className: styles.tipLines, custom: change, variants: tipText(reduced), initial: "from", animate: "to", exit: "gone", transition: { duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard, ease: enter }, children: [_jsx("span", { className: styles.tipPrimary, children: tip.primary }), _jsx("span", { className: styles.tipSecondary, children: tip.secondary })] }, tip.key) })] }) }) })] });
|
|
302
|
+
}
|
|
303
|
+
export default ActivityHeatmap;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/* Levels are tints of the accent over a neutral empty square, so the grid follows the selected accent and both themes. */
|
|
2
|
+
.root { --level-0: color-mix(in oklab, var(--foreground) 6.5%, var(--surface)); --level-1: color-mix(in oklab, var(--accent) 26%, var(--level-0)); --level-2: color-mix(in oklab, var(--accent) 50%, var(--level-0)); --level-3: color-mix(in oklab, var(--accent) 76%, var(--level-0)); --level-4: var(--accent); container-type: inline-size; position: relative; display: grid; min-width: 0; gap: var(--space-inset); color: var(--foreground); }
|
|
3
|
+
.header { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-group) var(--space-inset); }
|
|
4
|
+
.summary { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; column-gap: .3em; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
|
|
5
|
+
.total { color: var(--foreground); font-weight: 500; white-space: nowrap; }
|
|
6
|
+
.period { white-space: nowrap; }
|
|
7
|
+
.actions { display: flex; flex: none; align-items: center; }
|
|
8
|
+
.rollingFrame { display: inline-flex; justify-content: flex-end; overflow: clip; overflow-clip-margin: .2em; }
|
|
9
|
+
.rolling { display: inline-flex; flex: none; }
|
|
10
|
+
/* Each place is its own window, so a turning digit clips at the line instead of brushing the next line of text. */
|
|
11
|
+
.place { position: relative; display: inline-flex; overflow: clip; overflow-clip-margin: .15em; }
|
|
12
|
+
.placeChar, .riseLine { display: inline-block; }
|
|
13
|
+
.rise { position: relative; display: inline-flex; }
|
|
14
|
+
|
|
15
|
+
/* The scroller starts at its right edge on narrow screens (the most recent weeks) without script, through its writing direction. */
|
|
16
|
+
.scroller { direction: rtl; overflow-x: auto; overflow-y: hidden; margin: -6px; padding: 6px; overscroll-behavior-x: contain; scrollbar-color: var(--border-strong) transparent; scrollbar-width: thin; }
|
|
17
|
+
.canvas { --gap: 3px; --label: 30px; --cell: clamp(9px, calc((100cqi - var(--label) - var(--gap) * 53) / 53), 15px); --step: calc(var(--cell) + var(--gap)); direction: ltr; display: grid; width: max-content; grid-template-columns: var(--label) auto; grid-template-rows: auto auto; column-gap: var(--gap); row-gap: var(--space-related); }
|
|
18
|
+
.months { position: relative; grid-row: 1; grid-column: 2; height: 1rem; color: var(--text-muted); font-size: var(--text-xs); line-height: 1rem; }
|
|
19
|
+
/* Month labels glide to their new week when a range starts on a different weekday. */
|
|
20
|
+
.month { position: absolute; top: 0; left: 0; white-space: nowrap; transform: translateX(calc(var(--col) * var(--step))); transition: transform var(--duration-spring) var(--ease-spring); }
|
|
21
|
+
/* Weekday labels stay pinned while a narrow grid scrolls under them. Set --heatmap-surface when the heatmap sits on another surface. */
|
|
22
|
+
.weekdays { position: sticky; z-index: 1; left: -6px; display: grid; grid-row: 1 / 3; grid-column: 1; grid-template-rows: repeat(7, var(--cell)); align-content: end; row-gap: var(--gap); margin: 0 calc(var(--gap) * -1) 0 -6px; padding: 0 var(--gap) 0 6px; background: var(--heatmap-surface, var(--surface)); color: var(--text-muted); font-size: var(--text-xs); }
|
|
23
|
+
.weekday { display: flex; height: var(--cell); align-items: center; white-space: nowrap; line-height: 1; }
|
|
24
|
+
.plot { position: relative; grid-row: 2; grid-column: 2; }
|
|
25
|
+
.grid { display: grid; gap: var(--gap); outline: none; }
|
|
26
|
+
.row { display: flex; gap: var(--gap); }
|
|
27
|
+
|
|
28
|
+
/* One square per day. The wave delay rides a custom property so a reveal and a recolor sweep the same diagonal. */
|
|
29
|
+
.cell { --delay: var(--wave); display: block; width: var(--cell); height: var(--cell); flex: none; border-radius: calc(var(--cell) * .28); background: var(--level-0); outline: 1.5px solid transparent; outline-offset: 1px; cursor: default; -webkit-tap-highlight-color: transparent; transition: background-color 320ms var(--ease-standard) var(--delay), transform var(--duration-spring) var(--ease-spring) var(--delay), opacity var(--duration-fast) var(--ease-standard); }
|
|
30
|
+
.grid[data-direction="back"] .cell { --delay: var(--wave-back); }
|
|
31
|
+
.cell[data-level="1"] { background: var(--level-1); }
|
|
32
|
+
.cell[data-level="2"] { background: var(--level-2); }
|
|
33
|
+
.cell[data-level="3"] { background: var(--level-3); }
|
|
34
|
+
.cell[data-level="4"] { background: var(--level-4); }
|
|
35
|
+
/* Days outside the range shrink away in the sweep and grow back when a range needs them. */
|
|
36
|
+
.cell[data-empty] { transform: scale(0); background: var(--level-0); }
|
|
37
|
+
.grid[data-reveal="hidden"] .cell { opacity: 0; transform: scale(.5); transition: none; }
|
|
38
|
+
.grid[data-reveal="hidden"] .cell[data-empty] { transform: scale(0); }
|
|
39
|
+
.grid[data-reveal="revealing"] .cell { transition: background-color 320ms var(--ease-standard) var(--delay), transform var(--duration-spring) var(--ease-spring) var(--delay), opacity 360ms var(--ease-enter) var(--delay); }
|
|
40
|
+
.grid[data-highlight] .cell:not([data-empty]) { opacity: .2; }
|
|
41
|
+
.grid[data-highlight="0"] .cell[data-level="0"], .grid[data-highlight="1"] .cell[data-level="1"], .grid[data-highlight="2"] .cell[data-level="2"], .grid[data-highlight="3"] .cell[data-level="3"], .grid[data-highlight="4"] .cell[data-level="4"] { opacity: 1; }
|
|
42
|
+
@media (hover: hover) and (pointer: fine) { .cell:not([data-empty]):hover { outline-color: color-mix(in oklab, var(--foreground) 45%, transparent); } }
|
|
43
|
+
|
|
44
|
+
/* The selection ring glides between days, and follows its day when the range changes. */
|
|
45
|
+
.ring { position: absolute; top: -3px; left: -3px; width: calc(var(--cell) + 6px); height: calc(var(--cell) + 6px); border: 1.5px solid var(--foreground); border-radius: calc(var(--cell) * .28 + 3px); opacity: 0; pointer-events: none; transform: translate(calc(var(--col) * var(--step)), calc(var(--row) * var(--step))); transition: transform var(--duration-spring) var(--ease-spring), opacity var(--duration-fast) var(--ease-standard); }
|
|
46
|
+
.ring[data-shown] { opacity: 1; }
|
|
47
|
+
|
|
48
|
+
.legend { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--space-group); color: var(--text-muted); font-size: var(--text-xs); }
|
|
49
|
+
/* The caption names the previewed level; it sits apart from the scale so its changing width never nudges the swatches. */
|
|
50
|
+
.caption { position: relative; min-width: 0; overflow: hidden; color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; text-overflow: ellipsis; }
|
|
51
|
+
.scale { display: flex; flex: none; align-items: center; gap: var(--space-related); }
|
|
52
|
+
.captionShort { display: none; }
|
|
53
|
+
/* Narrow cards keep the day count and let the highlighted squares say which level it is. */
|
|
54
|
+
@container (max-width: 440px) { .captionLong { display: none; } .captionShort { display: inline; } }
|
|
55
|
+
.legendText { line-height: 1; }
|
|
56
|
+
.swatches { display: flex; }
|
|
57
|
+
/* The visible square stays the size of a day while the button keeps a comfortable target around it. */
|
|
58
|
+
.swatch { position: relative; display: grid; width: 15px; height: 20px; place-items: center; padding: 0; border: 0; border-radius: var(--radius-detail); background: transparent; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
59
|
+
.swatch::before { content: ""; width: 11px; height: 11px; border-radius: var(--radius-detail); background: var(--level-0); }
|
|
60
|
+
.swatch[data-level="1"]::before { background: var(--level-1); }
|
|
61
|
+
.swatch[data-level="2"]::before { background: var(--level-2); }
|
|
62
|
+
.swatch[data-level="3"]::before { background: var(--level-3); }
|
|
63
|
+
.swatch[data-level="4"]::before { background: var(--level-4); }
|
|
64
|
+
.swatch[aria-pressed="true"] { box-shadow: inset 0 0 0 1.5px var(--foreground); }
|
|
65
|
+
|
|
66
|
+
/* One bubble for the whole grid. The outer layer glides between cells; the bubble fades and scales from its tail. */
|
|
67
|
+
.tip { position: absolute; z-index: 5; top: 0; left: 0; width: 0; height: 0; pointer-events: none; }
|
|
68
|
+
.bubble { position: absolute; bottom: 8px; left: 0; display: block; padding: 7px 12px 8px; border: 1px solid color-mix(in oklab, var(--background) 14%, var(--foreground)); border-radius: var(--radius-control); background: var(--foreground); color: var(--background); box-shadow: var(--shadow-raised); transform-origin: 50% 100%; will-change: transform, opacity; }
|
|
69
|
+
.tipBody { position: relative; display: block; overflow: clip; overflow-clip-margin: 8px; }
|
|
70
|
+
.tipMeasure { position: absolute; top: 0; left: 0; display: grid; width: max-content; visibility: hidden; }
|
|
71
|
+
.tipLines { display: grid; width: max-content; }
|
|
72
|
+
.tipMeasure > span, .tipLines > span { white-space: nowrap; }
|
|
73
|
+
.tipPrimary { font-size: var(--text-sm); font-weight: 500; line-height: 1.3; font-variant-numeric: tabular-nums; }
|
|
74
|
+
.tipSecondary { color: color-mix(in oklab, var(--background) 68%, var(--foreground)); font-size: var(--text-xs); line-height: 1.35; }
|
|
75
|
+
|
|
76
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
77
|
+
|
|
78
|
+
@media (prefers-reduced-motion: reduce) {
|
|
79
|
+
.cell, .grid[data-reveal="revealing"] .cell { transition: background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }
|
|
80
|
+
.grid[data-reveal="hidden"] .cell { transform: none; }
|
|
81
|
+
.cell[data-empty], .grid[data-reveal="hidden"] .cell[data-empty] { opacity: 0; transform: none; }
|
|
82
|
+
.month, .ring { transition: opacity var(--duration-fast) var(--ease-standard); }
|
|
83
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
export type AlertTone = "info" | "success" | "warning" | "danger";
|
|
3
|
+
export interface AlertProps extends HTMLAttributes<HTMLDivElement> {
|
|
4
|
+
tone?: AlertTone;
|
|
5
|
+
title: string;
|
|
6
|
+
children?: ReactNode;
|
|
7
|
+
/** Controls presence. Hiding the alert collapses its height and fades it out. */
|
|
8
|
+
open?: boolean;
|
|
9
|
+
/** Shows a dismiss button. An uncontrolled alert collapses first, then calls this. */
|
|
10
|
+
onDismiss?: () => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function Alert({ open, onDismiss, ...props }: AlertProps): import("react").JSX.Element;
|
|
13
|
+
export default Alert;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
|
+
import { Check, Info as InfoCircle, TriangleAlert as WarningTriangle, CircleX as XmarkCircle, X } from "lucide-react";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import styles from "./alert.module.css";
|
|
8
|
+
const icons = { info: InfoCircle, success: Check, warning: WarningTriangle, danger: XmarkCircle };
|
|
9
|
+
const exitFast = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
|
|
10
|
+
const textIn = { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` };
|
|
11
|
+
const textOut = { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };
|
|
12
|
+
const iconIn = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };
|
|
13
|
+
const shown = { opacity: 1, y: "0em", scale: 1, filter: "blur(0px)" };
|
|
14
|
+
const fadeOut = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
|
|
15
|
+
/** Outgoing copies are hidden from assistive tech while they fade, so the live region reads only the current text. */
|
|
16
|
+
function Swap(props) {
|
|
17
|
+
const present = useIsPresent();
|
|
18
|
+
return _jsx(motion.span, { ...props, "aria-hidden": present ? props["aria-hidden"] : true });
|
|
19
|
+
}
|
|
20
|
+
/** Follows its content height. After `morphKey` changes, the height springs from the old size to the new one and then returns to auto, so passive reflows (a resize, a font swap) follow instantly. It clips only while moving, so focus rings stay visible at rest. */
|
|
21
|
+
function HeightFrame({ className, reduce, morphKey, children }) {
|
|
22
|
+
const frame = useRef(null);
|
|
23
|
+
const content = useRef(null);
|
|
24
|
+
const height = useMotionValue("auto");
|
|
25
|
+
const changedAt = useRef(0);
|
|
26
|
+
useLayoutEffect(() => { changedAt.current = performance.now(); }, [morphKey]);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const node = content.current;
|
|
29
|
+
if (!node || typeof ResizeObserver === "undefined")
|
|
30
|
+
return;
|
|
31
|
+
let last;
|
|
32
|
+
let controls;
|
|
33
|
+
const settle = () => { height.jump("auto"); if (frame.current)
|
|
34
|
+
Object.assign(frame.current.style, { overflow: "", height: "auto" }); };
|
|
35
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
36
|
+
if (!entry)
|
|
37
|
+
return;
|
|
38
|
+
const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;
|
|
39
|
+
const current = height.get();
|
|
40
|
+
const from = typeof current === "number" ? current : last;
|
|
41
|
+
last = next;
|
|
42
|
+
controls?.stop();
|
|
43
|
+
if (reduce || from === undefined || from === next || performance.now() - changedAt.current > 120)
|
|
44
|
+
return settle();
|
|
45
|
+
// Pin the old height before this frame paints, then spring to the new one.
|
|
46
|
+
if (frame.current)
|
|
47
|
+
Object.assign(frame.current.style, { overflow: "hidden", height: `${from}px` });
|
|
48
|
+
controls = animate(height, [from, next], { ...motionTokens.spring.smooth, onComplete: settle });
|
|
49
|
+
});
|
|
50
|
+
observer.observe(node);
|
|
51
|
+
return () => { observer.disconnect(); controls?.stop(); };
|
|
52
|
+
}, [height, reduce]);
|
|
53
|
+
return _jsx(motion.div, { ref: frame, className: className, style: { height }, children: _jsx("div", { ref: content, className: styles.content, children: children }) });
|
|
54
|
+
}
|
|
55
|
+
function AlertBox({ tone = "info", title, children, className, reduce, onDismiss, animateIcon = false, ...props }) {
|
|
56
|
+
const Icon = icons[tone];
|
|
57
|
+
const text = typeof children === "string" || typeof children === "number" ? String(children) : null;
|
|
58
|
+
const hasDetails = children !== undefined && children !== null && children !== false && children !== "";
|
|
59
|
+
const enter = reduce ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
|
|
60
|
+
const swap = { initial: reduce ? { opacity: 0 } : textIn, animate: shown, exit: reduce ? fadeOut : textOut, transition: enter };
|
|
61
|
+
return _jsxs("div", { ...props, className: [styles.alert, styles[tone], className].filter(Boolean).join(" "), role: tone === "danger" ? "alert" : "status", children: [_jsx(motion.span, { className: styles.icon, initial: animateIcon && !reduce ? iconIn : false, animate: shown, transition: motionTokens.spring.snappy, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(Swap, { className: styles.glyph, initial: reduce ? { opacity: 0 } : iconIn, animate: shown, exit: reduce ? fadeOut : { ...iconIn, transition: exitFast }, transition: reduce ? enter : motionTokens.spring.snappy, children: _jsx(Icon, { width: 18, height: 18, "aria-hidden": "true" }) }, tone) }) }), _jsxs(HeightFrame, { className: styles.copy, reduce: reduce, morphKey: `${title}\n${hasDetails}\n${text ?? ""}`, children: [_jsx("strong", { className: styles.title, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(Swap, { className: styles.line, ...swap, children: title }, title) }) }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: hasDetails && _jsx(motion.p, { className: styles.description, ...swap, children: text === null ? children : _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(Swap, { className: styles.line, ...swap, children: text }, text) }) }, "details") })] }), onDismiss && _jsx("button", { type: "button", className: styles.dismiss, "aria-label": `Dismiss: ${title}`, onClick: onDismiss, children: _jsx(X, { width: 16, height: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] });
|
|
62
|
+
}
|
|
63
|
+
export function Alert({ open, onDismiss, ...props }) {
|
|
64
|
+
const reduce = useReducedMotion();
|
|
65
|
+
const [dismissed, setDismissed] = useState(false);
|
|
66
|
+
const shell = useRef(null);
|
|
67
|
+
const setClip = (value) => { if (shell.current)
|
|
68
|
+
shell.current.style.overflow = value; };
|
|
69
|
+
if (open === undefined && !onDismiss)
|
|
70
|
+
return _jsx(AlertBox, { ...props, reduce: reduce });
|
|
71
|
+
const isOpen = open ?? !dismissed;
|
|
72
|
+
const dismiss = onDismiss && (() => open === undefined ? setDismissed(true) : onDismiss());
|
|
73
|
+
const presence = reduce ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } };
|
|
74
|
+
const exit = { height: 0, opacity: 0, transition: reduce ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: exitFast } };
|
|
75
|
+
// Presence collapses the height with the fade, so the content below closes the gap instead of jumping.
|
|
76
|
+
return _jsx(AnimatePresence, { initial: false, onExitComplete: () => { if (open === undefined)
|
|
77
|
+
onDismiss?.(); }, children: isOpen && _jsx(motion.div, { className: styles.presence, initial: { height: 0, opacity: 0 }, animate: { height: "auto", opacity: 1 }, exit: exit, transition: presence, ref: shell, onAnimationStart: () => setClip("hidden"), onAnimationComplete: () => setClip(""), children: _jsx(AlertBox, { ...props, reduce: reduce, onDismiss: dismiss, animateIcon: true }) }, "alert") });
|
|
78
|
+
}
|
|
79
|
+
export default Alert;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
.presence { min-width: 0; }
|
|
2
|
+
.alert { display: flex; align-items: start; gap: var(--space-group); border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); padding: var(--space-inset); background: var(--surface); box-shadow: var(--shadow-resting); }
|
|
3
|
+
.icon, .glyph { position: relative; display: grid; flex: 0 0 auto; place-items: center; }
|
|
4
|
+
.icon { width: 18px; height: 18px; margin-top: 1px; }
|
|
5
|
+
.icon svg { display: block; }
|
|
6
|
+
.copy { flex: 1; min-width: 0; }
|
|
7
|
+
.content { position: relative; }
|
|
8
|
+
.title { position: relative; display: block; font-size: var(--text-sm); font-weight: 500; }
|
|
9
|
+
.line { display: block; }
|
|
10
|
+
.description { position: relative; margin: var(--space-tight) 0 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
|
11
|
+
.info .icon { color: var(--accent); } .success .icon { color: var(--success); } .warning .icon { color: var(--warning); } .danger .icon { color: var(--danger); }
|
|
12
|
+
.dismiss { display: grid; width: 28px; height: 28px; flex: 0 0 auto; place-items: center; margin: -5px -6px -5px 0; border: 0; border-radius: var(--radius-full); corner-shape: round; background: transparent; color: var(--text-muted); cursor: pointer; transition: transform var(--duration-spring) var(--ease-spring), background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
|
|
13
|
+
.dismiss:active { transform: scale(.96); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }
|
|
14
|
+
@media (hover: hover) and (pointer: fine) { .dismiss:hover { background: var(--surface-muted); color: var(--foreground); } }
|
|
15
|
+
@media (prefers-reduced-motion: reduce) { .dismiss { transition: none; } .dismiss:active { transform: none; } }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface AnimatedCounterProps {
|
|
2
|
+
value: number;
|
|
3
|
+
label?: string;
|
|
4
|
+
prefix?: string;
|
|
5
|
+
suffix?: string;
|
|
6
|
+
decimals?: number;
|
|
7
|
+
/** Roll every digit up from zero the first time the counter scrolls into view. */
|
|
8
|
+
animateOnView?: boolean;
|
|
9
|
+
/** Formatting locale. Fixed by default so server and client render the same digits. */
|
|
10
|
+
locale?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function AnimatedCounter({ value, label, prefix, suffix, decimals, animateOnView, locale }: AnimatedCounterProps): import("react").JSX.Element;
|
|
13
|
+
export default AnimatedCounter;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import styles from "./animated-counter.module.css";
|
|
7
|
+
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
8
|
+
const rise = { hidden: { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } } };
|
|
9
|
+
const fade = { hidden: { opacity: 0, y: 0, filter: "blur(0px)" }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } } };
|
|
10
|
+
const reveal = { ...motionTokens.spring.smooth, visualDuration: motionTokens.duration.considered };
|
|
11
|
+
/** Split a formatted number into columns keyed by place value, so 999 → 1,000 keeps the ones column the ones column. */
|
|
12
|
+
function partsFor(value, decimals, locale) {
|
|
13
|
+
const parts = new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, numberingSystem: "latn" }).formatToParts(value);
|
|
14
|
+
let place = parts.reduce((count, part) => count + (part.type === "integer" ? part.value.length : 0), 0);
|
|
15
|
+
let fraction = 0;
|
|
16
|
+
let order = 0;
|
|
17
|
+
return parts.flatMap((part, index) => {
|
|
18
|
+
if (part.type === "integer")
|
|
19
|
+
return [...part.value].map(char => ({ key: `i${--place}`, digit: Number(char), order: order++ }));
|
|
20
|
+
if (part.type === "fraction")
|
|
21
|
+
return [...part.value].map(char => ({ key: `f${fraction++}`, digit: Number(char), order: order++ }));
|
|
22
|
+
return [{ key: part.type === "group" ? `g${place}` : part.type === "decimal" ? "d" : `${part.type}${index}`, text: part.value }];
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** One digit on the wheel. Its offset from the wheel position decides where it sits and how visible it is. */
|
|
26
|
+
function Glyph({ position, digit }) {
|
|
27
|
+
const offset = useTransform(position, current => ((((digit - current) % 10) + 15) % 10) - 5);
|
|
28
|
+
const y = useTransform(offset, current => `${current}em`);
|
|
29
|
+
const opacity = useTransform(offset, current => Math.max(0, 1 - Math.abs(current)));
|
|
30
|
+
const visibility = useTransform(offset, current => Math.abs(current) >= 1 ? "hidden" : "visible");
|
|
31
|
+
const filter = useTransform(offset, current => Math.abs(current) < .02 || Math.abs(current) >= 1 ? "none" : `blur(${(Math.abs(current) * motionTokens.blur.subtle).toFixed(2)}px)`);
|
|
32
|
+
return _jsx(motion.span, { className: styles.glyph, style: { y, opacity, filter, visibility }, children: digit });
|
|
33
|
+
}
|
|
34
|
+
const presence = { initial: { width: 0, opacity: 0 }, animate: { width: "auto", opacity: 1 }, exit: { width: 0, opacity: 0 } };
|
|
35
|
+
/** A digit wheel. It always turns in the direction the whole number moved, wrapping 9 → 0 like an odometer. */
|
|
36
|
+
function Column({ digit, direction, armed, delay, reduceMotion }) {
|
|
37
|
+
const position = useMotionValue(armed ? 0 : digit);
|
|
38
|
+
const wheel = useRef({ digit: armed ? 0 : digit, target: armed ? 0 : digit, revealed: !armed });
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const state = wheel.current;
|
|
41
|
+
if (armed || state.digit === digit) {
|
|
42
|
+
if (!armed)
|
|
43
|
+
state.revealed = true;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
state.target += direction < 0 && state.revealed ? -((state.digit - digit + 10) % 10) : (digit - state.digit + 10) % 10;
|
|
47
|
+
state.digit = digit;
|
|
48
|
+
if (reduceMotion)
|
|
49
|
+
position.jump(state.target);
|
|
50
|
+
else
|
|
51
|
+
animate(position, state.target, state.revealed ? motionTokens.spring.smooth : { ...reveal, delay });
|
|
52
|
+
state.revealed = true;
|
|
53
|
+
}, [armed, delay, digit, direction, position, reduceMotion]);
|
|
54
|
+
return _jsxs(motion.span, { className: styles.column, ...presence, transition: reduceMotion ? { duration: 0 } : motionTokens.spring.morph, children: [_jsx("span", { className: styles.sizer, children: "0" }), DIGITS.map(item => _jsx(Glyph, { position: position, digit: item }, item))] });
|
|
55
|
+
}
|
|
56
|
+
export function AnimatedCounter({ value, label, prefix = "", suffix = "", decimals = 0, animateOnView = false, locale = "en-US" }) {
|
|
57
|
+
const ref = useRef(null);
|
|
58
|
+
const inView = useInView(ref, { once: true, amount: .6 });
|
|
59
|
+
const reduceMotion = !!useReducedMotion();
|
|
60
|
+
const [previous, setPrevious] = useState(value);
|
|
61
|
+
const [direction, setDirection] = useState(1);
|
|
62
|
+
if (value !== previous) {
|
|
63
|
+
setPrevious(value);
|
|
64
|
+
setDirection(value > previous ? 1 : -1);
|
|
65
|
+
}
|
|
66
|
+
const parts = partsFor(value, decimals, locale);
|
|
67
|
+
const text = `${prefix}${parts.map(part => "text" in part ? part.text : part.digit).join("")}${suffix}`;
|
|
68
|
+
const armed = animateOnView && !inView;
|
|
69
|
+
return _jsxs("span", { ref: ref, className: styles.counter, children: [label && _jsx("span", { className: styles.label, children: _jsx("span", { className: styles.labelSwap, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(motion.span, { className: styles.labelText, variants: reduceMotion ? fade : rise, initial: "hidden", animate: "shown", exit: "gone", children: label }, label) }) }) }), _jsx("span", { className: styles.srOnly, children: text }), _jsxs("span", { className: styles.value, "aria-hidden": "true", children: [prefix && _jsx("span", { className: styles.symbol, children: prefix }), _jsx(AnimatePresence, { initial: false, children: parts.map(part => "digit" in part
|
|
70
|
+
? _jsx(Column, { digit: part.digit, direction: direction, armed: armed, delay: Math.min(part.order * motionTokens.stagger.item, .25), reduceMotion: reduceMotion }, part.key)
|
|
71
|
+
: _jsx(motion.span, { className: styles.symbol, ...presence, transition: reduceMotion ? { duration: 0 } : motionTokens.spring.morph, children: part.text }, part.key)) }), suffix && _jsx("span", { className: styles.symbol, children: suffix })] })] });
|
|
72
|
+
}
|
|
73
|
+
export default AnimatedCounter;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
.counter { position: relative; display: inline-flex; flex-direction: column; color: var(--foreground); font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: 1; font-variant-numeric: tabular-nums; }
|
|
2
|
+
.label { margin-bottom: var(--space-related); color: var(--text-muted); font-family: var(--font-body); font-size: var(--text-xs); font-weight: 400; letter-spacing: var(--tracking-body); line-height: var(--leading-body); }
|
|
3
|
+
/* Each digit is a clipped wheel; separators clip sideways only so a comma keeps its tail. */
|
|
4
|
+
.value { display: inline-flex; align-items: flex-start; white-space: nowrap; user-select: none; }
|
|
5
|
+
/* The wheel's window reaches a little past the line box and feathers out, so a turning digit fades at the edge instead of being cut. */
|
|
6
|
+
.column { --feather: .16em; position: relative; display: inline-block; overflow: hidden; margin-block: calc(var(--feather) * -1); padding-block: var(--feather); mask-image: linear-gradient(to bottom, transparent, #000 calc(var(--feather) * 1.5), #000 calc(100% - var(--feather) * 1.5), transparent); }
|
|
7
|
+
.sizer { visibility: hidden; }
|
|
8
|
+
.glyph { position: absolute; inset: var(--feather) 0; text-align: center; }
|
|
9
|
+
.labelText { display: inline-block; }
|
|
10
|
+
.labelSwap { position: relative; display: block; }
|
|
11
|
+
.symbol { display: inline-block; overflow-x: clip; }
|
|
12
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|