@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,215 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
5
|
+
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion } from "motion/react";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import { Tooltip } from "../tooltip/tooltip.js";
|
|
8
|
+
import styles from "./floating-button-group.module.css";
|
|
9
|
+
const isAction = (item) => item.type !== "separator";
|
|
10
|
+
const standard = [...motionTokens.ease.standard];
|
|
11
|
+
const enter = [...motionTokens.ease.enter];
|
|
12
|
+
const rest = { opacity: 1, y: 0, filter: "blur(0px)" };
|
|
13
|
+
const textIn = { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` };
|
|
14
|
+
const textOut = { opacity: 0, y: -3, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } };
|
|
15
|
+
const fadeIn = { opacity: 0 };
|
|
16
|
+
const fadeOut = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
|
|
17
|
+
function Phase({ children, reduced }) {
|
|
18
|
+
const present = useIsPresent();
|
|
19
|
+
return _jsx(motion.span, { className: styles.phase, "aria-hidden": present ? undefined : true, initial: reduced ? fadeIn : textIn, animate: rest, exit: reduced ? fadeOut : textOut, transition: { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard, ease: enter }, children: children });
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Icon and label. Every label the item can show is laid out invisibly in the same grid cell, so the button always has the width
|
|
23
|
+
* of the widest one: a label change crossfades in place and never resizes the button, its neighbours or the tray.
|
|
24
|
+
*/
|
|
25
|
+
function ItemBody({ label, icon, iconOnly, reserve, reduced }) {
|
|
26
|
+
const ghosts = iconOnly ? [] : Array.from(new Set([label, ...reserve]));
|
|
27
|
+
return _jsxs("span", { className: styles.slot, children: [ghosts.map(text => _jsxs("span", { className: styles.ghost, "aria-hidden": "true", children: [icon ? _jsx("span", { className: styles.iconSpace }) : null, _jsx("span", { className: styles.text, children: text })] }, text)), _jsx(AnimatePresence, { initial: false, children: _jsxs(Phase, { reduced: reduced, children: [icon ? _jsx("span", { className: styles.icon, "aria-hidden": "true", children: icon }) : null, iconOnly ? null : _jsx("span", { className: styles.text, children: label })] }, label) })] });
|
|
28
|
+
}
|
|
29
|
+
function TipContent({ label, shortcut }) {
|
|
30
|
+
return _jsxs("span", { className: styles.tip, children: [label, shortcut ? _jsx("kbd", { className: styles.kbd, children: shortcut }) : null] });
|
|
31
|
+
}
|
|
32
|
+
/** Side tooltips for a column. The library Tooltip opens above or below, which would cover the neighbouring button. */
|
|
33
|
+
function SideTip({ side, content, children }) {
|
|
34
|
+
return _jsxs(TooltipPrimitive.Root, { children: [_jsx(TooltipPrimitive.Trigger, { asChild: true, children: children }), _jsx(TooltipPrimitive.Portal, { children: _jsx(TooltipPrimitive.Content, { className: styles.sideTip, side: side, sideOffset: 10, collisionPadding: 12, children: content }) })] });
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Separate soft buttons in a quiet tray. One shared highlight morphs, in position and size, to whichever button the pointer
|
|
38
|
+
* or keyboard focus is on, fades when the pointer leaves the tray, and settles darker in place while a button is pressed.
|
|
39
|
+
* It is the toolbar's only hover and focus indicator. Arrow keys move focus, Home and End jump to the ends, Enter and Space act.
|
|
40
|
+
*/
|
|
41
|
+
export function FloatingButtonGroup({ items, label, variant = "muted", size = "md", orientation = "horizontal", iconOnly: groupIconOnly = false, tooltipSide, onAction, className, style }) {
|
|
42
|
+
const reduced = useReducedMotion() ?? false;
|
|
43
|
+
const tray = useRef(null);
|
|
44
|
+
const nodes = useRef(new Map());
|
|
45
|
+
const [hoverId, setHoverId] = useState(null);
|
|
46
|
+
const [focusId, setFocusId] = useState(null);
|
|
47
|
+
const [pressing, setPressing] = useState(false);
|
|
48
|
+
const [tabStop, setTabStop] = useState(null);
|
|
49
|
+
const actions = items.filter(isAction);
|
|
50
|
+
const byId = new Map(actions.map(action => [action.id, action]));
|
|
51
|
+
const enabled = (id) => !!id && !!byId.get(id) && !byId.get(id)?.disabled;
|
|
52
|
+
// Pointer wins over keyboard focus while it is in the tray; a disabled item under the pointer hands back to focus.
|
|
53
|
+
const activeId = enabled(hoverId) ? hoverId : focusId && byId.has(focusId) ? focusId : null;
|
|
54
|
+
const stop = enabled(tabStop) ? tabStop : actions.find(action => !action.disabled)?.id ?? null;
|
|
55
|
+
const vertical = orientation === "vertical";
|
|
56
|
+
const side = tooltipSide ?? (vertical ? "right" : "top");
|
|
57
|
+
const idsKey = actions.map(action => action.id).join("\u0000");
|
|
58
|
+
// The highlight lives in motion values so moving it never re-renders the buttons.
|
|
59
|
+
const x = useMotionValue(0), y = useMotionValue(0), width = useMotionValue(0), height = useMotionValue(0);
|
|
60
|
+
const opacity = useMotionValue(0), scale = useMotionValue(1);
|
|
61
|
+
const traveling = useRef(false);
|
|
62
|
+
const activeRef = useRef(activeId);
|
|
63
|
+
const pressingRef = useRef(pressing);
|
|
64
|
+
/** The button's box in tray coordinates, exact to the device pixel (offsetLeft rounds to whole pixels) and free of ancestor transforms. */
|
|
65
|
+
const measure = useCallback((node) => {
|
|
66
|
+
const root = tray.current;
|
|
67
|
+
if (!root)
|
|
68
|
+
return null;
|
|
69
|
+
const outer = root.getBoundingClientRect(), box = node.getBoundingClientRect();
|
|
70
|
+
const unscale = outer.width ? root.offsetWidth / outer.width : 1;
|
|
71
|
+
const dpr = window.devicePixelRatio || 1;
|
|
72
|
+
const snap = (value) => Math.round(value * unscale * dpr) / dpr;
|
|
73
|
+
return { x: snap(box.left - outer.left) - root.clientLeft, y: snap(box.top - outer.top) - root.clientTop, width: snap(box.width), height: snap(box.height) };
|
|
74
|
+
}, []);
|
|
75
|
+
const place = useCallback((id, mode) => {
|
|
76
|
+
const node = id ? nodes.current.get(id) : undefined;
|
|
77
|
+
if (!node) {
|
|
78
|
+
animate(opacity, 0, { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard, ease: standard });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const rect = measure(node);
|
|
82
|
+
if (!rect)
|
|
83
|
+
return;
|
|
84
|
+
const visible = opacity.get() > .15;
|
|
85
|
+
// Travel between buttons on the morph spring. Appearing from nothing, and every move with reduced motion, lands in place.
|
|
86
|
+
const travel = !reduced && visible && (mode === "move" || traveling.current);
|
|
87
|
+
if (travel) {
|
|
88
|
+
traveling.current = true;
|
|
89
|
+
animate(x, rect.x, motionTokens.spring.morph);
|
|
90
|
+
animate(y, rect.y, motionTokens.spring.morph);
|
|
91
|
+
animate(width, rect.width, motionTokens.spring.morph);
|
|
92
|
+
animate(height, rect.height, { ...motionTokens.spring.morph, onComplete: () => { traveling.current = false; } });
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
traveling.current = false;
|
|
96
|
+
x.jump(rect.x);
|
|
97
|
+
y.jump(rect.y);
|
|
98
|
+
width.jump(rect.width);
|
|
99
|
+
height.jump(rect.height);
|
|
100
|
+
if (!visible && !reduced) {
|
|
101
|
+
scale.jump(.94);
|
|
102
|
+
animate(scale, pressingRef.current ? .965 : 1, motionTokens.spring.snappy);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (opacity.get() !== 1)
|
|
106
|
+
animate(opacity, 1, { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.fast, ease: standard });
|
|
107
|
+
}, [height, measure, opacity, reduced, scale, width, x, y]);
|
|
108
|
+
useLayoutEffect(() => {
|
|
109
|
+
activeRef.current = activeId;
|
|
110
|
+
place(activeId, "move");
|
|
111
|
+
}, [activeId, place]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
pressingRef.current = pressing;
|
|
114
|
+
animate(scale, pressing && activeId ? .965 : 1, reduced ? { duration: 0 } : motionTokens.spring.snappy);
|
|
115
|
+
}, [activeId, pressing, reduced, scale]);
|
|
116
|
+
// A font that loads or a container that reflows moves the buttons; the highlight follows without travel.
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const root = tray.current;
|
|
119
|
+
if (!root || typeof ResizeObserver === "undefined")
|
|
120
|
+
return;
|
|
121
|
+
const observer = new ResizeObserver(() => { if (activeRef.current)
|
|
122
|
+
place(activeRef.current, "follow"); });
|
|
123
|
+
observer.observe(root);
|
|
124
|
+
nodes.current.forEach(node => observer.observe(node));
|
|
125
|
+
return () => observer.disconnect();
|
|
126
|
+
}, [idsKey, place]);
|
|
127
|
+
// A native listener, because React bubbles pointer events through portals: moving from a button onto its tooltip
|
|
128
|
+
// would not count as leaving the tray, and the highlight would stay behind.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const root = tray.current;
|
|
131
|
+
if (!root)
|
|
132
|
+
return;
|
|
133
|
+
const leave = () => { setHoverId(null); setPressing(false); };
|
|
134
|
+
root.addEventListener("pointerleave", leave);
|
|
135
|
+
return () => root.removeEventListener("pointerleave", leave);
|
|
136
|
+
}, []);
|
|
137
|
+
const buttons = () => Array.from(tray.current?.querySelectorAll("[data-fbg-item]") ?? []);
|
|
138
|
+
const onKeyDown = (event) => {
|
|
139
|
+
const all = buttons();
|
|
140
|
+
const index = all.findIndex(node => node === document.activeElement);
|
|
141
|
+
if (index < 0)
|
|
142
|
+
return;
|
|
143
|
+
const next = vertical ? "ArrowDown" : "ArrowRight";
|
|
144
|
+
const previous = vertical ? "ArrowUp" : "ArrowLeft";
|
|
145
|
+
const usable = (node) => node.getAttribute("aria-disabled") !== "true";
|
|
146
|
+
let target;
|
|
147
|
+
if (event.key === "Home")
|
|
148
|
+
target = all.find(usable);
|
|
149
|
+
else if (event.key === "End")
|
|
150
|
+
target = [...all].reverse().find(usable);
|
|
151
|
+
else if (event.key === next || event.key === previous) {
|
|
152
|
+
const step = event.key === next ? 1 : -1;
|
|
153
|
+
for (let offset = 1; offset <= all.length; offset += 1) {
|
|
154
|
+
const candidate = all[(index + step * offset + all.length * offset) % all.length];
|
|
155
|
+
if (usable(candidate)) {
|
|
156
|
+
target = candidate;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else
|
|
162
|
+
return;
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
if (!target)
|
|
165
|
+
return;
|
|
166
|
+
// The keyboard takes the highlight back until the pointer moves again.
|
|
167
|
+
setHoverId(null);
|
|
168
|
+
target.focus();
|
|
169
|
+
};
|
|
170
|
+
const onBlur = (event) => {
|
|
171
|
+
if (tray.current?.contains(event.relatedTarget))
|
|
172
|
+
return;
|
|
173
|
+
setFocusId(null);
|
|
174
|
+
setPressing(false);
|
|
175
|
+
};
|
|
176
|
+
const classes = [styles.group, styles[variant], styles[size], vertical ? styles.vertical : "", className].filter(Boolean).join(" ");
|
|
177
|
+
const group = _jsxs("div", { ref: tray, className: classes, style: style, role: "toolbar", "aria-label": label, "aria-orientation": orientation, "data-variant": variant, onKeyDown: onKeyDown, onBlur: onBlur, children: [_jsx(motion.span, { className: styles.highlight, "data-pressing": pressing && activeId ? "" : undefined, style: { x, y, width, height, opacity, scale }, "aria-hidden": "true" }), items.map((item, position) => {
|
|
178
|
+
if (!isAction(item))
|
|
179
|
+
return _jsx("span", { className: styles.separator, role: "separator", "aria-orientation": vertical ? "horizontal" : "vertical" }, item.id ?? `separator-${position}`);
|
|
180
|
+
const { id, label: itemLabel, icon, onSelect, disabled = false, pressed, shortcut, reserveLabels = [] } = item;
|
|
181
|
+
const iconOnly = !!icon && (item.iconOnly ?? groupIconOnly);
|
|
182
|
+
const button = _jsx("button", { ref: node => { if (node)
|
|
183
|
+
nodes.current.set(id, node);
|
|
184
|
+
else
|
|
185
|
+
nodes.current.delete(id); }, type: "button", className: styles.item, "data-fbg-item": "", "data-active": activeId === id ? "" : undefined, "data-icon": icon ? "" : undefined, "data-icon-only": iconOnly ? "" : undefined, "aria-label": itemLabel, "aria-pressed": pressed === undefined ? undefined : pressed, "aria-disabled": disabled || undefined, tabIndex: stop === id ? 0 : -1, onPointerMove: event => { if (event.pointerType === "mouse" || event.pointerType === "pen")
|
|
186
|
+
setHoverId(id); }, onPointerDown: event => { if (disabled || event.button !== 0)
|
|
187
|
+
return; setHoverId(id); setPressing(true); }, onPointerUp: () => setPressing(false), onPointerCancel: () => setPressing(false), onPointerLeave: () => setPressing(false), onKeyDown: event => { if ((event.key === "Enter" || event.key === " ") && !event.repeat && !disabled)
|
|
188
|
+
setPressing(true); }, onKeyUp: event => { if (event.key === "Enter" || event.key === " ")
|
|
189
|
+
setPressing(false); }, onFocus: event => {
|
|
190
|
+
setTabStop(id);
|
|
191
|
+
let keyboard = false;
|
|
192
|
+
try {
|
|
193
|
+
keyboard = event.currentTarget.matches(":focus-visible");
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
keyboard = false;
|
|
197
|
+
}
|
|
198
|
+
setFocusId(keyboard ? id : null);
|
|
199
|
+
}, onClick: () => {
|
|
200
|
+
if (disabled)
|
|
201
|
+
return;
|
|
202
|
+
onSelect?.();
|
|
203
|
+
onAction?.(id);
|
|
204
|
+
}, children: _jsx(ItemBody, { label: itemLabel, icon: icon, iconOnly: iconOnly, reserve: reserveLabels, reduced: reduced }) });
|
|
205
|
+
// Tooltips render no wrapper element, so every button stays a direct child of the tray and measures against it.
|
|
206
|
+
if (!iconOnly && !shortcut)
|
|
207
|
+
return _jsx(Fragment, { children: button }, id);
|
|
208
|
+
const tip = _jsx(TipContent, { label: itemLabel, shortcut: shortcut });
|
|
209
|
+
return side === "top" || side === "bottom"
|
|
210
|
+
? _jsx(Tooltip, { side: side, content: tip, children: button }, id)
|
|
211
|
+
: _jsx(SideTip, { side: side, content: tip, children: button }, id);
|
|
212
|
+
})] });
|
|
213
|
+
return side === "left" || side === "right" ? _jsx(TooltipPrimitive.Provider, { delayDuration: 250, skipDelayDuration: 300, disableHoverableContent: true, children: group }) : group;
|
|
214
|
+
}
|
|
215
|
+
export default FloatingButtonGroup;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/* Separate soft buttons in a quiet tray, with real gaps between them. The tray always keeps its natural size and never wraps or clips its buttons. The buttons stay transparent: one shared highlight
|
|
2
|
+
moves underneath their content, so there is only ever one moving surface. Corners are concentric with the tray. */
|
|
3
|
+
.group {
|
|
4
|
+
--fbg-h: 36px; --fbg-pad: 4px; --fbg-gap: 4px; --fbg-pad-x: 12px;
|
|
5
|
+
--fbg-outer: var(--radius-control);
|
|
6
|
+
--fbg-radius: calc(var(--fbg-outer) - var(--fbg-pad) - 1px);
|
|
7
|
+
--fbg-highlight: color-mix(in oklab, var(--foreground) 7%, transparent);
|
|
8
|
+
--fbg-highlight-press: color-mix(in oklab, var(--foreground) 12%, transparent);
|
|
9
|
+
--fbg-on: color-mix(in oklab, var(--control-on) 12%, transparent);
|
|
10
|
+
--fbg-rule: color-mix(in oklab, var(--foreground) 12%, transparent);
|
|
11
|
+
/* The muted tray is a translucent tint, not --surface-muted, so it still reads when it sits on a muted surface. */
|
|
12
|
+
--fbg-tray: color-mix(in oklab, var(--foreground) 4.5%, transparent);
|
|
13
|
+
--fbg-tray-edge: color-mix(in oklab, var(--foreground) 3%, transparent);
|
|
14
|
+
position: relative; isolation: isolate; display: inline-flex; flex: none; flex-wrap: nowrap; align-items: center; gap: var(--fbg-gap); padding: var(--fbg-pad); border: 1px solid var(--fbg-tray-edge); border-radius: var(--fbg-outer); background: var(--fbg-tray); color: var(--text-secondary); font-family: var(--font-body); letter-spacing: var(--tracking-body); -webkit-tap-highlight-color: transparent;
|
|
15
|
+
}
|
|
16
|
+
.sm { --fbg-h: 30px; --fbg-pad: 3px; --fbg-gap: 3px; --fbg-pad-x: 10px; --fbg-outer: calc(var(--radius-control) - 3px); }
|
|
17
|
+
.md { --fbg-h: 36px; }
|
|
18
|
+
/* Raised for overlays such as a selection toolbar: the floating layer shadow and a hairline edge. */
|
|
19
|
+
.floating { border-color: var(--border); background: var(--surface-raised); box-shadow: var(--shadow-floating); }
|
|
20
|
+
.vertical { flex-direction: column; align-items: stretch; }
|
|
21
|
+
:global(:root[data-theme="dark"]) .group {
|
|
22
|
+
--fbg-highlight: color-mix(in oklab, var(--foreground) 9%, transparent);
|
|
23
|
+
--fbg-highlight-press: color-mix(in oklab, var(--foreground) 15%, transparent);
|
|
24
|
+
--fbg-on: color-mix(in oklab, var(--control-on) 16%, transparent);
|
|
25
|
+
--fbg-tray: color-mix(in oklab, var(--foreground) 6%, transparent);
|
|
26
|
+
--fbg-tray-edge: color-mix(in oklab, var(--foreground) 5%, transparent);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* The highlight paints above the buttons' own backgrounds (a pressed tint) and below their content. */
|
|
30
|
+
.highlight { position: absolute; z-index: 1; top: 0; left: 0; border-radius: var(--fbg-radius); background: var(--fbg-highlight); pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard); }
|
|
31
|
+
.highlight[data-pressing] { background: var(--fbg-highlight-press); transition-duration: var(--duration-instant); }
|
|
32
|
+
|
|
33
|
+
/* Buttons are not positioned, so the highlight measures them against the tray and stacks above their background. */
|
|
34
|
+
.item { display: inline-flex; min-width: var(--fbg-h); height: var(--fbg-h); flex: none; align-items: center; justify-content: center; margin: 0; padding: 0 var(--fbg-pad-x); border: 0; border-radius: var(--fbg-radius); background: transparent; color: inherit; font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: 1; white-space: nowrap; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }
|
|
35
|
+
.sm .item { font-size: var(--text-xs); }
|
|
36
|
+
.item[data-icon]:not([data-icon-only]) { padding-left: calc(var(--fbg-pad-x) - 2px); }
|
|
37
|
+
.item[data-icon-only] { width: var(--fbg-h); padding: 0; }
|
|
38
|
+
.item[data-active], .item[aria-pressed="true"] { color: var(--foreground); }
|
|
39
|
+
.item[aria-pressed="true"] { background: var(--fbg-on); }
|
|
40
|
+
.item[aria-disabled="true"] { color: var(--text-muted); cursor: not-allowed; opacity: .5; }
|
|
41
|
+
.vertical .item:not([data-icon-only]) { justify-content: flex-start; }
|
|
42
|
+
.vertical .item[data-icon-only] { align-self: center; }
|
|
43
|
+
|
|
44
|
+
/* Ghosts of every label share one grid cell with the visible one, so the slot always has the widest label's width. */
|
|
45
|
+
.slot { position: relative; z-index: 2; display: inline-grid; align-items: center; justify-items: center; }
|
|
46
|
+
.slot > * { grid-area: 1 / 1; }
|
|
47
|
+
.vertical .slot { justify-items: start; }
|
|
48
|
+
.ghost { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; visibility: hidden; pointer-events: none; }
|
|
49
|
+
.iconSpace { display: block; width: 16px; height: 16px; flex: none; }
|
|
50
|
+
.phase { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
|
51
|
+
.icon { display: inline-flex; flex: none; }
|
|
52
|
+
.icon svg { width: 16px; height: 16px; stroke-width: 1.75; }
|
|
53
|
+
.text { display: block; }
|
|
54
|
+
|
|
55
|
+
.separator { flex: none; align-self: center; width: 1px; height: calc(var(--fbg-h) * .5); margin-inline: 2px; border-radius: var(--radius-full); corner-shape: round; background: var(--fbg-rule); }
|
|
56
|
+
.vertical .separator { width: calc(var(--fbg-h) * .5); height: 1px; margin: 2px 0; }
|
|
57
|
+
|
|
58
|
+
.tip { display: inline-flex; align-items: baseline; gap: var(--space-group); white-space: nowrap; }
|
|
59
|
+
.kbd { font: inherit; color: color-mix(in oklab, var(--background) 58%, var(--foreground)); font-variant-numeric: tabular-nums; }
|
|
60
|
+
|
|
61
|
+
/* Side tooltips for a column match the library tooltip, rising a few pixels away from the rail instead of up. */
|
|
62
|
+
.sideTip { --tip-x: -3px; z-index: 90; max-width: 15rem; padding: var(--space-group) var(--space-inset); border: 1px solid color-mix(in oklab, var(--background) 14%, var(--foreground)); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--foreground); color: var(--background); box-shadow: var(--shadow-raised); font-family: var(--font-body); font-size: var(--text-sm); font-weight: 400; letter-spacing: var(--tracking-body); line-height: var(--leading-body); transform-origin: var(--radix-tooltip-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-fast) var(--ease-enter); }
|
|
63
|
+
.sideTip[data-side="left"] { --tip-x: 3px; }
|
|
64
|
+
@starting-style {
|
|
65
|
+
.sideTip[data-state$="-open"] { opacity: 0; transform: translateX(var(--tip-x)) scale(.97); }
|
|
66
|
+
}
|
|
67
|
+
/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */
|
|
68
|
+
.sideTip[data-state="closed"] { opacity: 0; transform: scale(.98); transition: opacity 110ms var(--ease-standard), transform 110ms var(--ease-standard); animation: side-tip-exit 110ms linear both; }
|
|
69
|
+
@keyframes side-tip-exit { to { --side-tip-exit: 1; } }
|
|
70
|
+
|
|
71
|
+
@media (prefers-reduced-motion: reduce) {
|
|
72
|
+
.item, .highlight { transition: color var(--duration-instant) linear, background-color var(--duration-instant) linear, opacity var(--duration-instant) linear; }
|
|
73
|
+
.sideTip, .sideTip[data-state="closed"] { transform: none; transition: opacity 90ms linear; }
|
|
74
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type GaugeTone = "accent" | "success" | "warning" | "danger";
|
|
2
|
+
/** A band that starts at `from`: the highest band the value reaches sets the tone and names the state, so it never rests on color alone. */
|
|
3
|
+
export interface GaugeThreshold {
|
|
4
|
+
from: number;
|
|
5
|
+
tone: GaugeTone;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
export interface GaugeProps {
|
|
9
|
+
value: number;
|
|
10
|
+
min?: number;
|
|
11
|
+
max?: number;
|
|
12
|
+
label: string;
|
|
13
|
+
detail?: string;
|
|
14
|
+
tone?: GaugeTone;
|
|
15
|
+
thresholds?: GaugeThreshold[];
|
|
16
|
+
}
|
|
17
|
+
export declare function Gauge({ value, min, max, label, detail, tone, thresholds }: GaugeProps): import("react").JSX.Element;
|
|
18
|
+
export default Gauge;
|
|
@@ -0,0 +1,101 @@
|
|
|
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, useMotionValueEvent, useReducedMotion, useTransform } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import styles from "./gauge.module.css";
|
|
7
|
+
/** Copy enters from the side the value moved toward: a rise comes up from below, a fall drops from above. */
|
|
8
|
+
const rise = { hidden: (direction) => ({ opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` }), shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: (direction) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }) };
|
|
9
|
+
// Same keys as `rise` so the settled style is identical whichever branch renders on the server.
|
|
10
|
+
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 } } };
|
|
11
|
+
/** The first fill is slower and never overshoots, like a ring closing when the view opens. */
|
|
12
|
+
const reveal = { ...motionTokens.spring.smooth, visualDuration: motionTokens.duration.considered * 1.6 };
|
|
13
|
+
/** New copy rises in while the old copy leaves, popped out of flow so the line never holds both. */
|
|
14
|
+
function Swap({ text, direction = 1 }) {
|
|
15
|
+
const reduceMotion = !!useReducedMotion();
|
|
16
|
+
return _jsx("span", { className: styles.swap, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: direction, children: _jsx(motion.span, { className: styles.text, custom: direction, variants: reduceMotion ? fade : rise, initial: "hidden", animate: "shown", exit: "gone", children: text }, text) }) });
|
|
17
|
+
}
|
|
18
|
+
/** A 270 degree ring open at the bottom, drawn clockwise from its lower left end. Redrawn from the sweep every frame so the round cap stays exact. */
|
|
19
|
+
const radius = 42, start = -135, span = 270;
|
|
20
|
+
function arcFor(sweep) {
|
|
21
|
+
const end = start + span * Math.min(1, Math.max(0, sweep));
|
|
22
|
+
const at = (angle) => `${(50 + radius * Math.sin((angle * Math.PI) / 180)).toFixed(3)} ${(50 - radius * Math.cos((angle * Math.PI) / 180)).toFixed(3)}`;
|
|
23
|
+
return `M ${at(start)} A ${radius} ${radius} 0 ${end - start > 180 ? 1 : 0} 1 ${at(end)}`;
|
|
24
|
+
}
|
|
25
|
+
const track = arcFor(1);
|
|
26
|
+
export function Gauge({ value, min = 0, max = 100, label, detail, tone = "accent", thresholds }) {
|
|
27
|
+
const safeMax = max > min ? max : min + 1;
|
|
28
|
+
const percentage = Math.min(1, Math.max(0, (value - min) / (safeMax - min)));
|
|
29
|
+
const displayValue = Math.round(percentage * 100);
|
|
30
|
+
const bands = [...thresholds ?? []].sort((a, b) => a.from - b.from);
|
|
31
|
+
const bandAt = (at) => bands.filter(threshold => at >= threshold.from - 1e-6).pop();
|
|
32
|
+
const band = bandAt(value);
|
|
33
|
+
const ref = useRef(null);
|
|
34
|
+
const sizer = useRef(null);
|
|
35
|
+
const inView = useInView(ref, { once: true, amount: .5 });
|
|
36
|
+
const reduceMotion = !!useReducedMotion();
|
|
37
|
+
const sweep = useMotionValue(0);
|
|
38
|
+
const count = useMotionValue(0);
|
|
39
|
+
const digitsWidth = useMotionValue("auto");
|
|
40
|
+
const [filled, setFilled] = useState(false);
|
|
41
|
+
const [live, setLive] = useState(band);
|
|
42
|
+
const path = useTransform(sweep, arcFor);
|
|
43
|
+
// The cap fades in over the first sliver instead of popping in as a dot.
|
|
44
|
+
const arcOpacity = useTransform(sweep, current => Math.min(1, Math.max(0, current / .02)));
|
|
45
|
+
const digits = useTransform(count, current => String(Math.round(Math.min(100, Math.max(0, current)))));
|
|
46
|
+
const [moved, setMoved] = useState({ value: displayValue, direction: 1 });
|
|
47
|
+
if (moved.value !== displayValue)
|
|
48
|
+
setMoved({ value: displayValue, direction: displayValue < moved.value ? -1 : 1 });
|
|
49
|
+
// The ring fills once it is seen, then springs to each new value with a little life; the count follows without overshooting.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
// Reduced motion lands on the value at once; the state appears a frame later, after hydration has matched the server.
|
|
52
|
+
if (reduceMotion) {
|
|
53
|
+
sweep.jump(percentage);
|
|
54
|
+
count.jump(percentage * 100);
|
|
55
|
+
const frame = requestAnimationFrame(() => setFilled(true));
|
|
56
|
+
return () => cancelAnimationFrame(frame);
|
|
57
|
+
}
|
|
58
|
+
if (!inView)
|
|
59
|
+
return;
|
|
60
|
+
const arc = animate(sweep, percentage, filled ? motionTokens.spring.morph : reveal);
|
|
61
|
+
const number = animate(count, percentage * 100, { ...(filled ? motionTokens.spring.smooth : reveal), onComplete: () => setFilled(true) });
|
|
62
|
+
return () => { arc.stop(); number.stop(); };
|
|
63
|
+
}, [count, filled, inView, percentage, reduceMotion, sweep]);
|
|
64
|
+
// The state arrives as the first fill lands. After that it follows the number on screen, so the color and label change exactly as the count crosses a threshold.
|
|
65
|
+
useMotionValueEvent(count, "change", current => {
|
|
66
|
+
const next = bandAt(min + (current / 100) * (safeMax - min));
|
|
67
|
+
const changed = next?.from !== live?.from || next?.label !== live?.label;
|
|
68
|
+
if (filled) {
|
|
69
|
+
if (changed)
|
|
70
|
+
setLive(next);
|
|
71
|
+
}
|
|
72
|
+
else if (inView && Math.abs(current - percentage * 100) < 1) {
|
|
73
|
+
if (changed)
|
|
74
|
+
setLive(next);
|
|
75
|
+
setFilled(true);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
const shown = reduceMotion || !filled ? band : live;
|
|
79
|
+
const activeTone = shown?.tone ?? tone;
|
|
80
|
+
// Digits are right aligned in a box sized to the target number, so the ones column never jumps while counting and the group recentres on a spring.
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const node = sizer.current;
|
|
83
|
+
if (!node || typeof ResizeObserver === "undefined")
|
|
84
|
+
return;
|
|
85
|
+
let measured = null;
|
|
86
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
87
|
+
if (!entry)
|
|
88
|
+
return;
|
|
89
|
+
const next = entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;
|
|
90
|
+
if (measured !== null && measured !== node.textContent && !reduceMotion)
|
|
91
|
+
animate(digitsWidth, next, motionTokens.spring.smooth);
|
|
92
|
+
else
|
|
93
|
+
digitsWidth.jump(next);
|
|
94
|
+
measured = node.textContent;
|
|
95
|
+
});
|
|
96
|
+
observer.observe(node);
|
|
97
|
+
return () => observer.disconnect();
|
|
98
|
+
}, [digitsWidth, reduceMotion]);
|
|
99
|
+
return _jsxs("figure", { ref: ref, className: [styles.figure, styles[activeTone]].join(" "), "aria-label": `${label}: ${value} of ${safeMax}${band ? `, ${band.label}` : ""}`, children: [_jsxs("div", { className: styles.visual, role: "meter", "aria-label": label, "aria-valuemin": min, "aria-valuemax": safeMax, "aria-valuenow": Math.min(safeMax, Math.max(min, value)), "aria-valuetext": `${displayValue}%${band ? `, ${band.label}` : ""}`, children: [_jsxs("svg", { viewBox: "0 0 100 90", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { className: styles.track, d: track }), _jsx(motion.path, { className: styles.arc, d: path, style: { opacity: arcOpacity } })] }), _jsxs("div", { className: styles.readout, "aria-hidden": "true", children: [_jsxs("span", { className: styles.number, children: [_jsx(motion.span, { className: styles.digits, style: { width: digitsWidth }, children: digits }), _jsx("span", { className: styles.unit, children: "%" }), _jsx("span", { ref: sizer, className: styles.sizer, children: displayValue })] }), thresholds && _jsx("span", { className: styles.status, children: _jsx(Swap, { text: filled ? shown?.label ?? "" : "", direction: moved.direction }) })] })] }), _jsxs("figcaption", { children: [_jsx("strong", { children: _jsx(Swap, { text: label }) }), detail && _jsx("span", { className: styles.detail, children: _jsx(Swap, { text: detail, direction: moved.direction }) })] })] });
|
|
100
|
+
}
|
|
101
|
+
export default Gauge;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
.figure { display: grid; justify-items: center; min-width: 0; gap: 10px; margin: 0; text-align: center; }.figure figcaption { display: grid; gap: 3px; min-width: 0; }.figure strong { font-size: var(--text-sm); font-weight: 500; }.detail { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
|
2
|
+
.accent { --gauge-color: var(--accent); --gauge-text: var(--accent-strong); }.success { --gauge-color: var(--success); --gauge-text: var(--success); }.warning { --gauge-color: var(--warning); --gauge-text: var(--warning); }.danger { --gauge-color: var(--danger); --gauge-text: var(--danger); }
|
|
3
|
+
|
|
4
|
+
/* An activity style ring: a thick stroke with round caps over a faint tint of its own color. The stroke scales with the ring. */
|
|
5
|
+
.visual { position: relative; width: min(100%, 176px); aspect-ratio: 100 / 90; }
|
|
6
|
+
.visual svg { display: block; width: 100%; height: 100%; overflow: visible; }
|
|
7
|
+
.track, .arc { fill: none; stroke-width: 9; stroke-linecap: round; transition: stroke var(--duration-standard) var(--ease-standard); }
|
|
8
|
+
.track { stroke: color-mix(in oklab, var(--gauge-color) 13%, transparent); }
|
|
9
|
+
.arc { stroke: var(--gauge-color); }
|
|
10
|
+
|
|
11
|
+
/* The readout sits on the ring's true centre, which is the middle of a square as wide as the ring. */
|
|
12
|
+
.readout { position: absolute; top: 0; left: 0; display: grid; width: 100%; aspect-ratio: 1; place-content: center; justify-items: center; gap: 4px; pointer-events: none; }
|
|
13
|
+
.number { position: relative; display: inline-flex; align-items: baseline; 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; }
|
|
14
|
+
.digits { display: inline-flex; justify-content: flex-end; white-space: nowrap; }
|
|
15
|
+
.unit { margin-left: 1px; color: var(--text-secondary); font-family: var(--font-body); font-size: .5em; letter-spacing: 0; }
|
|
16
|
+
.sizer { position: absolute; top: 0; left: 0; visibility: hidden; white-space: nowrap; pointer-events: none; }
|
|
17
|
+
.status { min-width: 100px; min-height: 1.4em; color: var(--gauge-text); font-size: var(--text-xs); font-weight: 500; line-height: 1.4; transition: color var(--duration-standard) var(--ease-standard); }
|
|
18
|
+
|
|
19
|
+
/* Label, status and detail copy swap in place: the outgoing line is popped out of flow while the new one rises in. */
|
|
20
|
+
.swap { position: relative; display: block; }
|
|
21
|
+
.swap > .text { display: inline-block; }
|
|
22
|
+
@media (prefers-reduced-motion: reduce) { .track, .arc, .status { transition: none; } }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* A button that commits only after it is held, for destructive or hard to undo actions where a stray tap must not count. A fill tracks the hold on a
|
|
4
|
+
* linear timeline; letting go early rewinds it on a spring, and finishing morphs the label and icon into a done state. Space and Enter can be held too.
|
|
5
|
+
* Prefer a regular confirmation dialog when people need to read consequences first; use this when the consequence is already on screen.
|
|
6
|
+
*/
|
|
7
|
+
export interface HoldToConfirmProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick" | "onDrag" | "onDragEnd" | "onDragStart" | "onAnimationStart" | "onAnimationEnd"> {
|
|
8
|
+
/** The instruction and the action, for example “Hold to delete project”. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Shown once the hold completes, for example “Deleted”. */
|
|
11
|
+
confirmedLabel?: string;
|
|
12
|
+
/** Called once when the hold completes. */
|
|
13
|
+
onConfirm: () => void;
|
|
14
|
+
/** Hold length in milliseconds. */
|
|
15
|
+
duration?: number;
|
|
16
|
+
icon?: ReactNode;
|
|
17
|
+
/** `accent` (default) fills with the selected accent; `danger` is for an irreversible action; `neutral` fills with the foreground. */
|
|
18
|
+
tone?: "accent" | "danger" | "neutral";
|
|
19
|
+
/** Controls the done state. Set it back to false to reset the button; leave it undefined to let the button keep its own state. */
|
|
20
|
+
confirmed?: boolean;
|
|
21
|
+
/** Reports when a hold starts and ends, for surrounding hints such as “Keep holding”. */
|
|
22
|
+
onHoldChange?: (holding: boolean) => void;
|
|
23
|
+
}
|
|
24
|
+
export declare function HoldToConfirm({ label, confirmedLabel, onConfirm, duration, icon, tone, confirmed, onHoldChange, className, disabled, ...props }: HoldToConfirmProps): import("react").JSX.Element;
|
|
25
|
+
export default HoldToConfirm;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useEffectEvent, useId, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
|
|
5
|
+
import { Trash2 } from "lucide-react";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import styles from "./hold-to-confirm.module.css";
|
|
8
|
+
const enter = [...motionTokens.ease.enter];
|
|
9
|
+
const standard = [...motionTokens.ease.standard];
|
|
10
|
+
const rest = { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" };
|
|
11
|
+
const textIn = { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` };
|
|
12
|
+
const textOut = { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } };
|
|
13
|
+
const iconIn = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };
|
|
14
|
+
const iconOut = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: standard } };
|
|
15
|
+
const fadeIn = { opacity: 0 };
|
|
16
|
+
const fadeOut = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
|
|
17
|
+
/** Scale rides the spring; opacity and blur tween so the blur never overshoots below zero. */
|
|
18
|
+
const iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter }, filter: { duration: motionTokens.duration.fast, ease: enter } };
|
|
19
|
+
/** The tick draws itself from its short stroke, the way a hand would write it. */
|
|
20
|
+
function DrawnCheck({ reduced }) {
|
|
21
|
+
return _jsx("svg", { width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx(motion.path, { d: "M4 12.5l5 5L20 6.5", initial: reduced ? false : { pathLength: 0, opacity: 0 }, animate: { pathLength: 1, opacity: 1 }, transition: { pathLength: { duration: .32, ease: enter, delay: .08 }, opacity: { duration: .05, delay: .08 } } }) });
|
|
22
|
+
}
|
|
23
|
+
/** Icon and label. The button renders it twice: once on the surface and once inside the fill, so the text changes colour exactly at the fill edge. */
|
|
24
|
+
function Face({ icon, text, done, width, reduced, measure }) {
|
|
25
|
+
return _jsxs("span", { className: styles.face, children: [_jsx("span", { className: styles.iconSlot, children: _jsx(AnimatePresence, { initial: false, children: _jsx(motion.span, { className: styles.iconPhase, initial: reduced ? fadeIn : iconIn, animate: rest, exit: reduced ? fadeOut : iconOut, transition: reduced ? { duration: motionTokens.duration.fast } : iconEnter, children: done ? _jsx(DrawnCheck, { reduced: reduced }) : icon }, done ? "done" : "idle") }) }), _jsxs(motion.span, { className: styles.labelFrame, style: { width }, children: [measure && _jsx("span", { ref: measure, className: styles.measure, children: text }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(motion.span, { className: styles.label, initial: reduced ? fadeIn : textIn, animate: rest, exit: reduced ? fadeOut : textOut, transition: { duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard, ease: enter }, children: text }, text) })] })] });
|
|
26
|
+
}
|
|
27
|
+
/** Springs the label frame to the width of new text, so the button morphs instead of snapping. A late web font or a reflow follows instantly. */
|
|
28
|
+
function useLabelWidth(reduced) {
|
|
29
|
+
const width = useMotionValue("auto");
|
|
30
|
+
const [node, setNode] = useState(null);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!node || typeof ResizeObserver === "undefined")
|
|
33
|
+
return;
|
|
34
|
+
let lastText = null;
|
|
35
|
+
let sizing = null;
|
|
36
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
37
|
+
// Layout width, not the painted box: the done label is measured while the pressed button is still scaled down.
|
|
38
|
+
const next = entry?.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;
|
|
39
|
+
const text = node.textContent;
|
|
40
|
+
const morph = lastText !== null && lastText !== text && !reduced && typeof width.get() === "number";
|
|
41
|
+
lastText = text;
|
|
42
|
+
sizing?.stop();
|
|
43
|
+
if (morph)
|
|
44
|
+
sizing = animate(width, next, motionTokens.spring.morph);
|
|
45
|
+
else
|
|
46
|
+
width.jump(next);
|
|
47
|
+
});
|
|
48
|
+
observer.observe(node);
|
|
49
|
+
return () => { observer.disconnect(); sizing?.stop(); };
|
|
50
|
+
}, [node, reduced, width]);
|
|
51
|
+
return [width, setNode];
|
|
52
|
+
}
|
|
53
|
+
export function HoldToConfirm({ label, confirmedLabel = "Done", onConfirm, duration = 1200, icon = _jsx(Trash2, { strokeWidth: 1.75 }), tone = "accent", confirmed, onHoldChange, className, disabled, ...props }) {
|
|
54
|
+
const reduced = useReducedMotion() ?? false;
|
|
55
|
+
const hintId = useId();
|
|
56
|
+
const [ownDone, setOwnDone] = useState(false);
|
|
57
|
+
const [completions, setCompletions] = useState(0);
|
|
58
|
+
const done = confirmed ?? ownDone;
|
|
59
|
+
const [holding, setHolding] = useState(false);
|
|
60
|
+
const progress = useMotionValue(0);
|
|
61
|
+
const scale = useMotionValue(1);
|
|
62
|
+
const clipPath = useTransform(progress, value => `inset(0 ${((1 - Math.min(1, Math.max(0, value))) * 100).toFixed(3)}% 0 0)`);
|
|
63
|
+
const [width, measure] = useLabelWidth(reduced);
|
|
64
|
+
const source = useRef(null);
|
|
65
|
+
const pointerType = useRef("mouse");
|
|
66
|
+
const fill = useRef(null);
|
|
67
|
+
const press = useRef(null);
|
|
68
|
+
const button = useRef(null);
|
|
69
|
+
const text = done ? confirmedLabel : label;
|
|
70
|
+
const seconds = (duration / 1000).toLocaleString("en-US", { maximumFractionDigits: 1 });
|
|
71
|
+
function pressTo(pressed) {
|
|
72
|
+
press.current?.stop();
|
|
73
|
+
if (reduced) {
|
|
74
|
+
scale.jump(1);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const depth = (button.current?.offsetWidth ?? 0) > 220 ? .985 : .97;
|
|
78
|
+
press.current = animate(scale, pressed ? depth : 1, motionTokens.spring.snappy);
|
|
79
|
+
}
|
|
80
|
+
function rewind() {
|
|
81
|
+
fill.current?.stop();
|
|
82
|
+
if (reduced)
|
|
83
|
+
progress.jump(0);
|
|
84
|
+
else
|
|
85
|
+
fill.current = animate(progress, 0, { ...motionTokens.spring.smooth, velocity: 0 });
|
|
86
|
+
}
|
|
87
|
+
function stopHolding() {
|
|
88
|
+
source.current = null;
|
|
89
|
+
setHolding(false);
|
|
90
|
+
onHoldChange?.(false);
|
|
91
|
+
pressTo(false);
|
|
92
|
+
}
|
|
93
|
+
function complete() {
|
|
94
|
+
if (!source.current)
|
|
95
|
+
return;
|
|
96
|
+
stopHolding();
|
|
97
|
+
if (pointerType.current === "touch")
|
|
98
|
+
navigator.vibrate?.(12);
|
|
99
|
+
setOwnDone(true);
|
|
100
|
+
setCompletions(count => count + 1);
|
|
101
|
+
onConfirm();
|
|
102
|
+
}
|
|
103
|
+
/** Starts or resumes the fill from wherever it is, so a quick re-press after letting go carries on instead of starting over. */
|
|
104
|
+
function begin(from) {
|
|
105
|
+
if (done || disabled || source.current)
|
|
106
|
+
return;
|
|
107
|
+
source.current = from;
|
|
108
|
+
setHolding(true);
|
|
109
|
+
onHoldChange?.(true);
|
|
110
|
+
pressTo(true);
|
|
111
|
+
fill.current?.stop();
|
|
112
|
+
fill.current = animate(progress, 1, { duration: (1 - progress.get()) * duration / 1000, ease: "linear", onComplete: complete });
|
|
113
|
+
}
|
|
114
|
+
function release() {
|
|
115
|
+
if (!source.current)
|
|
116
|
+
return;
|
|
117
|
+
stopHolding();
|
|
118
|
+
rewind();
|
|
119
|
+
}
|
|
120
|
+
// A reset rewinds the fill as the label morphs back; a confirmation the parent declined rewinds too.
|
|
121
|
+
const syncFill = useEffectEvent(() => {
|
|
122
|
+
if (done && progress.get() < 1 && !source.current) {
|
|
123
|
+
fill.current?.stop();
|
|
124
|
+
if (reduced)
|
|
125
|
+
progress.jump(1);
|
|
126
|
+
else
|
|
127
|
+
fill.current = animate(progress, 1, motionTokens.spring.smooth);
|
|
128
|
+
}
|
|
129
|
+
if (!done && progress.get() > 0 && !source.current)
|
|
130
|
+
rewind();
|
|
131
|
+
});
|
|
132
|
+
useEffect(() => { syncFill(); }, [done, completions]);
|
|
133
|
+
useEffect(() => () => { fill.current?.stop(); press.current?.stop(); }, []);
|
|
134
|
+
function onPointerDown(event) {
|
|
135
|
+
if (!event.isPrimary || event.button !== 0)
|
|
136
|
+
return;
|
|
137
|
+
pointerType.current = event.pointerType;
|
|
138
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
139
|
+
begin("pointer");
|
|
140
|
+
}
|
|
141
|
+
function onPointerMove(event) {
|
|
142
|
+
if (source.current !== "pointer")
|
|
143
|
+
return;
|
|
144
|
+
// Sliding well off the button cancels, the way a native press does.
|
|
145
|
+
const box = event.currentTarget.getBoundingClientRect(), slack = 24;
|
|
146
|
+
if (event.clientX < box.left - slack || event.clientX > box.right + slack || event.clientY < box.top - slack || event.clientY > box.bottom + slack)
|
|
147
|
+
release();
|
|
148
|
+
}
|
|
149
|
+
function onKeyDown(event) {
|
|
150
|
+
if (event.key !== " " && event.key !== "Enter")
|
|
151
|
+
return;
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
if (!event.repeat)
|
|
154
|
+
begin("key");
|
|
155
|
+
}
|
|
156
|
+
function onKeyUp(event) {
|
|
157
|
+
if (event.key !== " " && event.key !== "Enter")
|
|
158
|
+
return;
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
if (source.current === "key")
|
|
161
|
+
release();
|
|
162
|
+
}
|
|
163
|
+
return _jsxs(_Fragment, { children: [_jsxs(motion.button, { ...props, ref: button, type: "button", className: [styles.button, className].filter(Boolean).join(" "), "data-tone": tone, "data-state": done ? "done" : holding ? "holding" : "idle", disabled: disabled, "aria-disabled": done || undefined, "aria-label": text, "aria-describedby": done ? undefined : hintId, style: { scale }, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: release, onPointerCancel: release, onLostPointerCapture: release, onKeyDown: onKeyDown, onKeyUp: onKeyUp, onBlur: release, onContextMenu: event => event.preventDefault(), children: [_jsx(Face, { icon: icon, text: text, done: done, width: width, reduced: reduced, measure: measure }), _jsx(motion.span, { className: styles.fill, style: { clipPath }, "aria-hidden": "true", children: _jsx(Face, { icon: icon, text: text, done: done, width: width, reduced: reduced }) })] }), _jsx("span", { id: hintId, className: styles.srOnly, children: `Press and hold for ${seconds} seconds to confirm. With a keyboard, hold Space or Enter.` }), _jsx("span", { className: styles.srOnly, role: "status", children: done ? confirmedLabel : "" })] });
|
|
164
|
+
}
|
|
165
|
+
export default HoldToConfirm;
|