@teamofsilicons/silicon-ui 0.3.0 → 0.4.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/components/animated-counter/animated-counter.js +1 -1
- package/components/bar-chart/bar-chart.js +1 -1
- package/components/billing-toggle/billing-toggle.js +2 -2
- package/components/bottom-sheet/bottom-sheet.js +1 -1
- package/components/button-group/button-group.module.css +1 -1
- package/components/calendar/calendar.js +15 -5
- package/components/calendar/calendar.module.css +6 -3
- package/components/card/card.js +4 -4
- package/components/card-stack/card-stack.js +2 -2
- package/components/carousel/carousel.module.css +1 -1
- package/components/chip-group/chip-group.js +1 -1
- package/components/combobox/combobox.js +5 -1
- package/components/command-palette/command-palette.js +18 -11
- package/components/confirm-morph/confirm-morph.js +5 -5
- package/components/context-menu/context-menu.js +10 -28
- package/components/copy-button/copy-button.js +4 -4
- package/components/date-picker/date-picker.js +3 -3
- package/components/date-picker/date-picker.module.css +1 -1
- package/components/date-range-picker/date-range-picker.js +13 -10
- package/components/date-range-picker/date-range-picker.module.css +2 -1
- package/components/donut-chart/donut-chart.module.css +1 -1
- package/components/drawer/drawer.module.css +2 -2
- package/components/elastic-slider/elastic-slider.js +2 -2
- package/components/expandable-card/expandable-card.js +1 -1
- package/components/expanding-button-group/expanding-button-group.module.css +2 -2
- package/components/expanding-search/expanding-search.js +3 -3
- package/components/file-dropzone/file-dropzone.js +3 -3
- package/components/filter-toolbar/filter-toolbar.js +7 -3
- package/components/floating-button-group/floating-button-group.module.css +1 -1
- package/components/gauge/gauge.js +1 -1
- package/components/hover-card/hover-card.js +2 -2
- package/components/line-chart/line-chart.js +1 -1
- package/components/mention-input/mention-input.js +4 -2
- package/components/money-input/money-input.js +6 -3
- package/components/money-input/money-input.module.css +1 -1
- package/components/morph-select/morph-select.js +7 -5
- package/components/multi-select/multi-select.js +4 -2
- package/components/nav-list/nav-list.d.ts +2 -2
- package/components/nav-list/nav-list.js +6 -13
- package/components/number-field/number-field.js +3 -3
- package/components/number-field/number-field.module.css +1 -1
- package/components/pagination/pagination.module.css +1 -1
- package/components/password-field/password-field.module.css +1 -1
- package/components/password-strength/password-strength.module.css +1 -1
- package/components/phone-input/phone-input.js +9 -7
- package/components/radio-group/radio-group.js +4 -1
- package/components/radio-group/radio-group.module.css +3 -2
- package/components/rich-text-editor/rich-text-editor.js +3 -3
- package/components/segmented-control/segmented-control.js +4 -1
- package/components/segmented-control/segmented-control.module.css +3 -1
- package/components/shortcut-recorder/shortcut-recorder.js +5 -5
- package/components/shortcut-recorder/shortcut-recorder.module.css +1 -1
- package/components/slot-text/slot-text.js +2 -1
- package/components/sparkline/sparkline.js +1 -1
- package/components/split-button/split-button.module.css +1 -1
- package/components/swipe-actions/swipe-actions.js +2 -2
- package/components/switch/switch.js +2 -2
- package/components/tabs/tabs.js +7 -3
- package/components/tabs/tabs.module.css +3 -1
- package/components/text-reveal/text-reveal.js +1 -1
- package/components/time-picker/time-picker.js +4 -2
- package/components/timeline/timeline.js +4 -4
- package/components/toast/toast.js +1 -1
- package/components/toast-stack/toast-stack.js +1 -1
- package/components/tree-view/tree-view.js +16 -12
- package/components/tree-view/tree-view.module.css +4 -2
- package/components/usage-meter/usage-meter.js +1 -1
- package/components/user-menu/user-menu.js +16 -14
- package/lib/fluid-hover.d.ts +83 -0
- package/lib/fluid-hover.js +184 -0
- package/lib/menu-highlight.d.ts +24 -11
- package/lib/menu-highlight.js +29 -28
- package/lib/motion-tokens.d.ts +76 -18
- package/lib/motion-tokens.js +45 -10
- package/package.json +1 -1
- package/styles.css +6 -3
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
|
|
|
4
4
|
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, useTransform } from "motion/react";
|
|
5
5
|
import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
|
6
6
|
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import { FluidHoverHighlight, useFluidHover } from "../../lib/fluid-hover.js";
|
|
7
8
|
import { useToday } from "../../lib/use-today.js";
|
|
8
9
|
import styles from "./date-range-picker.module.css";
|
|
9
10
|
const DAY = 864e5;
|
|
@@ -50,21 +51,21 @@ const GROW = physical(.48, .12), SHRINK = physical(.38, 0), STRETCH = physical(.
|
|
|
50
51
|
const TRIGGER_RADIUS = 18, PANEL_RADIUS = 26, WIDE_CELL = 36, WIDE_MIN = 712, EDGE = 8;
|
|
51
52
|
const roll = {
|
|
52
53
|
enter: (direction) => ({ opacity: 0, y: `${direction * .45}em`, filter: `blur(${blur.subtle}px)` }),
|
|
53
|
-
center: { opacity: 1, y: "0em", filter: "blur(0px)", transition: { duration: .
|
|
54
|
-
exit: (direction) => ({ opacity: 0, y: `${direction * -.45}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: .
|
|
54
|
+
center: { opacity: 1, y: "0em", filter: "blur(0px)", transition: { duration: motionTokens.duration.exit, ease: enterEase } },
|
|
55
|
+
exit: (direction) => ({ opacity: 0, y: `${direction * -.45}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standardEase } }),
|
|
55
56
|
};
|
|
56
|
-
const fade = { enter: { opacity: 0 }, center: { opacity: 1, y: "0em", filter: "blur(0px)", transition: { duration: .
|
|
57
|
+
const fade = { enter: { opacity: 0 }, center: { opacity: 1, y: "0em", filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
|
|
57
58
|
/** Panel content arrives just behind the growing surface and leaves ahead of it. */
|
|
58
59
|
const faceIn = {
|
|
59
60
|
hidden: { opacity: 0, y: -4, filter: `blur(${blur.soft}px)` },
|
|
60
|
-
shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: .
|
|
61
|
-
gone: { opacity: 0, y: -2, filter: `blur(${blur.soft}px)`, transition: { duration: .
|
|
61
|
+
shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.exit, ease: enterEase, delay: motionTokens.duration.instant } },
|
|
62
|
+
gone: { opacity: 0, y: -2, filter: `blur(${blur.soft}px)`, transition: { duration: motionTokens.duration.instant, ease: standardEase } },
|
|
62
63
|
};
|
|
63
|
-
const faceFade = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: .
|
|
64
|
+
const faceFade = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
|
|
64
65
|
const slide = {
|
|
65
66
|
enter: (direction) => ({ opacity: 0, x: direction * 48, filter: `blur(${blur.soft}px)` }),
|
|
66
|
-
center: { opacity: 1, x: 0, filter: "blur(0px)", transition: { x: SLIDE, opacity: { duration: .
|
|
67
|
-
exit: (direction) => ({ opacity: 0, x: direction * -36, filter: `blur(${blur.soft}px)`, transition: { x: SLIDE, opacity: { duration: .
|
|
67
|
+
center: { opacity: 1, x: 0, filter: "blur(0px)", transition: { x: SLIDE, opacity: { duration: motionTokens.duration.exit, ease: enterEase }, filter: { duration: motionTokens.duration.exit, ease: enterEase } } },
|
|
68
|
+
exit: (direction) => ({ opacity: 0, x: direction * -36, filter: `blur(${blur.soft}px)`, transition: { x: SLIDE, opacity: { duration: motionTokens.duration.fast, ease: standardEase }, filter: { duration: motionTokens.duration.fast, ease: standardEase } } }),
|
|
68
69
|
};
|
|
69
70
|
/** Words roll one by one: a later date rises from below, an earlier one drops from above; words that stay the same hold still. */
|
|
70
71
|
function Rolling({ text, direction, reduced }) {
|
|
@@ -216,6 +217,8 @@ export function DateRangePicker({ value, defaultValue = null, onChange, label =
|
|
|
216
217
|
const triggerRef = useRef(null);
|
|
217
218
|
const panelRef = useRef(null);
|
|
218
219
|
const railRef = useRef(null);
|
|
220
|
+
// Fluid hover on the presets: the nearest one is lit, gaps and padding included, and a click there picks it. Focus has its own fill.
|
|
221
|
+
const presetHover = useFluidHover(railRef, { items: "[data-preset]", axis: "xy", followFocus: false });
|
|
219
222
|
const pendingFocus = useRef(null);
|
|
220
223
|
/* ---------- One surface: the trigger's box springs to the panel's box and back ---------- */
|
|
221
224
|
// Before the first measurement the surface simply fills the root and the root wraps the trigger, so server markup already looks right.
|
|
@@ -524,10 +527,10 @@ export function DateRangePicker({ value, defaultValue = null, onChange, label =
|
|
|
524
527
|
const layoutId = reduced ? undefined : `${uid}-value`;
|
|
525
528
|
const cellSize = compact && panelWidth ? Math.max(32, Math.min(42, Math.floor((panelWidth - 24) / 7))) : WIDE_CELL;
|
|
526
529
|
const quiet = open ? (reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${blur.subtle}px)` }) : { opacity: 1, filter: "blur(0px)" };
|
|
527
|
-
const quietTransition = open ? { duration: .
|
|
530
|
+
const quietTransition = open ? { duration: motionTokens.duration.instant, ease: standardEase } : { duration: motionTokens.duration.exit, ease: enterEase, delay: reduced ? 0 : .1 };
|
|
528
531
|
return _jsxs(motion.div, { ref: rootRef, className: [styles.root, className].filter(Boolean).join(" "), style: { width: rootWidth }, "data-open": open || undefined, onKeyDown: onRootKey, onBlur: onRootBlur, children: [_jsxs("button", { ref: triggerRef, type: "button", className: styles.trigger, "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": open ? `${uid}-panel` : undefined, "aria-label": `${label}: ${committedText}`, disabled: !today, inert: open || undefined, onClick: openPanel, children: [_jsx(motion.span, { className: styles.triggerIcon, initial: false, animate: quiet, transition: quietTransition, children: _jsx(CalendarDays, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), open || !layoutId
|
|
529
532
|
? _jsx(motion.span, { className: styles.value, "data-empty": !committed || undefined, initial: false, animate: open ? { opacity: 0 } : { opacity: 1 }, transition: quietTransition, children: _jsx(Rolling, { text: committedText, direction: committedDirection, reduced: reduced }) }, "resting")
|
|
530
|
-
: _jsx(motion.span, { layoutId: layoutId, layout: "position", className: styles.value, "data-empty": !committed || undefined, transition: SHRINK, children: _jsx(Rolling, { text: committedText, direction: committedDirection, reduced: reduced }) }, "shared"), _jsx(motion.span, { className: styles.chevron, initial: false, animate: quiet, transition: quietTransition, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), _jsx(motion.div, { className: styles.surface, style: { width, height, borderRadius: radius, x }, children: _jsx(AnimatePresence, { children: open && today && _jsxs(motion.div, { ref: panelRef, id: `${uid}-panel`, className: styles.panel, role: "dialog", "aria-label": label, "data-compact": compact || undefined, style: { "--cell": `${cellSize}px`, width: compact && panelWidth ? panelWidth : undefined }, exit: { opacity: 1, transition: { duration: .
|
|
533
|
+
: _jsx(motion.span, { layoutId: layoutId, layout: "position", className: styles.value, "data-empty": !committed || undefined, transition: SHRINK, children: _jsx(Rolling, { text: committedText, direction: committedDirection, reduced: reduced }) }, "shared"), _jsx(motion.span, { className: styles.chevron, initial: false, animate: quiet, transition: quietTransition, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), _jsx(motion.div, { className: styles.surface, style: { width, height, borderRadius: radius, x }, children: _jsx(AnimatePresence, { children: open && today && _jsxs(motion.div, { ref: panelRef, id: `${uid}-panel`, className: styles.panel, role: "dialog", "aria-label": label, "data-compact": compact || undefined, style: { "--cell": `${cellSize}px`, width: compact && panelWidth ? panelWidth : undefined }, exit: { opacity: 1, transition: { duration: motionTokens.duration.fast } }, children: [_jsxs(motion.div, { className: styles.body, variants: reduced ? faceFade : faceIn, initial: "hidden", animate: "shown", exit: "gone", children: [_jsxs("div", { ref: railRef, className: styles.rail, role: "group", "aria-label": "Presets", onKeyDown: onRailKey, ...presetHover.handlers, children: [_jsx(FluidHoverHighlight, { hover: presetHover, className: styles.railHover }), _jsx(motion.span, { className: styles.railHighlight, style: { x: gx, y: gy, width: gw, height: gh, opacity: go }, "aria-hidden": "true" }), presets.map((preset, index) => _jsx("button", { type: "button", className: styles.preset, "data-preset": index, "aria-pressed": index === activePreset, onClick: () => choosePreset(preset), children: preset.label }, preset.label))] }), _jsxs("div", { className: styles.calendars, onPointerLeave: () => { if (anchor)
|
|
531
534
|
setHover(null); }, children: [_jsx("button", { type: "button", className: styles.navButton, "data-side": "prev", "aria-label": "Previous month", onClick: () => goTo(addMonths(view, -1)), disabled: Boolean(minDate && monthDiff(view, minDate) <= 0), children: _jsx(ChevronLeft, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("button", { type: "button", className: styles.navButton, "data-side": "next", "aria-label": "Next month", onClick: () => goTo(addMonths(view, 1)), disabled: Boolean(maxDate && monthDiff(addMonths(view, count - 1), maxDate) >= 0), children: _jsx(ChevronRight, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("div", { className: styles.viewport, children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", custom: direction, children: _jsx(Months, { direction: direction, reduced: reduced, children: visible.map(month => _jsx(Month, { month: month, range: shown, tabbable: tabbable, today: today, minDate: minDate, maxDate: maxDate, weekStartsOn: weekStartsOn, reduced: reduced, formatters: formatters, idBase: uid, onPick: pick, onHover: date => { if (anchor)
|
|
532
535
|
setHover(date); }, onKey: onDayKey, onFocusDay: date => setFocusKey(keyOf(date)) }, keyOf(month))) }, `${keyOf(view)}-${count}`) }) })] })] }), _jsxs("div", { className: styles.footer, children: [_jsxs("div", { className: styles.summary, children: [_jsx(motion.span, { layoutId: layoutId, layout: layoutId ? "position" : undefined, className: styles.summaryValue, transition: GROW, variants: layoutId ? undefined : faceFade, initial: layoutId ? undefined : "hidden", animate: layoutId ? undefined : "shown", exit: layoutId ? undefined : "gone", children: _jsx(Rolling, { text: shownText, direction: shownDirection, reduced: reduced }) }), _jsx(motion.span, { className: styles.summaryCount, variants: reduced ? faceFade : faceIn, initial: "hidden", animate: "shown", exit: "gone", children: countText && _jsx(Rolling, { text: anchor && sameDay(anchor, hover) ? "Pick an end date" : countText, direction: shownDirection, reduced: reduced }) })] }), _jsxs(motion.div, { className: styles.actions, variants: reduced ? faceFade : faceIn, initial: "hidden", animate: "shown", exit: "gone", children: [_jsx("button", { type: "button", className: styles.ghost, onClick: () => close(true), children: "Cancel" }), _jsx("button", { type: "button", className: styles.primary, onClick: apply, disabled: !shown, children: "Apply" })] })] }), _jsx("p", { className: styles.srOnly, "aria-live": "polite", children: status })] }, "panel") }) })] });
|
|
533
536
|
}
|
|
@@ -66,6 +66,8 @@
|
|
|
66
66
|
.body { display: flex; gap: var(--space-group); }
|
|
67
67
|
|
|
68
68
|
.rail { position: relative; display: flex; width: 9.25rem; flex: none; flex-direction: column; gap: var(--space-hair); padding-right: var(--space-group); border-right: 1px solid var(--border-subtle); }
|
|
69
|
+
/* The hover fill glides between presets, under the chosen preset's highlight. */
|
|
70
|
+
.railHover { border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--foreground) 4%, transparent); }
|
|
69
71
|
.railHighlight { position: absolute; top: 0; left: 0; border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--foreground) 6.5%, transparent); pointer-events: none; }
|
|
70
72
|
.preset {
|
|
71
73
|
position: relative;
|
|
@@ -198,7 +200,6 @@
|
|
|
198
200
|
.root:not([data-open]):has(> .trigger:hover:not(:disabled)) .surface { background: color-mix(in oklab, var(--foreground) 3%, var(--surface-raised)); }
|
|
199
201
|
.root:not([data-open]):has(> .trigger:hover:not(:disabled)) .surface::after { border-color: var(--border-strong); }
|
|
200
202
|
.preset:hover { color: var(--foreground); }
|
|
201
|
-
.preset:not([aria-pressed="true"]):hover { background: color-mix(in oklab, var(--foreground) 4%, transparent); }
|
|
202
203
|
.navButton:hover:not(:disabled) { background: color-mix(in oklab, var(--foreground) 6.5%, transparent); color: var(--foreground); }
|
|
203
204
|
.day:hover:not(:disabled):not([data-edge]) > span { background: color-mix(in oklab, var(--foreground) 7%, transparent); }
|
|
204
205
|
.ghost:hover { background: var(--surface-muted); color: var(--foreground); }
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
.svg { display: block; width: 100%; height: 100%; overflow: visible; cursor: default; }
|
|
11
11
|
.svg[data-hovering] { cursor: pointer; }
|
|
12
12
|
/* The track shows only before the first sweep and when there is nothing to draw, so the gaps between segments stay the page colour. */
|
|
13
|
-
.track { fill: none; stroke: var(--surface-muted); transition: opacity var(--duration-
|
|
13
|
+
.track { fill: none; stroke: var(--surface-muted); transition: opacity var(--duration-expressive, .48s) var(--ease-standard); opacity: 0; }
|
|
14
14
|
.track[data-idle] { opacity: 1; }
|
|
15
15
|
/* Shapes are written every frame from script; only colour and dimming ease here, on the compositor. */
|
|
16
16
|
.segment { fill: var(--slice); shape-rendering: geometricPrecision; transition: fill var(--duration-standard) var(--ease-standard), opacity var(--duration-standard) var(--ease-standard); }
|
|
@@ -89,9 +89,9 @@
|
|
|
89
89
|
|
|
90
90
|
@media (prefers-reduced-motion: reduce) {
|
|
91
91
|
.keyframes.overlay[data-state="open"] { animation: overlay-in var(--duration-instant) linear both; }
|
|
92
|
-
.keyframes.overlay[data-state="closed"] { animation: overlay-out
|
|
92
|
+
.keyframes.overlay[data-state="closed"] { animation: overlay-out var(--duration-fast) linear both; }
|
|
93
93
|
.keyframes.content[data-state="open"] { animation: drawer-fade-in var(--duration-instant) linear both; }
|
|
94
|
-
.keyframes.content[data-state="closed"] { animation: drawer-fade-out
|
|
94
|
+
.keyframes.content[data-state="closed"] { animation: drawer-fade-out var(--duration-fast) linear both; }
|
|
95
95
|
.close, .close:active { transition: none; }
|
|
96
96
|
.close:active { transform: none; }
|
|
97
97
|
}
|
|
@@ -109,7 +109,7 @@ export function ElasticSlider({ label, value, defaultValue, onValueChange, onVal
|
|
|
109
109
|
function pulse(mark, haptic) {
|
|
110
110
|
const node = tickRefs.current.get(mark);
|
|
111
111
|
if (node && !reduced)
|
|
112
|
-
animate(node, { scale: [1, 2.1, 1] }, { duration: .
|
|
112
|
+
animate(node, { scale: [1, 2.1, 1] }, { duration: motionTokens.duration.standard, ease: enterEase });
|
|
113
113
|
if (haptic && typeof navigator !== "undefined" && "vibrate" in navigator)
|
|
114
114
|
navigator.vibrate?.(6);
|
|
115
115
|
}
|
|
@@ -255,7 +255,7 @@ export function ElasticSlider({ label, value, defaultValue, onValueChange, onVal
|
|
|
255
255
|
return _jsxs("div", { className: [styles.root, className].filter(Boolean).join(" "), "data-disabled": disabled || undefined, "data-dragging": dragging || undefined, "data-tag": bubble || undefined, "data-marks": labelled.length > 0 || undefined, children: [_jsxs("div", { className: styles.header, children: [_jsx("span", { id: labelId, className: styles.label, children: label }), showValue && _jsx("span", { className: styles.readout, "aria-hidden": "true", children: text })] }), _jsxs("div", { className: styles.body, children: [startIcon && _jsx(motion.span, { className: styles.start, style: { x: startShift }, "aria-hidden": "true", children: startIcon }), _jsxs("div", { className: styles.control, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerEnd, onPointerCancel: onPointerEnd, onLostPointerCapture: onPointerEnd, onMouseDown: event => event.preventDefault(), children: [_jsxs("div", { ref: trackRef, className: styles.rail, children: [_jsx(motion.div, { className: styles.track, style: { scaleX, scaleY, originX }, children: _jsx(motion.div, { className: styles.fill, style: { clipPath } }) }), markList.filter(mark => mark.value > min && mark.value < max).map(mark => _jsx("span", { ref: node => { if (node)
|
|
256
256
|
tickRefs.current.set(mark.value, node);
|
|
257
257
|
else
|
|
258
|
-
tickRefs.current.delete(mark.value); }, className: styles.tick, "data-on": mark.value <= current || undefined, style: { left: `${pct(mark.value)}%` } }, mark.value))] }), _jsxs("div", { className: styles.thumbs, children: [_jsx(motion.div, { className: styles.layer, style: { x: bubbleX }, children: _jsx(motion.span, { className: styles.bubbleAnchor, style: { x: over, "--at": bubbleAt }, children: _jsx(AnimatePresence, { children: bubble && _jsx(motion.span, { className: styles.bubble, "aria-hidden": "true", style: { rotate }, initial: reduced ? { opacity: 0 } : bubbleIn, animate: bubbleRest, exit: reduced ? { opacity: 0, transition: { duration: duration.instant } } : bubbleOut, transition: reduced ? { duration: .
|
|
258
|
+
tickRefs.current.delete(mark.value); }, className: styles.tick, "data-on": mark.value <= current || undefined, style: { left: `${pct(mark.value)}%` } }, mark.value))] }), _jsxs("div", { className: styles.thumbs, children: [_jsx(motion.div, { className: styles.layer, style: { x: bubbleX }, children: _jsx(motion.span, { className: styles.bubbleAnchor, style: { x: over, "--at": bubbleAt }, children: _jsx(AnimatePresence, { children: bubble && _jsx(motion.span, { className: styles.bubble, "aria-hidden": "true", style: { rotate }, initial: reduced ? { opacity: 0 } : bubbleIn, animate: bubbleRest, exit: reduced ? { opacity: 0, transition: { duration: duration.instant } } : bubbleOut, transition: reduced ? { duration: motionTokens.duration.fast } : bubbleEnter, children: text }, "bubble") }) }) }), _jsx(motion.div, { className: styles.layer, style: { x: thumbX }, children: _jsx(motion.div, { className: styles.shift, style: { x: over }, children: _jsx(motion.div, { ref: thumbRef, role: "slider", "data-thumb": "", className: styles.thumb, tabIndex: disabled ? -1 : 0, "aria-labelledby": labelId, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": current, "aria-valuetext": text, "aria-orientation": "horizontal", "aria-disabled": disabled || undefined, initial: false, animate: { scale: dragging ? 1.28 : 1 }, transition: reduced ? { duration: 0 } : spring.snappy, onKeyDown: onKeyDown, onBlur: () => setKeyed(false) }) }) })] })] }), endIcon && _jsx(motion.span, { className: styles.end, style: { x: endShift }, "aria-hidden": "true", children: endIcon }), labelled.length > 0 && _jsx("div", { className: styles.marks, "aria-hidden": "true", children: labelled.map(mark => _jsx("span", { className: styles.markLabel, "data-on": mark.value === current || undefined, "data-edge": mark.value === min ? "start" : mark.value === max ? "end" : undefined, style: { left: `${pct(mark.value)}%` }, onClick: () => { if (disabled || mark.value === latest.current)
|
|
259
259
|
return; emit(mark.value); pulse(mark.value, false); onValueCommit?.(mark.value); }, children: mark.label }, mark.value)) })] }), name && _jsx("input", { type: "hidden", name: name, value: current, disabled: disabled })] });
|
|
260
260
|
}
|
|
261
261
|
export default ElasticSlider;
|
|
@@ -17,7 +17,7 @@ const settleTime = 450;
|
|
|
17
17
|
const boxTransition = (expanded, hold) => expanded || !hold ? morph : { ...morph, delay: closeHold };
|
|
18
18
|
const contentTransition = (expanded) => expanded
|
|
19
19
|
? { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.standard], delay: 0.12 }
|
|
20
|
-
: { duration:
|
|
20
|
+
: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] };
|
|
21
21
|
const still = { duration: 0 };
|
|
22
22
|
/** Changed words in the summary rise in; unchanged words hold still. */
|
|
23
23
|
const wordMotion = {
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
:global(:root[data-theme="dark"]) .item[data-tone="danger"][data-expanded] { background: color-mix(in oklab, var(--danger) 14%, var(--surface)); }
|
|
18
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
19
|
|
|
20
|
-
.icon { position: relative; display: grid; width: var(--ebg-icon); height: var(--ebg-icon); flex: none; place-items: center; transition: transform
|
|
21
|
-
.item:active:not([aria-disabled="true"]) .icon { transform: scale(.86); transition-duration:
|
|
20
|
+
.icon { position: relative; display: grid; width: var(--ebg-icon); height: var(--ebg-icon); flex: none; place-items: center; transition: transform var(--duration-standard) var(--ease-spring); }
|
|
21
|
+
.item:active:not([aria-disabled="true"]) .icon { transform: scale(.86); transition-duration: var(--duration-fast); transition-timing-function: var(--ease-standard); }
|
|
22
22
|
.glyph { display: grid; grid-area: 1 / 1; place-items: center; width: 100%; height: 100%; }
|
|
23
23
|
.glyph svg { width: 100%; height: 100%; stroke-width: 1.75; }
|
|
24
24
|
|
|
@@ -112,10 +112,10 @@ function Listbox({ id, label, groups, query, activeIndex, optionId, reduced, key
|
|
|
112
112
|
}, [activeId, layoutKey, reduced, travel, fade, keyboard]);
|
|
113
113
|
return _jsx("div", { ref: listRef, id: id, role: "listbox", "aria-label": label, className: styles.listbox, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: groups.map(group => {
|
|
114
114
|
const headingId = `${id}-${group.name || "results"}`.replace(/\s+/g, "-");
|
|
115
|
-
return _jsxs(motion.div, { role: "group", "aria-labelledby": group.name ? headingId : undefined, className: styles.group, layout: reduced ? false : "position", layoutDependency: layoutKey, initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: { duration: .
|
|
115
|
+
return _jsxs(motion.div, { role: "group", "aria-labelledby": group.name ? headingId : undefined, className: styles.group, layout: reduced ? false : "position", layoutDependency: layoutKey, initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } }, transition: { opacity: { duration: motionTokens.duration.fast, ease: enter }, layout: motionTokens.spring.smooth }, children: [group.name && _jsx("div", { id: headingId, className: styles.groupLabel, role: "presentation", children: group.name }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: group.items.map(item => {
|
|
116
116
|
const position = flat.indexOf(item);
|
|
117
117
|
const active = position === activeIndex;
|
|
118
|
-
return _jsxs(motion.div, { id: optionId(item), role: "option", "aria-selected": active, "data-active": active || undefined, className: styles.option, layout: reduced ? false : "position", layoutDependency: layoutKey, initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: { duration: .
|
|
118
|
+
return _jsxs(motion.div, { id: optionId(item), role: "option", "aria-selected": active, "data-active": active || undefined, className: styles.option, layout: reduced ? false : "position", layoutDependency: layoutKey, initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } }, transition: { opacity: { duration: motionTokens.duration.fast, ease: enter }, layout: motionTokens.spring.smooth }, onPointerMove: () => { if (!active) {
|
|
119
119
|
keyboard.current = false;
|
|
120
120
|
onHover(position);
|
|
121
121
|
} }, onMouseDown: event => event.preventDefault(), onClick: () => onChoose(item), children: [active && _jsx(motion.span, { className: styles.highlight, style: { y: travel, opacity: fade }, "aria-hidden": "true" }), item.icon && _jsx("span", { className: styles.optionIcon, "aria-hidden": "true", children: item.icon }), _jsxs("span", { className: styles.optionText, children: [_jsx("span", { className: styles.optionTitle, children: _jsx(Match, { text: item.title, query: query }) }), item.meta && _jsx("span", { className: styles.optionMeta, children: item.meta })] })] }, item.id);
|
|
@@ -235,7 +235,7 @@ export function ExpandingSearch({ label, items, suggestions = [], suggestionsLab
|
|
|
235
235
|
setFocused(false);
|
|
236
236
|
if (open.current && !inputRef.current?.value.trim())
|
|
237
237
|
collapse(false);
|
|
238
|
-
}, onKeyDown: handleKeyDown }), _jsx(AnimatePresence, { initial: false, children: expanded && query && _jsx(motion.button, { type: "button", tabIndex: -1, className: styles.clear, "aria-label": "Clear search", onMouseDown: event => event.preventDefault(), onClick: () => { setQuery(""); setActive(-1); inputRef.current?.focus({ preventScroll: true }); }, initial: clearHidden, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: { ...clearHidden, transition: { duration: reduced ? .1 : motionTokens.duration.instant, ease: standard } }, transition: reduced ? { duration: .
|
|
238
|
+
}, onKeyDown: handleKeyDown }), _jsx(AnimatePresence, { initial: false, children: expanded && query && _jsx(motion.button, { type: "button", tabIndex: -1, className: styles.clear, "aria-label": "Clear search", onMouseDown: event => event.preventDefault(), onClick: () => { setQuery(""); setActive(-1); inputRef.current?.focus({ preventScroll: true }); }, initial: clearHidden, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: { ...clearHidden, transition: { duration: reduced ? .1 : motionTokens.duration.instant, ease: standard } }, transition: reduced ? { duration: motionTokens.duration.fast } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast }, filter: { duration: motionTokens.duration.fast } }, children: _jsx(X, { width: 16, height: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }, "clear") })] }), _jsx(AnimatePresence, { children: panelOpen && _jsx(Panel, { width: width, reduced: reduced, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(motion.div, { className: styles.view, initial: reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant, ease: standard } }, transition: { duration: reduced ? .15 : motionTokens.duration.fast, ease: enter }, children: mode === "empty"
|
|
239
239
|
? _jsxs("div", { className: styles.empty, children: [_jsxs("p", { className: styles.emptyTitle, children: ["No results for \u201C", trimmed, "\u201D"] }), _jsx("p", { className: styles.emptyHint, children: emptyHint })] })
|
|
240
240
|
: _jsx(Listbox, { id: listboxId, label: mode === "suggestions" ? suggestionsLabel : `Results for ${trimmed}`, groups: groups, query: mode === "results" ? trimmed : "", activeIndex: activeIndex, optionId: optionId, reduced: reduced, keyboard: keyboard, onHover: setActive, onChoose: choose }) }, mode) }) }, "panel") }), _jsx("span", { className: styles.srOnly, role: "status", "aria-live": "polite", children: announcement })] });
|
|
241
241
|
}
|
|
@@ -12,7 +12,7 @@ const exitFast = { duration: motionTokens.duration.fast, ease: [...motionTokens.
|
|
|
12
12
|
const instant = { duration: 0 };
|
|
13
13
|
const fade = { duration: motionTokens.duration.instant };
|
|
14
14
|
/** Exits are shorter than entries: the same critically damped spring, settled sooner. */
|
|
15
|
-
const collapse = { ...motionTokens.spring.smooth, visualDuration: .
|
|
15
|
+
const collapse = { ...motionTokens.spring.smooth, visualDuration: motionTokens.spring.moderate.visualDuration };
|
|
16
16
|
const textIn = { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` };
|
|
17
17
|
const textOut = { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };
|
|
18
18
|
const shown = { opacity: 1, y: "0em", filter: "blur(0px)" };
|
|
@@ -21,7 +21,7 @@ const clamp = (value) => Math.min(Math.max(value, 0), 100);
|
|
|
21
21
|
const SHAKE = { x: [0, -7, 6, -4, 3, -1.5, 0] };
|
|
22
22
|
const shakeTransition = { duration: .42, ease: "easeOut" };
|
|
23
23
|
/** New rows drop out of the target into the list, with a touch of life as they land. */
|
|
24
|
-
const land = { type: "spring", visualDuration: .
|
|
24
|
+
const land = { type: "spring", visualDuration: motionTokens.spring.slow.visualDuration, bounce: .14 };
|
|
25
25
|
export function formatFileSize(bytes) {
|
|
26
26
|
if (bytes < 1024)
|
|
27
27
|
return `${bytes} B`;
|
|
@@ -76,7 +76,7 @@ function ErrorRow({ text }) {
|
|
|
76
76
|
}
|
|
77
77
|
/** The circle closes, then the tick draws through it. */
|
|
78
78
|
function DrawnCheck({ reduce }) {
|
|
79
|
-
return _jsxs("svg", { className: styles.stateIcon, width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.25, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx(motion.path, { d: "M12 2.5a9.5 9.5 0 1 1 0 19a9.5 9.5 0 1 1 0-19", initial: reduce ? false : { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration: motionTokens.duration.
|
|
79
|
+
return _jsxs("svg", { className: styles.stateIcon, width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.25, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx(motion.path, { d: "M12 2.5a9.5 9.5 0 1 1 0 19a9.5 9.5 0 1 1 0-19", initial: reduce ? false : { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration: motionTokens.duration.expressive * .7, ease: [...motionTokens.ease.enter] } }), _jsx(motion.path, { d: "m8.2 12.4 2.6 2.6 5-5.2", initial: reduce ? false : { pathLength: 0 }, animate: { pathLength: 1 }, transition: { ...enter, delay: .16 } })] });
|
|
80
80
|
}
|
|
81
81
|
function Thumb({ item }) {
|
|
82
82
|
const [broken, setBroken] = useState(false);
|
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from
|
|
|
4
4
|
import { Check, ChevronLeft, ChevronRight, Plus, X } from "lucide-react";
|
|
5
5
|
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, useTransform } from "motion/react";
|
|
6
6
|
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import { useNearestItem } from "../../lib/fluid-hover.js";
|
|
7
8
|
import styles from "./filter-toolbar.module.css";
|
|
8
9
|
const enter = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
|
|
9
10
|
const leave = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] };
|
|
@@ -128,6 +129,10 @@ function MenuList({ rows, labelledBy, onChoose, onBack, onLeave, radio }) {
|
|
|
128
129
|
const typed = useRef({ text: "", at: 0 });
|
|
129
130
|
const [current, setCurrent] = useState(() => Math.max(0, rows.findIndex(row => row.checked)));
|
|
130
131
|
const [highlight, setHighlight] = useState(null);
|
|
132
|
+
// Fluid hover: the mouse focuses the nearest row, gaps and padding included, so the highlight (which follows focus) lights
|
|
133
|
+
// it and the keyboard carries on from there. A click in a gap picks that row.
|
|
134
|
+
const nearest = useNearestItem({ items: "[data-row]", onPick: item => { pointer.current = true; if (document.activeElement !== item)
|
|
135
|
+
item.focus({ preventScroll: true }); } });
|
|
131
136
|
const items = () => Array.from(list.current?.querySelectorAll("[data-row]") ?? []);
|
|
132
137
|
const move = (item) => {
|
|
133
138
|
const node = list.current;
|
|
@@ -183,9 +188,8 @@ function MenuList({ rows, labelledBy, onChoose, onBack, onLeave, radio }) {
|
|
|
183
188
|
if (!list.current?.contains(event.relatedTarget))
|
|
184
189
|
setHighlight(previous => previous && { ...previous, glide: false, ring: false, shown: false });
|
|
185
190
|
}
|
|
186
|
-
return _jsxs("div", { ref: list, className: styles.list, role: "menu", "aria-labelledby": labelledBy, "data-current": present ? "" : undefined, onKeyDown: onKeyDown, onFocus: onFocus, onBlur: onBlur, onPointerLeave: () => { if (pointer.current && list.current?.contains(document.activeElement))
|
|
187
|
-
onLeave(); }, children: [_jsx(motion.span, { className: styles.highlight, "data-ring": highlight?.ring || undefined, "aria-hidden": "true", initial: false, animate: highlight ? { y: highlight.top, height: highlight.height, opacity: highlight.shown ? 1 : 0 } : { opacity: 0 }, transition: { default: highlight?.glide ? motionTokens.spring.snappy : still, opacity: { duration: reduced ? 0 : .08 } } }), rows.map((row, index) => _jsxs("button", { type: "button", className: styles.item, role: radio ? "menuitemradio" : "menuitem", "aria-checked": radio ? !!row.checked : undefined, tabIndex: index === current ? 0 : -1, "data-row": row.key, "data-text": row.label,
|
|
188
|
-
event.currentTarget.focus({ preventScroll: true }); }, onClick: (event) => onChoose(row, event.detail === 0), children: [row.icon ? _jsx("span", { className: styles.itemIcon, "aria-hidden": "true", children: row.icon }) : null, _jsx("span", { className: styles.itemLabel, children: row.label }), row.meta ? _jsx("span", { className: styles.itemMeta, children: row.meta }) : null] }, row.key))] });
|
|
191
|
+
return _jsxs("div", { ref: list, className: styles.list, role: "menu", "aria-labelledby": labelledBy, "data-current": present ? "" : undefined, onKeyDown: onKeyDown, onFocus: onFocus, onBlur: onBlur, ...nearest, onPointerLeave: () => { if (pointer.current && list.current?.contains(document.activeElement))
|
|
192
|
+
onLeave(); }, children: [_jsx(motion.span, { className: styles.highlight, "data-ring": highlight?.ring || undefined, "aria-hidden": "true", initial: false, animate: highlight ? { y: highlight.top, height: highlight.height, opacity: highlight.shown ? 1 : 0 } : { opacity: 0 }, transition: { default: highlight?.glide ? motionTokens.spring.snappy : still, opacity: { duration: reduced ? 0 : .08 } } }), rows.map((row, index) => _jsxs("button", { type: "button", className: styles.item, role: radio ? "menuitemradio" : "menuitem", "aria-checked": radio ? !!row.checked : undefined, tabIndex: index === current ? 0 : -1, "data-row": row.key, "data-text": row.label, onClick: (event) => onChoose(row, event.detail === 0), children: [row.icon ? _jsx("span", { className: styles.itemIcon, "aria-hidden": "true", children: row.icon }) : null, _jsx("span", { className: styles.itemLabel, children: row.label }), row.meta ? _jsx("span", { className: styles.itemMeta, children: row.meta }) : null] }, row.key))] });
|
|
189
193
|
}
|
|
190
194
|
/** The room the panel may use: the viewport, narrowed on each axis by any ancestor that clips that axis, so a panel
|
|
191
195
|
* inside a scrolling or clipped container flips and shifts within what is actually visible. */
|
|
@@ -70,5 +70,5 @@
|
|
|
70
70
|
|
|
71
71
|
@media (prefers-reduced-motion: reduce) {
|
|
72
72
|
.item, .highlight { transition: color var(--duration-instant) linear, background-color var(--duration-instant) linear, opacity var(--duration-instant) linear; }
|
|
73
|
-
.sideTip, .sideTip[data-state="closed"] { transform: none; transition: opacity
|
|
73
|
+
.sideTip, .sideTip[data-state="closed"] { transform: none; transition: opacity var(--duration-fast) linear; }
|
|
74
74
|
}
|
|
@@ -9,7 +9,7 @@ const rise = { hidden: (direction) => ({ opacity: 0, y: `${.3 * direction}em`, f
|
|
|
9
9
|
// Same keys as `rise` so the settled style is identical whichever branch renders on the server.
|
|
10
10
|
const fade = { hidden: { opacity: 0, y: 0, filter: "blur(0px)" }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } } };
|
|
11
11
|
/** The first fill is slower and never overshoots, like a ring closing when the view opens. */
|
|
12
|
-
const reveal = { ...motionTokens.spring.smooth, visualDuration: motionTokens.duration.
|
|
12
|
+
const reveal = { ...motionTokens.spring.smooth, visualDuration: motionTokens.duration.expressive * 1.6 };
|
|
13
13
|
/** New copy rises in while the old copy leaves, popped out of flow so the line never holds both. */
|
|
14
14
|
function Swap({ text, direction = 1 }) {
|
|
15
15
|
const reduceMotion = !!useReducedMotion();
|
|
@@ -59,8 +59,8 @@ const Surface = forwardRef(function Surface({ instant, reduced, className, style
|
|
|
59
59
|
const unsubscribe = progress.on("change", apply);
|
|
60
60
|
apply(progress.get());
|
|
61
61
|
const controls = isPresent
|
|
62
|
-
? animate(progress, 1, still ? { duration: .
|
|
63
|
-
: animate(progress, 0, { duration: .
|
|
62
|
+
? animate(progress, 1, still ? { duration: motionTokens.duration.fast, ease: enter } : motionTokens.spring.smooth)
|
|
63
|
+
: animate(progress, 0, { duration: motionTokens.duration.instant, ease: standard });
|
|
64
64
|
// The fade leads the grow on the way in and runs alongside it on the way out; it starts from the current opacity either way.
|
|
65
65
|
const fade = isPresent
|
|
66
66
|
? animate(opacity, 1, { duration: reduced ? .15 : still ? .09 : .16, ease: enter })
|
|
@@ -10,7 +10,7 @@ const physical = ({ visualDuration, bounce }, restDelta = .001) => { const root
|
|
|
10
10
|
const settle = physical(spring.smooth);
|
|
11
11
|
const glide = physical(spring.snappy, .0001);
|
|
12
12
|
const follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: .01 };
|
|
13
|
-
const draw = { duration: duration.
|
|
13
|
+
const draw = { duration: duration.expressive * 1.75, ease: [...ease.inOut] };
|
|
14
14
|
const fadeFast = { duration: duration.instant, ease: [...ease.standard] };
|
|
15
15
|
/** Room above the top gridline, and the gap between the crosshair and its tooltip. */
|
|
16
16
|
const TOP = 12, GAP = 14;
|
|
@@ -4,6 +4,7 @@ import { forwardRef, useCallback, useId, useImperativeHandle, useLayoutEffect, u
|
|
|
4
4
|
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
5
|
import { Hash } from "lucide-react";
|
|
6
6
|
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import { useNearestItem } from "../../lib/fluid-hover.js";
|
|
7
8
|
import styles from "./mention-input.module.css";
|
|
8
9
|
const EMPTY = { text: "", mentions: [] };
|
|
9
10
|
const SYMBOL = { person: "@", channel: "#" };
|
|
@@ -111,6 +112,8 @@ export const MentionInput = forwardRef(function MentionInput({ value: valueProp,
|
|
|
111
112
|
const [focused, setFocused] = useState(false);
|
|
112
113
|
const [dismissed, setDismissed] = useState(null);
|
|
113
114
|
const [active, setActive] = useState(0);
|
|
115
|
+
// Fluid hover: the mouse makes the nearest option active, gaps and padding included, and a click there picks it.
|
|
116
|
+
const nearest = useNearestItem({ items: '[role="option"]', onPick: item => setActive(Number(item.dataset.index)) });
|
|
114
117
|
const commit = useCallback((next) => {
|
|
115
118
|
if (valueProp === undefined)
|
|
116
119
|
setInner(next);
|
|
@@ -404,8 +407,7 @@ export const MentionInput = forwardRef(function MentionInput({ value: valueProp,
|
|
|
404
407
|
const optionId = (index) => `${uid}-option-${index}`;
|
|
405
408
|
const popIn = reduced ? { opacity: 0 } : { opacity: 0, scale: .96, y: anchor?.above ? 4 : -4 };
|
|
406
409
|
const countLabel = open ? (suggestions.length ? `${suggestions.length} ${trigger?.kind === "person" ? (suggestions.length === 1 ? "person" : "people") : (suggestions.length === 1 ? "channel" : "channels")}` : "No matches") : "";
|
|
407
|
-
return _jsxs("div", { ref: rootRef, className: [styles.root, className].filter(Boolean).join(" "), "data-disabled": disabled || undefined, children: [_jsxs(motion.div, { ref: fieldRef, className: styles.field, style: { height }, "data-focused": focused || undefined, children: [_jsxs("div", { ref: backdropRef, className: styles.backdrop, "aria-hidden": "true", children: [mirror, "\u200b"] }), _jsx("textarea", { ref: areaRef, id: id, name: name, className: styles.textarea, value: value.text, placeholder: placeholder, disabled: disabled, rows: minRows, spellCheck: true, role: "combobox", "aria-label": ariaLabel, "aria-describedby": describedBy, "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open && suggestions.length ? optionId(activeIndex) : undefined, onChange: onInput, onSelect: onSelect, onKeyDown: onKeyDown, onScroll: onScroll, onFocus: () => setFocused(true), onBlur: () => { setFocused(false); setCaret(null); } })] }), _jsx("span", { className: styles.srOnly, "aria-live": "polite", children: countLabel }), _jsx(AnimatePresence, { children: open && anchor && _jsx(motion.div, { className: styles.popover, "data-above": anchor.above || undefined, style: { left: anchor.x, top: anchor.y, height: panelHeight, transformOrigin: anchor.above ? "14px 100%" : "14px 0" }, initial: popIn, animate: { opacity: 1, scale: 1, y: 0, transition: reduced ? { duration: duration.fast } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } } }, exit: { ...popIn, transition: { duration: duration.exit * .7, ease: standard } }, onMouseDown: event => event.preventDefault(), children: _jsx("div", { ref: bodyRef, className: styles.popoverBody, children: suggestions.length ? _jsxs("ul", { ref: listRef, id: listId, role: "listbox", "aria-label": trigger?.kind === "person" ? "People" : "Channels", className: styles.list, children: [_jsx(motion.li, { "aria-hidden": "true", className: styles.highlight, style: { y: hy, height: hh } }), suggestions.map((suggestion, index) => _jsxs("li", { id: optionId(index), role: "option", "aria-selected": index === activeIndex, "data-index": index, className: styles.option,
|
|
408
|
-
setActive(index); }, onClick: () => choose(suggestion), children: [suggestion.kind === "person"
|
|
410
|
+
return _jsxs("div", { ref: rootRef, className: [styles.root, className].filter(Boolean).join(" "), "data-disabled": disabled || undefined, children: [_jsxs(motion.div, { ref: fieldRef, className: styles.field, style: { height }, "data-focused": focused || undefined, children: [_jsxs("div", { ref: backdropRef, className: styles.backdrop, "aria-hidden": "true", children: [mirror, "\u200b"] }), _jsx("textarea", { ref: areaRef, id: id, name: name, className: styles.textarea, value: value.text, placeholder: placeholder, disabled: disabled, rows: minRows, spellCheck: true, role: "combobox", "aria-label": ariaLabel, "aria-describedby": describedBy, "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open && suggestions.length ? optionId(activeIndex) : undefined, onChange: onInput, onSelect: onSelect, onKeyDown: onKeyDown, onScroll: onScroll, onFocus: () => setFocused(true), onBlur: () => { setFocused(false); setCaret(null); } })] }), _jsx("span", { className: styles.srOnly, "aria-live": "polite", children: countLabel }), _jsx(AnimatePresence, { children: open && anchor && _jsx(motion.div, { className: styles.popover, "data-above": anchor.above || undefined, style: { left: anchor.x, top: anchor.y, height: panelHeight, transformOrigin: anchor.above ? "14px 100%" : "14px 0" }, initial: popIn, animate: { opacity: 1, scale: 1, y: 0, transition: reduced ? { duration: duration.fast } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } } }, exit: { ...popIn, transition: { duration: duration.exit * .7, ease: standard } }, onMouseDown: event => event.preventDefault(), ...nearest, children: _jsx("div", { ref: bodyRef, className: styles.popoverBody, children: suggestions.length ? _jsxs("ul", { ref: listRef, id: listId, role: "listbox", "aria-label": trigger?.kind === "person" ? "People" : "Channels", className: styles.list, children: [_jsx(motion.li, { "aria-hidden": "true", className: styles.highlight, style: { y: hy, height: hh } }), suggestions.map((suggestion, index) => _jsxs("li", { id: optionId(index), role: "option", "aria-selected": index === activeIndex, "data-index": index, className: styles.option, onClick: () => choose(suggestion), children: [suggestion.kind === "person"
|
|
409
411
|
? _jsx("span", { className: styles.avatar, "aria-hidden": "true", children: suggestion.item.avatar
|
|
410
412
|
// eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic
|
|
411
413
|
? _jsx("img", { src: suggestion.item.avatar, alt: "", width: 28, height: 28, loading: "lazy" })
|
|
@@ -4,6 +4,7 @@ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayo
|
|
|
4
4
|
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
5
|
import { Check, ChevronDown } from "lucide-react";
|
|
6
6
|
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import { useNearestItem } from "../../lib/fluid-hover.js";
|
|
7
8
|
import styles from "./money-input.module.css";
|
|
8
9
|
const enterEase = [...motionTokens.ease.enter];
|
|
9
10
|
const standardEase = [...motionTokens.ease.standard];
|
|
@@ -374,6 +375,9 @@ export const MoneyInput = forwardRef(function MoneyInput({ label, hideLabel = fa
|
|
|
374
375
|
/* ---------------------------------------------- Currency menu ---------------------------------------------- */
|
|
375
376
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
376
377
|
const [activeCode, setActiveCode] = useState(code);
|
|
378
|
+
// Fluid hover: the mouse makes the nearest option active, gaps and padding included, and a click there picks it.
|
|
379
|
+
const nearest = useNearestItem({ items: '[role="option"]', onPick: item => { if (item.dataset.code)
|
|
380
|
+
setActiveCode(item.dataset.code); } });
|
|
377
381
|
const menuRef = useRef(null);
|
|
378
382
|
const listRef = useRef(null);
|
|
379
383
|
const menuButtonRef = useRef(null);
|
|
@@ -489,11 +493,10 @@ export const MoneyInput = forwardRef(function MoneyInput({ label, hideLabel = fa
|
|
|
489
493
|
setDraft(settle(draft, info.digits)); onBlur?.(event); } })] }), info.position === "suffix" && _jsx(SymbolSlot, { symbol: info.symbol, reduced: reduced })] }) }), canSwitch ? _jsxs("div", { ref: menuRef, className: styles.menuAnchor, children: [_jsxs("button", { ref: menuButtonRef, type: "button", className: styles.currencyButton, disabled: disabled, "aria-haspopup": "listbox", "aria-expanded": menuOpen, "aria-controls": `${uid}-currencies`, "aria-label": `Currency, ${info.name}`, "data-open": menuOpen || undefined, onClick: () => (menuOpen ? closeMenu(true) : openMenu()), onKeyDown: event => { if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
490
494
|
event.preventDefault();
|
|
491
495
|
openMenu();
|
|
492
|
-
} }, children: [_jsx("span", { className: styles.code, children: code }), _jsx(motion.span, { className: styles.chevron, "aria-hidden": "true", initial: false, animate: { rotate: menuOpen ? 180 : 0 }, transition: reduced ? { duration: 0 } : GLIDE, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75 }) })] }), _jsx(AnimatePresence, { children: menuOpen && _jsx(motion.div, { className: styles.menu, initial: reduced ? { opacity: 0 } : { opacity: 0, scale: .96, y: -4, filter: `blur(${blur.subtle}px)` }, animate: { opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: duration.instant } } : { opacity: 0, scale: .97, y: -2, filter: `blur(${blur.subtle}px)`, transition: { duration: duration.instant, ease: standardEase } }, transition: reduced ? { duration: duration.instant } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enterEase }, filter: { duration: duration.fast, ease: enterEase } }, children: _jsxs("div", { ref: listRef, id: `${uid}-currencies`, className: styles.list, role: "listbox", tabIndex: -1, "aria-label": "Currency", "aria-activedescendant": `${uid}-cur-${activeCode}`, onKeyDown: onMenuKeyDown, children: [_jsx(motion.span, { className: styles.highlight, style: { y: hy, height: hh, opacity: ho }, "aria-hidden": "true" }), options.map(option => _jsxs("div", { id: `${uid}-cur-${option.code}`, role: "option", "aria-selected": option.code === code, className: styles.option, "data-active": option.code === activeCode || undefined, ref: node => { if (node)
|
|
496
|
+
} }, children: [_jsx("span", { className: styles.code, children: code }), _jsx(motion.span, { className: styles.chevron, "aria-hidden": "true", initial: false, animate: { rotate: menuOpen ? 180 : 0 }, transition: reduced ? { duration: 0 } : GLIDE, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75 }) })] }), _jsx(AnimatePresence, { children: menuOpen && _jsx(motion.div, { className: styles.menu, initial: reduced ? { opacity: 0 } : { opacity: 0, scale: .96, y: -4, filter: `blur(${blur.subtle}px)` }, animate: { opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: duration.instant } } : { opacity: 0, scale: .97, y: -2, filter: `blur(${blur.subtle}px)`, transition: { duration: duration.instant, ease: standardEase } }, transition: reduced ? { duration: duration.instant } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enterEase }, filter: { duration: duration.fast, ease: enterEase } }, children: _jsxs("div", { ref: listRef, id: `${uid}-currencies`, className: styles.list, role: "listbox", tabIndex: -1, "aria-label": "Currency", "aria-activedescendant": `${uid}-cur-${activeCode}`, onKeyDown: onMenuKeyDown, onPointerDown: event => event.preventDefault(), ...nearest, children: [_jsx(motion.span, { className: styles.highlight, style: { y: hy, height: hh, opacity: ho }, "aria-hidden": "true" }), options.map(option => _jsxs("div", { id: `${uid}-cur-${option.code}`, role: "option", "aria-selected": option.code === code, className: styles.option, "data-active": option.code === activeCode || undefined, ref: node => { if (node)
|
|
493
497
|
optionRefs.current.set(option.code, node);
|
|
494
498
|
else
|
|
495
|
-
optionRefs.current.delete(option.code); },
|
|
496
|
-
setActiveCode(option.code); }, onPointerDown: event => event.preventDefault(), onClick: () => chooseCurrency(option), children: [_jsx("span", { className: styles.optionSymbol, "aria-hidden": "true", children: option.symbol }), _jsx("span", { className: styles.optionCode, children: option.code }), _jsx("span", { className: styles.optionName, children: option.name }), _jsx("span", { className: styles.check, "data-on": option.code === code || undefined, "aria-hidden": "true", children: _jsx(Check, { size: 16, strokeWidth: 1.75 }) })] }, option.code))] }) }, "menu") })] }) : _jsx("span", { className: styles.codeStatic, children: code })] }), (chips.length > 0 || range) && _jsxs("div", { className: styles.toolbar, children: [chips.length > 0 && _jsx("div", { className: styles.chips, role: "group", "aria-label": "Quick add", children: chips.map(amount => {
|
|
499
|
+
optionRefs.current.delete(option.code); }, "data-code": option.code, onPointerDown: event => event.preventDefault(), onClick: () => chooseCurrency(option), children: [_jsx("span", { className: styles.optionSymbol, "aria-hidden": "true", children: option.symbol }), _jsx("span", { className: styles.optionCode, children: option.code }), _jsx("span", { className: styles.optionName, children: option.name }), _jsx("span", { className: styles.check, "data-on": option.code === code || undefined, "aria-hidden": "true", children: _jsx(Check, { size: 16, strokeWidth: 1.75 }) })] }, option.code))] }) }, "menu") })] }) : _jsx("span", { className: styles.codeStatic, children: code })] }), (chips.length > 0 || range) && _jsxs("div", { className: styles.toolbar, children: [chips.length > 0 && _jsx("div", { className: styles.chips, role: "group", "aria-label": "Quick add", children: chips.map(amount => {
|
|
497
500
|
const add = amount * pow10(info.digits);
|
|
498
501
|
const atMax = max !== undefined && (minor ?? 0) >= max;
|
|
499
502
|
return _jsxs("button", { type: "button", className: styles.chip, disabled: disabled, "aria-disabled": atMax || undefined, "aria-controls": inputId, "aria-label": `Add ${info.whole.format(amount)}`, onClick: () => nudge(add, "chip"), children: ["+", info.whole.format(amount)] }, amount);
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
.chips { display: flex; flex-wrap: wrap; gap: var(--space-related); }
|
|
61
61
|
.chip { height: 30px; padding: 0 var(--space-group); border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface); color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-xs); font-weight: 500; font-variant-numeric: tabular-nums; -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
|
|
62
62
|
@media (hover: hover) and (pointer: fine) { .chip:hover:not(:disabled) { background: var(--surface-muted); color: var(--foreground); } }
|
|
63
|
-
.chip:active:not(:disabled) { transform: scale(.96); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast),
|
|
63
|
+
.chip:active:not(:disabled) { transform: scale(.96); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), var(--duration-fast); }
|
|
64
64
|
.chip[aria-disabled="true"] { opacity: .45; }
|
|
65
65
|
.chip:disabled { opacity: .45; cursor: not-allowed; }
|
|
66
66
|
.range { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; transition: color var(--duration-standard) var(--ease-standard); }
|
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
|
|
|
4
4
|
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
5
|
import { Check, ChevronDown, Search, X } from "lucide-react";
|
|
6
6
|
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import { useNearestItem } from "../../lib/fluid-hover.js";
|
|
7
8
|
import styles from "./morph-select.module.css";
|
|
8
9
|
const { blur } = motionTokens;
|
|
9
10
|
const enter = [...motionTokens.ease.enter];
|
|
@@ -49,9 +50,9 @@ function matches(option, needle) {
|
|
|
49
50
|
const valueVariants = {
|
|
50
51
|
enter: (change) => change.fly ? { opacity: 1, x: change.fly.x, y: change.fly.y, filter: "blur(0px)" } : { opacity: 0, x: 0, y: `${change.dir * .5}em`, filter: `blur(${blur.soft}px)` },
|
|
51
52
|
rest: { opacity: 1, x: 0, y: 0, filter: "blur(0px)" },
|
|
52
|
-
exit: (change) => ({ opacity: 0, x: 0, y: change.fly ? "-0.3em" : `${change.dir * -.45}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: .
|
|
53
|
+
exit: (change) => ({ opacity: 0, x: 0, y: change.fly ? "-0.3em" : `${change.dir * -.45}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } }),
|
|
53
54
|
};
|
|
54
|
-
const valueFade = { enter: { opacity: 0 }, rest: { opacity: 1, x: 0, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: .
|
|
55
|
+
const valueFade = { enter: { opacity: 0 }, rest: { opacity: 1, x: 0, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
|
|
55
56
|
/** The closed trigger's width, rounded up. offsetWidth rounds to the nearest pixel, and half a pixel short is enough to ellipsize the label. */
|
|
56
57
|
const triggerWidth = (node) => {
|
|
57
58
|
const exact = node.getBoundingClientRect().width, rounded = node.offsetWidth;
|
|
@@ -80,6 +81,8 @@ export function MorphSelect({ label, hideLabel = false, items, value, defaultVal
|
|
|
80
81
|
const [open, setOpen] = useState(false);
|
|
81
82
|
const [query, setQuery] = useState("");
|
|
82
83
|
const [active, setActive] = useState(null);
|
|
84
|
+
// Fluid hover: the mouse makes the nearest option active, group labels and padding included, and a click there picks it.
|
|
85
|
+
const nearest = useNearestItem({ items: '[role="option"]', onPick: item => setActive(item.dataset.value ?? null) });
|
|
83
86
|
const [change, setChange] = useState({ key: 0, value: null, dir: 1, fly: null });
|
|
84
87
|
const needle = query.trim().toLowerCase();
|
|
85
88
|
const visibleSections = useMemo(() => needle
|
|
@@ -377,15 +380,14 @@ export function MorphSelect({ label, hideLabel = false, items, value, defaultVal
|
|
|
377
380
|
close(true);
|
|
378
381
|
else
|
|
379
382
|
openList(); }, onKeyDown: event => onKey(event, false), onKeyUp: event => { if (event.key === " ")
|
|
380
|
-
window.setTimeout(() => { suppressClick.current = false; }); }, children: _jsx("span", { ref: slotRef, className: styles.slot, "data-hidden": showSearch || undefined, children: _jsx(AnimatePresence, { initial: false, custom: changeForLayer, children: _jsx(motion.span, { className: styles.layer, custom: changeForLayer, variants: reduced ? valueFade : valueVariants, initial: "enter", animate: "rest", exit: "exit", transition: reduced ? { duration: .
|
|
383
|
+
window.setTimeout(() => { suppressClick.current = false; }); }, children: _jsx("span", { ref: slotRef, className: styles.slot, "data-hidden": showSearch || undefined, children: _jsx(AnimatePresence, { initial: false, custom: changeForLayer, children: _jsx(motion.span, { className: styles.layer, custom: changeForLayer, variants: reduced ? valueFade : valueVariants, initial: "enter", animate: "rest", exit: "exit", transition: reduced ? { duration: motionTokens.duration.instant } : changeForLayer.fly ? { x: FLY, y: FLY, opacity: { duration: 0 } } : { y: GLIDE, opacity: { duration: motionTokens.duration.exit, ease: enter }, filter: { duration: motionTokens.duration.exit, ease: enter } }, children: renderValue(selectedOption) }, layerKey) }) }) }), canSearch && _jsxs(motion.div, { className: styles.searchRow, inert: !showSearch || undefined, initial: false, animate: showSearch ? { opacity: 1, filter: "blur(0px)" } : { opacity: 0, filter: reduced ? "blur(0px)" : `blur(${blur.subtle}px)` }, transition: showSearch ? { duration: motionTokens.duration.exit, ease: enter, delay: reduced ? 0 : motionTokens.stagger.word } : { duration: motionTokens.duration.instant, ease: standard }, children: [_jsx(Search, { className: styles.searchIcon, size: 16, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { ref: inputRef, className: styles.input, type: "text", role: "combobox", "aria-label": `Search ${label.toLowerCase()}`, "aria-expanded": open, "aria-controls": listId, "aria-autocomplete": "list", "aria-activedescendant": open && current !== null ? optionId(indexOf.get(current) ?? 0) : undefined, placeholder: searchPlaceholder ?? selectedOption?.label ?? "Search", value: query, autoComplete: "off", spellCheck: false, onChange: event => onQuery(event.target.value), onKeyDown: event => onKey(event, true) }), _jsx(AnimatePresence, { initial: false, children: query && _jsx(motion.button, { type: "button", className: styles.clear, "aria-label": "Clear search", onPointerDown: event => event.preventDefault(), onClick: () => { onQuery(""); inputRef.current?.focus(); }, initial: { opacity: 0, scale: .6 }, animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: .6, transition: { duration: motionTokens.duration.instant } }, transition: reduced ? { duration: 0 } : GLIDE, children: _jsx(X, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }) }, "clear") }), _jsx("button", { type: "button", className: styles.closeHit, "aria-label": `Close ${label.toLowerCase()}`, onClick: () => close(true) })] }), _jsx(motion.span, { className: styles.chevron, "aria-hidden": "true", initial: false, animate: { rotate: open ? 180 : 0 }, transition: reduced ? { duration: 0 } : GLIDE, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75 }) })] }), _jsx(motion.div, { ref: listFaceRef, className: styles.listFace, inert: !open || undefined, "aria-hidden": !open || undefined, initial: false, animate: open ? { opacity: 1, y: 0, filter: "blur(0px)" } : { opacity: 0, y: reduced ? 0 : -6, filter: reduced ? "blur(0px)" : `blur(${blur.subtle}px)` }, transition: open ? { y: GROW, opacity: { duration: motionTokens.duration.exit, ease: enter, delay: reduced ? 0 : motionTokens.stagger.word }, filter: { duration: motionTokens.duration.exit, ease: enter, delay: motionTokens.stagger.word } } : { duration: motionTokens.duration.instant, ease: standard }, children: _jsx("div", { ref: scrollRef, className: styles.scroll, children: _jsxs("div", { id: listId, className: styles.options, role: "listbox", "aria-labelledby": labelId, onPointerDown: event => event.preventDefault(), ...nearest, children: [_jsx(motion.span, { className: styles.highlight, style: { y: hy, height: hh, opacity: ho }, "aria-hidden": "true" }), visibleSections.map(section => {
|
|
381
384
|
const rows = section.options.map(option => {
|
|
382
385
|
const index = indexOf.get(option.value) ?? 0;
|
|
383
386
|
const isSelected = option.value === selected, isActive = option.value === current;
|
|
384
387
|
return _jsxs("div", { id: optionId(index), role: "option", "aria-selected": isSelected, "aria-disabled": option.disabled || undefined, className: styles.option, "data-active": isActive || undefined, ref: node => { if (node)
|
|
385
388
|
optionRefs.current.set(option.value, node);
|
|
386
389
|
else
|
|
387
|
-
optionRefs.current.delete(option.value); },
|
|
388
|
-
setActive(option.value); }, onPointerDown: event => event.preventDefault(), onClick: event => commit(option, event.currentTarget), children: [_jsxs("span", { className: styles.optionValue, "data-part": "value", children: [option.icon && _jsx("span", { className: styles.icon, "aria-hidden": "true", children: option.icon }), _jsx("span", { className: styles.valueText, children: option.label })] }), option.meta && _jsx("span", { className: styles.meta, children: option.meta }), _jsx("span", { className: styles.check, "data-on": isSelected || undefined, "aria-hidden": "true", children: _jsx(Check, { size: 16, strokeWidth: 1.75 }) })] }, option.value);
|
|
390
|
+
optionRefs.current.delete(option.value); }, "data-value": option.value, onPointerDown: event => event.preventDefault(), onClick: event => commit(option, event.currentTarget), children: [_jsxs("span", { className: styles.optionValue, "data-part": "value", children: [option.icon && _jsx("span", { className: styles.icon, "aria-hidden": "true", children: option.icon }), _jsx("span", { className: styles.valueText, children: option.label })] }), option.meta && _jsx("span", { className: styles.meta, children: option.meta }), _jsx("span", { className: styles.check, "data-on": isSelected || undefined, "aria-hidden": "true", children: _jsx(Check, { size: 16, strokeWidth: 1.75 }) })] }, option.value);
|
|
389
391
|
});
|
|
390
392
|
return section.label
|
|
391
393
|
? _jsxs("div", { role: "group", "aria-labelledby": `${uid}-${section.key}`, className: styles.group, children: [_jsx("div", { id: `${uid}-${section.key}`, className: styles.groupLabel, role: "presentation", children: section.label }), rows] }, section.key)
|
|
@@ -4,6 +4,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
|
4
4
|
import { ChevronDown, X } from "lucide-react";
|
|
5
5
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
6
6
|
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import { useNearestItem } from "../../lib/fluid-hover.js";
|
|
7
8
|
import styles from "./multi-select.module.css";
|
|
8
9
|
const enter = motionTokens.ease.enter;
|
|
9
10
|
const standard = motionTokens.ease.standard;
|
|
@@ -41,6 +42,8 @@ export function MultiSelect({ label, options, value, defaultValue = [], onValueC
|
|
|
41
42
|
const [internal, setInternal] = useState(defaultValue);
|
|
42
43
|
const [open, setOpen] = useState(false);
|
|
43
44
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
45
|
+
// Fluid hover: the mouse makes the nearest option active, gaps and padding included, and a click there toggles it.
|
|
46
|
+
const nearest = useNearestItem({ items: '[role="option"]', onPick: item => setActiveIndex(Number(item.dataset.index)) });
|
|
44
47
|
const selected = value ?? internal;
|
|
45
48
|
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
|
46
49
|
const labelFor = (item) => options.find((option) => option.value === item)?.label ?? item;
|
|
@@ -100,6 +103,5 @@ export function MultiSelect({ label, options, value, defaultValue = [], onValueC
|
|
|
100
103
|
}
|
|
101
104
|
};
|
|
102
105
|
const reduce = useReducedMotion();
|
|
103
|
-
return _jsxs("div", { ref: rootRef, className: [styles.field, className].filter(Boolean).join(" "), children: [_jsx("span", { id: labelId, className: styles.label, children: label }), _jsxs("div", { className: styles.control, children: [_jsxs("button", { type: "button", className: `${styles.trigger} ${selected.length ? styles.hasClear : ""}`, disabled: disabled, "aria-labelledby": `${labelId} ${valueId}`, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": `${id}-listbox`, onClick: () => setOpen((current) => !current), onKeyDown: onKeyDown, children: [_jsx("span", { id: valueId, className: styles.srOnly, children: selected.length ? selected.map(labelFor).join(", ") : placeholder }), _jsx("span", { className: styles.value, "aria-hidden": "true", children: _jsxs(AnimatePresence, { initial: false, children: [visible.map((item) => _jsx(motion.span, { className: styles.slot, variants: reduce ? slotFade : slot, initial: "hidden", animate: "shown", exit: "gone", children: _jsx(motion.span, { className: styles.chip, variants: reduce ? chipStill : chip, children: item.label }) }, `chip-${item.value}`)), remaining > 0 && _jsx(motion.span, { className: styles.slot, variants: reduce ? slotFade : slot, initial: "hidden", animate: "shown", exit: "gone", children: _jsxs(motion.span, { className: styles.more, variants: reduce ? chipStill : chip, children: ["+", _jsx("span", { className: styles.count, children: _jsx(AnimatePresence, { initial: false, custom: countDirection, children: _jsx(motion.span, { custom: countDirection, variants: reduce ? fade : roll, initial: "hidden", animate: "shown", exit: "gone", children: remaining }, remaining) }) })] }) }, "more"), !selected.length && _jsx(motion.span, { className: styles.placeholder, variants: fade, initial: "hidden", animate: "shown", exit: "gone", children: placeholder }, "placeholder")] }) }), _jsx(ChevronDown, { className: styles.chevron, size: 16, "aria-hidden": "true" })] }), _jsx(AnimatePresence, { initial: false, children: selected.length > 0 && !disabled && _jsx(motion.button, { type: "button", "aria-label": "Clear selections", className: styles.clear, onClick: clear, initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transition: reduce ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast } } }, exit: { opacity: 0, ...(reduce ? {} : { scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }), transition: { duration: motionTokens.duration.instant, ease: standard } }, whileTap: { scale: reduce ? 1 : 0.96, transition: { duration: motionTokens.duration.instant, ease: standard } }, children: _jsx(X, { size: 14, "aria-hidden": "true" }) }) }), _jsx(AnimatePresence, { initial: false, children: open && _jsx(motion.div, { id: `${id}-listbox`, className: styles.menu, role: "listbox", "aria-label": label, "aria-multiselectable": "true", initial: reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: .97 }, animate: { opacity: 1, y: 0, scale: 1, transition: reduce ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter } } }, exit: { opacity: 0, ...(reduce ? {} : { y: -4, scale: .98 }), transition: { duration: motionTokens.duration.instant, ease: standard } }, children: options.map((option, index) => _jsxs("button", { type: "button", role: "option", "aria-selected": selectedSet.has(option.value), "aria-disabled": option.disabled || undefined, className: styles.option, "data-active": activeIndex === index, disabled: option.disabled,
|
|
104
|
-
setActiveIndex(index); }, onClick: () => toggle(option), children: [_jsx("span", { children: option.label }), _jsx(AnimatePresence, { initial: false, children: selectedSet.has(option.value) && _jsx(CheckMark, { reduce: reduce }, "check") })] }, option.value)) }) })] }), description && _jsx("span", { className: styles.description, children: description })] });
|
|
106
|
+
return _jsxs("div", { ref: rootRef, className: [styles.field, className].filter(Boolean).join(" "), children: [_jsx("span", { id: labelId, className: styles.label, children: label }), _jsxs("div", { className: styles.control, children: [_jsxs("button", { type: "button", className: `${styles.trigger} ${selected.length ? styles.hasClear : ""}`, disabled: disabled, "aria-labelledby": `${labelId} ${valueId}`, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": `${id}-listbox`, onClick: () => setOpen((current) => !current), onKeyDown: onKeyDown, children: [_jsx("span", { id: valueId, className: styles.srOnly, children: selected.length ? selected.map(labelFor).join(", ") : placeholder }), _jsx("span", { className: styles.value, "aria-hidden": "true", children: _jsxs(AnimatePresence, { initial: false, children: [visible.map((item) => _jsx(motion.span, { className: styles.slot, variants: reduce ? slotFade : slot, initial: "hidden", animate: "shown", exit: "gone", children: _jsx(motion.span, { className: styles.chip, variants: reduce ? chipStill : chip, children: item.label }) }, `chip-${item.value}`)), remaining > 0 && _jsx(motion.span, { className: styles.slot, variants: reduce ? slotFade : slot, initial: "hidden", animate: "shown", exit: "gone", children: _jsxs(motion.span, { className: styles.more, variants: reduce ? chipStill : chip, children: ["+", _jsx("span", { className: styles.count, children: _jsx(AnimatePresence, { initial: false, custom: countDirection, children: _jsx(motion.span, { custom: countDirection, variants: reduce ? fade : roll, initial: "hidden", animate: "shown", exit: "gone", children: remaining }, remaining) }) })] }) }, "more"), !selected.length && _jsx(motion.span, { className: styles.placeholder, variants: fade, initial: "hidden", animate: "shown", exit: "gone", children: placeholder }, "placeholder")] }) }), _jsx(ChevronDown, { className: styles.chevron, size: 16, "aria-hidden": "true" })] }), _jsx(AnimatePresence, { initial: false, children: selected.length > 0 && !disabled && _jsx(motion.button, { type: "button", "aria-label": "Clear selections", className: styles.clear, onClick: clear, initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transition: reduce ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast } } }, exit: { opacity: 0, ...(reduce ? {} : { scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }), transition: { duration: motionTokens.duration.instant, ease: standard } }, whileTap: { scale: reduce ? 1 : 0.96, transition: { duration: motionTokens.duration.instant, ease: standard } }, children: _jsx(X, { size: 14, "aria-hidden": "true" }) }) }), _jsx(AnimatePresence, { initial: false, children: open && _jsx(motion.div, { id: `${id}-listbox`, className: styles.menu, role: "listbox", "aria-label": label, "aria-multiselectable": "true", ...nearest, initial: reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: .97 }, animate: { opacity: 1, y: 0, scale: 1, transition: reduce ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter } } }, exit: { opacity: 0, ...(reduce ? {} : { y: -4, scale: .98 }), transition: { duration: motionTokens.duration.instant, ease: standard } }, children: options.map((option, index) => _jsxs("button", { type: "button", role: "option", "aria-selected": selectedSet.has(option.value), "aria-disabled": option.disabled || undefined, className: styles.option, "data-active": activeIndex === index, disabled: option.disabled, "data-index": index, onClick: () => toggle(option), children: [_jsx("span", { children: option.label }), _jsx(AnimatePresence, { initial: false, children: selectedSet.has(option.value) && _jsx(CheckMark, { reduce: reduce }, "check") })] }, option.value)) }) })] }), description && _jsx("span", { className: styles.description, children: description })] });
|
|
105
107
|
}
|
|
@@ -19,8 +19,8 @@ export interface NavListProps {
|
|
|
19
19
|
onNavigate?: (choice: NavChoice) => void;
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
|
-
* A sidebar's list of destinations: a `<nav>` with a list of NavItem rows, optionally grouped by NavSection.
|
|
23
|
-
*
|
|
22
|
+
* A sidebar's list of destinations: a `<nav>` with a list of NavItem rows, optionally grouped by NavSection. The row nearest
|
|
23
|
+
* the pointer is lit (fluid hover), and the current page's marker glides when the page changes.
|
|
24
24
|
*/
|
|
25
25
|
export declare function NavList({ label, children, className, ref, onNavigate }: NavListProps): import("react").JSX.Element;
|
|
26
26
|
export interface NavSectionProps {
|