@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,13 @@
|
|
|
1
|
+
.root { display: flex; width: 100%; flex-direction: column; align-items: center; padding: clamp(var(--space-block), 8vw, var(--space-page)) var(--space-comfort); text-align: center; }
|
|
2
|
+
.icon { position: relative; display: grid; width: var(--space-page); height: var(--space-page); place-items: center; border: 1px solid var(--border); border-radius: var(--radius-container); background: var(--surface-muted); color: var(--text-secondary); animation: empty-state-icon var(--duration-considered) var(--ease-enter) both; }
|
|
3
|
+
.glyph { display: grid; place-items: center; }
|
|
4
|
+
.frame { width: 100%; max-width: 100%; }
|
|
5
|
+
.copy { display: flow-root; }
|
|
6
|
+
.root h3 { position: relative; margin: var(--space-comfort) 0 0; color: var(--foreground); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); }
|
|
7
|
+
.root p { position: relative; max-width: 18rem; margin: var(--space-related) auto 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
|
|
8
|
+
/* Centered copy wraps into even lines, so a new description never leaves a single word hanging. */
|
|
9
|
+
.line { display: block; text-wrap: balance; }
|
|
10
|
+
.action { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-group); margin-top: var(--space-comfort); }
|
|
11
|
+
/* The icon settles in once when the state first appears; later changes crossfade in place. */
|
|
12
|
+
@keyframes empty-state-icon { from { opacity: 0; transform: scale(.92); } to { opacity: 1; transform: scale(1); } }
|
|
13
|
+
@media (prefers-reduced-motion: reduce) { .icon { animation: none; } }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
|
+
export interface ExpandableCardProps extends Omit<ComponentPropsWithoutRef<"article">, "title" | "children" | "onAnimationStart" | "onAnimationEnd" | "onAnimationIteration" | "onDrag" | "onDragStart" | "onDragEnd" | "onDragOver" | "onDragLeave" | "onDragEnter" | "onDragExit" | "onDrop"> {
|
|
3
|
+
title: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
defaultExpanded?: boolean;
|
|
7
|
+
/** Collapsed width cap in px. The card is centered and never wider than its container. Fills the container when omitted. */
|
|
8
|
+
width?: number;
|
|
9
|
+
/** Expanded width cap in px, so the card can grow sideways into more room. Defaults to `width`. */
|
|
10
|
+
expandedWidth?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function ExpandableCard({ title, description, children, defaultExpanded, width, expandedWidth, className, style, onKeyDown, ...rest }: ExpandableCardProps): import("react").JSX.Element;
|
|
13
|
+
export default ExpandableCard;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
|
+
import { ChevronDown as NavArrowDown } from "lucide-react";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import styles from "./expandable-card.module.css";
|
|
8
|
+
/**
|
|
9
|
+
* One morph: the box grows in width and height on the same spring, which never overshoots, so close is the exact mirror of open.
|
|
10
|
+
* Size is animated for real (not with a scale), so the text, border, and corner radius never stretch.
|
|
11
|
+
*/
|
|
12
|
+
const morph = motionTokens.spring.smooth;
|
|
13
|
+
/** On close the details fade out first, then the box starts to shrink; on open they fade in once the box has made room. */
|
|
14
|
+
const closeHold = 0.06;
|
|
15
|
+
/** A close that reverses an open still in flight skips the hold, so the spring turns around with its velocity instead of stalling. */
|
|
16
|
+
const settleTime = 450;
|
|
17
|
+
const boxTransition = (expanded, hold) => expanded || !hold ? morph : { ...morph, delay: closeHold };
|
|
18
|
+
const contentTransition = (expanded) => expanded
|
|
19
|
+
? { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.standard], delay: 0.12 }
|
|
20
|
+
: { duration: 0.1, ease: [...motionTokens.ease.standard] };
|
|
21
|
+
const still = { duration: 0 };
|
|
22
|
+
/** Changed words in the summary rise in; unchanged words hold still. */
|
|
23
|
+
const wordMotion = {
|
|
24
|
+
enter: { opacity: 0, y: ".35em", filter: `blur(${motionTokens.blur.soft}px)` },
|
|
25
|
+
center: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } },
|
|
26
|
+
exit: { opacity: 0, y: "-.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } },
|
|
27
|
+
};
|
|
28
|
+
function RollingText({ text, reduced }) {
|
|
29
|
+
return _jsxs("span", { className: styles.roll, children: [_jsx("span", { className: styles.srOnly, children: text }), _jsx("span", { "aria-hidden": "true", children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: text.split(/(\s+)/).map((word, index) => _jsx(motion.span, { className: styles.word, variants: wordMotion, initial: reduced ? false : "enter", animate: "center", exit: reduced ? undefined : "exit", children: word }, `${index}:${word}`)) }) })] });
|
|
30
|
+
}
|
|
31
|
+
export function ExpandableCard({ title, description, children, defaultExpanded = false, width, expandedWidth, className, style, onKeyDown, ...rest }) {
|
|
32
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
33
|
+
const [hold, setHold] = useState(true);
|
|
34
|
+
const lastToggle = useRef(0);
|
|
35
|
+
// The room the card can use, measured from its centering track. Until it is known, CSS caps the width.
|
|
36
|
+
const [room, setRoom] = useState(null);
|
|
37
|
+
const trackRef = useRef(null);
|
|
38
|
+
const triggerRef = useRef(null);
|
|
39
|
+
const panelId = useId();
|
|
40
|
+
const reduceMotion = useReducedMotion() ?? false;
|
|
41
|
+
useLayoutEffect(() => {
|
|
42
|
+
const track = trackRef.current;
|
|
43
|
+
if (!track)
|
|
44
|
+
return;
|
|
45
|
+
const measure = () => setRoom(track.clientWidth);
|
|
46
|
+
measure();
|
|
47
|
+
const observer = new ResizeObserver(measure);
|
|
48
|
+
observer.observe(track);
|
|
49
|
+
return () => observer.disconnect();
|
|
50
|
+
}, []);
|
|
51
|
+
const openCap = expandedWidth ?? width;
|
|
52
|
+
const fit = (cap) => room === null ? undefined : Math.min(room, cap ?? room);
|
|
53
|
+
const boxWidth = fit(expanded ? openCap : width);
|
|
54
|
+
// The details are laid out at their final width the whole time, so nothing reflows while the box changes size.
|
|
55
|
+
const innerWidth = fit(openCap);
|
|
56
|
+
// The width lives in a motion value: the first measurement lands without motion, later changes spring from the current width and velocity.
|
|
57
|
+
const boxWidthValue = useMotionValue("100%");
|
|
58
|
+
const placed = useRef(false);
|
|
59
|
+
const widthTransition = reduceMotion ? still : boxTransition(expanded, hold);
|
|
60
|
+
useLayoutEffect(() => {
|
|
61
|
+
if (boxWidth === undefined)
|
|
62
|
+
return;
|
|
63
|
+
if (!placed.current) {
|
|
64
|
+
placed.current = true;
|
|
65
|
+
boxWidthValue.jump(boxWidth);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const controls = animate(boxWidthValue, boxWidth, widthTransition);
|
|
69
|
+
return () => controls.stop();
|
|
70
|
+
}, [boxWidth, boxWidthValue]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
71
|
+
// Caps for the first paint, before the room is measured.
|
|
72
|
+
const capVars = { "--expandable-card-width": width ? `${width}px` : undefined, "--expandable-card-expanded-width": openCap ? `${openCap}px` : undefined };
|
|
73
|
+
const toggle = (next) => {
|
|
74
|
+
const now = performance.now();
|
|
75
|
+
setHold(now - lastToggle.current > settleTime);
|
|
76
|
+
lastToggle.current = now;
|
|
77
|
+
setExpanded(next);
|
|
78
|
+
};
|
|
79
|
+
const handleKeyDown = (event) => {
|
|
80
|
+
onKeyDown?.(event);
|
|
81
|
+
if (event.defaultPrevented || event.key !== "Escape" || !expanded)
|
|
82
|
+
return;
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
toggle(false);
|
|
85
|
+
triggerRef.current?.focus();
|
|
86
|
+
};
|
|
87
|
+
return _jsx("div", { ref: trackRef, className: styles.track, children: _jsxs(motion.article, { ...rest, className: [styles.card, className].filter(Boolean).join(" "), "data-expanded": expanded, "data-measured": room === null ? undefined : "", style: { ...style, ...capVars, width: boxWidthValue }, onKeyDown: handleKeyDown, children: [_jsxs("button", { ref: triggerRef, type: "button", className: styles.trigger, "aria-expanded": expanded, "aria-controls": panelId, onClick: () => toggle(!expanded), children: [_jsxs("span", { className: styles.copy, children: [_jsx("strong", { children: title }), description && _jsx("span", { className: styles.description, children: _jsx(RollingText, { text: description, reduced: reduceMotion }) })] }), _jsx(motion.span, { className: styles.arrow, initial: false, animate: { rotate: expanded ? 180 : 0 }, transition: reduceMotion ? still : boxTransition(expanded, hold), children: _jsx(NavArrowDown, { width: 18, height: 18, "aria-hidden": "true" }) })] }), _jsx(motion.div, { id: panelId, className: styles.panel, inert: !expanded, initial: false, animate: { height: expanded ? "auto" : 0 }, transition: reduceMotion ? still : boxTransition(expanded, hold), children: _jsx(motion.div, { className: styles.panelInner, style: innerWidth === undefined ? undefined : { width: innerWidth - 2 }, initial: false, animate: { opacity: expanded ? 1 : 0 }, transition: reduceMotion ? still : contentTransition(expanded), children: children }) })] }) });
|
|
88
|
+
}
|
|
89
|
+
export default ExpandableCard;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* The track centers the card and measures the room it may grow into. */
|
|
2
|
+
.track { display: flex; width: 100%; min-width: 0; justify-content: center; }
|
|
3
|
+
.card { width: 100%; max-width: var(--expandable-card-width, 100%); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-overlay); background: var(--surface); box-shadow: var(--shadow-resting); }
|
|
4
|
+
.card[data-expanded="true"] { max-width: var(--expandable-card-expanded-width, var(--expandable-card-width, 100%)); }
|
|
5
|
+
/* Once measured, the width is animated in px, so the CSS cap steps aside and never clamps the morph. */
|
|
6
|
+
.card[data-measured] { max-width: 100%; }
|
|
7
|
+
/* The card clips the header to its corners, so the header's hover fill never changes shape while the card opens or closes. */
|
|
8
|
+
.trigger { display: flex; width: 100%; min-height: 64px; align-items: center; justify-content: space-between; gap: var(--space-comfort); border: 0; padding: var(--space-inset) var(--space-comfort); background: transparent; color: var(--foreground); font: inherit; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }
|
|
9
|
+
@media (hover: hover) and (pointer: fine) { .trigger:hover { background: var(--surface-muted); } .trigger:hover .arrow { color: var(--foreground); } }
|
|
10
|
+
.trigger:active { background: var(--surface-muted); }
|
|
11
|
+
.copy { min-width: 0; }
|
|
12
|
+
.copy strong { display: block; font-size: var(--text-sm); font-weight: 500; }
|
|
13
|
+
.description { display: block; margin-top: 4px; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
|
14
|
+
.roll { position: relative; display: block; }
|
|
15
|
+
.word { display: inline-block; white-space: pre; }
|
|
16
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
17
|
+
.arrow { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: center; color: var(--text-muted); transition: color var(--duration-fast) var(--ease-standard); }
|
|
18
|
+
.card[data-expanded="true"] .arrow { color: var(--foreground); }
|
|
19
|
+
/* The divider is an inset line rather than a border, so a collapsed panel is truly zero height and nothing pops at the end of the close. */
|
|
20
|
+
.panel { overflow: hidden; box-shadow: inset 0 1px 0 var(--border); background: var(--surface-muted); color: var(--text-secondary); font-size: var(--text-sm); }
|
|
21
|
+
.panelInner { max-width: none; padding: var(--space-inset) var(--space-comfort) var(--space-comfort); }
|
|
22
|
+
@media (prefers-reduced-motion: reduce) { .trigger, .arrow { transition: none; } }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
export type ExpandingButtonGroupSize = "sm" | "md";
|
|
3
|
+
export type ExpandingButtonGroupTone = "neutral" | "danger";
|
|
4
|
+
export interface ExpandingButtonGroupItem {
|
|
5
|
+
/** Stable key for the action. Also the value passed to onAction. */
|
|
6
|
+
id: string;
|
|
7
|
+
/** Visible label once the button expands, and its accessible name at all times. Use a short verb, such as "Archive". */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Icon shown while collapsed. A 16px lucide icon fits; it is sized by the group. */
|
|
10
|
+
icon: ReactNode;
|
|
11
|
+
/** Runs the action. Return false, or reject, to skip the confirmation. A promise keeps the button busy until it settles. */
|
|
12
|
+
onSelect?: () => void | boolean | Promise<void | boolean>;
|
|
13
|
+
/** Past tense word shown in place after the action, such as "Archived". The icon becomes a check for confirmDuration. Omit for actions that open something. */
|
|
14
|
+
doneLabel?: string;
|
|
15
|
+
/** "danger" colors the expanded state with the danger token, for destructive actions such as Delete. */
|
|
16
|
+
tone?: ExpandingButtonGroupTone;
|
|
17
|
+
/** Dimmed and inert. Stays focusable and still reveals its label, so people can learn what it would do. */
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ExpandingButtonGroupProps {
|
|
21
|
+
items: readonly ExpandingButtonGroupItem[];
|
|
22
|
+
/** Accessible name of the toolbar, such as "Message actions". */
|
|
23
|
+
label: string;
|
|
24
|
+
size?: ExpandingButtonGroupSize;
|
|
25
|
+
/** Id of the action whose label shows at rest, usually the primary one. Defaults to the first enabled action: one action is always
|
|
26
|
+
* expanded. Pointing at or focusing another moves the expansion there, and it returns here when the pointer and focus leave. */
|
|
27
|
+
defaultExpanded?: string | null;
|
|
28
|
+
/** Called with the item id whenever an action runs, before its own onSelect. */
|
|
29
|
+
onAction?: (id: string) => void;
|
|
30
|
+
/** How long the confirmation stays, in milliseconds. */
|
|
31
|
+
confirmDuration?: number;
|
|
32
|
+
className?: string;
|
|
33
|
+
style?: CSSProperties;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A compact pill of icon buttons for actions. One action always shows its label; the one you point at or focus takes over the
|
|
37
|
+
* label while its neighbours slide aside, and an action can confirm in place: the icon draws a check and the label becomes a past tense word.
|
|
38
|
+
* On touch, the first tap reveals the label and a second tap runs the action.
|
|
39
|
+
*/
|
|
40
|
+
export declare function ExpandingButtonGroup({ items, label, size, defaultExpanded, onAction, confirmDuration, className, style }: ExpandingButtonGroupProps): import("react").JSX.Element;
|
|
41
|
+
export default ExpandingButtonGroup;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import styles from "./expanding-button-group.module.css";
|
|
7
|
+
/** Geometry per size. The icon keeps the same left inset collapsed and expanded, so it never moves while the label is revealed. */
|
|
8
|
+
const metrics = {
|
|
9
|
+
sm: { height: 28, icon: 14, start: 7, gap: 5, end: 11 },
|
|
10
|
+
md: { height: 36, icon: 16, start: 10, gap: 7, end: 14 },
|
|
11
|
+
};
|
|
12
|
+
/** Pixels the pointer must travel into a neighbouring action before the expansion moves to it. */
|
|
13
|
+
const hysteresis = 4;
|
|
14
|
+
const enter = [...motionTokens.ease.enter];
|
|
15
|
+
const standard = [...motionTokens.ease.standard];
|
|
16
|
+
/** The label slides out from behind the icon as the width opens, and tucks back a little faster than it came. */
|
|
17
|
+
const labelVariants = {
|
|
18
|
+
shown: { opacity: 1, x: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: enter, delay: .04 } },
|
|
19
|
+
hidden: { opacity: 0, x: -6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },
|
|
20
|
+
};
|
|
21
|
+
const reducedLabelVariants = {
|
|
22
|
+
shown: { opacity: 1, x: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } },
|
|
23
|
+
hidden: { opacity: 0, x: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } },
|
|
24
|
+
};
|
|
25
|
+
const rest = { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" };
|
|
26
|
+
const textIn = { opacity: 0, y: "0.5em", filter: `blur(${motionTokens.blur.soft}px)` };
|
|
27
|
+
const textOut = { opacity: 0, y: "-0.4em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } };
|
|
28
|
+
const iconIn = { opacity: 0, scale: .55, filter: `blur(${motionTokens.blur.subtle}px)` };
|
|
29
|
+
const iconOut = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: standard } };
|
|
30
|
+
const fadeIn = { ...rest, opacity: 0 };
|
|
31
|
+
const fadeOut = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
|
|
32
|
+
const textEnter = { duration: motionTokens.duration.standard, ease: enter };
|
|
33
|
+
/** Scale rides the spring; opacity and blur tween so blur never overshoots below zero. */
|
|
34
|
+
const iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter }, filter: { duration: motionTokens.duration.fast, ease: enter } };
|
|
35
|
+
const instant = { duration: motionTokens.duration.instant };
|
|
36
|
+
/** The confirmation tick draws itself from its short stroke. */
|
|
37
|
+
function DrawnCheck({ reduced }) {
|
|
38
|
+
return _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", 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: motionTokens.duration.standard, ease: enter, delay: .06 }, opacity: { duration: .05, delay: .06 } } }) });
|
|
39
|
+
}
|
|
40
|
+
function Item({ item, size, expanded, done, busy, tabStop, reduced, onPointerDown, onClick, onFocus }) {
|
|
41
|
+
const m = metrics[size];
|
|
42
|
+
const labelRef = useRef(null);
|
|
43
|
+
const doneRef = useRef(null);
|
|
44
|
+
const [text, setText] = useState(0);
|
|
45
|
+
// Hidden copies of both words share the label's grid cell, so the slot is always as wide as the longer one. The confirmation
|
|
46
|
+
// swap then never changes the width, and the server render already has the right width before anything is measured.
|
|
47
|
+
// Changes under half a pixel are ignored, so a re-measure never makes the row wobble.
|
|
48
|
+
useLayoutEffect(() => {
|
|
49
|
+
const measure = () => {
|
|
50
|
+
const next = Math.max(labelRef.current?.getBoundingClientRect().width ?? 0, doneRef.current?.getBoundingClientRect().width ?? 0);
|
|
51
|
+
setText(current => Math.abs(current - next) < .5 ? current : next);
|
|
52
|
+
};
|
|
53
|
+
measure();
|
|
54
|
+
if (typeof ResizeObserver === "undefined")
|
|
55
|
+
return;
|
|
56
|
+
const observer = new ResizeObserver(measure);
|
|
57
|
+
if (labelRef.current)
|
|
58
|
+
observer.observe(labelRef.current);
|
|
59
|
+
if (doneRef.current)
|
|
60
|
+
observer.observe(doneRef.current);
|
|
61
|
+
return () => observer.disconnect();
|
|
62
|
+
}, [item.label, item.doneLabel, size]);
|
|
63
|
+
const showDone = done && !!item.doneLabel;
|
|
64
|
+
const measured = text > 0;
|
|
65
|
+
const target = expanded && measured ? m.start + m.icon + m.gap + text + m.end : m.height;
|
|
66
|
+
const width = useMotionValue(target);
|
|
67
|
+
const settled = useRef(false);
|
|
68
|
+
// Width rides the morph spring, so neighbours slide and the group's own width follows without a jump. The first measured
|
|
69
|
+
// width (and every width under reduced motion) is placed at once.
|
|
70
|
+
useLayoutEffect(() => {
|
|
71
|
+
if (!settled.current || reduced) {
|
|
72
|
+
width.jump(target);
|
|
73
|
+
if (measured)
|
|
74
|
+
settled.current = true;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
animate(width, target, motionTokens.spring.morph);
|
|
78
|
+
}, [target, measured, reduced, width]);
|
|
79
|
+
const word = showDone ? item.doneLabel : item.label;
|
|
80
|
+
return _jsxs(motion.button, { type: "button", className: styles.item, style: { width }, "data-ebg-item": "", "data-id": item.id, "data-tone": item.tone ?? "neutral", "data-state": showDone ? "done" : "idle", "data-expanded": expanded || undefined, "data-unmeasured": measured ? undefined : "", "aria-label": item.label, "aria-disabled": item.disabled || undefined, "aria-busy": busy || undefined, tabIndex: tabStop ? 0 : -1, onPointerDown: event => onPointerDown(item, event), onClick: () => onClick(item), onFocus: event => onFocus(item, event), children: [_jsx("span", { className: styles.icon, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, children: _jsx(motion.span, { className: styles.glyph, initial: reduced ? fadeIn : iconIn, animate: rest, exit: reduced ? fadeOut : iconOut, transition: reduced ? instant : iconEnter, children: showDone ? _jsx(DrawnCheck, { reduced: reduced }) : item.icon }, showDone ? "done" : "icon") }) }), _jsxs(motion.span, { className: styles.label, "aria-hidden": "true", variants: reduced ? reducedLabelVariants : labelVariants, initial: false, animate: expanded ? "shown" : "hidden", children: [_jsx(AnimatePresence, { initial: false, children: _jsx(motion.span, { className: styles.word, initial: reduced ? fadeIn : textIn, animate: rest, exit: reduced ? fadeOut : textOut, transition: reduced ? instant : textEnter, children: word }, word) }), _jsx("span", { ref: labelRef, className: styles.sizer, children: item.label }), item.doneLabel ? _jsx("span", { ref: doneRef, className: styles.sizer, children: item.doneLabel }) : null] })] });
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A compact pill of icon buttons for actions. One action always shows its label; the one you point at or focus takes over the
|
|
84
|
+
* label while its neighbours slide aside, and an action can confirm in place: the icon draws a check and the label becomes a past tense word.
|
|
85
|
+
* On touch, the first tap reveals the label and a second tap runs the action.
|
|
86
|
+
*/
|
|
87
|
+
export function ExpandingButtonGroup({ items, label, size = "md", defaultExpanded = null, onAction, confirmDuration = 1400, className, style }) {
|
|
88
|
+
const reduced = useReducedMotion() ?? false;
|
|
89
|
+
const root = useRef(null);
|
|
90
|
+
const [intent, setIntent] = useState(null);
|
|
91
|
+
const [done, setDone] = useState(null);
|
|
92
|
+
const [busy, setBusy] = useState(null);
|
|
93
|
+
const [announcement, setAnnouncement] = useState("");
|
|
94
|
+
const [focused, setFocused] = useState(null);
|
|
95
|
+
// Refs mirror what the pointer and keyboard are doing right now, so leave and blur handlers can fall back correctly.
|
|
96
|
+
const pointerOver = useRef(null);
|
|
97
|
+
const pointerAt = useRef(null);
|
|
98
|
+
const keyFocus = useRef(null);
|
|
99
|
+
const touchOpen = useRef(null);
|
|
100
|
+
const press = useRef(null);
|
|
101
|
+
const doneRef = useRef(null);
|
|
102
|
+
const timer = useRef(null);
|
|
103
|
+
const mounted = useRef(true);
|
|
104
|
+
const seq = useRef(0);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
mounted.current = true;
|
|
107
|
+
return () => { mounted.current = false; if (timer.current)
|
|
108
|
+
clearTimeout(timer.current); };
|
|
109
|
+
}, []);
|
|
110
|
+
const has = (id) => !!id && items.some(item => item.id === id);
|
|
111
|
+
// One action is always expanded: the resting one is defaultExpanded, else the first enabled action.
|
|
112
|
+
const firstEnabled = items.find(item => !item.disabled)?.id ?? items[0]?.id ?? null;
|
|
113
|
+
const restId = has(defaultExpanded) ? defaultExpanded : firstEnabled;
|
|
114
|
+
const expandedId = has(intent) ? intent : restId;
|
|
115
|
+
const tabStop = has(focused) ? focused : restId;
|
|
116
|
+
// A tap outside closes a label opened by a first tap.
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!intent || touchOpen.current !== intent)
|
|
119
|
+
return;
|
|
120
|
+
const onDown = (event) => {
|
|
121
|
+
if (root.current?.contains(event.target))
|
|
122
|
+
return;
|
|
123
|
+
touchOpen.current = null;
|
|
124
|
+
setIntent(doneRef.current ?? keyFocus.current);
|
|
125
|
+
};
|
|
126
|
+
document.addEventListener("pointerdown", onDown, true);
|
|
127
|
+
return () => document.removeEventListener("pointerdown", onDown, true);
|
|
128
|
+
}, [intent]);
|
|
129
|
+
const confirm = useCallback((item) => {
|
|
130
|
+
if (timer.current)
|
|
131
|
+
clearTimeout(timer.current);
|
|
132
|
+
seq.current += 1;
|
|
133
|
+
doneRef.current = item.id;
|
|
134
|
+
setDone({ id: item.id, seq: seq.current });
|
|
135
|
+
// A zero width space alternates so the same word is announced again on a repeat action.
|
|
136
|
+
setAnnouncement(`${item.doneLabel}${seq.current % 2 ? "" : ""}`);
|
|
137
|
+
timer.current = setTimeout(() => {
|
|
138
|
+
doneRef.current = null;
|
|
139
|
+
setDone(null);
|
|
140
|
+
// Collapse unless the pointer or the keyboard is still on it.
|
|
141
|
+
if (pointerOver.current !== item.id && keyFocus.current !== item.id) {
|
|
142
|
+
if (touchOpen.current === item.id)
|
|
143
|
+
touchOpen.current = null;
|
|
144
|
+
setIntent(current => current === item.id ? pointerOver.current ?? keyFocus.current : current);
|
|
145
|
+
}
|
|
146
|
+
}, confirmDuration);
|
|
147
|
+
}, [confirmDuration]);
|
|
148
|
+
const activate = useCallback(async (item) => {
|
|
149
|
+
if (item.disabled || busy === item.id || doneRef.current === item.id)
|
|
150
|
+
return;
|
|
151
|
+
setIntent(item.id);
|
|
152
|
+
onAction?.(item.id);
|
|
153
|
+
let result;
|
|
154
|
+
try {
|
|
155
|
+
const pending = item.onSelect?.();
|
|
156
|
+
if (pending instanceof Promise) {
|
|
157
|
+
setBusy(item.id);
|
|
158
|
+
result = await pending;
|
|
159
|
+
}
|
|
160
|
+
else
|
|
161
|
+
result = pending;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
result = false;
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
if (mounted.current)
|
|
168
|
+
setBusy(current => current === item.id ? null : current);
|
|
169
|
+
}
|
|
170
|
+
if (!mounted.current || result === false || !item.doneLabel)
|
|
171
|
+
return;
|
|
172
|
+
confirm(item);
|
|
173
|
+
}, [busy, confirm, onAction]);
|
|
174
|
+
const onItemPointerDown = (item, event) => {
|
|
175
|
+
press.current = { id: item.id, kind: event.pointerType, wasExpanded: expandedId === item.id };
|
|
176
|
+
};
|
|
177
|
+
const onItemClick = (item) => {
|
|
178
|
+
const last = press.current;
|
|
179
|
+
press.current = null;
|
|
180
|
+
// Touch has no hover: the first tap opens the label, the second runs it. An item already showing its label runs at once.
|
|
181
|
+
if (last && last.id === item.id && last.kind === "touch" && !last.wasExpanded) {
|
|
182
|
+
touchOpen.current = item.id;
|
|
183
|
+
setIntent(item.id);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (last?.kind === "touch")
|
|
187
|
+
touchOpen.current = item.id;
|
|
188
|
+
void activate(item);
|
|
189
|
+
};
|
|
190
|
+
const onItemFocus = (item, event) => {
|
|
191
|
+
setFocused(item.id);
|
|
192
|
+
// Only keyboard focus expands; a mouse click or a tap already has its own path, and would otherwise leave the label open.
|
|
193
|
+
let visible = true;
|
|
194
|
+
try {
|
|
195
|
+
visible = event.currentTarget.matches(":focus-visible");
|
|
196
|
+
}
|
|
197
|
+
catch { /* older engines expand on any focus */ }
|
|
198
|
+
if (!visible)
|
|
199
|
+
return;
|
|
200
|
+
keyFocus.current = item.id;
|
|
201
|
+
setIntent(item.id);
|
|
202
|
+
};
|
|
203
|
+
const onBlur = (event) => {
|
|
204
|
+
if (root.current?.contains(event.relatedTarget))
|
|
205
|
+
return;
|
|
206
|
+
keyFocus.current = null;
|
|
207
|
+
touchOpen.current = null;
|
|
208
|
+
setIntent(pointerOver.current ?? doneRef.current);
|
|
209
|
+
};
|
|
210
|
+
// Hover follows real pointer movement only. Expanding moves the buttons, and a button that slides under a resting pointer
|
|
211
|
+
// must not take over, or two neighbours could trade places back and forth.
|
|
212
|
+
const onPointerMove = (event) => {
|
|
213
|
+
if (event.pointerType !== "mouse")
|
|
214
|
+
return;
|
|
215
|
+
const at = pointerAt.current;
|
|
216
|
+
if (at && Math.abs(at.x - event.clientX) < 1 && Math.abs(at.y - event.clientY) < 1)
|
|
217
|
+
return;
|
|
218
|
+
pointerAt.current = { x: event.clientX, y: event.clientY };
|
|
219
|
+
const button = event.target.closest("[data-ebg-item]");
|
|
220
|
+
const id = button?.dataset.id;
|
|
221
|
+
if (!button || !id || id === pointerOver.current)
|
|
222
|
+
return;
|
|
223
|
+
// Hysteresis: moving from one action to the next needs the pointer a few pixels inside the new one, so resting on a
|
|
224
|
+
// boundary never flips between them.
|
|
225
|
+
if (pointerOver.current) {
|
|
226
|
+
const box = button.getBoundingClientRect();
|
|
227
|
+
if (event.clientX < box.left + hysteresis || event.clientX > box.right - hysteresis)
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
pointerOver.current = id;
|
|
231
|
+
setIntent(id);
|
|
232
|
+
};
|
|
233
|
+
const onPointerLeave = (event) => {
|
|
234
|
+
if (event.pointerType !== "mouse")
|
|
235
|
+
return;
|
|
236
|
+
pointerAt.current = null;
|
|
237
|
+
pointerOver.current = null;
|
|
238
|
+
setIntent(doneRef.current ?? keyFocus.current);
|
|
239
|
+
};
|
|
240
|
+
const onKeyDown = (event) => {
|
|
241
|
+
const list = Array.from(root.current?.querySelectorAll("[data-ebg-item]") ?? []);
|
|
242
|
+
const index = list.findIndex(button => button === document.activeElement);
|
|
243
|
+
if (index < 0)
|
|
244
|
+
return;
|
|
245
|
+
const last = list.length - 1;
|
|
246
|
+
const target = event.key === "ArrowRight" ? (index === last ? 0 : index + 1)
|
|
247
|
+
: event.key === "ArrowLeft" ? (index === 0 ? last : index - 1)
|
|
248
|
+
: event.key === "Home" ? 0 : event.key === "End" ? last : -1;
|
|
249
|
+
if (target < 0)
|
|
250
|
+
return;
|
|
251
|
+
event.preventDefault();
|
|
252
|
+
list[target].focus();
|
|
253
|
+
};
|
|
254
|
+
const m = metrics[size];
|
|
255
|
+
const vars = { "--ebg-h": `${m.height}px`, "--ebg-icon": `${m.icon}px`, "--ebg-start": `${m.start}px`, "--ebg-gap": `${m.gap}px`, "--ebg-end": `${m.end}px`, ...style };
|
|
256
|
+
return _jsxs("div", { ref: root, role: "toolbar", "aria-label": label, "aria-orientation": "horizontal", className: [styles.group, styles[size], className].filter(Boolean).join(" "), style: vars, onKeyDown: onKeyDown, onBlur: onBlur, onPointerMove: onPointerMove, onPointerLeave: onPointerLeave, children: [items.map(item => _jsx(Item, { item: item, size: size, expanded: expandedId === item.id, done: done?.id === item.id, busy: busy === item.id, tabStop: tabStop === item.id, reduced: reduced, onPointerDown: onItemPointerDown, onClick: onItemClick, onFocus: onItemFocus }, item.id)), _jsx("span", { className: styles.srOnly, role: "status", "aria-live": "polite", children: announcement })] });
|
|
257
|
+
}
|
|
258
|
+
export default ExpandingButtonGroup;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/* A flat pill on the surface. Geometry comes from the component as --ebg-* variables, so the measured widths and the CSS agree. */
|
|
2
|
+
.group { --ebg-h: 36px; --ebg-icon: 16px; --ebg-start: 10px; --ebg-gap: 7px; --ebg-end: 14px; --ebg-pad: 3px; position: relative; isolation: isolate; display: inline-flex; max-width: 100%; flex: none; align-items: center; gap: 2px; padding: var(--ebg-pad); border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface); }
|
|
3
|
+
|
|
4
|
+
/* The box never scales: its width is the animation, so a press answers with the icon and with color. The label overflows to the
|
|
5
|
+
right of the icon and the clipped width reveals it. min-width keeps the spring's settle from pinching a collapsed button. */
|
|
6
|
+
.item { position: relative; display: inline-flex; height: var(--ebg-h); min-width: var(--ebg-h); flex: none; align-items: center; justify-content: flex-start; gap: var(--ebg-gap); overflow: clip; border: 0; border-radius: var(--radius-full); corner-shape: round; padding: 0 0 0 var(--ebg-start); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: 1; letter-spacing: var(--tracking-body); white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard); }
|
|
7
|
+
.sm .item { font-size: var(--text-xs); }
|
|
8
|
+
.item[data-expanded] { color: var(--foreground); background: var(--surface-muted); }
|
|
9
|
+
.item[data-tone="danger"][data-expanded] { color: var(--danger); background: color-mix(in oklab, var(--danger) 10%, var(--surface)); }
|
|
10
|
+
/* The confirmation carries its meaning on the check, with a faint tint behind the whole button. */
|
|
11
|
+
.item[data-state="done"]:not([data-tone="danger"]) { background: color-mix(in oklab, var(--success) 10%, var(--surface)); }
|
|
12
|
+
.item[data-state="done"]:not([data-tone="danger"]) .icon { color: var(--success); }
|
|
13
|
+
.item[aria-busy="true"] { cursor: progress; }
|
|
14
|
+
.item[aria-busy="true"] .icon { animation: busy 1.1s var(--ease-in-out) infinite; }
|
|
15
|
+
.item[aria-disabled="true"] { cursor: not-allowed; color: var(--text-muted); opacity: .5; }
|
|
16
|
+
.item[aria-disabled="true"][data-expanded] { background: var(--surface-muted); color: var(--text-secondary); }
|
|
17
|
+
:global(:root[data-theme="dark"]) .item[data-tone="danger"][data-expanded] { background: color-mix(in oklab, var(--danger) 14%, var(--surface)); }
|
|
18
|
+
:global(:root[data-theme="dark"]) .item[data-state="done"]:not([data-tone="danger"]) { background: color-mix(in oklab, var(--success) 14%, var(--surface)); }
|
|
19
|
+
|
|
20
|
+
.icon { position: relative; display: grid; width: var(--ebg-icon); height: var(--ebg-icon); flex: none; place-items: center; transition: transform 320ms var(--ease-spring); }
|
|
21
|
+
.item:active:not([aria-disabled="true"]) .icon { transform: scale(.86); transition-duration: 90ms; transition-timing-function: var(--ease-standard); }
|
|
22
|
+
.glyph { display: grid; grid-area: 1 / 1; place-items: center; width: 100%; height: 100%; }
|
|
23
|
+
.glyph svg { width: 100%; height: 100%; stroke-width: 1.75; }
|
|
24
|
+
|
|
25
|
+
/* Outgoing and incoming words share one cell, so a confirmation crossfades in place while the width springs between them. */
|
|
26
|
+
.label { display: inline-grid; flex: none; align-items: center; justify-items: start; }
|
|
27
|
+
.word { grid-area: 1 / 1; white-space: nowrap; }
|
|
28
|
+
|
|
29
|
+
/* Invisible copies of both words hold the slot at the longer one; they are also what gets measured for the width target. */
|
|
30
|
+
.sizer { grid-area: 1 / 1; visibility: hidden; pointer-events: none; white-space: nowrap; }
|
|
31
|
+
|
|
32
|
+
/* Before the first measurement (the server render), the expanded action takes its natural width, which is exactly the width the
|
|
33
|
+
spring later targets, so hydration never moves anything. */
|
|
34
|
+
.item[data-unmeasured][data-expanded] { width: auto !important; padding-inline-end: var(--ebg-end); }
|
|
35
|
+
|
|
36
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
|
37
|
+
|
|
38
|
+
@keyframes busy { 50% { opacity: .45; } }
|
|
39
|
+
|
|
40
|
+
@media (prefers-reduced-motion: reduce) {
|
|
41
|
+
.item { transition: color var(--duration-instant) linear, background-color var(--duration-instant) linear; }
|
|
42
|
+
.icon, .item:active:not([aria-disabled="true"]) .icon { transform: none; transition: none; }
|
|
43
|
+
.item[aria-busy="true"] .icon { animation: none; opacity: .6; }
|
|
44
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export interface ExpandingSearchItem {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
/** One short line under the title, such as the type, owner, or last edit. */
|
|
6
|
+
meta?: string;
|
|
7
|
+
/** Results gather under this heading, in the order groups first appear in `items`. */
|
|
8
|
+
group?: string;
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
/** Extra words that should also find this item. */
|
|
11
|
+
keywords?: string[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A search that waits as an icon button until it is needed. Use it in headers and toolbars where a full field would crowd the layout.
|
|
15
|
+
* The button morphs into the field, results unfold beneath it, and Escape, choosing a result, or leaving the field empty folds it back into the icon.
|
|
16
|
+
* Place it in the space the field may grow into; it fills that space's width and keeps the button on the `anchor` edge.
|
|
17
|
+
*/
|
|
18
|
+
export interface ExpandingSearchProps {
|
|
19
|
+
/** Names the button, the field, and the results, such as "Search projects and docs". */
|
|
20
|
+
label: string;
|
|
21
|
+
items: ExpandingSearchItem[];
|
|
22
|
+
/** Shown before anything is typed, such as recent searches. */
|
|
23
|
+
suggestions?: ExpandingSearchItem[];
|
|
24
|
+
suggestionsLabel?: string;
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
onSelect?: (item: ExpandingSearchItem) => void;
|
|
27
|
+
onExpandedChange?: (expanded: boolean) => void;
|
|
28
|
+
/** The widest the field grows. It never grows past its container. */
|
|
29
|
+
expandedWidth?: number;
|
|
30
|
+
maxResults?: number;
|
|
31
|
+
/** The edge the button sits on; the field grows away from it. */
|
|
32
|
+
anchor?: "start" | "end";
|
|
33
|
+
/** A short tip under the empty state. */
|
|
34
|
+
emptyHint?: string;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
export declare function ExpandingSearch({ label, items, suggestions, suggestionsLabel, placeholder, onSelect, onExpandedChange, expandedWidth, maxResults, anchor, emptyHint, className }: ExpandingSearchProps): import("react").JSX.Element;
|
|
38
|
+
export default ExpandingSearch;
|