@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 { AnimatePresence, motion, useReducedMotion } from "motion/react";
4
4
  import { ChevronDown, Clock3 } from "lucide-react";
5
5
  import { useEffect, useId, useLayoutEffect, 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 "./time-picker.module.css";
8
9
  const pad = (value) => String(value).padStart(2, "0");
9
10
  const toMinutes = (value) => { const [h = NaN, m = NaN] = value.split(":").map(Number); return Number.isFinite(h) && Number.isFinite(m) ? h * 60 + m : 0; };
@@ -27,6 +28,8 @@ export function TimePicker({ label, value, defaultValue = "09:00", onChange, des
27
28
  const [internal, setInternal] = useState(defaultValue);
28
29
  const [open, setOpen] = useState(false);
29
30
  const [activeIndex, setActiveIndex] = useState(-1);
31
+ // Fluid hover: the mouse makes the nearest time active, gaps and padding included, and a click there picks it.
32
+ const nearest = useNearestItem({ items: '[role="option"]', onPick: item => setActiveIndex(Number(item.dataset.index)) });
30
33
  const selected = value ?? internal;
31
34
  const reduce = useReducedMotion();
32
35
  const [previous, setPrevious] = useState(selected);
@@ -90,6 +93,5 @@ export function TimePicker({ label, value, defaultValue = "09:00", onChange, des
90
93
  setActiveIndex(index => (index + (event.key === "ArrowDown" ? 1 : -1) + options.length) % options.length);
91
94
  }
92
95
  };
93
- 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.anchor, children: [_jsxs("button", { type: "button", className: styles.trigger, disabled: disabled, "aria-labelledby": `${labelId} ${valueId}`, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": `${id}-listbox`, onClick: () => open ? setOpen(false) : openMenu(), onKeyDown: onKeyDown, children: [_jsx(Clock3, { size: 16, "aria-hidden": "true" }), _jsx("span", { id: valueId, className: styles.srOnly, children: selected ? display(selected) : placeholder }), _jsx("span", { className: styles.valueText, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, custom: direction, children: _jsx(motion.span, { className: selected ? styles.value : styles.placeholder, custom: direction, variants: reduce ? valueFade : valueRoll, initial: "enter", animate: "center", exit: "exit", children: selected ? display(selected) : placeholder }, selected || "placeholder") }) }), _jsx(ChevronDown, { className: styles.chevron, size: 16, "aria-hidden": "true" })] }), _jsx(AnimatePresence, { initial: false, children: open && _jsx(motion.div, { id: `${id}-listbox`, className: styles.menu, role: "listbox", "aria-label": `${label} options`, 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", { ref: node => { optionRefs.current[index] = node; }, id: `${id}-option-${index}`, type: "button", role: "option", "aria-selected": option === selected, "data-active": activeIndex === index || undefined, className: styles.option, onPointerMove: () => { if (activeIndex !== index)
94
- setActiveIndex(index); }, onClick: () => choose(option), children: [display(option), option === selected && _jsx("span", { className: styles.dot, "aria-hidden": "true" })] }, option)) }) })] }), description && _jsx("span", { className: styles.description, children: description })] });
96
+ 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.anchor, children: [_jsxs("button", { type: "button", className: styles.trigger, disabled: disabled, "aria-labelledby": `${labelId} ${valueId}`, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": `${id}-listbox`, onClick: () => open ? setOpen(false) : openMenu(), onKeyDown: onKeyDown, children: [_jsx(Clock3, { size: 16, "aria-hidden": "true" }), _jsx("span", { id: valueId, className: styles.srOnly, children: selected ? display(selected) : placeholder }), _jsx("span", { className: styles.valueText, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, custom: direction, children: _jsx(motion.span, { className: selected ? styles.value : styles.placeholder, custom: direction, variants: reduce ? valueFade : valueRoll, initial: "enter", animate: "center", exit: "exit", children: selected ? display(selected) : placeholder }, selected || "placeholder") }) }), _jsx(ChevronDown, { className: styles.chevron, size: 16, "aria-hidden": "true" })] }), _jsx(AnimatePresence, { initial: false, children: open && _jsx(motion.div, { id: `${id}-listbox`, className: styles.menu, role: "listbox", "aria-label": `${label} options`, ...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", { ref: node => { optionRefs.current[index] = node; }, id: `${id}-option-${index}`, type: "button", role: "option", "aria-selected": option === selected, "data-active": activeIndex === index || undefined, className: styles.option, "data-index": index, onClick: () => choose(option), children: [display(option), option === selected && _jsx("span", { className: styles.dot, "aria-hidden": "true" })] }, option)) }) })] }), description && _jsx("span", { className: styles.description, children: description })] });
95
97
  }
@@ -51,7 +51,7 @@ function relative(time, now) {
51
51
  /** A day's section. A new day opens from nothing and pushes the older days down; it clips only while it grows. */
52
52
  function DaySection({ id, fresh, reduced, children }) {
53
53
  const [entering, setEntering] = useState(fresh);
54
- return _jsx(motion.section, { className: styles.group, "aria-labelledby": id, "data-entering": entering || undefined, initial: fresh ? { height: 0, opacity: 0 } : false, animate: { height: "auto", opacity: 1 }, exit: { height: 0, opacity: 0, transition: { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.exit, ease: standard } }, transition: reduced ? { duration: .15 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter } }, onAnimationComplete: () => setEntering(false), children: children });
54
+ return _jsx(motion.section, { className: styles.group, "aria-labelledby": id, "data-entering": entering || undefined, initial: fresh ? { height: 0, opacity: 0 } : false, animate: { height: "auto", opacity: 1 }, exit: { height: 0, opacity: 0, transition: { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.exit, ease: standard } }, transition: reduced ? { duration: motionTokens.duration.fast } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter } }, onAnimationComplete: () => setEntering(false), children: children });
55
55
  }
56
56
  function TimelineRow({ row, last, expanded, onToggle, timeLabel, timeFull }) {
57
57
  const clock = useContext(RevealClock);
@@ -63,11 +63,11 @@ function TimelineRow({ row, last, expanded, onToggle, timeLabel, timeFull }) {
63
63
  const [entering, setEntering] = useState(fresh);
64
64
  const shown = revealDelay !== null;
65
65
  const delay = (revealDelay ?? 0) + (fresh ? .12 : 0);
66
- const pop = reduced ? { duration: 0, opacity: { duration: .15 } } : { ...motionTokens.spring.morph, visualDuration: .36, bounce: .32, delay, opacity: { duration: motionTokens.duration.fast, ease: enter, delay } };
67
- const draw = reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, visualDuration: .34, delay: delay + .1 };
66
+ const pop = reduced ? { duration: 0, opacity: { duration: motionTokens.duration.fast } } : { ...motionTokens.spring.morph, visualDuration: motionTokens.spring.slow.visualDuration, bounce: .32, delay, opacity: { duration: motionTokens.duration.fast, ease: enter, delay } };
67
+ const draw = reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, visualDuration: motionTokens.spring.moderate.visualDuration, delay: delay + .1 };
68
68
  const tone = row.avatar ? undefined : row.tone ?? "neutral";
69
69
  const Trigger = row.detail ? "button" : "div";
70
- return _jsxs(motion.li, { className: styles.item, "data-entering": entering || undefined, initial: fresh ? { height: 0 } : false, animate: { height: "auto" }, exit: { height: 0, opacity: 0, transition: reduced ? { duration: .1 } : { height: { ...motionTokens.spring.smooth, visualDuration: .3 }, opacity: { duration: .12 } } }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, onAnimationComplete: () => setEntering(false), onViewportEnter: () => setRevealDelay(current => current ?? clock.schedule()), viewport: { once: true, amount: .2 }, children: [_jsx(motion.span, { className: styles.marker, "data-tone": tone, "aria-hidden": "true", initial: { scale: .4, opacity: 0 }, animate: shown ? { scale: 1, opacity: 1 } : undefined, transition: pop, children: row.avatar ? _jsx("img", { src: row.avatar, alt: "", width: 28, height: 28, decoding: "async" }) : row.icon }), !last && _jsx(motion.span, { className: styles.segment, "aria-hidden": "true", initial: { scaleY: 0 }, animate: shown ? { scaleY: 1 } : undefined, transition: draw }), _jsxs(motion.div, { className: styles.content, initial: fresh ? (reduced ? { opacity: 0 } : { opacity: 0, y: -10, filter: `blur(${motionTokens.blur.soft}px)` }) : false, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, transition: reduced ? { duration: .15 } : { y: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter, delay: .05 }, filter: { duration: motionTokens.duration.standard, ease: enter, delay: .05 } }, children: [_jsxs(Trigger, { className: styles.trigger, ...(row.detail ? { type: "button", "data-timeline-trigger": "", "aria-expanded": expanded, "aria-controls": expanded ? detailId : undefined, onClick: onToggle } : {}), children: [_jsxs("span", { className: styles.text, children: [_jsxs("span", { className: styles.title, children: [row.actor && _jsx("span", { className: styles.actor, children: row.actor }), row.actor ? " " : "", row.title] }), row.meta && _jsx("span", { className: styles.meta, children: row.meta })] }), _jsxs("time", { className: styles.time, dateTime: new Date(row.time).toISOString(), title: timeFull, children: [_jsx(RiseText, { text: timeLabel, reduced: reduced }), _jsx("span", { className: styles.srOnly, children: timeFull })] }), row.detail && _jsx(motion.span, { className: styles.chevron, "aria-hidden": "true", initial: false, animate: { rotate: expanded ? 180 : 0 }, transition: reduced ? { duration: 0 } : motionTokens.spring.snappy, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75 }) })] }), _jsx(AnimatePresence, { initial: false, children: expanded && row.detail && _jsx(motion.div, { id: detailId, className: styles.detail, initial: { height: 0 }, animate: { height: "auto" }, exit: { height: 0, transition: reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, visualDuration: .28 } }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, children: _jsx(motion.div, { className: styles.detailInner, initial: reduced ? { opacity: 0 } : { opacity: 0, y: -4, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant, ease: standard } }, transition: reduced ? { duration: .15 } : { duration: motionTokens.duration.standard, ease: enter, delay: .06 }, children: row.detail }) }, "detail") })] })] });
70
+ return _jsxs(motion.li, { className: styles.item, "data-entering": entering || undefined, initial: fresh ? { height: 0 } : false, animate: { height: "auto" }, exit: { height: 0, opacity: 0, transition: reduced ? { duration: motionTokens.duration.instant } : { height: { ...motionTokens.spring.smooth, visualDuration: motionTokens.spring.moderate.visualDuration }, opacity: { duration: motionTokens.duration.instant } } }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, onAnimationComplete: () => setEntering(false), onViewportEnter: () => setRevealDelay(current => current ?? clock.schedule()), viewport: { once: true, amount: .2 }, children: [_jsx(motion.span, { className: styles.marker, "data-tone": tone, "aria-hidden": "true", initial: { scale: .4, opacity: 0 }, animate: shown ? { scale: 1, opacity: 1 } : undefined, transition: pop, children: row.avatar ? _jsx("img", { src: row.avatar, alt: "", width: 28, height: 28, decoding: "async" }) : row.icon }), !last && _jsx(motion.span, { className: styles.segment, "aria-hidden": "true", initial: { scaleY: 0 }, animate: shown ? { scaleY: 1 } : undefined, transition: draw }), _jsxs(motion.div, { className: styles.content, initial: fresh ? (reduced ? { opacity: 0 } : { opacity: 0, y: -10, filter: `blur(${motionTokens.blur.soft}px)` }) : false, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, transition: reduced ? { duration: motionTokens.duration.fast } : { y: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter, delay: motionTokens.duration.instant }, filter: { duration: motionTokens.duration.standard, ease: enter, delay: motionTokens.duration.instant } }, children: [_jsxs(Trigger, { className: styles.trigger, ...(row.detail ? { type: "button", "data-timeline-trigger": "", "aria-expanded": expanded, "aria-controls": expanded ? detailId : undefined, onClick: onToggle } : {}), children: [_jsxs("span", { className: styles.text, children: [_jsxs("span", { className: styles.title, children: [row.actor && _jsx("span", { className: styles.actor, children: row.actor }), row.actor ? " " : "", row.title] }), row.meta && _jsx("span", { className: styles.meta, children: row.meta })] }), _jsxs("time", { className: styles.time, dateTime: new Date(row.time).toISOString(), title: timeFull, children: [_jsx(RiseText, { text: timeLabel, reduced: reduced }), _jsx("span", { className: styles.srOnly, children: timeFull })] }), row.detail && _jsx(motion.span, { className: styles.chevron, "aria-hidden": "true", initial: false, animate: { rotate: expanded ? 180 : 0 }, transition: reduced ? { duration: 0 } : motionTokens.spring.snappy, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75 }) })] }), _jsx(AnimatePresence, { initial: false, children: expanded && row.detail && _jsx(motion.div, { id: detailId, className: styles.detail, initial: { height: 0 }, animate: { height: "auto" }, exit: { height: 0, transition: reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, visualDuration: motionTokens.spring.moderate.visualDuration } }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, children: _jsx(motion.div, { className: styles.detailInner, initial: reduced ? { opacity: 0 } : { opacity: 0, y: -4, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant, ease: standard } }, transition: reduced ? { duration: motionTokens.duration.fast } : { duration: motionTokens.duration.standard, ease: enter, delay: motionTokens.duration.instant }, children: row.detail }) }, "detail") })] })] });
71
71
  }
72
72
  /** An event's time in epoch milliseconds (an unreadable time sorts as the oldest). */
73
73
  function timeOf(at) {
@@ -108,7 +108,7 @@ export default function Toast({ title, description, open = true, onOpenChange, d
108
108
  // A thrown toast keeps its release velocity; a closed one sinks back toward the edge it came from.
109
109
  // The throw settles loosely: it is invisible once the fade ends, so no long unseen tail keeps it mounted.
110
110
  exit: (to) => to
111
- ? { x: to, opacity: 0, transition: { x: { type: "spring", visualDuration: .3, bounce: 0, velocity: releaseVelocity.current, restDelta: 2, restSpeed: 40 }, opacity: { duration: motionTokens.duration.exit } } }
111
+ ? { x: to, opacity: 0, transition: { x: { type: "spring", visualDuration: motionTokens.spring.moderate.visualDuration, bounce: 0, velocity: releaseVelocity.current, restDelta: 2, restSpeed: 40 }, opacity: { duration: motionTokens.duration.exit } } }
112
112
  : { opacity: 0, y: 8, scale: .97, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] } },
113
113
  };
114
114
  const swap = { initial: reduce ? { opacity: 0 } : textIn, animate: textShown, exit: reduce ? fadeOut : textOut, transition: reduce ? { duration: motionTokens.duration.instant } : enter };
@@ -282,7 +282,7 @@ function ToastItem({ toast, target, expanded, front, hidden, paused, reduce, sto
282
282
  if (!cancelled && offset > 0 && velocity > -200 && offset + project(velocity) > width * .4) {
283
283
  swiped.current = true;
284
284
  if (!reduce)
285
- animate(x, width + 40, { type: "spring", visualDuration: .32, bounce: 0, velocity: Math.max(velocity, 0) });
285
+ animate(x, width + 40, { type: "spring", visualDuration: motionTokens.spring.moderate.visualDuration, bounce: 0, velocity: Math.max(velocity, 0) });
286
286
  store.dismiss(id);
287
287
  return;
288
288
  }
@@ -4,6 +4,7 @@ import { useId, useRef, useState } from "react";
4
4
  import { ChevronRight, File, Folder, FolderOpen } from "lucide-react";
5
5
  import { AnimatePresence, motion, useIsPresent, useReducedMotion } from "motion/react";
6
6
  import { motionTokens } from "../../lib/motion-tokens.js";
7
+ import { FluidHoverHighlight, useFluidHover } from "../../lib/fluid-hover.js";
7
8
  import styles from "./tree-view.module.css";
8
9
  /** A row is 36px plus the 2px gap above it; both collapse together, so nothing snaps when a row leaves. */
9
10
  const rowHeight = 38;
@@ -53,6 +54,9 @@ export function TreeView({ nodes, defaultExpandedIds = [], expandedIds, onExpand
53
54
  /** The folder that opened last; its new children fade in one after another, then everything else stays still. */
54
55
  const [openedId, setOpenedId] = useState(null);
55
56
  const refs = useRef(new Map());
57
+ const tree = useRef(null);
58
+ // Fluid hover: the nearest row is lit, gaps and padding included, and keyboard focus moves the fill without travel.
59
+ const hover = useFluidHover(tree, { items: '[role="treeitem"]' });
56
60
  const expanded = expandedIds ? new Set(expandedIds) : internalExpanded;
57
61
  const visible = flatten(nodes, expanded);
58
62
  const focusableId = visible.some(({ node }) => node.id === focusedId) ? focusedId : (visible[0]?.node.id ?? null);
@@ -131,17 +135,17 @@ export function TreeView({ nodes, defaultExpandedIds = [], expandedIds, onExpand
131
135
  toggle(node);
132
136
  }
133
137
  }
134
- return _jsx("div", { className: styles.tree, role: "tree", "aria-label": ariaLabel, "aria-multiselectable": "false", children: _jsx(AnimatePresence, { initial: false, mode: "sync", children: visible.map((item, index) => {
135
- const hasChildren = Boolean(item.node.children?.length);
136
- const isExpanded = hasChildren && expanded.has(item.node.id);
137
- const isSelected = selectedId === item.node.id;
138
- // Only rows that mount use this delay: the space opens in one motion and the new children follow in order.
139
- const delay = openedIndex >= 0 && index > openedIndex ? Math.min(index - openedIndex - 1, 7) * motionTokens.stagger.item : 0;
140
- return _jsx(motion.div, { className: styles.row, layout: reduced ? false : "position", initial: reduced ? false : { height: 0, opacity: 0, x: -6, overflow: "hidden" }, animate: { height: rowHeight, opacity: 1, x: 0, pointerEvents: "auto", transitionEnd: { overflow: "visible" }, transition: reduced ? still : { height: motionTokens.spring.smooth, opacity: { ...fadeIn, delay }, x: { ...motionTokens.spring.smooth, delay } } }, exit: reduced ? { opacity: 0, transition: still } : { height: 0, opacity: 0, x: -4, overflow: "hidden", pointerEvents: "none", transition: { height: motionTokens.spring.smooth, opacity: fadeOut, x: fadeOut } }, transition: reduced ? still : { layout: motionTokens.spring.smooth }, children: _jsxs("button", { ref: element => { if (element)
141
- refs.current.set(item.node.id, element);
142
- else
143
- refs.current.delete(item.node.id); }, type: "button", className: styles.item, role: "treeitem", "aria-level": item.depth, "aria-posinset": item.position, "aria-setsize": item.setSize, "aria-expanded": hasChildren ? isExpanded : undefined, "aria-selected": isSelected, tabIndex: focusableId === item.node.id ? 0 : -1, style: { "--tree-depth": item.depth }, onFocus: () => setFocusedId(item.node.id), onKeyDown: event => onKeyDown(event, item, index), onClick: () => { setFocusedId(item.node.id); select(item.node); if (hasChildren)
144
- toggle(item.node); }, children: [isSelected && _jsx(Selection, { layoutId: selectionLayoutId, reduced: reduced }), item.depth > 1 && _jsx(motion.span, { "aria-hidden": "true", className: styles.branch, initial: reduced ? false : { opacity: 0, scaleY: 0 }, animate: { opacity: 1, scaleY: 1 }, transition: reduced ? still : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay } }), _jsx(motion.span, { className: styles.disclosure, "aria-hidden": "true", initial: false, animate: { rotate: isExpanded ? 90 : 0 }, transition: reduced ? still : motionTokens.spring.snappy, children: hasChildren ? _jsx(ChevronRight, { size: 14, strokeWidth: 1.9 }) : null }), _jsx("span", { className: [styles.icon, hasChildren ? styles.folder : fileTone(item.node.label, styles)].join(" "), "aria-hidden": "true", children: item.node.icon ?? (hasChildren ? _jsx(FolderIcon, { open: isExpanded, reduced: reduced }) : _jsx(File, { size: 16, strokeWidth: 1.7 })) }), _jsx("span", { className: styles.label, children: item.node.label })] }) }, item.node.id);
145
- }) }) });
138
+ return _jsxs("div", { ref: tree, className: styles.tree, role: "tree", "aria-label": ariaLabel, "aria-multiselectable": "false", ...hover.handlers, children: [_jsx(FluidHoverHighlight, { hover: hover, className: styles.hover }), _jsx(AnimatePresence, { initial: false, mode: "sync", children: visible.map((item, index) => {
139
+ const hasChildren = Boolean(item.node.children?.length);
140
+ const isExpanded = hasChildren && expanded.has(item.node.id);
141
+ const isSelected = selectedId === item.node.id;
142
+ // Only rows that mount use this delay: the space opens in one motion and the new children follow in order.
143
+ const delay = openedIndex >= 0 && index > openedIndex ? Math.min(index - openedIndex - 1, 7) * motionTokens.stagger.item : 0;
144
+ return _jsx(motion.div, { className: styles.row, layout: reduced ? false : "position", initial: reduced ? false : { height: 0, opacity: 0, x: -6, overflow: "hidden" }, animate: { height: rowHeight, opacity: 1, x: 0, pointerEvents: "auto", transitionEnd: { overflow: "visible" }, transition: reduced ? still : { height: motionTokens.spring.smooth, opacity: { ...fadeIn, delay }, x: { ...motionTokens.spring.smooth, delay } } }, exit: reduced ? { opacity: 0, transition: still } : { height: 0, opacity: 0, x: -4, overflow: "hidden", pointerEvents: "none", transition: { height: motionTokens.spring.smooth, opacity: fadeOut, x: fadeOut } }, transition: reduced ? still : { layout: motionTokens.spring.smooth }, children: _jsxs("button", { ref: element => { if (element)
145
+ refs.current.set(item.node.id, element);
146
+ else
147
+ refs.current.delete(item.node.id); }, type: "button", className: styles.item, role: "treeitem", "aria-level": item.depth, "aria-posinset": item.position, "aria-setsize": item.setSize, "aria-expanded": hasChildren ? isExpanded : undefined, "aria-selected": isSelected, tabIndex: focusableId === item.node.id ? 0 : -1, style: { "--tree-depth": item.depth }, onFocus: () => setFocusedId(item.node.id), onKeyDown: event => onKeyDown(event, item, index), onClick: () => { setFocusedId(item.node.id); select(item.node); if (hasChildren)
148
+ toggle(item.node); }, children: [isSelected && _jsx(Selection, { layoutId: selectionLayoutId, reduced: reduced }), item.depth > 1 && _jsx(motion.span, { "aria-hidden": "true", className: styles.branch, initial: reduced ? false : { opacity: 0, scaleY: 0 }, animate: { opacity: 1, scaleY: 1 }, transition: reduced ? still : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay } }), _jsx(motion.span, { className: styles.disclosure, "aria-hidden": "true", initial: false, animate: { rotate: isExpanded ? 90 : 0 }, transition: reduced ? still : motionTokens.spring.snappy, children: hasChildren ? _jsx(ChevronRight, { size: 14, strokeWidth: 1.9 }) : null }), _jsx("span", { className: [styles.icon, hasChildren ? styles.folder : fileTone(item.node.label, styles)].join(" "), "aria-hidden": "true", children: item.node.icon ?? (hasChildren ? _jsx(FolderIcon, { open: isExpanded, reduced: reduced }) : _jsx(File, { size: 16, strokeWidth: 1.7 })) }), _jsx("span", { className: styles.label, children: item.node.label })] }) }, item.node.id);
149
+ }) })] });
146
150
  }
147
151
  export default TreeView;
@@ -1,9 +1,11 @@
1
- .tree { display: block; width: 100%; min-width: 0; box-sizing: border-box; padding: var(--space-related) var(--space-related) var(--space-related); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); overflow: hidden; }
1
+ .tree { position: relative; display: block; width: 100%; min-width: 0; box-sizing: border-box; padding: var(--space-related) var(--space-related) var(--space-related); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); overflow: hidden; }
2
2
  /* Rows clip only while their height animates (Motion sets overflow), so the selection can glide between them unclipped. */
3
3
  .row { position: relative; display: flow-root; min-width: 0; height: 38px; }
4
4
  .item { position: relative; display: flex; width: 100%; min-width: 0; height: 36px; margin-top: var(--space-hair); align-items: center; gap: var(--space-related); padding: 0 var(--space-group); padding-left: calc(var(--space-group) + (var(--tree-depth) - 1) * var(--space-comfort)); border: 0; border-radius: var(--radius-detail); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); line-height: 1; text-align: left; cursor: pointer; outline: none; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
5
5
  /* Selection changes color only; a heavier weight would reflow the label. */
6
6
  .item[aria-selected="true"] { color: var(--foreground); }
7
+ /* One hover fill glides between rows, under the selection and the row content. */
8
+ .hover { border-radius: var(--radius-detail); background: var(--surface-muted); }
7
9
  .selection { position: absolute; inset: 0; z-index: 0; border-radius: var(--radius-detail); background: var(--surface-muted); }
8
10
  .branch { position: absolute; top: var(--space-tight); bottom: var(--space-tight); left: calc(var(--space-inset) + (var(--tree-depth) - 2) * var(--space-comfort)); z-index: 1; width: 1px; background: var(--border); transform-origin: top; pointer-events: none; }
9
11
  .disclosure, .icon, .label { position: relative; z-index: 1; }
@@ -18,5 +20,5 @@
18
20
  .json { color: color-mix(in oklab, var(--file-json) 78%, var(--foreground)); }
19
21
  /* Its own line height: the row sets 1, and the clipped box (needed for the ellipsis) would cut descenders like g. */
20
22
  .label { min-width: 0; overflow: hidden; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
21
- @media (hover: hover) and (pointer: fine) { .item:hover { background: var(--surface-muted); color: var(--foreground); } }
23
+ @media (hover: hover) and (pointer: fine) { .item:hover { color: var(--foreground); } }
22
24
  @media (prefers-reduced-motion: reduce) { .item, .icon { transition: none; } }
@@ -9,7 +9,7 @@ const { spring, duration, ease, blur } = motionTokens;
9
9
  /** Motion drops inherited velocity on time-defined springs, so values that retarget mid-flight run the same springs written as stiffness and damping. */
10
10
  const physical = ({ visualDuration, bounce }, restDelta = .001) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };
11
11
  const settle = physical(spring.smooth);
12
- const reveal = physical({ visualDuration: duration.considered + .1, bounce: 0 });
12
+ const reveal = physical({ visualDuration: duration.expressive + .1, bounce: 0 });
13
13
  const turn = physical(spring.smooth);
14
14
  const fit = physical(spring.morph, .01);
15
15
  /** A segment stops two pixels short of the next, so neighbours read apart without an outline. */
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
5
5
  import { AnimatePresence, motion, useReducedMotion } from "motion/react";
6
6
  import { ChevronDown, LoaderCircle, LogOut, Monitor, Moon, Sun, SunMoon } from "lucide-react";
7
7
  import { motionTokens } from "../../lib/motion-tokens.js";
8
+ import { useNearestItem } from "../../lib/fluid-hover.js";
8
9
  import styles from "./user-menu.module.css";
9
10
  /** Statuses in menu order. Each one has its own dot shape as well as color: solid, barred, or hollow. */
10
11
  export const userStatuses = [
@@ -27,16 +28,16 @@ const exitEase = [0.4, 0, 1, 1];
27
28
  /** The panel grows out of the trigger on a critically damped spring; rows follow a beat later. Closing is a short fade. */
28
29
  const panelMotion = {
29
30
  closed: { opacity: 0, scale: .94 },
30
- open: { opacity: 1, scale: 1, transition: { type: "spring", visualDuration: .3, bounce: 0, opacity: { duration: motionTokens.duration.fast, ease: enter }, delayChildren: .03, staggerChildren: .016 } },
31
+ open: { opacity: 1, scale: 1, transition: { type: "spring", visualDuration: motionTokens.spring.moderate.visualDuration, bounce: 0, opacity: { duration: motionTokens.duration.fast, ease: enter }, delayChildren: .03, staggerChildren: .016 } },
31
32
  exit: { opacity: 0, scale: .97, transition: { duration: motionTokens.duration.instant, ease: exitEase } },
32
33
  };
33
34
  const sheetMotion = {
34
35
  closed: { y: "100%" },
35
- open: { y: 0, transition: { ...motionTokens.spring.smooth, visualDuration: .36, delayChildren: .06, staggerChildren: .02 } },
36
- exit: { y: "100%", transition: { duration: .22, ease: exitEase } },
36
+ open: { y: 0, transition: { ...motionTokens.spring.smooth, visualDuration: motionTokens.spring.slow.visualDuration, delayChildren: .06, staggerChildren: .02 } },
37
+ exit: { y: "100%", transition: { duration: motionTokens.duration.exit, ease: exitEase } },
37
38
  };
38
39
  const stillMotion = { closed: { opacity: 0 }, open: { opacity: 1, transition: { duration: motionTokens.duration.instant } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
39
- const rowMotion = { closed: { opacity: 0, y: 3 }, open: { opacity: 1, y: 0, transition: { duration: .2, ease: enter } } };
40
+ const rowMotion = { closed: { opacity: 0, y: 3 }, open: { opacity: 1, y: 0, transition: { duration: motionTokens.duration.exit, ease: enter } } };
40
41
  /** A presence mark that morphs between statuses: the color crossfades while a hole opens for away and a bar slides in for busy. */
41
42
  export function PresenceDot({ status, className }) {
42
43
  return _jsx("span", { className: [styles.dot, className].filter(Boolean).join(" "), "data-status": status, "aria-hidden": "true" });
@@ -224,19 +225,20 @@ export function UserMenu({ user, status: statusProp, defaultStatus = "available"
224
225
  }
225
226
  setHighlight(current => ({ top: stop.offsetTop, height: stop.offsetHeight, tone: stop.dataset.tone, glide: current !== null }));
226
227
  }
227
- // Focus follows a mouse or pen, so the keyboard carries on from wherever the pointer left the highlight.
228
- function onItemPointerMove(event) {
229
- if (event.pointerType === "touch")
230
- return;
231
- if (document.activeElement !== event.currentTarget)
232
- event.currentTarget.focus({ preventScroll: true });
233
- }
228
+ // Fluid hover: the mouse focuses the nearest row, separators and padding included, so the highlight (which follows focus)
229
+ // lights it and the keyboard carries on from there. A click in a gap picks that row.
230
+ const nearest = useNearestItem({ items: '[data-stop="item"]', onPick: item => { if (document.activeElement !== item)
231
+ item.focus({ preventScroll: true }); } });
234
232
  // The inline switches are not rows, so the pointer over them releases the highlight instead of dragging it along.
235
233
  function onListPointerMove(event) {
236
- if (event.pointerType === "touch" || !highlight)
234
+ if (event.pointerType === "touch")
237
235
  return;
238
236
  const group = event.target instanceof HTMLElement ? event.target.closest('[data-stop="group"]') : null;
239
- if (!group || group.contains(document.activeElement))
237
+ if (!group) {
238
+ nearest.onPointerMove(event);
239
+ return;
240
+ }
241
+ if (!highlight || group.contains(document.activeElement))
240
242
  return;
241
243
  setHighlight(null);
242
244
  surfaceRef.current?.focus({ preventScroll: true });
@@ -308,7 +310,7 @@ export function UserMenu({ user, status: statusProp, defaultStatus = "available"
308
310
  const glide = highlight?.glide && !reduced ? motionTokens.spring.snappy : { duration: 0 };
309
311
  const row = reduced ? undefined : rowMotion;
310
312
  const menuProps = { id: menuId, role: "menu", "aria-labelledby": triggerId, tabIndex: -1, onKeyDown };
311
- const content = _jsxs(_Fragment, { children: [_jsxs(motion.div, { className: styles.header, variants: row, children: [_jsx(Face, { user: user, status: showStatus ? status : undefined, size: "md" }), _jsxs("div", { className: styles.identity, children: [_jsxs("div", { className: styles.nameRow, children: [_jsx("span", { className: styles.name, children: user.name }), user.plan && _jsx("span", { className: styles.plan, children: user.plan })] }), _jsx("span", { className: styles.email, title: user.email, children: user.email })] })] }), _jsxs("div", { ref: listRef, className: styles.list, onFocus: onListFocus, onPointerMove: onListPointerMove, onPointerLeave: onListPointerLeave, children: [_jsx(motion.span, { className: styles.highlight, "data-tone": highlight?.tone, "aria-hidden": "true", initial: false, animate: highlight ? { y: highlight.top, height: highlight.height, opacity: 1 } : { opacity: 0 }, transition: { default: glide, opacity: { duration: reduced ? 0 : motionTokens.duration.instant } } }), items.length > 0 && _jsxs(_Fragment, { children: [_jsx("div", { className: styles.separator, role: "separator" }), items.map(item => _jsxs(motion.button, { type: "button", role: "menuitem", tabIndex: -1, className: styles.item, "data-stop": "item", "data-label": item.label, variants: row, onPointerMove: onItemPointerMove, onClick: () => { close(true); item.onSelect?.(); }, children: [_jsx("span", { className: styles.icon, "aria-hidden": "true", children: item.icon }), _jsx("span", { className: styles.itemLabel, children: item.label }), item.keys && _jsx(Keys, { keys: item.keys })] }, item.label))] }), (showTheme || showStatus) && _jsxs(_Fragment, { children: [_jsx("div", { className: styles.separator, role: "separator" }), showStatus && _jsx(Segmented, { label: "Status", icon: _jsx(PresenceDot, { status: status }), value: status, onChange: changeStatus, options: userStatuses.map(option => ({ ...option, icon: _jsx(PresenceDot, { status: option.value }) })), variants: row }), showTheme && _jsx(Segmented, { label: "Theme", icon: _jsx(SunMoon, { size: 16, strokeWidth: 1.75 }), value: theme, onChange: changeTheme, options: themes, variants: row })] }), _jsx("div", { className: styles.separator, role: "separator" }), _jsxs(motion.button, { type: "button", role: "menuitem", tabIndex: -1, className: styles.item, "data-stop": "item", "data-tone": "danger", "data-label": "Sign out", variants: row, "aria-busy": signingOut || undefined, onPointerMove: onItemPointerMove, onClick: signOut, children: [_jsx("span", { className: styles.icon, "aria-hidden": "true", children: signingOut ? _jsx(LoaderCircle, { className: styles.spinner, size: 16, strokeWidth: 1.75 }) : _jsx(LogOut, { size: 16, strokeWidth: 1.75 }) }), _jsx("span", { className: styles.itemLabel, children: _jsx(Rise, { text: signingOut ? "Signing out" : "Sign out", reduced: reduced }) }), signOutKeys && _jsx(Keys, { keys: signOutKeys })] })] })] });
313
+ const content = _jsxs(_Fragment, { children: [_jsxs(motion.div, { className: styles.header, variants: row, children: [_jsx(Face, { user: user, status: showStatus ? status : undefined, size: "md" }), _jsxs("div", { className: styles.identity, children: [_jsxs("div", { className: styles.nameRow, children: [_jsx("span", { className: styles.name, children: user.name }), user.plan && _jsx("span", { className: styles.plan, children: user.plan })] }), _jsx("span", { className: styles.email, title: user.email, children: user.email })] })] }), _jsxs("div", { ref: listRef, className: styles.list, onFocus: onListFocus, onPointerMove: onListPointerMove, onClick: nearest.onClick, onPointerLeave: onListPointerLeave, children: [_jsx(motion.span, { className: styles.highlight, "data-tone": highlight?.tone, "aria-hidden": "true", initial: false, animate: highlight ? { y: highlight.top, height: highlight.height, opacity: 1 } : { opacity: 0 }, transition: { default: glide, opacity: { duration: reduced ? 0 : motionTokens.duration.instant } } }), items.length > 0 && _jsxs(_Fragment, { children: [_jsx("div", { className: styles.separator, role: "separator" }), items.map(item => _jsxs(motion.button, { type: "button", role: "menuitem", tabIndex: -1, className: styles.item, "data-stop": "item", "data-label": item.label, variants: row, onClick: () => { close(true); item.onSelect?.(); }, children: [_jsx("span", { className: styles.icon, "aria-hidden": "true", children: item.icon }), _jsx("span", { className: styles.itemLabel, children: item.label }), item.keys && _jsx(Keys, { keys: item.keys })] }, item.label))] }), (showTheme || showStatus) && _jsxs(_Fragment, { children: [_jsx("div", { className: styles.separator, role: "separator" }), showStatus && _jsx(Segmented, { label: "Status", icon: _jsx(PresenceDot, { status: status }), value: status, onChange: changeStatus, options: userStatuses.map(option => ({ ...option, icon: _jsx(PresenceDot, { status: option.value }) })), variants: row }), showTheme && _jsx(Segmented, { label: "Theme", icon: _jsx(SunMoon, { size: 16, strokeWidth: 1.75 }), value: theme, onChange: changeTheme, options: themes, variants: row })] }), _jsx("div", { className: styles.separator, role: "separator" }), _jsxs(motion.button, { type: "button", role: "menuitem", tabIndex: -1, className: styles.item, "data-stop": "item", "data-tone": "danger", "data-label": "Sign out", variants: row, "aria-busy": signingOut || undefined, onClick: signOut, children: [_jsx("span", { className: styles.icon, "aria-hidden": "true", children: signingOut ? _jsx(LoaderCircle, { className: styles.spinner, size: 16, strokeWidth: 1.75 }) : _jsx(LogOut, { size: 16, strokeWidth: 1.75 }) }), _jsx("span", { className: styles.itemLabel, children: _jsx(Rise, { text: signingOut ? "Signing out" : "Sign out", reduced: reduced }) }), signOutKeys && _jsx(Keys, { keys: signOutKeys })] })] })] });
312
314
  const panel = _jsx(AnimatePresence, { children: open && !sheet && _jsx(motion.div, { ref: surfaceRef, ...menuProps, className: styles.panel, "data-inline": inline || undefined, "data-align": align, variants: reduced ? stillMotion : panelMotion, initial: "closed", animate: "open", exit: "exit", children: content }, "panel") });
313
315
  const bottomSheet = _jsxs(AnimatePresence, { children: [open && sheet && _jsx(motion.div, { className: styles.scrim, "aria-hidden": "true", initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: reduced ? .1 : .2, ease: standard }, onClick: () => close(true) }, "scrim"), open && sheet && _jsxs(motion.div, { ref: surfaceRef, ...menuProps, className: `${styles.panel} ${styles.sheet}`, "aria-modal": "true", variants: reduced ? stillMotion : sheetMotion, initial: "closed", animate: "open", exit: "exit", drag: reduced ? false : "y", dragConstraints: { top: 0, bottom: 0 }, dragElastic: { top: .04, bottom: .9 }, dragMomentum: false, onDragEnd: onDragEnd, children: [_jsx("span", { className: styles.handle, "aria-hidden": "true" }), content] }, "sheet")] });
314
316
  return _jsxs("span", { ref: rootRef, className: styles.root, children: [_jsxs("button", { ref: setTriggerRef, id: triggerId, type: "button", className: [styles.trigger, className].filter(Boolean).join(" "), "data-state": open ? "open" : "closed", "data-name": showName || undefined, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? menuId : undefined, "aria-label": `Account menu, ${user.name}, ${user.name.trim().split(/\s+/).slice(0, 2).map(part => part[0]?.toUpperCase()).join("")}${showStatus ? `, ${statusLabel(status)}` : ""}`, onClick: onTriggerClick, onKeyDown: onTriggerKeyDown, children: [_jsx(Face, { user: user, status: showStatus ? status : undefined, size: "sm" }), showName && _jsxs(_Fragment, { children: [_jsx("span", { className: styles.triggerName, children: user.name }), _jsx(ChevronDown, { className: styles.chevron, size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] })] }), sheet ? createPortal(bottomSheet, document.body) : inline ? panel : hydrated ? createPortal(panel, document.body) : null] });
@@ -0,0 +1,83 @@
1
+ import type { FocusEvent, MouseEvent, PointerEvent, RefObject } from "react";
2
+ /** An item's box in the container's own layout space (unaffected by transforms or scrolling). */
3
+ export interface FluidRect {
4
+ top: number;
5
+ left: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export interface FluidHoverState {
10
+ /** Where the highlight sits, or null when nothing is lit. */
11
+ rect: FluidRect | null;
12
+ /** The lit item. */
13
+ item: HTMLElement | null;
14
+ /** False when the highlight should jump (keyboard, a resize) rather than travel. */
15
+ glide: boolean;
16
+ /** Steps on each new pointer entry, so the highlight fades in where it lands. */
17
+ session: number;
18
+ }
19
+ export interface FluidHoverOptions {
20
+ /** Selector for the items inside the container. */
21
+ items: string;
22
+ /** "y" for lists (default), "x" for a row of tabs, "xy" for grids. */
23
+ axis?: "x" | "y" | "xy";
24
+ /** Items the highlight skips (and a gap click never activates). Defaults to disabled or aria-disabled items. */
25
+ isItemDisabled?: (item: HTMLElement) => boolean;
26
+ /** "skip" (default) lights the nearest enabled item even over a disabled one; "clear" lights nothing over a disabled item (a calendar's unavailable days). */
27
+ disabled?: "skip" | "clear";
28
+ /** Route a click in a gap to the lit item. On by default; turn it off where empty space should stay inert. */
29
+ gapClick?: boolean;
30
+ /** Light the item that receives keyboard focus. On by default. */
31
+ followFocus?: boolean;
32
+ }
33
+ /** The item's box relative to the container, summing offsets through positioned ancestors in between. */
34
+ export declare function rectOf(item: HTMLElement, container: HTMLElement): FluidRect;
35
+ /**
36
+ * The item the pointer is inside, or else the one whose edge is nearest along the axis (both axes for a grid). Edges, not
37
+ * centres, so a gap next to a wide item still lights the item the pointer is closer to.
38
+ */
39
+ export declare function pickNearest(items: HTMLElement[], x: number, y: number, axis: "x" | "y" | "xy"): HTMLElement | null;
40
+ export declare function useFluidHover<T extends HTMLElement>(container: RefObject<T | null>, options: FluidHoverOptions): {
41
+ handlers: {
42
+ onMouseEnter: () => void;
43
+ onMouseMove: (event: MouseEvent<T>) => void;
44
+ onMouseLeave: () => void;
45
+ onKeyDownCapture: () => void;
46
+ onFocus: (event: FocusEvent<T>) => void;
47
+ onBlur: (event: FocusEvent<T>) => void;
48
+ onClick: (event: MouseEvent<T>) => void;
49
+ };
50
+ reset: () => void;
51
+ light: (item: HTMLElement | null, glide: boolean) => void;
52
+ /** Where the highlight sits, or null when nothing is lit. */
53
+ rect: FluidRect | null;
54
+ /** The lit item. */
55
+ item: HTMLElement | null;
56
+ /** False when the highlight should jump (keyboard, a resize) rather than travel. */
57
+ glide: boolean;
58
+ /** Steps on each new pointer entry, so the highlight fades in where it lands. */
59
+ session: number;
60
+ };
61
+ export interface NearestItemOptions extends Omit<FluidHoverOptions, "followFocus"> {
62
+ /** Called with the nearest enabled item as the mouse moves over the container. */
63
+ onPick: (item: HTMLElement) => void;
64
+ }
65
+ /**
66
+ * Fluid hover's picking without its highlight, for a list that already keeps its own active item and highlight (a listbox
67
+ * with aria-activedescendant, a roving list): the mouse picks the nearest enabled item, gaps and padding included, and a
68
+ * click in a gap goes to that item. Spread the result on the container.
69
+ */
70
+ export declare function useNearestItem<T extends HTMLElement>({ items: selector, axis, isItemDisabled, gapClick, onPick }: NearestItemOptions): {
71
+ onPointerMove: (event: PointerEvent<T>) => void;
72
+ onClick: (event: MouseEvent<T>) => void;
73
+ };
74
+ export interface FluidHoverHighlightProps {
75
+ /** The state from useFluidHover (or any { rect, glide, session }). */
76
+ hover: Pick<FluidHoverState, "rect" | "glide" | "session">;
77
+ /** Radius, fill, z-index. The container must be position: relative; the highlight is placed at its top-left corner. */
78
+ className?: string;
79
+ /** Extra attributes for styling, such as data-tone. */
80
+ tone?: string;
81
+ }
82
+ /** The one highlight a fluid hover set renders: it springs between items on transforms, and fades in and out. */
83
+ export declare function FluidHoverHighlight({ hover, className, tone }: FluidHoverHighlightProps): import("react").JSX.Element;
@@ -0,0 +1,184 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ /**
4
+ * Fluid hover: in a set of items (a menu, a list, tabs, a grid), the item nearest the pointer is lit, even while the
5
+ * pointer is in a gap, the padding or past the last item, and one highlight springs between items. A click in a gap goes
6
+ * to the lit item, so what is lit is what a click hits. A new pointer entry fades the highlight in where it lands instead
7
+ * of sliding it over from the last item. The keyboard moves it without travel, and reduced motion keeps the fade but
8
+ * drops the travel.
9
+ *
10
+ * const list = useRef<HTMLUListElement>(null);
11
+ * const hover = useFluidHover(list, { items: "[data-item]" });
12
+ * <ul ref={list} className={styles.list} {...hover.handlers}> // .list { position: relative }
13
+ * <FluidHoverHighlight hover={hover} className={styles.highlight} />
14
+ * …items…
15
+ * </ul>
16
+ *
17
+ * Adapted from Fluid Functionalism's use-fluid-hover and fluid-hover-highlight by Micka Touillaud (MIT),
18
+ * https://github.com/mickadesign/fluid-functionalism. Items are found by a selector instead of registering themselves,
19
+ * so a component adopts it without changing its items.
20
+ */
21
+ import { useEffect, useEffectEvent, useRef, useState } from "react";
22
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
23
+ import { motionTokens } from "./motion-tokens.js";
24
+ const defaultDisabled = (item) => item.matches(":disabled, [aria-disabled='true'], [data-disabled]");
25
+ /** Controls that sit between items keep their own clicks. */
26
+ const CONTROL = "input, textarea, select, button, a, summary, [contenteditable], [role='textbox'], [role='searchbox'], [role='button']";
27
+ /** The item's box relative to the container, summing offsets through positioned ancestors in between. */
28
+ export function rectOf(item, container) {
29
+ let top = item.offsetTop, left = item.offsetLeft;
30
+ let parent = item.offsetParent;
31
+ while (parent && parent !== container && container.contains(parent)) {
32
+ top += parent.offsetTop + parent.clientTop;
33
+ left += parent.offsetLeft + parent.clientLeft;
34
+ parent = parent.offsetParent;
35
+ }
36
+ return { top, left, width: item.offsetWidth, height: item.offsetHeight };
37
+ }
38
+ /**
39
+ * The item the pointer is inside, or else the one whose edge is nearest along the axis (both axes for a grid). Edges, not
40
+ * centres, so a gap next to a wide item still lights the item the pointer is closer to.
41
+ */
42
+ export function pickNearest(items, x, y, axis) {
43
+ let nearest = null, best = Infinity;
44
+ for (const item of items) {
45
+ const r = item.getBoundingClientRect();
46
+ if (!r.width && !r.height)
47
+ continue;
48
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom)
49
+ return item;
50
+ const dx = Math.max(r.left - x, 0, x - r.right), dy = Math.max(r.top - y, 0, y - r.bottom);
51
+ const distance = axis === "x" ? dx : axis === "y" ? dy : Math.hypot(dx, dy);
52
+ if (distance < best) {
53
+ best = distance;
54
+ nearest = item;
55
+ }
56
+ }
57
+ return nearest;
58
+ }
59
+ /** Hands a gap click to the item as a real mouse click (detail, modifiers and coordinates kept), so the item reads it as a pointer press, not a keyboard one. */
60
+ function forwardClick(item, event) {
61
+ const { detail, clientX, clientY, screenX, screenY, ctrlKey, shiftKey, altKey, metaKey, button, buttons } = event;
62
+ item.dispatchEvent(new globalThis.MouseEvent("click", { bubbles: true, cancelable: true, composed: true, view: window, detail, clientX, clientY, screenX, screenY, ctrlKey, shiftKey, altKey, metaKey, button, buttons }));
63
+ }
64
+ /** Whether an event happened inside the element whose handler hears it, not in a portalled child (a submenu, a tooltip). */
65
+ const isOwn = (event) => !(event.currentTarget instanceof Node) || !(event.target instanceof Node) || event.currentTarget.contains(event.target);
66
+ export function useFluidHover(container, options) {
67
+ const { items: selector, axis = "y", isItemDisabled = defaultDisabled, disabled = "skip", gapClick = true, followFocus = true } = options;
68
+ const [state, setState] = useState({ rect: null, item: null, glide: false, session: 0 });
69
+ const current = useRef(state);
70
+ const pointer = useRef(false);
71
+ const entering = useRef(false);
72
+ const frame = useRef(0);
73
+ function commit(next) {
74
+ const prev = current.current;
75
+ if (prev.item === next.item && prev.session === next.session && sameRect(prev.rect, next.rect))
76
+ return;
77
+ current.current = next;
78
+ setState(next);
79
+ }
80
+ function light(item, glide) {
81
+ const root = container.current;
82
+ if (!root || !item)
83
+ return commit({ ...current.current, rect: null, item: null, glide });
84
+ let session = current.current.session;
85
+ if (entering.current) {
86
+ entering.current = false;
87
+ session += 1;
88
+ }
89
+ commit({ rect: rectOf(item, root), item, glide: glide && session === current.current.session, session });
90
+ }
91
+ const enabledItems = () => {
92
+ const root = container.current;
93
+ return root ? [...root.querySelectorAll(selector)].filter(item => !isItemDisabled(item)) : [];
94
+ };
95
+ // The lit item can move under a still highlight (a resize, content above it changing): follow it without travel.
96
+ const follow = useEffectEvent(() => { const { item } = current.current; if (item?.isConnected)
97
+ light(item, false); });
98
+ useEffect(() => {
99
+ const root = container.current;
100
+ if (!root || typeof ResizeObserver === "undefined")
101
+ return;
102
+ const observer = new ResizeObserver(() => follow());
103
+ observer.observe(root);
104
+ return () => observer.disconnect();
105
+ }, [container]);
106
+ useEffect(() => () => cancelAnimationFrame(frame.current), []);
107
+ const handlers = {
108
+ onMouseEnter: () => { entering.current = true; },
109
+ onMouseMove: (event) => {
110
+ if (!isOwn(event))
111
+ return;
112
+ pointer.current = true;
113
+ const { clientX, clientY } = event;
114
+ cancelAnimationFrame(frame.current);
115
+ frame.current = requestAnimationFrame(() => {
116
+ const over = disabled === "clear" ? document.elementFromPoint(clientX, clientY)?.closest(selector) : null;
117
+ light(over && isItemDisabled(over) ? null : pickNearest(enabledItems(), clientX, clientY, axis), true);
118
+ });
119
+ },
120
+ onMouseLeave: () => { cancelAnimationFrame(frame.current); entering.current = false; light(null, false); },
121
+ onKeyDownCapture: () => { pointer.current = false; },
122
+ onFocus: (event) => {
123
+ if (!followFocus || pointer.current || !isOwn(event))
124
+ return;
125
+ const item = event.target instanceof HTMLElement ? event.target.closest(selector) : null;
126
+ if (item && container.current?.contains(item) && !isItemDisabled(item))
127
+ light(item, false);
128
+ },
129
+ onBlur: (event) => {
130
+ if (!pointer.current && !event.currentTarget.contains(event.relatedTarget))
131
+ light(null, false);
132
+ },
133
+ onClick: (event) => {
134
+ if (!gapClick || !isOwn(event))
135
+ return;
136
+ const target = event.target;
137
+ if (!target.isConnected || target.closest(selector) || target.closest(CONTROL))
138
+ return;
139
+ const { item } = current.current;
140
+ if (item?.isConnected && !isItemDisabled(item))
141
+ forwardClick(item, event);
142
+ },
143
+ };
144
+ /** Clears the highlight at once, such as when a menu opens again. */
145
+ const reset = () => { cancelAnimationFrame(frame.current); entering.current = true; light(null, false); };
146
+ return { ...state, handlers, reset, light };
147
+ }
148
+ /**
149
+ * Fluid hover's picking without its highlight, for a list that already keeps its own active item and highlight (a listbox
150
+ * with aria-activedescendant, a roving list): the mouse picks the nearest enabled item, gaps and padding included, and a
151
+ * click in a gap goes to that item. Spread the result on the container.
152
+ */
153
+ // A hook only so callbacks that touch refs can be passed during render; it keeps no state.
154
+ export function useNearestItem({ items: selector, axis = "y", isItemDisabled = defaultDisabled, gapClick = true, onPick }) {
155
+ const nearest = (event) => {
156
+ if (!isOwn(event))
157
+ return null;
158
+ const items = [...event.currentTarget.querySelectorAll(selector)].filter(item => !isItemDisabled(item));
159
+ return pickNearest(items, event.clientX, event.clientY, axis);
160
+ };
161
+ return {
162
+ onPointerMove: (event) => { if (event.pointerType !== "mouse")
163
+ return; const item = nearest(event); if (item)
164
+ onPick(item); },
165
+ onClick: (event) => {
166
+ if (!gapClick)
167
+ return;
168
+ const target = event.target;
169
+ if (!target.isConnected || target.closest(selector) || target.closest(CONTROL))
170
+ return;
171
+ const item = nearest(event);
172
+ if (item)
173
+ forwardClick(item, event);
174
+ },
175
+ };
176
+ }
177
+ const sameRect = (a, b) => a === b || (!!a && !!b && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height);
178
+ /** The one highlight a fluid hover set renders: it springs between items on transforms, and fades in and out. */
179
+ export function FluidHoverHighlight({ hover, className, tone }) {
180
+ const reduced = useReducedMotion() ?? false;
181
+ const { rect, glide, session } = hover;
182
+ const place = rect ? { x: rect.left, y: rect.top, width: rect.width, height: rect.height } : undefined;
183
+ return (_jsx(AnimatePresence, { initial: false, children: place ? (_jsx(motion.span, { className: className, "data-tone": tone, "aria-hidden": "true", style: { position: "absolute", top: 0, left: 0, pointerEvents: "none" }, initial: { opacity: 0, ...place }, animate: { opacity: 1, ...place }, exit: { opacity: 0, transition: motionTokens.spring.fast.exit }, transition: { default: glide && !reduced ? motionTokens.spring.fast : { duration: 0 }, opacity: { duration: motionTokens.duration.fast } } }, session)) : null }));
184
+ }
@@ -1,28 +1,41 @@
1
- import type { FocusEvent } from "react";
2
- type Highlight = {
1
+ import type { FocusEvent, MouseEvent } from "react";
2
+ import { type FluidRect } from "./fluid-hover.js";
3
+ /** A fluid hover highlight in a menu: where it sits, whether it travels, its pointer session, and its tone. */
4
+ type FluidHighlight = {
5
+ rect: FluidRect | null;
6
+ glide: boolean;
7
+ session: number;
8
+ danger: boolean;
9
+ };
10
+ /** A marker positioned by top and height only (a list's current-page marker). */
11
+ type BarHighlight = {
3
12
  top: number;
4
13
  height: number;
5
14
  danger: boolean;
6
15
  glide: boolean;
7
16
  };
8
17
  /**
9
- * One highlight that glides between the items of a Radix menu or listbox for the pointer, and jumps instantly for the keyboard.
10
- * Radix focuses the highlighted item, so the highlight follows focus inside the content. Spread `contentProps` on the content,
11
- * call `reset` when it opens, and render `<MenuHighlight state={highlight} className={styles.highlight} />` as its first child.
12
- * Items mark a destructive tone with `data-tone="danger"`.
18
+ * Fluid hover for a Radix menu or listbox (lib/fluid-hover): the item nearest the pointer is lit, gaps and padding
19
+ * included, a click in a gap picks the lit item, and the keyboard moves the highlight with Radix's focus, without travel.
20
+ * Spread `contentProps` on the content, call `reset` when it opens, and render `<MenuHighlight state={highlight} … />`
21
+ * as its first child. Items mark a destructive tone with `data-tone="danger"`.
13
22
  */
14
23
  export declare function useMenuHighlight(itemSelector?: string): {
15
- highlight: Highlight | null;
24
+ highlight: FluidHighlight;
16
25
  reset: () => void;
17
26
  contentProps: {
18
- onFocus: (event: FocusEvent<HTMLElement>) => void;
19
- onPointerMoveCapture: () => void;
27
+ onMouseEnter: (event: MouseEvent<HTMLElement>) => void;
28
+ onMouseMove: (event: MouseEvent<HTMLElement>) => void;
29
+ onMouseLeave: () => void;
20
30
  onKeyDownCapture: () => void;
31
+ onFocus: (event: FocusEvent<HTMLElement>) => void;
32
+ onBlur: (event: FocusEvent<HTMLElement, Element>) => void;
33
+ onClick: (event: MouseEvent<HTMLElement>) => void;
21
34
  };
22
35
  };
23
- /** The gliding highlight itself. Position it absolutely inside the content, under the items. */
36
+ /** The highlight itself: the fluid hover highlight, or a marker that moves by top and height (a current-page marker). */
24
37
  export declare function MenuHighlight({ state, className }: {
25
- state: Highlight | null;
38
+ state: FluidHighlight | BarHighlight | null;
26
39
  className?: string;
27
40
  }): import("react").JSX.Element;
28
41
  export {};