@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,330 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useContext, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react";
|
|
4
|
+
import * as Menu from "@radix-ui/react-dropdown-menu";
|
|
5
|
+
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, useTransform } from "motion/react";
|
|
6
|
+
import { MoreHorizontal } from "lucide-react";
|
|
7
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
8
|
+
import styles from "./swipe-actions.module.css";
|
|
9
|
+
/** Width of one action while a row rests open. */
|
|
10
|
+
const ACTION = 76;
|
|
11
|
+
/** Movement before a press is read as a swipe or handed to the page as a scroll. */
|
|
12
|
+
const SLOP = 8;
|
|
13
|
+
/** Seconds of travel projected from the release velocity, roughly a 0.99 deceleration rate. */
|
|
14
|
+
const PROJECTION = .1;
|
|
15
|
+
/** Release speed in px/s that counts as a flick. */
|
|
16
|
+
const FLICK = 900;
|
|
17
|
+
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
|
18
|
+
/** Past its last stop the row still follows, but every pixel costs more, like pulling against elastic. */
|
|
19
|
+
const rubberBand = (overshoot, dimension) => (1 - 1 / (overshoot * .55 / dimension + 1)) * dimension;
|
|
20
|
+
const sideOf = (value) => value > 0 ? "leading" : value < 0 ? "trailing" : null;
|
|
21
|
+
const GroupContext = createContext({ openId: null, setOpenId: () => { }, rows: new Map() });
|
|
22
|
+
export function SwipeActions({ label, children, className }) {
|
|
23
|
+
const [openId, setOpenId] = useState(null);
|
|
24
|
+
const [rows] = useState(() => new Map());
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!openId)
|
|
27
|
+
return;
|
|
28
|
+
const onPointerDown = (event) => { const row = rows.get(openId); if (row && event.target instanceof Node && row.contains(event.target))
|
|
29
|
+
return; setOpenId(null); };
|
|
30
|
+
const onKeyDown = (event) => { if (event.key === "Escape")
|
|
31
|
+
setOpenId(null); };
|
|
32
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
33
|
+
document.addEventListener("keydown", onKeyDown);
|
|
34
|
+
return () => { document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("keydown", onKeyDown); };
|
|
35
|
+
}, [openId, rows]);
|
|
36
|
+
const group = useMemo(() => ({ openId, setOpenId, rows }), [openId, rows]);
|
|
37
|
+
return _jsx(GroupContext.Provider, { value: group, children: _jsx("div", { className: [styles.surface, className].filter(Boolean).join(" "), children: _jsx("ul", { role: "list", "aria-label": label, tabIndex: -1, className: styles.list, children: _jsx(AnimatePresence, { initial: false, children: children }) }) }) });
|
|
38
|
+
}
|
|
39
|
+
function velocityOf(samples) {
|
|
40
|
+
const last = samples[samples.length - 1];
|
|
41
|
+
if (!last)
|
|
42
|
+
return 0;
|
|
43
|
+
const first = samples.find(sample => last[0] - sample[0] <= 80) ?? last;
|
|
44
|
+
const elapsed = (last[0] - first[0]) / 1000;
|
|
45
|
+
return elapsed > 0 ? (last[1] - first[1]) / elapsed : 0;
|
|
46
|
+
}
|
|
47
|
+
export function SwipeActionsRow({ label, leading = [], trailing = [], fullSwipe = true, children, className }) {
|
|
48
|
+
const { openId, setOpenId, rows } = useContext(GroupContext);
|
|
49
|
+
const id = useId();
|
|
50
|
+
const reduced = useReducedMotion() ?? false;
|
|
51
|
+
const present = useIsPresent();
|
|
52
|
+
const rowRef = useRef(null);
|
|
53
|
+
const x = useMotionValue(0);
|
|
54
|
+
const cover = useMotionValue(0);
|
|
55
|
+
const [covering, setCovering] = useState(null);
|
|
56
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
57
|
+
const width = useRef(0);
|
|
58
|
+
const drag = useRef(null);
|
|
59
|
+
const travel = useRef(null);
|
|
60
|
+
const stretch = useRef(null);
|
|
61
|
+
const armed = useRef(false);
|
|
62
|
+
const leaving = useRef(false);
|
|
63
|
+
const swallowClick = useRef(false);
|
|
64
|
+
const focusNeighbour = useRef(false);
|
|
65
|
+
const restoreTimer = useRef(0);
|
|
66
|
+
const actionsOf = (side) => side === "leading" ? leading : trailing;
|
|
67
|
+
const openWidth = (side) => actionsOf(side).length * ACTION;
|
|
68
|
+
const outermost = (side) => side === "leading" ? 0 : trailing.length - 1;
|
|
69
|
+
const canCommit = (side) => fullSwipe && actionsOf(side).length > 0;
|
|
70
|
+
const threshold = (side) => Math.max(openWidth(side) + 48, width.current * .56);
|
|
71
|
+
function stretchTo(value) {
|
|
72
|
+
stretch.current?.stop();
|
|
73
|
+
if (reduced) {
|
|
74
|
+
cover.jump(value);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
stretch.current = animate(cover, value, motionTokens.spring.morph);
|
|
78
|
+
}
|
|
79
|
+
/** Springs the row to a stop, carrying the release velocity. Travel snaps instantly with reduced motion. */
|
|
80
|
+
function settle(target, velocity = 0) {
|
|
81
|
+
if (armed.current || (target !== 0 && cover.get() !== 0))
|
|
82
|
+
stretchTo(0);
|
|
83
|
+
armed.current = false;
|
|
84
|
+
const from = x.get();
|
|
85
|
+
const home = () => { if (target === 0) {
|
|
86
|
+
stretch.current?.stop();
|
|
87
|
+
cover.jump(0);
|
|
88
|
+
} };
|
|
89
|
+
if (reduced) {
|
|
90
|
+
x.jump(target);
|
|
91
|
+
home();
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// Heading home, the spring may not carry the row past zero, or the opposite actions would flash.
|
|
95
|
+
const toward = target === 0 && Math.sign(velocity) === -Math.sign(from);
|
|
96
|
+
const launch = toward ? Math.sign(velocity) * Math.min(Math.abs(velocity), Math.abs(from) * 12) : velocity;
|
|
97
|
+
travel.current = animate(x, target, { ...(target === 0 ? motionTokens.spring.smooth : motionTokens.spring.snappy), velocity: launch, onComplete: home });
|
|
98
|
+
}
|
|
99
|
+
if (target === 0)
|
|
100
|
+
setOpenId(current => current === id ? null : current);
|
|
101
|
+
else
|
|
102
|
+
setOpenId(id);
|
|
103
|
+
}
|
|
104
|
+
/** The chosen action stretches over the whole row while the row leaves in its direction; the list then closes the gap. */
|
|
105
|
+
function commit(side, index, velocity = 0) {
|
|
106
|
+
const action = actionsOf(side)[index];
|
|
107
|
+
if (!action || leaving.current)
|
|
108
|
+
return;
|
|
109
|
+
const direction = side === "leading" ? 1 : -1;
|
|
110
|
+
armed.current = false;
|
|
111
|
+
setCovering({ side, index });
|
|
112
|
+
stretchTo(1);
|
|
113
|
+
if (action.keepRow) {
|
|
114
|
+
action.onSelect();
|
|
115
|
+
settle(0, velocity);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
leaving.current = true;
|
|
119
|
+
const row = rowRef.current;
|
|
120
|
+
if (row)
|
|
121
|
+
row.dataset.removing = "";
|
|
122
|
+
travel.current?.stop();
|
|
123
|
+
const end = direction * (width.current + 2);
|
|
124
|
+
if (reduced)
|
|
125
|
+
x.jump(end);
|
|
126
|
+
else
|
|
127
|
+
travel.current = animate(x, end, { ...motionTokens.spring.smooth, velocity: Math.sign(velocity) === direction ? velocity : 0 });
|
|
128
|
+
setOpenId(current => current === id ? null : current);
|
|
129
|
+
action.onSelect();
|
|
130
|
+
// A list that keeps the item gets its row back instead of an empty band of colour.
|
|
131
|
+
window.clearTimeout(restoreTimer.current);
|
|
132
|
+
restoreTimer.current = window.setTimeout(() => { if (!leaving.current)
|
|
133
|
+
return; leaving.current = false; if (row)
|
|
134
|
+
delete row.dataset.removing; settle(0); }, 1400);
|
|
135
|
+
}
|
|
136
|
+
function arm(value, pointerType) {
|
|
137
|
+
const side = sideOf(value);
|
|
138
|
+
const limit = side ? threshold(side) : Infinity;
|
|
139
|
+
const next = !!side && canCommit(side) && Math.abs(value) > (armed.current ? limit - 20 : limit);
|
|
140
|
+
if (next === armed.current || !side) {
|
|
141
|
+
if (!side && armed.current) {
|
|
142
|
+
armed.current = false;
|
|
143
|
+
stretchTo(0);
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
armed.current = next;
|
|
148
|
+
if (next) {
|
|
149
|
+
setCovering({ side, index: outermost(side) });
|
|
150
|
+
if (pointerType === "touch")
|
|
151
|
+
navigator.vibrate?.(8);
|
|
152
|
+
}
|
|
153
|
+
stretchTo(next ? 1 : 0);
|
|
154
|
+
}
|
|
155
|
+
function constrain(raw) {
|
|
156
|
+
const side = sideOf(raw);
|
|
157
|
+
if (!side)
|
|
158
|
+
return 0;
|
|
159
|
+
const dimension = width.current || 320;
|
|
160
|
+
const limit = actionsOf(side).length === 0 ? 0 : canCommit(side) ? dimension : openWidth(side);
|
|
161
|
+
const distance = Math.abs(raw);
|
|
162
|
+
return Math.sign(raw) * (distance <= limit ? distance : limit + rubberBand(distance - limit, dimension));
|
|
163
|
+
}
|
|
164
|
+
function release(velocity) {
|
|
165
|
+
const value = x.get(), side = sideOf(value);
|
|
166
|
+
if (!side || actionsOf(side).length === 0) {
|
|
167
|
+
settle(0, velocity);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const direction = side === "leading" ? 1 : -1;
|
|
171
|
+
const distance = Math.abs(value), outward = velocity * direction, open = openWidth(side);
|
|
172
|
+
const projected = distance + outward * PROJECTION;
|
|
173
|
+
const flung = distance > open && outward > FLICK && projected > threshold(side);
|
|
174
|
+
// A flick back toward home cancels an armed full swipe.
|
|
175
|
+
if (canCommit(side) && ((armed.current && outward > -FLICK) || flung)) {
|
|
176
|
+
commit(side, outermost(side), velocity);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
settle(projected > open / 2 ? direction * open : 0, velocity);
|
|
180
|
+
}
|
|
181
|
+
function onPointerDown(event) {
|
|
182
|
+
swallowClick.current = false;
|
|
183
|
+
if (leaving.current || !event.isPrimary || (event.pointerType === "mouse" && event.button !== 0))
|
|
184
|
+
return;
|
|
185
|
+
// Catch the row mid-flight: the next move retargets from wherever it is now.
|
|
186
|
+
travel.current?.stop();
|
|
187
|
+
drag.current = { pointer: event.pointerId, type: event.pointerType, startX: event.clientX, startY: event.clientY, origin: x.get(), locked: null, samples: [[event.timeStamp, event.clientX]] };
|
|
188
|
+
}
|
|
189
|
+
function onPointerMove(event) {
|
|
190
|
+
const current = drag.current;
|
|
191
|
+
if (!current || current.pointer !== event.pointerId || current.locked === "y")
|
|
192
|
+
return;
|
|
193
|
+
if (!current.locked) {
|
|
194
|
+
const dx = event.clientX - current.startX, dy = event.clientY - current.startY;
|
|
195
|
+
if (Math.hypot(dx, dy) < SLOP)
|
|
196
|
+
return;
|
|
197
|
+
if (Math.abs(dy) > Math.abs(dx)) {
|
|
198
|
+
current.locked = "y";
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Measure from here, so crossing the slop never jumps the row.
|
|
202
|
+
current.locked = "x";
|
|
203
|
+
current.startX = event.clientX;
|
|
204
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
205
|
+
if (rowRef.current)
|
|
206
|
+
rowRef.current.dataset.dragging = "";
|
|
207
|
+
setOpenId(id);
|
|
208
|
+
}
|
|
209
|
+
const value = constrain(current.origin + event.clientX - current.startX);
|
|
210
|
+
x.set(value);
|
|
211
|
+
current.samples.push([event.timeStamp, event.clientX]);
|
|
212
|
+
if (current.samples.length > 12)
|
|
213
|
+
current.samples.shift();
|
|
214
|
+
arm(value, current.type);
|
|
215
|
+
}
|
|
216
|
+
function onPointerEnd(event) {
|
|
217
|
+
const current = drag.current;
|
|
218
|
+
if (!current || current.pointer !== event.pointerId)
|
|
219
|
+
return;
|
|
220
|
+
drag.current = null;
|
|
221
|
+
if (current.locked === "x") {
|
|
222
|
+
swallowClick.current = true;
|
|
223
|
+
if (rowRef.current)
|
|
224
|
+
delete rowRef.current.dataset.dragging;
|
|
225
|
+
current.samples.push([event.timeStamp, event.clientX]);
|
|
226
|
+
release(event.type === "pointercancel" ? 0 : velocityOf(current.samples));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// A tap on an open row closes it instead of acting on what is underneath.
|
|
230
|
+
if (current.locked === null && Math.abs(x.get()) > .5) {
|
|
231
|
+
swallowClick.current = true;
|
|
232
|
+
settle(0);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function onClickCapture(event) {
|
|
236
|
+
if (!swallowClick.current)
|
|
237
|
+
return;
|
|
238
|
+
swallowClick.current = false;
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
event.stopPropagation();
|
|
241
|
+
}
|
|
242
|
+
function moveFocusToNeighbour() {
|
|
243
|
+
const row = rowRef.current;
|
|
244
|
+
if (!row)
|
|
245
|
+
return;
|
|
246
|
+
const staying = (step) => {
|
|
247
|
+
let node = row[step];
|
|
248
|
+
while (node instanceof HTMLElement && "removing" in node.dataset)
|
|
249
|
+
node = node[step];
|
|
250
|
+
return node;
|
|
251
|
+
};
|
|
252
|
+
const neighbour = staying("nextElementSibling") ?? staying("previousElementSibling");
|
|
253
|
+
(neighbour?.querySelector("[data-swipe-more]") ?? row.parentElement)?.focus({ preventScroll: true });
|
|
254
|
+
}
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
const row = rowRef.current;
|
|
257
|
+
if (!row)
|
|
258
|
+
return;
|
|
259
|
+
rows.set(id, row);
|
|
260
|
+
width.current = row.offsetWidth;
|
|
261
|
+
const observer = new ResizeObserver(([entry]) => { if (entry)
|
|
262
|
+
width.current = entry.contentRect.width; });
|
|
263
|
+
observer.observe(row);
|
|
264
|
+
return () => { observer.disconnect(); rows.delete(id); window.clearTimeout(restoreTimer.current); setOpenId(current => current === id ? null : current); };
|
|
265
|
+
}, [id, rows, setOpenId]);
|
|
266
|
+
// Another row opened, or someone touched outside: put this one away.
|
|
267
|
+
const closeForOthers = useEffectEvent(() => { if (openId !== id && !leaving.current && drag.current?.locked !== "x" && x.get() !== 0)
|
|
268
|
+
settle(0); });
|
|
269
|
+
useEffect(() => { closeForOthers(); }, [openId]);
|
|
270
|
+
// Undo brought the item back while it was still leaving: slide it home as the height reopens.
|
|
271
|
+
const returnHome = useEffectEvent(() => {
|
|
272
|
+
if (!leaving.current)
|
|
273
|
+
return;
|
|
274
|
+
leaving.current = false;
|
|
275
|
+
window.clearTimeout(restoreTimer.current);
|
|
276
|
+
if (rowRef.current)
|
|
277
|
+
delete rowRef.current.dataset.removing;
|
|
278
|
+
settle(0);
|
|
279
|
+
});
|
|
280
|
+
useEffect(() => { if (present)
|
|
281
|
+
returnHome(); }, [present]);
|
|
282
|
+
const menuItems = [...leading.map((action, index) => ({ action, side: "leading", index })), ...trailing.map((action, index) => ({ action, side: "trailing", index }))];
|
|
283
|
+
const collapse = reduced
|
|
284
|
+
? { opacity: 0, transition: { duration: .15 } }
|
|
285
|
+
: { height: 0, opacity: 0, transition: { height: { ...motionTokens.spring.smooth, delay: .12 }, opacity: { duration: motionTokens.duration.instant, delay: .34 } } };
|
|
286
|
+
return _jsxs(motion.li, { ref: rowRef, className: [styles.row, className].filter(Boolean).join(" "), style: { "--swipe-action-width": `${ACTION}px` }, initial: reduced ? { opacity: 0 } : { height: 0, opacity: 0 }, animate: { height: "auto", opacity: 1 }, exit: collapse, transition: reduced ? { duration: .15 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, children: [["leading", "trailing"].map(side => actionsOf(side).map((action, index) => {
|
|
287
|
+
const count = actionsOf(side).length;
|
|
288
|
+
const rank = side === "leading" ? count - 1 - index : index;
|
|
289
|
+
const coverRank = covering?.side === side ? (side === "leading" ? count - 1 - covering.index : covering.index) : null;
|
|
290
|
+
return _jsx(ActionLayer, { action: action, side: side, rank: rank, count: count, coverRank: coverRank, x: x, cover: cover, onPress: () => commit(side, index) }, `${side}-${index}`);
|
|
291
|
+
})), _jsxs(motion.div, { className: styles.content, style: { x }, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerEnd, onPointerCancel: onPointerEnd, onClickCapture: onClickCapture, children: [_jsx("div", { className: styles.body, children: children }), menuItems.length > 0 && _jsxs(Menu.Root, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(Menu.Trigger, { className: styles.more, "data-swipe-more": "", "aria-label": `More actions for ${label}`, onPointerDown: event => event.preventDefault(), onClick: event => { if (event.detail > 0)
|
|
292
|
+
setMenuOpen(open => !open); }, children: _jsx(MoreHorizontal, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx(Menu.Portal, { children: _jsx(Menu.Content, { className: styles.menu, align: "end", sideOffset: 6, collisionPadding: 12, loop: true, onCloseAutoFocus: event => { if (!focusNeighbour.current)
|
|
293
|
+
return; focusNeighbour.current = false; event.preventDefault(); moveFocusToNeighbour(); }, children: menuItems.map(({ action, side, index }, order) => _jsxs(Menu.Item, { className: styles.item, "data-tone": action.tone, style: { "--i": order }, onSelect: () => { focusNeighbour.current = !action.keepRow; commit(side, index); }, children: [_jsx("span", { className: styles.itemIcon, "aria-hidden": "true", children: action.icon }), action.label] }, `${side}-${index}`)) }) })] })] })] });
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* One revealed action. Rank 0 sits against the content and the highest rank at the row's outer edge. Every layer spans the full row and slides in by
|
|
297
|
+
* transform only, so the actions share the revealed space evenly, widen together past the resting width, and the covering action can stretch across the row.
|
|
298
|
+
*/
|
|
299
|
+
function ActionLayer({ action, side, rank, count, coverRank, x, cover, onPress }) {
|
|
300
|
+
const direction = side === "leading" ? 1 : -1;
|
|
301
|
+
const segment = useTransform(x, value => Math.max(0, value * direction) / count);
|
|
302
|
+
// Distance from the row's outer edge to this layer's inner edge.
|
|
303
|
+
const inset = useTransform([x, cover], ([value = 0, progress = 0]) => {
|
|
304
|
+
const revealed = Math.max(0, value * direction), base = (count - rank) * revealed / count;
|
|
305
|
+
if (coverRank === rank)
|
|
306
|
+
return base + progress * (revealed - base);
|
|
307
|
+
if (coverRank !== null && rank > coverRank)
|
|
308
|
+
return base * (1 - progress);
|
|
309
|
+
return base;
|
|
310
|
+
});
|
|
311
|
+
const shift = useTransform(inset, value => direction * value);
|
|
312
|
+
// The glyph rides the centre of its share, then hugs the content edge while this action covers the row.
|
|
313
|
+
const glyphX = useTransform([segment, cover], ([share = 0, progress = 0]) => -direction * (coverRank === rank ? share / 2 + progress * (ACTION / 2 - share / 2) : share / 2));
|
|
314
|
+
const iconReveal = useTransform([segment, cover], ([share = 0, progress = 0]) => {
|
|
315
|
+
const own = clamp01((share - ACTION * .25) / (ACTION * .55));
|
|
316
|
+
if (coverRank === rank)
|
|
317
|
+
return Math.max(own, progress);
|
|
318
|
+
return coverRank === null ? own : own * (1 - progress);
|
|
319
|
+
});
|
|
320
|
+
const labelReveal = useTransform([segment, cover], ([share = 0, progress = 0]) => {
|
|
321
|
+
const own = clamp01((share - ACTION * .72) / (ACTION * .24));
|
|
322
|
+
if (coverRank === rank)
|
|
323
|
+
return Math.max(own, progress);
|
|
324
|
+
return coverRank === null ? own : own * (1 - progress);
|
|
325
|
+
});
|
|
326
|
+
const iconScale = useTransform(iconReveal, value => .6 + .4 * value);
|
|
327
|
+
// The menu button is the accessible path, so the revealed copy stays out of the tab order and the accessibility tree.
|
|
328
|
+
return _jsx(motion.button, { type: "button", tabIndex: -1, "aria-hidden": "true", className: styles.layer, "data-side": side, "data-tone": action.tone ?? "neutral", style: { x: shift, zIndex: rank + 1 }, onClick: onPress, children: _jsx(motion.span, { className: styles.anchor, style: { x: glyphX }, children: _jsxs("span", { className: styles.glyph, children: [_jsx(motion.span, { className: styles.icon, style: { opacity: iconReveal, scale: iconScale }, children: action.icon }), _jsx(motion.span, { className: styles.label, style: { opacity: labelReveal }, children: action.label })] }) }) });
|
|
329
|
+
}
|
|
330
|
+
export default SwipeActions;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/* The surface clips every row, so revealed actions never spill past its rounded corners. It hides once the last row has left. */
|
|
2
|
+
.surface { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-container); background: var(--surface); transition: border-color var(--duration-standard) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard); }
|
|
3
|
+
/* When the last row is on its way out, the frame fades with it instead of leaving a hairline behind. */
|
|
4
|
+
.surface:not(:has(> .list > .row:not([data-removing]))) { border-color: transparent; background-color: transparent; }
|
|
5
|
+
.surface:has(> .list:empty) { display: none; }
|
|
6
|
+
/* Pulled down one pixel so the last separator tucks under the frame, instead of toggling with :last-child while rows leave. */
|
|
7
|
+
.list { margin: 0 0 -1px; padding: 0; list-style: none; }
|
|
8
|
+
.list:focus-visible { outline: none; }
|
|
9
|
+
.row { position: relative; isolation: isolate; overflow: hidden; }
|
|
10
|
+
/* The content follows the finger by transform only; vertical pans stay with the page. */
|
|
11
|
+
.content { position: relative; z-index: 10; display: flex; min-height: 64px; align-items: center; gap: var(--space-related); padding: var(--space-group) var(--space-related) var(--space-group) var(--space-inset); background: var(--surface); touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
|
|
12
|
+
.content::after { position: absolute; right: 0; bottom: 0; left: var(--swipe-actions-inset, var(--space-inset)); height: 1px; background: var(--border); content: ""; pointer-events: none; }
|
|
13
|
+
.content img { -webkit-user-drag: none; }
|
|
14
|
+
.body { min-width: 0; flex: 1 1 auto; }
|
|
15
|
+
.more { display: grid; width: 32px; height: 32px; flex: none; place-items: center; border: 0; border-radius: calc(var(--radius-control) - 6px); background: transparent; color: var(--text-muted); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
|
|
16
|
+
/* The button anchors a menu, so a press answers with colour, never scale. */
|
|
17
|
+
.more:active, .more[data-state="open"] { background: var(--surface-muted); color: var(--foreground); }
|
|
18
|
+
.more:focus-visible { outline: none; }
|
|
19
|
+
@media (hover: hover) and (pointer: fine) {
|
|
20
|
+
.content { cursor: grab; }
|
|
21
|
+
.more:hover { background: var(--surface-muted); color: var(--foreground); }
|
|
22
|
+
}
|
|
23
|
+
.row[data-dragging] .content { cursor: grabbing; }
|
|
24
|
+
.row[data-dragging] .more { background: transparent; color: var(--text-muted); }
|
|
25
|
+
|
|
26
|
+
/* Each action spans the full row from the edge it hides behind and slides in by transform. Outer actions stack above inner ones. */
|
|
27
|
+
.layer { position: absolute; top: 0; bottom: 0; width: 100%; padding: 0; border: 0; background: color-mix(in oklab, var(--foreground) 20%, var(--surface)); color: var(--foreground); font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
28
|
+
.layer[data-side="trailing"] { left: 100%; }
|
|
29
|
+
.layer[data-side="leading"] { right: 100%; }
|
|
30
|
+
.layer[data-tone="accent"] { background: var(--accent); color: var(--accent-foreground); }
|
|
31
|
+
.layer[data-tone="danger"] { background: var(--danger); color: var(--background); }
|
|
32
|
+
.layer:focus { outline: none; }
|
|
33
|
+
.anchor { position: absolute; top: 0; bottom: 0; width: 0; }
|
|
34
|
+
.layer[data-side="trailing"] .anchor { left: 0; }
|
|
35
|
+
.layer[data-side="leading"] .anchor { right: 0; }
|
|
36
|
+
.glyph { position: absolute; top: 0; bottom: 0; left: calc(var(--swipe-action-width) / -2); display: flex; width: var(--swipe-action-width); flex-direction: column; align-items: center; justify-content: center; gap: 3px; }
|
|
37
|
+
.icon { display: grid; place-items: center; }
|
|
38
|
+
.icon svg { width: 20px; height: 20px; }
|
|
39
|
+
.label { font-size: var(--text-xs); font-weight: 500; letter-spacing: var(--tracking-body); line-height: var(--leading-body); white-space: nowrap; }
|
|
40
|
+
|
|
41
|
+
/* The menu grows from its trigger, matching the library dropdown. */
|
|
42
|
+
.menu { --menu-x: 0px; --menu-y: -5px; position: relative; z-index: 60; min-width: 11rem; padding: 5px; border: 1px solid var(--border); border-radius: var(--radius-container); background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: var(--radix-dropdown-menu-content-transform-origin); }
|
|
43
|
+
.menu[data-side="top"] { --menu-y: 5px; }
|
|
44
|
+
.menu[data-state="open"] { animation: menu-fade-in var(--duration-fast) var(--ease-enter) both, menu-rise-in var(--duration-spring) var(--ease-spring) both; }
|
|
45
|
+
.menu[data-state="closed"] { animation: menu-out var(--duration-instant) var(--ease-standard) both; }
|
|
46
|
+
.menu[data-state="open"] .item { animation: menu-item-in var(--duration-standard) var(--ease-enter) both; animation-delay: calc(min(var(--i, 0), 4) * 35ms); }
|
|
47
|
+
.item { position: relative; display: flex; min-height: 36px; align-items: center; gap: 10px; padding: 0 11px; border-radius: calc(var(--radius-container) - 6px); color: var(--foreground); font-size: var(--text-sm); cursor: pointer; outline: none; transition: background-color var(--duration-instant) var(--ease-standard); }
|
|
48
|
+
.item[data-highlighted] { background: var(--surface-muted); }
|
|
49
|
+
.item[data-tone="danger"] { color: var(--danger); }
|
|
50
|
+
.item[data-tone="danger"][data-highlighted] { background: color-mix(in oklab, var(--danger) 8%, var(--surface-raised)); }
|
|
51
|
+
.itemIcon { display: inline-flex; width: 17px; color: var(--text-secondary); }
|
|
52
|
+
.itemIcon svg { width: 16px; height: 16px; }
|
|
53
|
+
.item[data-tone="danger"] .itemIcon { color: var(--danger); }
|
|
54
|
+
@keyframes menu-fade-in { from { opacity: 0; } }
|
|
55
|
+
@keyframes menu-rise-in { from { transform: translate(var(--menu-x), var(--menu-y)) scale(.97); } }
|
|
56
|
+
@keyframes menu-out { to { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); } }
|
|
57
|
+
@keyframes menu-item-in { from { opacity: 0; transform: translate(calc(var(--menu-x) * .4), calc(var(--menu-y) * .4)); } }
|
|
58
|
+
@keyframes menu-fade-out { to { opacity: 0; } }
|
|
59
|
+
@media (prefers-reduced-motion: reduce) {
|
|
60
|
+
.surface, .more, .item { transition: none; }
|
|
61
|
+
.menu[data-state="open"] { animation: menu-fade-in var(--duration-instant) linear both; }
|
|
62
|
+
.menu[data-state="closed"] { animation: menu-fade-out var(--duration-instant) linear both; }
|
|
63
|
+
.menu[data-state="open"] .item { animation: none; }
|
|
64
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
2
|
+
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
|
3
|
+
export interface SwitchProps extends ComponentPropsWithoutRef<typeof SwitchPrimitive.Root> {
|
|
4
|
+
label?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const Switch: import("react").ForwardRefExoticComponent<SwitchProps & import("react").RefAttributes<HTMLButtonElement>>;
|
|
7
|
+
export default Switch;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { forwardRef, useEffect, useRef, useState } from "react";
|
|
4
|
+
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
|
5
|
+
import { animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import styles from "./switch.module.css";
|
|
8
|
+
/** Track inner width (42 - 6 padding) minus the 18px thumb; keep in sync with switch.module.css. */
|
|
9
|
+
const size = 18;
|
|
10
|
+
const travel = 18;
|
|
11
|
+
/** How far the thumb widens toward the other side while pressed. */
|
|
12
|
+
const stretch = 5;
|
|
13
|
+
/** Critically damped: the thumb lands on its end without overshooting the state it reports. */
|
|
14
|
+
const glide = { type: "spring", visualDuration: 0.3, bounce: 0 };
|
|
15
|
+
export const Switch = forwardRef(function Switch({ label, className, checked, defaultChecked, onCheckedChange, onPointerDown, onPointerUp, onPointerLeave, onPointerCancel, onKeyDown, onKeyUp, onBlur, ...props }, ref) {
|
|
16
|
+
const reduceMotion = useReducedMotion();
|
|
17
|
+
const [internal, setInternal] = useState(defaultChecked ?? false);
|
|
18
|
+
const [pressed, setPressed] = useState(false);
|
|
19
|
+
const on = checked ?? internal;
|
|
20
|
+
// A brief stretch along the travel, so the thumb reads as moving mass rather than a sliding dot.
|
|
21
|
+
const scaleX = useMotionValue(1);
|
|
22
|
+
const shown = useRef(on);
|
|
23
|
+
// A pointer or Space press already stretched the thumb, so its release should not add a second stretch on top.
|
|
24
|
+
const releasedAt = useRef(-Infinity);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (shown.current === on)
|
|
27
|
+
return;
|
|
28
|
+
shown.current = on;
|
|
29
|
+
const fromPress = performance.now() - releasedAt.current < 250;
|
|
30
|
+
if (reduceMotion || fromPress)
|
|
31
|
+
return;
|
|
32
|
+
const controls = animate(scaleX, [1, 1.16, 1], { duration: 0.34, times: [0, 0.4, 1], ease: ["easeOut", "easeInOut"] });
|
|
33
|
+
return () => controls.stop();
|
|
34
|
+
}, [on, reduceMotion, scaleX]);
|
|
35
|
+
const extra = pressed && !reduceMotion && !props.disabled ? stretch : 0;
|
|
36
|
+
const classes = [styles.switch, className].filter(Boolean).join(" ");
|
|
37
|
+
return (_jsxs(SwitchPrimitive.Root, { ...props, ref: ref, checked: on, onCheckedChange: next => { if (checked === undefined)
|
|
38
|
+
setInternal(next); onCheckedChange?.(next); }, onPointerDown: event => { onPointerDown?.(event); if (event.button === 0)
|
|
39
|
+
setPressed(true); }, onPointerUp: event => { onPointerUp?.(event); if (pressed && !props.disabled)
|
|
40
|
+
releasedAt.current = performance.now(); setPressed(false); }, onPointerLeave: event => { onPointerLeave?.(event); setPressed(false); }, onPointerCancel: event => { onPointerCancel?.(event); setPressed(false); }, onKeyDown: event => { onKeyDown?.(event); if (event.key === " ")
|
|
41
|
+
setPressed(true); }, onKeyUp: event => { onKeyUp?.(event); if (pressed && !props.disabled)
|
|
42
|
+
releasedAt.current = performance.now(); setPressed(false); }, onBlur: event => { onBlur?.(event); setPressed(false); }, className: classes, "aria-label": props["aria-label"] ?? label, children: [_jsx("span", { className: styles.track, children: _jsx(motion.span, { className: styles.thumb, style: { scaleX }, initial: false, animate: { x: on ? travel - extra : 0, width: size + extra }, transition: reduceMotion ? { duration: 0 } : { x: glide, width: motionTokens.spring.snappy } }) }), label ? _jsx("span", { className: styles.label, children: label }) : null] }));
|
|
43
|
+
});
|
|
44
|
+
Switch.displayName = "Switch";
|
|
45
|
+
export default Switch;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
.switch { display: inline-flex; min-height: var(--control-height-md); align-items: center; gap: var(--space-group); padding: 0; border: 0; color: var(--foreground); background: transparent; font: inherit; font-size: var(--text-sm); cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
2
|
+
.switch:disabled { cursor: not-allowed; opacity: .5; }
|
|
3
|
+
/* Fixed box: the thumb moves inside it with transforms, so nothing around the switch shifts. The track crossfades between the two fills. */
|
|
4
|
+
.track { position: relative; box-sizing: border-box; display: flex; flex: 0 0 auto; width: 42px; height: 24px; align-items: center; padding: 3px; border-radius: var(--radius-full); corner-shape: round; background: var(--control-track); isolation: isolate; transition: background-color var(--duration-fast) var(--ease-standard); }
|
|
5
|
+
.track::before { content: ""; position: absolute; inset: 0; z-index: -1; border-radius: inherit; corner-shape: inherit; background: var(--control-on); opacity: 0; transition: opacity var(--duration-standard) var(--ease-standard); }
|
|
6
|
+
.switch[data-state="checked"] .track::before { opacity: 1; }
|
|
7
|
+
.thumb { flex: 0 0 auto; width: 18px; height: 18px; border-radius: var(--radius-full); corner-shape: round; background: var(--control-thumb); box-shadow: var(--control-thumb-shadow); will-change: transform; transition: background-color var(--duration-standard) var(--ease-standard); }
|
|
8
|
+
.switch[data-state="checked"] .thumb { background: var(--control-thumb-on); }
|
|
9
|
+
@media (hover: hover) and (pointer: fine) { .switch[data-state="unchecked"]:hover:not(:disabled) .track { background: var(--control-track-hover); } }
|
|
10
|
+
.switch:focus-visible { outline: none; }
|
|
11
|
+
.label { line-height: var(--leading-body); }
|
|
12
|
+
@media (prefers-reduced-motion: reduce) { .track, .track::before, .thumb { transition: none; } }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
2
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
3
|
+
type RootProps = ComponentPropsWithoutRef<typeof TabsPrimitive.Root>;
|
|
4
|
+
export declare function Tabs({ value, defaultValue, onValueChange, className, ...props }: RootProps): import("react").JSX.Element;
|
|
5
|
+
export declare function TabsList({ className, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.List>): import("react").JSX.Element;
|
|
6
|
+
export declare function TabsTrigger({ className, children, value, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>): import("react").JSX.Element;
|
|
7
|
+
export declare function TabsContent({ className, value, forceMount, children, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.Content>): import("react").JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createElement as _createElement } from "react";
|
|
3
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
|
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
5
|
+
import { createContext, useCallback, useContext, useId, useLayoutEffect, useRef, useState } from "react";
|
|
6
|
+
import { ChevronLeft as NavArrowLeft, ChevronRight as NavArrowRight } from "lucide-react";
|
|
7
|
+
import { AnimatePresence, LayoutGroup, animate, motion, useReducedMotion } from "motion/react";
|
|
8
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
9
|
+
import styles from "./tabs.module.css";
|
|
10
|
+
/** `direction` is +1 when the new tab sits after the old one; `panelHeightRef` holds the visible panel height so the next panel can morph from it; `leavingRectRef` pins the outgoing panel where it was on screen. */
|
|
11
|
+
const TabsContext = createContext({ active: "", layoutId: "tabs", direction: 1, panelHeightRef: { current: null }, leavingRectRef: { current: null } });
|
|
12
|
+
export function Tabs({ value, defaultValue, onValueChange, className, ...props }) {
|
|
13
|
+
const [internal, setInternal] = useState(defaultValue ?? "");
|
|
14
|
+
const [direction, setDirection] = useState(1);
|
|
15
|
+
const active = value ?? internal;
|
|
16
|
+
const layoutId = useId();
|
|
17
|
+
const root = useRef(null);
|
|
18
|
+
const panelHeightRef = useRef(null);
|
|
19
|
+
const leavingRectRef = useRef(null);
|
|
20
|
+
function handleChange(next) {
|
|
21
|
+
const frame = root.current;
|
|
22
|
+
leavingRectRef.current = frame?.querySelector(':scope > [role="tabpanel"][data-state="active"]')?.getBoundingClientRect() ?? null;
|
|
23
|
+
const order = frame ? Array.from(frame.querySelectorAll('[role="tab"][data-value]')).filter(tab => tab.closest(`.${styles.root}`) === frame).map(tab => tab.dataset.value) : [];
|
|
24
|
+
const from = order.indexOf(active), to = order.indexOf(next);
|
|
25
|
+
if (from >= 0 && to >= 0 && from !== to)
|
|
26
|
+
setDirection(to > from ? 1 : -1);
|
|
27
|
+
if (value === undefined)
|
|
28
|
+
setInternal(next);
|
|
29
|
+
onValueChange?.(next);
|
|
30
|
+
}
|
|
31
|
+
return _jsx(TabsContext.Provider, { value: { active, layoutId, direction, panelHeightRef, leavingRectRef }, children: _jsx(LayoutGroup, { id: layoutId, children: _jsx(TabsPrimitive.Root, { ...props, ref: root, className: [styles.root, className].filter(Boolean).join(" "), value: active, onValueChange: handleChange }) }) });
|
|
32
|
+
}
|
|
33
|
+
export function TabsList({ className, ...props }) {
|
|
34
|
+
const { active } = useContext(TabsContext);
|
|
35
|
+
const reduced = useReducedMotion();
|
|
36
|
+
const shell = useRef(null);
|
|
37
|
+
const viewport = useRef(null);
|
|
38
|
+
const list = useRef(null);
|
|
39
|
+
const [edges, setEdges] = useState({ overflow: false, left: false, right: false });
|
|
40
|
+
const update = useCallback(() => {
|
|
41
|
+
const frame = shell.current;
|
|
42
|
+
const scroll = viewport.current;
|
|
43
|
+
if (!frame || !scroll)
|
|
44
|
+
return;
|
|
45
|
+
const max = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
46
|
+
const next = { overflow: scroll.scrollWidth > frame.clientWidth + 1, left: scroll.scrollLeft > 1, right: scroll.scrollLeft < max - 1 };
|
|
47
|
+
setEdges(previous => previous.overflow === next.overflow && previous.left === next.left && previous.right === next.right ? previous : next);
|
|
48
|
+
}, []);
|
|
49
|
+
const reveal = useCallback((tab) => {
|
|
50
|
+
const scroll = viewport.current;
|
|
51
|
+
if (!scroll || !tab)
|
|
52
|
+
return;
|
|
53
|
+
const frame = scroll.getBoundingClientRect();
|
|
54
|
+
const item = tab.getBoundingClientRect();
|
|
55
|
+
const max = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
56
|
+
const left = frame.left + (scroll.scrollLeft > 1 ? 34 : 0);
|
|
57
|
+
const right = frame.right - (scroll.scrollLeft < max - 1 ? 34 : 0);
|
|
58
|
+
const delta = item.left < left ? item.left - left : item.right > right ? item.right - right : 0;
|
|
59
|
+
if (delta)
|
|
60
|
+
scroll.scrollBy({ left: delta, behavior: reduced ? "instant" : "smooth" });
|
|
61
|
+
}, [reduced]);
|
|
62
|
+
useLayoutEffect(() => {
|
|
63
|
+
const frame = shell.current;
|
|
64
|
+
const scroll = viewport.current;
|
|
65
|
+
const content = list.current;
|
|
66
|
+
if (!frame || !scroll || !content)
|
|
67
|
+
return;
|
|
68
|
+
const observer = new ResizeObserver(update);
|
|
69
|
+
observer.observe(frame);
|
|
70
|
+
observer.observe(scroll);
|
|
71
|
+
observer.observe(content);
|
|
72
|
+
scroll.addEventListener("scroll", update, { passive: true });
|
|
73
|
+
update();
|
|
74
|
+
return () => { observer.disconnect(); scroll.removeEventListener("scroll", update); };
|
|
75
|
+
}, [update]);
|
|
76
|
+
useLayoutEffect(() => { reveal(list.current?.querySelector('[role="tab"][data-state="active"]') ?? null); }, [active, reveal]);
|
|
77
|
+
const scrollTabs = (direction) => viewport.current?.scrollBy({ left: direction * (viewport.current?.clientWidth ?? 0) * .75, behavior: reduced ? "instant" : "smooth" });
|
|
78
|
+
return _jsxs("div", { ref: shell, className: styles.listShell, "data-overflow": edges.overflow, "data-left": edges.left, "data-right": edges.right, children: [edges.overflow && _jsx("button", { type: "button", className: `${styles.scrollButton} ${styles.scrollLeft}`, "aria-label": "Scroll tabs left", disabled: !edges.left, onClick: () => scrollTabs(-1), children: _jsx(NavArrowLeft, { width: 17, height: 17, "aria-hidden": "true" }) }), _jsx(motion.div, { ref: viewport, layoutScroll: true, className: styles.viewport, onFocusCapture: event => { if (event.target instanceof HTMLElement && event.target.getAttribute("role") === "tab")
|
|
79
|
+
reveal(event.target); }, children: _jsx(TabsPrimitive.List, { ...props, ref: list, className: [styles.list, className].filter(Boolean).join(" ") }) }), edges.overflow && _jsx("button", { type: "button", className: `${styles.scrollButton} ${styles.scrollRight}`, "aria-label": "Scroll tabs right", disabled: !edges.right, onClick: () => scrollTabs(1), children: _jsx(NavArrowRight, { width: 17, height: 17, "aria-hidden": "true" }) })] });
|
|
80
|
+
}
|
|
81
|
+
export function TabsTrigger({ className, children, value, ...props }) {
|
|
82
|
+
const { active } = useContext(TabsContext);
|
|
83
|
+
const reduced = useReducedMotion();
|
|
84
|
+
// The LayoutGroup in Tabs scopes the highlight to this instance, so it glides between triggers but never flies in from another tab set.
|
|
85
|
+
return _jsxs(TabsPrimitive.Trigger, { ...props, value: value, "data-value": value, className: [styles.trigger, className].filter(Boolean).join(" "), children: [active === value && _jsx(motion.span, { className: styles.selection, layoutId: "selection", layoutDependency: active, transition: reduced ? { duration: 0 } : motionTokens.spring.morph, "aria-hidden": "true" }), _jsx("span", { className: styles.triggerLabel, children: children })] });
|
|
86
|
+
}
|
|
87
|
+
const panelMotion = {
|
|
88
|
+
enter: (direction) => ({ opacity: 0, x: direction * 8 }),
|
|
89
|
+
center: { opacity: 1, x: 0, transition: { opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, x: motionTokens.spring.smooth } },
|
|
90
|
+
exit: (direction) => ({ opacity: 0, x: direction * -6, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }),
|
|
91
|
+
};
|
|
92
|
+
/** Reduced motion: a short crossfade in place. Keys match panelMotion so server and client render identical styles. */
|
|
93
|
+
const panelFade = {
|
|
94
|
+
enter: { opacity: 0, x: 0 },
|
|
95
|
+
center: { opacity: 1, x: 0, transition: { duration: motionTokens.duration.instant } },
|
|
96
|
+
exit: { opacity: 0, x: 0, transition: { duration: .1 } },
|
|
97
|
+
};
|
|
98
|
+
export function TabsContent({ className, value, forceMount, children, ...props }) {
|
|
99
|
+
const { active, direction, panelHeightRef, leavingRectRef } = useContext(TabsContext);
|
|
100
|
+
const reduced = useReducedMotion();
|
|
101
|
+
const panel = useRef(null);
|
|
102
|
+
const selected = active === value;
|
|
103
|
+
const classes = [styles.content, className].filter(Boolean).join(" ");
|
|
104
|
+
// The incoming panel starts at the outgoing panel's height and settles at its own, so content below glides instead of jumping.
|
|
105
|
+
useLayoutEffect(() => {
|
|
106
|
+
const node = panel.current;
|
|
107
|
+
if (!node || forceMount)
|
|
108
|
+
return;
|
|
109
|
+
// Outgoing: the tab root may have changed size around it, so pin the panel to where it was while it fades.
|
|
110
|
+
if (!selected) {
|
|
111
|
+
const before = leavingRectRef.current;
|
|
112
|
+
const now = node.getBoundingClientRect();
|
|
113
|
+
if (before)
|
|
114
|
+
node.style.translate = `${before.left - now.left}px ${before.top - now.top}px`;
|
|
115
|
+
node.inert = true;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
node.style.translate = "";
|
|
119
|
+
node.inert = false;
|
|
120
|
+
const from = panelHeightRef.current;
|
|
121
|
+
const to = node.offsetHeight;
|
|
122
|
+
let controls;
|
|
123
|
+
const release = () => { node.style.height = ""; node.style.overflow = ""; };
|
|
124
|
+
if (from !== null && Math.abs(from - to) > 1 && !reduced) {
|
|
125
|
+
if (to > from)
|
|
126
|
+
node.style.overflow = "clip";
|
|
127
|
+
controls = animate(node, { height: [from, to] }, { ...motionTokens.spring.smooth, onComplete: release });
|
|
128
|
+
}
|
|
129
|
+
panelHeightRef.current = controls && from !== null ? from : to;
|
|
130
|
+
const observer = new ResizeObserver(() => { panelHeightRef.current = node.offsetHeight; });
|
|
131
|
+
observer.observe(node);
|
|
132
|
+
return () => { observer.disconnect(); controls?.stop(); release(); };
|
|
133
|
+
}, [selected, reduced, forceMount, panelHeightRef, leavingRectRef]);
|
|
134
|
+
if (forceMount)
|
|
135
|
+
return _jsx(TabsPrimitive.Content, { ...props, value: value, forceMount: true, className: classes, children: children });
|
|
136
|
+
return _jsx(AnimatePresence, { initial: false, mode: "popLayout", custom: direction, children: selected && _createElement(TabsPrimitive.Content, { ...props, key: value, value: value, forceMount: true, asChild: true },
|
|
137
|
+
_jsx(motion.div, { ref: panel, className: classes, custom: direction, variants: reduced ? panelFade : panelMotion, initial: "enter", animate: "center", exit: "exit", children: children })) });
|
|
138
|
+
}
|