@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.
Files changed (76) hide show
  1. package/components/animated-counter/animated-counter.js +1 -1
  2. package/components/bar-chart/bar-chart.js +1 -1
  3. package/components/billing-toggle/billing-toggle.js +2 -2
  4. package/components/bottom-sheet/bottom-sheet.js +1 -1
  5. package/components/button-group/button-group.module.css +1 -1
  6. package/components/calendar/calendar.js +15 -5
  7. package/components/calendar/calendar.module.css +6 -3
  8. package/components/card/card.js +4 -4
  9. package/components/card-stack/card-stack.js +2 -2
  10. package/components/carousel/carousel.module.css +1 -1
  11. package/components/chip-group/chip-group.js +1 -1
  12. package/components/combobox/combobox.js +5 -1
  13. package/components/command-palette/command-palette.js +18 -11
  14. package/components/confirm-morph/confirm-morph.js +5 -5
  15. package/components/context-menu/context-menu.js +10 -28
  16. package/components/copy-button/copy-button.js +4 -4
  17. package/components/date-picker/date-picker.js +3 -3
  18. package/components/date-picker/date-picker.module.css +1 -1
  19. package/components/date-range-picker/date-range-picker.js +13 -10
  20. package/components/date-range-picker/date-range-picker.module.css +2 -1
  21. package/components/donut-chart/donut-chart.module.css +1 -1
  22. package/components/drawer/drawer.module.css +2 -2
  23. package/components/elastic-slider/elastic-slider.js +2 -2
  24. package/components/expandable-card/expandable-card.js +1 -1
  25. package/components/expanding-button-group/expanding-button-group.module.css +2 -2
  26. package/components/expanding-search/expanding-search.js +3 -3
  27. package/components/file-dropzone/file-dropzone.js +3 -3
  28. package/components/filter-toolbar/filter-toolbar.js +7 -3
  29. package/components/floating-button-group/floating-button-group.module.css +1 -1
  30. package/components/gauge/gauge.js +1 -1
  31. package/components/hover-card/hover-card.js +2 -2
  32. package/components/line-chart/line-chart.js +1 -1
  33. package/components/mention-input/mention-input.js +4 -2
  34. package/components/money-input/money-input.js +6 -3
  35. package/components/money-input/money-input.module.css +1 -1
  36. package/components/morph-select/morph-select.js +7 -5
  37. package/components/multi-select/multi-select.js +4 -2
  38. package/components/nav-list/nav-list.d.ts +2 -2
  39. package/components/nav-list/nav-list.js +6 -13
  40. package/components/number-field/number-field.js +3 -3
  41. package/components/number-field/number-field.module.css +1 -1
  42. package/components/pagination/pagination.module.css +1 -1
  43. package/components/password-field/password-field.module.css +1 -1
  44. package/components/password-strength/password-strength.module.css +1 -1
  45. package/components/phone-input/phone-input.js +9 -7
  46. package/components/radio-group/radio-group.js +4 -1
  47. package/components/radio-group/radio-group.module.css +3 -2
  48. package/components/rich-text-editor/rich-text-editor.js +3 -3
  49. package/components/segmented-control/segmented-control.js +4 -1
  50. package/components/segmented-control/segmented-control.module.css +3 -1
  51. package/components/shortcut-recorder/shortcut-recorder.js +5 -5
  52. package/components/shortcut-recorder/shortcut-recorder.module.css +1 -1
  53. package/components/slot-text/slot-text.js +2 -1
  54. package/components/sparkline/sparkline.js +1 -1
  55. package/components/split-button/split-button.module.css +1 -1
  56. package/components/swipe-actions/swipe-actions.js +2 -2
  57. package/components/switch/switch.js +2 -2
  58. package/components/tabs/tabs.js +7 -3
  59. package/components/tabs/tabs.module.css +3 -1
  60. package/components/text-reveal/text-reveal.js +1 -1
  61. package/components/time-picker/time-picker.js +4 -2
  62. package/components/timeline/timeline.js +4 -4
  63. package/components/toast/toast.js +1 -1
  64. package/components/toast-stack/toast-stack.js +1 -1
  65. package/components/tree-view/tree-view.js +16 -12
  66. package/components/tree-view/tree-view.module.css +4 -2
  67. package/components/usage-meter/usage-meter.js +1 -1
  68. package/components/user-menu/user-menu.js +16 -14
  69. package/lib/fluid-hover.d.ts +83 -0
  70. package/lib/fluid-hover.js +184 -0
  71. package/lib/menu-highlight.d.ts +24 -11
  72. package/lib/menu-highlight.js +29 -28
  73. package/lib/motion-tokens.d.ts +76 -18
  74. package/lib/motion-tokens.js +45 -10
  75. package/package.json +1 -1
  76. 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: .26, ease: enterEase } },
54
- exit: (direction) => ({ opacity: 0, y: `${direction * -.45}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: .14, ease: standardEase } }),
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: .12 } }, exit: { opacity: 0, transition: { duration: .08 } } };
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: .26, ease: enterEase, delay: .07 } },
61
- gone: { opacity: 0, y: -2, filter: `blur(${blur.soft}px)`, transition: { duration: .12, ease: standardEase } },
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: .12 } }, gone: { opacity: 0, transition: { duration: .08 } } };
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: .22, ease: enterEase }, filter: { duration: .24, ease: enterEase } } },
67
- exit: (direction) => ({ opacity: 0, x: direction * -36, filter: `blur(${blur.soft}px)`, transition: { x: SLIDE, opacity: { duration: .14, ease: standardEase }, filter: { duration: .14, ease: standardEase } } }),
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: .12, ease: standardEase } : { duration: .22, ease: enterEase, delay: reduced ? 0 : .1 };
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: .14 } }, 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, children: [_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)
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-considered, .48s) var(--ease-standard); opacity: 0; }
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 100ms linear both; }
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 100ms linear both; }
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: .34, ease: enterEase });
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: .15 } : 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)
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: 0.1, ease: [...motionTokens.ease.standard] };
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 320ms var(--ease-spring); }
21
- .item:active:not([aria-disabled="true"]) .icon { transform: scale(.86); transition-duration: 90ms; transition-timing-function: var(--ease-standard); }
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: .08 } }, 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 => {
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: .08 } }, transition: { opacity: { duration: motionTokens.duration.fast, ease: enter }, layout: motionTokens.spring.smooth }, onPointerMove: () => { if (!active) {
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: .15 } : { ...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: .1, ease: standard } }, transition: { duration: reduced ? .15 : motionTokens.duration.fast, ease: enter }, children: mode === "empty"
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: .3 };
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: .42, bounce: .14 };
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.considered * .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 } })] });
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, onPointerMove: event => { pointer.current = true; if (document.activeElement !== event.currentTarget)
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 90ms linear; }
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.considered * 1.6 };
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: .14, ease: enter } : motionTokens.spring.smooth)
63
- : animate(progress, 0, { duration: .12, ease: standard });
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.considered * 1.75, ease: [...ease.inOut] };
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, onPointerMove: () => { if (index !== activeIndex)
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); }, onPointerMove: event => { if (event.pointerType === "mouse" && option.code !== activeCode)
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), 100ms; }
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: .12, ease: standard } }),
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: .1 } } };
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: .12 } : changeForLayer.fly ? { x: FLY, y: FLY, opacity: { duration: 0 } } : { y: GLIDE, opacity: { duration: .2, ease: enter }, filter: { duration: .22, 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: .18, ease: enter, delay: reduced ? 0 : .04 } : { duration: .1, 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: .1 } }, 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: .2, ease: enter, delay: reduced ? 0 : .04 }, filter: { duration: .22, ease: enter, delay: .04 } } : { duration: .1, ease: standard }, children: _jsx("div", { ref: scrollRef, className: styles.scroll, children: _jsxs("div", { id: listId, className: styles.options, role: "listbox", "aria-labelledby": labelId, children: [_jsx(motion.span, { className: styles.highlight, style: { y: hy, height: hh, opacity: ho }, "aria-hidden": "true" }), visibleSections.map(section => {
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); }, onPointerMove: event => { if (event.pointerType === "mouse" && !option.disabled && option.value !== current)
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, onPointerMove: () => { if (activeIndex !== index)
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. One hover highlight
23
- * glides between rows for the pointer, and the current page's marker glides when the page changes.
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 {