@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,167 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { animate, AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
4
|
+
import { Check, ChevronDown, Search, X } from "lucide-react";
|
|
5
|
+
import { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from "react";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import styles from "./combobox.module.css";
|
|
8
|
+
/** Follows the listbox height with a critically damped spring, so filtering never snaps the menu. */
|
|
9
|
+
function AutoHeight({ children, reduceMotion }) {
|
|
10
|
+
const innerRef = useRef(null);
|
|
11
|
+
const [height, setHeight] = useState("auto");
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const inner = innerRef.current;
|
|
14
|
+
if (!inner)
|
|
15
|
+
return;
|
|
16
|
+
const observer = new ResizeObserver(() => setHeight(inner.offsetHeight));
|
|
17
|
+
observer.observe(inner);
|
|
18
|
+
return () => observer.disconnect();
|
|
19
|
+
}, []);
|
|
20
|
+
return _jsx(motion.div, { className: styles.autoHeight, initial: false, animate: { height }, transition: reduceMotion ? { duration: 0 } : motionTokens.spring.smooth, children: _jsx("div", { ref: innerRef, children: children }) });
|
|
21
|
+
}
|
|
22
|
+
export const Combobox = forwardRef(function Combobox({ label, options, value: controlledValue, defaultValue = "", onValueChange, description, placeholder = "Search or select…", emptyMessage = "No matches found", id, className, disabled, onBlur, ...inputProps }, forwardedRef) {
|
|
23
|
+
const generatedId = useId();
|
|
24
|
+
const controlId = id ?? generatedId;
|
|
25
|
+
const listboxId = `${controlId}-listbox`;
|
|
26
|
+
const hintId = description ? `${controlId}-description` : undefined;
|
|
27
|
+
const rootRef = useRef(null);
|
|
28
|
+
const inputRef = useRef(null);
|
|
29
|
+
const optionRefs = useRef({});
|
|
30
|
+
const reduceMotion = useReducedMotion();
|
|
31
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
|
32
|
+
const [open, setOpen] = useState(false);
|
|
33
|
+
const [query, setQuery] = useState("");
|
|
34
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
35
|
+
const selectedValue = controlledValue ?? uncontrolledValue;
|
|
36
|
+
const selectedOption = options.find((option) => option.value === selectedValue);
|
|
37
|
+
useImperativeHandle(forwardedRef, () => inputRef.current);
|
|
38
|
+
// A chosen label settles into the field: it rises in from below with a soft blur. Clearing fades the
|
|
39
|
+
// placeholder in instead of snapping from the old label.
|
|
40
|
+
const settledValue = useRef(selectedValue);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (settledValue.current === selectedValue)
|
|
43
|
+
return;
|
|
44
|
+
settledValue.current = selectedValue;
|
|
45
|
+
const input = inputRef.current;
|
|
46
|
+
if (!input || reduceMotion || (selectedValue && open))
|
|
47
|
+
return;
|
|
48
|
+
const controls = selectedValue
|
|
49
|
+
? animate(input, { opacity: [0, 1], y: ["0.35em", "0em"], filter: [`blur(${motionTokens.blur.soft}px)`, "blur(0px)"] }, { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] })
|
|
50
|
+
: animate(input, { opacity: [0, 1] }, { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] });
|
|
51
|
+
return () => controls.complete();
|
|
52
|
+
}, [selectedValue, reduceMotion, open]);
|
|
53
|
+
const filteredOptions = useMemo(() => {
|
|
54
|
+
const normalizedQuery = query.trim().toLocaleLowerCase();
|
|
55
|
+
if (!normalizedQuery)
|
|
56
|
+
return options;
|
|
57
|
+
return options.filter((option) => [option.label, ...(option.keywords ?? [])].some((term) => term.toLocaleLowerCase().includes(normalizedQuery)));
|
|
58
|
+
}, [options, query]);
|
|
59
|
+
const enabledIndices = filteredOptions.reduce((indices, option, index) => {
|
|
60
|
+
if (!option.disabled)
|
|
61
|
+
indices.push(index);
|
|
62
|
+
return indices;
|
|
63
|
+
}, []);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!open)
|
|
66
|
+
return;
|
|
67
|
+
const activeOption = activeIndex >= 0 ? filteredOptions[activeIndex] : undefined;
|
|
68
|
+
const option = activeOption ? optionRefs.current[activeOption.value] : null;
|
|
69
|
+
const listbox = option?.parentElement;
|
|
70
|
+
if (option && listbox) {
|
|
71
|
+
const top = option.offsetTop;
|
|
72
|
+
const bottom = top + option.offsetHeight;
|
|
73
|
+
if (top < listbox.scrollTop)
|
|
74
|
+
listbox.scrollTop = top;
|
|
75
|
+
else if (bottom > listbox.scrollTop + listbox.clientHeight)
|
|
76
|
+
listbox.scrollTop = bottom - listbox.clientHeight;
|
|
77
|
+
}
|
|
78
|
+
}, [activeIndex, filteredOptions, open]);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const handlePointerDown = (event) => {
|
|
81
|
+
if (rootRef.current && !rootRef.current.contains(event.target)) {
|
|
82
|
+
setOpen(false);
|
|
83
|
+
setQuery("");
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
87
|
+
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
|
88
|
+
}, []);
|
|
89
|
+
const choose = (option) => {
|
|
90
|
+
if (option.disabled)
|
|
91
|
+
return;
|
|
92
|
+
setUncontrolledValue(option.value);
|
|
93
|
+
onValueChange?.(option.value);
|
|
94
|
+
setQuery("");
|
|
95
|
+
setOpen(false);
|
|
96
|
+
inputRef.current?.focus();
|
|
97
|
+
};
|
|
98
|
+
const clear = (event) => {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
setUncontrolledValue("");
|
|
101
|
+
onValueChange?.("");
|
|
102
|
+
setQuery("");
|
|
103
|
+
setOpen(true);
|
|
104
|
+
inputRef.current?.focus();
|
|
105
|
+
};
|
|
106
|
+
const openMenu = () => {
|
|
107
|
+
if (disabled)
|
|
108
|
+
return;
|
|
109
|
+
setOpen(true);
|
|
110
|
+
setQuery("");
|
|
111
|
+
setActiveIndex(-1);
|
|
112
|
+
};
|
|
113
|
+
const handleKeyDown = (event) => {
|
|
114
|
+
if (disabled)
|
|
115
|
+
return;
|
|
116
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
if (!open) {
|
|
119
|
+
openMenu();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!enabledIndices.length)
|
|
123
|
+
return;
|
|
124
|
+
const currentPosition = enabledIndices.indexOf(activeIndex);
|
|
125
|
+
const nextPosition = event.key === "ArrowDown"
|
|
126
|
+
? (currentPosition + 1) % enabledIndices.length
|
|
127
|
+
: (currentPosition - 1 + enabledIndices.length) % enabledIndices.length;
|
|
128
|
+
setActiveIndex(enabledIndices[nextPosition]);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (event.key === "Enter" && open && activeIndex >= 0) {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
const option = filteredOptions[activeIndex];
|
|
134
|
+
if (option)
|
|
135
|
+
choose(option);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (event.key === "Escape" && open) {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
setOpen(false);
|
|
141
|
+
setQuery("");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const inputValue = open ? query : selectedOption?.label ?? "";
|
|
146
|
+
const activeOption = activeIndex >= 0 ? filteredOptions[activeIndex] : undefined;
|
|
147
|
+
return (_jsxs("div", { ref: rootRef, className: styles.field, children: [_jsx("label", { htmlFor: controlId, children: label }), _jsxs("div", { className: [styles.control, open ? styles.open : "", disabled ? styles.disabled : "", className ?? ""].filter(Boolean).join(" "), children: [_jsx(Search, { className: styles.searchIcon, size: 16, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { ...inputProps, ref: inputRef, id: controlId, type: "text", role: "combobox", value: inputValue,
|
|
148
|
+
// While searching, the chosen label stays in place as muted placeholder copy instead of vanishing.
|
|
149
|
+
placeholder: selectedOption?.label ?? placeholder, disabled: disabled, "aria-describedby": hintId, "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-autocomplete": "list", "aria-activedescendant": open && activeOption ? `${controlId}-option-${activeOption.value}` : undefined,
|
|
150
|
+
// WAI-ARIA combobox: focus alone never opens the list (a Tab walk through a form would drop it over the next
|
|
151
|
+
// fields); typing, ArrowDown/ArrowUp or a click do. Leaving the field closes it, so it never stays open
|
|
152
|
+
// behind focus.
|
|
153
|
+
onBlur: (event) => {
|
|
154
|
+
onBlur?.(event);
|
|
155
|
+
// Focus moving anywhere but into the list (the next field, or the field's own Clear button) closes it.
|
|
156
|
+
const next = event.relatedTarget;
|
|
157
|
+
if (open && next && !document.getElementById(listboxId)?.contains(next)) {
|
|
158
|
+
setOpen(false);
|
|
159
|
+
setQuery("");
|
|
160
|
+
}
|
|
161
|
+
}, onClick: openMenu, onChange: (event) => {
|
|
162
|
+
setQuery(event.target.value);
|
|
163
|
+
setOpen(true);
|
|
164
|
+
setActiveIndex(-1);
|
|
165
|
+
}, onKeyDown: handleKeyDown }), _jsx(AnimatePresence, { initial: false, children: selectedOption && !disabled && (_jsx(motion.button, { type: "button", className: styles.clear, "aria-label": "Clear selection", onMouseDown: event => event.preventDefault(), onClick: clear, initial: reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transition: reduceMotion ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast } } }, exit: { opacity: 0, ...(reduceMotion ? {} : { scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }), transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, whileTap: { scale: reduceMotion ? 1 : 0.96, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, children: _jsx(X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })) }), _jsx(ChevronDown, { className: styles.chevron, size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }), description && _jsx("span", { id: hintId, className: styles.hint, children: description }), _jsx(AnimatePresence, { initial: false, children: open && (_jsx(motion.div, { className: styles.popover, initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }, animate: { opacity: 1, y: 0, scale: 1, transition: reduceMotion ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } }, exit: { opacity: 0, ...(reduceMotion ? {} : { y: -4, scale: 0.98 }), transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, role: "presentation", children: _jsx(AutoHeight, { reduceMotion: reduceMotion, children: _jsx("div", { id: listboxId, className: styles.listbox, role: "listbox", "aria-label": `${label} options`, tabIndex: -1, children: filteredOptions.length ? filteredOptions.map((option, index) => (_jsxs("div", { ref: (element) => { optionRefs.current[option.value] = element; }, id: `${controlId}-option-${option.value}`, className: styles.option, "data-active": index === activeIndex ? "true" : undefined, "data-disabled": option.disabled ? "true" : undefined, role: "option", "aria-selected": option.value === selectedValue, "aria-disabled": option.disabled || undefined, onMouseDown: (event) => event.preventDefault(), onMouseEnter: () => !option.disabled && setActiveIndex(index), onClick: () => choose(option), children: [_jsx("span", { children: option.label }), option.value === selectedValue && _jsx(Check, { className: styles.check, size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }, option.value))) : _jsx(motion.div, { className: styles.empty, role: "status", initial: reduceMotion ? false : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: emptyMessage }) }) }) })) })] }));
|
|
166
|
+
});
|
|
167
|
+
Combobox.displayName = "Combobox";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
.field { position: relative; display: grid; gap: var(--space-related); min-width: 0; }
|
|
2
|
+
.field label { font-size: var(--text-sm); font-weight: 500; }
|
|
3
|
+
.control { position: relative; z-index: 2; display: flex; min-height: var(--control-height-md); align-items: center; gap: var(--space-related); border: 1px solid var(--border); border-radius: var(--radius-control); padding: 0 var(--space-group); background: var(--surface); color: var(--foreground); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
|
|
4
|
+
@media (hover: hover) and (pointer: fine) { .control:hover:not(.disabled) { border-color: var(--border-strong); background: var(--surface-muted); } }
|
|
5
|
+
.control:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus-ring); }
|
|
6
|
+
.control.open { border-color: var(--border-strong); background: var(--surface-muted); }
|
|
7
|
+
.control.disabled { opacity: .5; cursor: not-allowed; }
|
|
8
|
+
.searchIcon { flex: 0 0 auto; color: var(--text-muted); }
|
|
9
|
+
.control input { width: 100%; min-width: 0; border: 0; outline: 0; padding: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); }
|
|
10
|
+
.control input:focus-visible { outline: none; }
|
|
11
|
+
.control input::placeholder { color: var(--text-muted); }
|
|
12
|
+
.chevron { flex: 0 0 auto; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring); }
|
|
13
|
+
.open .chevron { transform: rotate(180deg); }
|
|
14
|
+
.clear { display: grid; width: 22px; height: 22px; flex: 0 0 auto; place-items: center; border: 0; border-radius: var(--radius-control); background: transparent; color: var(--text-muted); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
|
|
15
|
+
@media (hover: hover) and (pointer: fine) { .clear:hover { background: var(--surface-muted); color: var(--foreground); } }
|
|
16
|
+
.clear:focus-visible { --focus-outline-offset: 1px; }
|
|
17
|
+
.hint { color: var(--text-muted); font-size: var(--text-xs); }
|
|
18
|
+
.popover { position: absolute; z-index: 80; top: calc(100% + 8px); right: 0; left: 0; overflow: hidden; transform-origin: top center; border: 1px solid var(--border); border-radius: var(--radius-container); padding: 5px; background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); will-change: transform, opacity; }
|
|
19
|
+
.autoHeight { overflow: hidden; }
|
|
20
|
+
.listbox { max-height: min(300px, 40vh); overflow-y: auto; overscroll-behavior: contain; }
|
|
21
|
+
/* Arrow keys move the highlight often, so it changes almost instantly. */
|
|
22
|
+
.option { display: flex; min-height: 36px; align-items: center; justify-content: space-between; gap: var(--space-group); border-radius: calc(var(--radius-container) - 5px); padding: 0 12px; color: var(--foreground); font-size: var(--text-sm); cursor: pointer; outline: none; user-select: none; transition: background-color 80ms var(--ease-standard), color 80ms var(--ease-standard); }
|
|
23
|
+
.option > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
24
|
+
.option[data-active="true"] { background: var(--surface-muted); }
|
|
25
|
+
.option[data-disabled="true"] { opacity: .42; cursor: not-allowed; }
|
|
26
|
+
.check { flex: 0 0 auto; color: var(--foreground); }
|
|
27
|
+
.empty { padding: var(--space-group); color: var(--text-muted); font-size: var(--text-sm); }
|
|
28
|
+
@media (prefers-reduced-motion: reduce) { .control, .chevron, .clear, .option { transition: none; } }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export interface CommandItem {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
group?: string;
|
|
7
|
+
keywords?: string[];
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
shortcut?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface CommandPaletteProps {
|
|
12
|
+
items: CommandItem[];
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
onSelect?: (item: CommandItem) => void;
|
|
15
|
+
onClose?: () => void;
|
|
16
|
+
label?: string; /** Focus the search field on mount, for a palette that opens on demand. */
|
|
17
|
+
autoFocus?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function CommandPalette({ items, placeholder, onSelect, onClose, label, autoFocus }: CommandPaletteProps): import("react").JSX.Element;
|
|
20
|
+
export default CommandPalette;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
|
+
import { Command as CommandIcon, Search, X } from "lucide-react";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import styles from "./command-palette.module.css";
|
|
8
|
+
const enter = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
|
|
9
|
+
const leave = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] };
|
|
10
|
+
const GLIDE_ROWS = 6;
|
|
11
|
+
/** Rows fade out while the rest glide into their place; when the list snaps they leave at once so nothing overlaps the new rows. */
|
|
12
|
+
const rowExit = { exit: (glide) => ({ opacity: 0, transition: glide ? leave : { duration: 0 } }) };
|
|
13
|
+
export function CommandPalette({ items, placeholder = "Search commands", onSelect, onClose, label = "Command palette", autoFocus = false }) {
|
|
14
|
+
const inputId = useId();
|
|
15
|
+
const [query, setQuery] = useState("");
|
|
16
|
+
const [activeIndex, setActiveIndex] = useState(null);
|
|
17
|
+
const [listHeight, setListHeight] = useState("auto");
|
|
18
|
+
const reduced = useReducedMotion();
|
|
19
|
+
const inputRef = useRef(null);
|
|
20
|
+
const listRef = useRef(null);
|
|
21
|
+
const pointer = useRef(false);
|
|
22
|
+
const lastPointer = useRef({ x: -1, y: -1 });
|
|
23
|
+
const highlightShown = useRef(false);
|
|
24
|
+
const highlightY = useMotionValue(0);
|
|
25
|
+
const highlightHeight = useMotionValue(0);
|
|
26
|
+
const highlightOpacity = useMotionValue(0);
|
|
27
|
+
const filtered = useMemo(() => {
|
|
28
|
+
const normalized = query.trim().toLowerCase();
|
|
29
|
+
if (!normalized)
|
|
30
|
+
return items;
|
|
31
|
+
return items.filter(item => [item.label, item.description, item.group, ...(item.keywords ?? [])].filter(Boolean).join(" ").toLowerCase().includes(normalized));
|
|
32
|
+
}, [items, query]);
|
|
33
|
+
const groupedItems = useMemo(() => {
|
|
34
|
+
const grouped = new Map();
|
|
35
|
+
filtered.forEach((item, index) => {
|
|
36
|
+
const group = item.group ?? "Actions";
|
|
37
|
+
const entries = grouped.get(group) ?? [];
|
|
38
|
+
entries.push({ item, index });
|
|
39
|
+
grouped.set(group, entries);
|
|
40
|
+
});
|
|
41
|
+
return [...grouped.entries()];
|
|
42
|
+
}, [filtered]);
|
|
43
|
+
// When every remaining row moves within about a viewport, the list closes its gaps with a glide. A long jump (the first letters typed into a long list) snaps like a search result list instead of streaking rows through the frame; only the frame height morphs.
|
|
44
|
+
const [shift, setShift] = useState({ list: filtered, glide: true });
|
|
45
|
+
if (shift.list !== filtered) {
|
|
46
|
+
const before = new Map(shift.list.map((item, index) => [item.id, index]));
|
|
47
|
+
setShift({ list: filtered, glide: filtered.every((item, index) => Math.abs((before.get(item.id) ?? index) - index) <= GLIDE_ROWS) });
|
|
48
|
+
}
|
|
49
|
+
const glide = shift.glide && !reduced;
|
|
50
|
+
const safeActiveIndex = activeIndex === null
|
|
51
|
+
? -1
|
|
52
|
+
: Math.min(activeIndex, Math.max(filtered.length - 1, 0));
|
|
53
|
+
const activeId = filtered[safeActiveIndex]?.id;
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (autoFocus)
|
|
56
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
57
|
+
}, [autoFocus]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const focusShortcut = (event) => {
|
|
60
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
inputRef.current?.focus();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
document.addEventListener("keydown", focusShortcut);
|
|
66
|
+
return () => document.removeEventListener("keydown", focusShortcut);
|
|
67
|
+
}, []);
|
|
68
|
+
// The results frame follows the list height, so filtering closes or opens the gap instead of snapping.
|
|
69
|
+
useLayoutEffect(() => {
|
|
70
|
+
const list = listRef.current;
|
|
71
|
+
if (!list)
|
|
72
|
+
return;
|
|
73
|
+
const observer = new ResizeObserver(([entry]) => setListHeight(Math.round(entry?.borderBoxSize?.[0]?.blockSize ?? list.offsetHeight)));
|
|
74
|
+
observer.observe(list);
|
|
75
|
+
return () => observer.disconnect();
|
|
76
|
+
}, []);
|
|
77
|
+
// One highlight follows the active result: a spring for the pointer, a 70ms slide for arrow keys, a fade when it first appears.
|
|
78
|
+
useLayoutEffect(() => {
|
|
79
|
+
const node = activeId ? document.getElementById(`${inputId}-${activeId}`) : null;
|
|
80
|
+
if (!node) {
|
|
81
|
+
highlightShown.current = false;
|
|
82
|
+
animate(highlightOpacity, 0, { duration: reduced ? 0 : .1 });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const move = !highlightShown.current || reduced ? { duration: 0 } : pointer.current ? motionTokens.spring.snappy : { duration: .07, ease: [...motionTokens.ease.enter] };
|
|
86
|
+
// Rows sit inside positioned groups, so their offset is summed up to the list (layout transforms stay out of it).
|
|
87
|
+
let top = 0;
|
|
88
|
+
for (let element = node; element && element !== listRef.current; element = element.offsetParent)
|
|
89
|
+
top += element.offsetTop + (element === node ? 0 : element.clientTop);
|
|
90
|
+
animate(highlightY, top, move);
|
|
91
|
+
animate(highlightHeight, node.offsetHeight, move);
|
|
92
|
+
animate(highlightOpacity, 1, { duration: reduced ? 0 : .08 });
|
|
93
|
+
highlightShown.current = true;
|
|
94
|
+
}, [activeId, inputId, reduced, highlightY, highlightHeight, highlightOpacity]);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!activeId)
|
|
97
|
+
return;
|
|
98
|
+
document.getElementById(`${inputId}-${activeId}`)?.scrollIntoView({ block: "nearest" });
|
|
99
|
+
}, [activeId, inputId]);
|
|
100
|
+
function choose(item) {
|
|
101
|
+
onSelect?.(item);
|
|
102
|
+
setQuery("");
|
|
103
|
+
setActiveIndex(null);
|
|
104
|
+
inputRef.current?.focus();
|
|
105
|
+
}
|
|
106
|
+
function handleKeyDown(event) {
|
|
107
|
+
pointer.current = false;
|
|
108
|
+
if (event.key === "ArrowDown") {
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
setActiveIndex(index => Math.min((index ?? -1) + 1, Math.max(filtered.length - 1, 0)));
|
|
111
|
+
}
|
|
112
|
+
if (event.key === "ArrowUp") {
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
setActiveIndex(index => Math.max((index ?? filtered.length) - 1, 0));
|
|
115
|
+
}
|
|
116
|
+
if (event.key === "Home") {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
setActiveIndex(0);
|
|
119
|
+
}
|
|
120
|
+
if (event.key === "End") {
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
setActiveIndex(Math.max(filtered.length - 1, 0));
|
|
123
|
+
}
|
|
124
|
+
// Enter runs the highlighted result; after typing, with nothing highlighted yet, it runs the top match.
|
|
125
|
+
const target = filtered[safeActiveIndex] ?? (query ? filtered[0] : undefined);
|
|
126
|
+
if (event.key === "Enter" && target) {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
choose(target);
|
|
129
|
+
}
|
|
130
|
+
if (event.key === "Escape") {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
if (query) {
|
|
133
|
+
setQuery("");
|
|
134
|
+
setActiveIndex(null);
|
|
135
|
+
}
|
|
136
|
+
else
|
|
137
|
+
onClose?.();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return _jsxs(motion.div, { className: styles.palette, initial: reduced ? false : { opacity: 0, y: 6, scale: .98 }, animate: { opacity: 1, y: 0, scale: 1 }, transition: reduced ? { duration: 0 } : { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } }, children: [_jsxs("div", { className: styles.searchRow, children: [_jsx("span", { className: styles.searchIcon, children: _jsx(Search, { width: 18, height: 18, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("label", { className: styles.visuallyHidden, htmlFor: inputId, children: label }), _jsx("input", { ref: inputRef, id: inputId, role: "combobox", "aria-autocomplete": "list", "aria-expanded": "true", "aria-controls": `${inputId}-results`, "aria-activedescendant": activeId ? `${inputId}-${activeId}` : undefined, value: query, onChange: event => { setQuery(event.target.value); setActiveIndex(null); }, onKeyDown: handleKeyDown, placeholder: placeholder, autoComplete: "off" }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: query ? _jsx(motion.button, { className: styles.clearButton, type: "button", "aria-label": "Clear search", onClick: () => { setQuery(""); setActiveIndex(null); inputRef.current?.focus(); }, initial: reduced ? false : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: leave }, transition: reduced ? { duration: 0 } : { default: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.instant } }, children: _jsx(X, { width: 15, height: 15, "aria-hidden": "true" }) }, "clear") : null }), onClose ? _jsx("button", { className: styles.closeButton, type: "button", "aria-label": "Close command palette", onClick: onClose, children: "Esc" }) : null, _jsx("kbd", { className: styles.commandKey, children: "\u2318 K" })] }), _jsx(motion.div, { className: styles.resultsFrame, initial: false, animate: { height: listHeight }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, children: _jsxs(motion.div, { ref: listRef, layoutScroll: true, id: `${inputId}-results`, className: styles.results, role: "listbox", "aria-label": "Command results", children: [_jsx(motion.span, { className: styles.highlight, style: { y: highlightY, height: highlightHeight, opacity: highlightOpacity }, "aria-hidden": "true" }), _jsx(AnimatePresence, { mode: "wait", initial: false, children: filtered.length ? _jsx(motion.div, { initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transition: leave }, transition: enter, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: glide, children: groupedItems.map(([group, entries]) => _jsxs(motion.div, { layout: glide ? "position" : false, className: styles.group, role: "group", "aria-label": group, variants: rowExit, initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: "exit", transition: reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }, children: [_jsx("span", { className: styles.groupHeading, "aria-hidden": "true", children: group }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: glide, children: entries.map(({ item, index }) => _jsxs(motion.button, { layout: glide ? "position" : false, id: `${inputId}-${item.id}`, className: `${styles.result} ${index === safeActiveIndex ? styles.activeResult : ""}`, type: "button", role: "option", "aria-selected": index === safeActiveIndex, onClick: () => choose(item), onPointerMove: event => {
|
|
141
|
+
// Ignore the synthetic moves browsers send while the list scrolls under a still pointer.
|
|
142
|
+
if (event.clientX === lastPointer.current.x && event.clientY === lastPointer.current.y)
|
|
143
|
+
return;
|
|
144
|
+
lastPointer.current = { x: event.clientX, y: event.clientY };
|
|
145
|
+
if (index !== safeActiveIndex) {
|
|
146
|
+
pointer.current = true;
|
|
147
|
+
setActiveIndex(index);
|
|
148
|
+
}
|
|
149
|
+
}, variants: rowExit, initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: "exit", transition: reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }, children: [_jsx("span", { className: styles.itemIcon, "aria-hidden": "true", children: item.icon ?? _jsx(CommandIcon, { width: 16, height: 16 }) }), _jsxs("span", { className: styles.resultCopy, children: [_jsx("strong", { children: item.label }), item.description && _jsx("small", { children: item.description })] }), item.shortcut && _jsx("kbd", { className: styles.shortcut, children: item.shortcut })] }, item.id)) })] }, group)) }) }, "results") : _jsxs(motion.div, { className: styles.empty, initial: reduced ? false : { opacity: 0, y: 6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, transition: leave }, transition: enter, children: [_jsx("span", { children: _jsx(Search, { width: 20, height: 20, "aria-hidden": "true" }) }), _jsx("strong", { children: "No matching actions" }), _jsx("small", { children: "Try a different word or clear the search." })] }, "empty") })] }) })] });
|
|
150
|
+
}
|
|
151
|
+
export default CommandPalette;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
.palette {
|
|
2
|
+
width: 100%;
|
|
3
|
+
overflow: hidden;
|
|
4
|
+
border: 1px solid var(--border);
|
|
5
|
+
border-radius: var(--radius-container);
|
|
6
|
+
background: var(--surface);
|
|
7
|
+
box-shadow: var(--shadow-floating);
|
|
8
|
+
transform-origin: top center;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.searchRow {
|
|
12
|
+
position: relative;
|
|
13
|
+
display: flex;
|
|
14
|
+
min-height: 54px;
|
|
15
|
+
align-items: center;
|
|
16
|
+
gap: 10px;
|
|
17
|
+
padding: 8px 14px;
|
|
18
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
19
|
+
transition: background var(--duration-fast) var(--ease-standard);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.searchRow:focus-within { background: var(--surface-muted); }
|
|
23
|
+
.searchIcon, .itemIcon { display: grid; flex: none; place-items: center; color: var(--text-muted); }
|
|
24
|
+
.searchIcon { width: 22px; height: 22px; }
|
|
25
|
+
.searchRow input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); }
|
|
26
|
+
.searchRow input::placeholder { color: var(--text-muted); }
|
|
27
|
+
.searchRow input:focus-visible { outline: none; }
|
|
28
|
+
|
|
29
|
+
.commandKey, .shortcut {
|
|
30
|
+
display: inline-flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
justify-content: center;
|
|
33
|
+
flex: none;
|
|
34
|
+
border: 1px solid var(--border);
|
|
35
|
+
border-radius: var(--radius-detail);
|
|
36
|
+
background: var(--surface-muted);
|
|
37
|
+
color: var(--text-muted);
|
|
38
|
+
font-family: inherit;
|
|
39
|
+
font-size: var(--text-xs);
|
|
40
|
+
font-weight: 500;
|
|
41
|
+
line-height: 1;
|
|
42
|
+
font-variant-numeric: tabular-nums;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.commandKey { min-width: 38px; height: 24px; padding: 0 6px; gap: 2px; }
|
|
46
|
+
.clearButton { display: grid; width: 24px; height: 24px; flex: none; place-items: center; border: 0; border-radius: var(--radius-detail); background: transparent; color: var(--text-muted); cursor: pointer; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
|
|
47
|
+
@media (hover: hover) and (pointer: fine) { .clearButton:hover, .closeButton:hover { background: var(--surface); color: var(--foreground); } }
|
|
48
|
+
.clearButton:active, .closeButton:active { background: var(--surface); color: var(--foreground); }
|
|
49
|
+
.closeButton { display: grid; min-width: 27px; height: 24px; flex: none; place-items: center; border: 1px solid var(--border-subtle); border-radius: var(--radius-detail); background: transparent; color: var(--text-muted); padding: 0 6px; font: inherit; font-size: var(--text-xs); font-weight: 500; line-height: 1; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
|
|
50
|
+
|
|
51
|
+
/* The frame animates to the list height; the list keeps its own scroll. */
|
|
52
|
+
.resultsFrame { overflow: hidden; }
|
|
53
|
+
.results { position: relative; max-height: 342px; overflow: auto; padding: 6px 8px; }
|
|
54
|
+
.highlight { position: absolute; top: 0; right: 8px; left: 8px; border-radius: var(--radius-detail); background: var(--surface-muted); opacity: 0; pointer-events: none; }
|
|
55
|
+
/* Groups are positioned so a row that pops out of the flow is placed against the same box it was measured in, even mid-glide. */
|
|
56
|
+
.group { position: relative; }
|
|
57
|
+
.group + .group { margin-top: 6px; }
|
|
58
|
+
.groupHeading { display: block; padding: 6px 8px 3px; color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; line-height: var(--leading-body); }
|
|
59
|
+
|
|
60
|
+
/* One line per result: the title, then its description in muted text, so the list stays short and scannable. */
|
|
61
|
+
.result { position: relative; display: flex; width: 100%; min-height: 34px; align-items: center; gap: 10px; border: 0; border-radius: var(--radius-detail); padding: 0 8px; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
|
|
62
|
+
/* The active row is drawn by the shared highlight; the row itself only tints its icon and arrow. */
|
|
63
|
+
.itemIcon { transition: color var(--duration-fast) var(--ease-standard); }
|
|
64
|
+
.itemIcon { display: grid; width: 16px; height: 16px; flex: none; place-items: center; color: var(--text-muted); }
|
|
65
|
+
.itemIcon svg { width: 16px; height: 16px; }
|
|
66
|
+
.resultCopy { display: flex; min-width: 0; flex: 1; align-items: baseline; gap: 8px; }
|
|
67
|
+
.resultCopy strong, .resultCopy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
68
|
+
.resultCopy strong { flex: none; max-width: 100%; }
|
|
69
|
+
.resultCopy small { min-width: 0; }
|
|
70
|
+
.resultCopy strong { font-size: var(--text-sm); font-weight: 500; }
|
|
71
|
+
.resultCopy small { color: var(--text-muted); font-size: var(--text-xs); }
|
|
72
|
+
.shortcut { min-width: 18px; height: 18px; padding: 0 4px; border-color: transparent; border-radius: var(--radius-detail); font-size: 11px; }
|
|
73
|
+
.activeResult .itemIcon { color: var(--foreground); }
|
|
74
|
+
.empty { display: grid; justify-items: center; gap: 4px; padding: 26px 12px 28px; text-align: center; }
|
|
75
|
+
.empty > span { display: grid; width: 32px; height: 32px; place-items: center; margin-bottom: 5px; color: var(--text-muted); }
|
|
76
|
+
.empty strong { font-size: var(--text-sm); font-weight: 500; }
|
|
77
|
+
.empty small { color: var(--text-muted); font-size: var(--text-xs); }
|
|
78
|
+
.visuallyHidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
79
|
+
|
|
80
|
+
@media (max-width: 480px) {
|
|
81
|
+
.searchRow { gap: 8px; padding: 8px 11px; }
|
|
82
|
+
.results { padding: 6px; }
|
|
83
|
+
.highlight { right: 6px; left: 6px; }
|
|
84
|
+
.result { gap: 8px; }
|
|
85
|
+
.shortcut, .commandKey { display: none; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@media (max-width: 340px) {
|
|
89
|
+
.result { padding-inline: 6px; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@media (prefers-reduced-motion: reduce) {
|
|
93
|
+
.searchRow, .itemIcon, .clearButton, .closeButton { transition: none; }
|
|
94
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export interface CommentAuthor {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
/** Square image URL. Initials are shown without one. */
|
|
6
|
+
avatar?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CommentReaction {
|
|
9
|
+
emoji: string;
|
|
10
|
+
/** Ids of the people who reacted. */
|
|
11
|
+
users: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface ThreadComment {
|
|
14
|
+
id: string;
|
|
15
|
+
author: CommentAuthor;
|
|
16
|
+
/** Plain text. "@Full Name" mentions of known people are highlighted. */
|
|
17
|
+
body: string;
|
|
18
|
+
/** Display label such as "2h" or "Sep 18". */
|
|
19
|
+
createdAt: string;
|
|
20
|
+
edited?: boolean;
|
|
21
|
+
/** A deleted comment that still has replies keeps its place as a quiet placeholder. */
|
|
22
|
+
deleted?: boolean;
|
|
23
|
+
reactions?: CommentReaction[];
|
|
24
|
+
replies?: ThreadComment[];
|
|
25
|
+
}
|
|
26
|
+
/** What changed, for syncing to a server. `comments` on `onCommentsChange` is always the full next tree. */
|
|
27
|
+
export type CommentThreadEvent = {
|
|
28
|
+
type: "reply";
|
|
29
|
+
comment: ThreadComment;
|
|
30
|
+
parentId: string | null;
|
|
31
|
+
} | {
|
|
32
|
+
type: "edit";
|
|
33
|
+
id: string;
|
|
34
|
+
body: string;
|
|
35
|
+
} | {
|
|
36
|
+
type: "delete";
|
|
37
|
+
id: string;
|
|
38
|
+
} | {
|
|
39
|
+
type: "react";
|
|
40
|
+
id: string;
|
|
41
|
+
emoji: string;
|
|
42
|
+
added: boolean;
|
|
43
|
+
};
|
|
44
|
+
/** Every word the thread shows or announces. Each one has an English default; words with a count or a name are functions, so an app can apply its own plural rules. */
|
|
45
|
+
export interface CommentThreadLabels {
|
|
46
|
+
/** Accessible name of the whole thread. */
|
|
47
|
+
thread?: string;
|
|
48
|
+
/** Header and resolved chip, such as "3 comments". */
|
|
49
|
+
commentCount?: (count: number) => string;
|
|
50
|
+
/** Resolved chip, such as "2 people". */
|
|
51
|
+
peopleCount?: (count: number) => string;
|
|
52
|
+
resolve?: string;
|
|
53
|
+
/** Title of the resolved chip. */
|
|
54
|
+
resolved?: string;
|
|
55
|
+
reopen?: string;
|
|
56
|
+
/** Shown when there are no comments. */
|
|
57
|
+
empty?: string;
|
|
58
|
+
reply?: string;
|
|
59
|
+
edit?: string;
|
|
60
|
+
delete?: string;
|
|
61
|
+
cancel?: string;
|
|
62
|
+
send?: string;
|
|
63
|
+
/** Saves an edit. */
|
|
64
|
+
save?: string;
|
|
65
|
+
/** Placeholder and accessible name of the edit field. */
|
|
66
|
+
editComment?: string;
|
|
67
|
+
/** After the time of an edited comment. */
|
|
68
|
+
edited?: string;
|
|
69
|
+
deleteConfirm?: string;
|
|
70
|
+
/** Placeholder text of a deleted comment that still has replies. */
|
|
71
|
+
deletedText?: string;
|
|
72
|
+
/** Accessible name of that placeholder. */
|
|
73
|
+
deletedComment?: string;
|
|
74
|
+
/** Above the composer while replying. The name inside it is highlighted. */
|
|
75
|
+
replyingTo?: (name: string) => string;
|
|
76
|
+
cancelReply?: string;
|
|
77
|
+
showReplies?: (count: number) => string;
|
|
78
|
+
hideReplies?: string;
|
|
79
|
+
addReaction?: string;
|
|
80
|
+
closeReactions?: string;
|
|
81
|
+
/** Accessible name of the reaction picker. */
|
|
82
|
+
reactions?: string;
|
|
83
|
+
/** Accessible name of an emoji in the picker. */
|
|
84
|
+
reactWith?: (emoji: string) => string;
|
|
85
|
+
/** Accessible name of a reaction under a comment. */
|
|
86
|
+
reactionCount?: (emoji: string, count: number, includesYou: boolean) => string;
|
|
87
|
+
/** Accessible name of the @mention suggestions. */
|
|
88
|
+
mentions?: string;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A threaded discussion: replies nest under their comment and collapse, reactions toggle with a count that rolls,
|
|
92
|
+
* authors edit and delete their own comments in place, mentions autocomplete in the composer, and resolving folds the
|
|
93
|
+
* whole thread into a single chip that reopens it. Controlled with `comments` and `resolved`, or uncontrolled.
|
|
94
|
+
*/
|
|
95
|
+
export interface CommentThreadProps {
|
|
96
|
+
comments?: ThreadComment[];
|
|
97
|
+
defaultComments?: ThreadComment[];
|
|
98
|
+
onCommentsChange?: (comments: ThreadComment[], event: CommentThreadEvent) => void;
|
|
99
|
+
/** The person writing. Their comments can be edited and deleted. */
|
|
100
|
+
currentUser: CommentAuthor;
|
|
101
|
+
/** People who can be mentioned. Defaults to everyone in the thread. */
|
|
102
|
+
people?: CommentAuthor[];
|
|
103
|
+
resolved?: boolean;
|
|
104
|
+
defaultResolved?: boolean;
|
|
105
|
+
onResolvedChange?: (resolved: boolean) => void;
|
|
106
|
+
/** What the thread is about, shown in its header, such as "Hero headline". */
|
|
107
|
+
title?: ReactNode;
|
|
108
|
+
/** Emoji offered by the reaction picker. */
|
|
109
|
+
reactions?: string[];
|
|
110
|
+
placeholder?: string;
|
|
111
|
+
/** Replies deeper than this attach to the deepest allowed parent. Defaults to 2. */
|
|
112
|
+
maxDepth?: number;
|
|
113
|
+
/** Label for comments written now. */
|
|
114
|
+
nowLabel?: string;
|
|
115
|
+
/** Words for localization. Leave out any key to keep its English default. */
|
|
116
|
+
labels?: CommentThreadLabels;
|
|
117
|
+
className?: string;
|
|
118
|
+
}
|
|
119
|
+
export declare const CommentThread: import("react").ForwardRefExoticComponent<CommentThreadProps & import("react").RefAttributes<HTMLElement>>;
|
|
120
|
+
export default CommentThread;
|