@teamofsilicons/silicon-ui 0.2.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 (163) hide show
  1. package/README.md +4 -3
  2. package/check.mjs +14 -5
  3. package/components/activity-heatmap/activity-heatmap.module.css +4 -4
  4. package/components/alert/alert.module.css +1 -1
  5. package/components/animated-counter/animated-counter.js +1 -1
  6. package/components/announcement-bar/announcement-bar.module.css +6 -6
  7. package/components/avatar/avatar.d.ts +2 -2
  8. package/components/avatar/avatar.js +1 -1
  9. package/components/avatar/avatar.module.css +5 -2
  10. package/components/avatar-group/avatar-group.module.css +4 -4
  11. package/components/badge/badge.module.css +4 -4
  12. package/components/bar-chart/bar-chart.js +1 -1
  13. package/components/bar-chart/bar-chart.module.css +4 -4
  14. package/components/billing-toggle/billing-toggle.js +2 -2
  15. package/components/billing-toggle/billing-toggle.module.css +10 -10
  16. package/components/bottom-sheet/bottom-sheet.js +1 -1
  17. package/components/bottom-sheet/bottom-sheet.module.css +4 -5
  18. package/components/breadcrumb/breadcrumb.d.ts +8 -2
  19. package/components/breadcrumb/breadcrumb.js +21 -9
  20. package/components/breadcrumb/breadcrumb.module.css +7 -3
  21. package/components/brush-chart/brush-chart.module.css +9 -9
  22. package/components/button/button.d.ts +7 -2
  23. package/components/button/button.js +3 -1
  24. package/components/button/button.module.css +10 -1
  25. package/components/button-group/button-group.module.css +7 -7
  26. package/components/calendar/calendar.js +15 -5
  27. package/components/calendar/calendar.module.css +13 -10
  28. package/components/card/card.js +4 -4
  29. package/components/card/card.module.css +2 -2
  30. package/components/card-stack/card-stack.js +2 -2
  31. package/components/card-stack/card-stack.module.css +2 -3
  32. package/components/carousel/carousel.module.css +3 -3
  33. package/components/chat-thread/chat-thread.module.css +41 -41
  34. package/components/checkbox/checkbox.module.css +1 -1
  35. package/components/chip-group/chip-group.js +1 -1
  36. package/components/chip-group/chip-group.module.css +2 -2
  37. package/components/code-block/code-block.module.css +7 -14
  38. package/components/color-picker/color-picker.module.css +20 -21
  39. package/components/combobox/combobox.js +5 -1
  40. package/components/combobox/combobox.module.css +2 -2
  41. package/components/command-palette/command-palette.js +18 -11
  42. package/components/command-palette/command-palette.module.css +18 -18
  43. package/components/comment-thread/comment-thread.module.css +35 -35
  44. package/components/confirm-morph/confirm-morph.js +5 -5
  45. package/components/confirm-morph/confirm-morph.module.css +9 -9
  46. package/components/context-menu/context-menu.js +10 -28
  47. package/components/context-menu/context-menu.module.css +3 -3
  48. package/components/copy-button/copy-button.js +4 -4
  49. package/components/countdown/countdown.module.css +4 -4
  50. package/components/date-picker/date-picker.js +3 -3
  51. package/components/date-picker/date-picker.module.css +7 -7
  52. package/components/date-range-picker/date-range-picker.js +13 -10
  53. package/components/date-range-picker/date-range-picker.module.css +23 -22
  54. package/components/dialog/dialog.module.css +2 -2
  55. package/components/donut-chart/donut-chart.module.css +5 -5
  56. package/components/drawer/drawer.module.css +7 -7
  57. package/components/dropdown-menu/dropdown-menu.d.ts +71 -1
  58. package/components/dropdown-menu/dropdown-menu.js +100 -6
  59. package/components/dropdown-menu/dropdown-menu.module.css +15 -5
  60. package/components/elastic-slider/elastic-slider.js +2 -2
  61. package/components/elastic-slider/elastic-slider.module.css +4 -4
  62. package/components/expandable-card/expandable-card.js +1 -1
  63. package/components/expandable-card/expandable-card.module.css +1 -1
  64. package/components/expanding-button-group/expanding-button-group.module.css +3 -3
  65. package/components/expanding-search/expanding-search.js +3 -3
  66. package/components/expanding-search/expanding-search.module.css +9 -9
  67. package/components/file-dropzone/file-dropzone.js +3 -3
  68. package/components/file-dropzone/file-dropzone.module.css +21 -21
  69. package/components/file-upload/file-upload.module.css +7 -7
  70. package/components/filter-toolbar/filter-toolbar.js +7 -3
  71. package/components/filter-toolbar/filter-toolbar.module.css +9 -9
  72. package/components/floating-button-group/floating-button-group.module.css +7 -7
  73. package/components/gauge/gauge.js +1 -1
  74. package/components/gauge/gauge.module.css +2 -2
  75. package/components/hover-card/hover-card.js +2 -2
  76. package/components/hover-card/hover-card.module.css +2 -2
  77. package/components/image-compare/image-compare.module.css +4 -4
  78. package/components/inline-edit/inline-edit.module.css +12 -12
  79. package/components/json-viewer/json-viewer.module.css +12 -12
  80. package/components/line-chart/line-chart.js +1 -1
  81. package/components/line-chart/line-chart.module.css +8 -8
  82. package/components/mention-input/mention-input.js +4 -2
  83. package/components/mention-input/mention-input.module.css +8 -8
  84. package/components/metric-card/metric-card.module.css +1 -1
  85. package/components/money-input/money-input.js +6 -3
  86. package/components/money-input/money-input.module.css +10 -10
  87. package/components/morph-select/morph-select.js +7 -5
  88. package/components/morph-select/morph-select.module.css +13 -13
  89. package/components/multi-select/multi-select.js +4 -2
  90. package/components/multi-select/multi-select.module.css +6 -6
  91. package/components/nav-list/nav-list.d.ts +64 -0
  92. package/components/nav-list/nav-list.js +96 -0
  93. package/components/nav-list/nav-list.module.css +25 -0
  94. package/components/notification-center/notification-center.module.css +27 -27
  95. package/components/number-field/number-field.js +3 -3
  96. package/components/number-field/number-field.module.css +3 -3
  97. package/components/otp-input/otp-input.module.css +4 -4
  98. package/components/pagination/pagination.module.css +1 -1
  99. package/components/password-field/password-field.module.css +2 -2
  100. package/components/password-strength/password-strength.module.css +8 -8
  101. package/components/phone-input/phone-input.js +9 -7
  102. package/components/phone-input/phone-input.module.css +19 -19
  103. package/components/popover/popover.module.css +2 -2
  104. package/components/progress/progress.module.css +1 -1
  105. package/components/radio-cards/radio-cards.module.css +8 -8
  106. package/components/radio-group/radio-group.js +4 -1
  107. package/components/radio-group/radio-group.module.css +4 -3
  108. package/components/resizable-panels/resizable-panels.module.css +5 -5
  109. package/components/rich-text-editor/rich-text-editor.js +3 -3
  110. package/components/rich-text-editor/rich-text-editor.module.css +12 -12
  111. package/components/ridgeline/ridgeline.module.css +7 -7
  112. package/components/scroll-area/scroll-area.module.css +8 -8
  113. package/components/segmented-control/segmented-control.d.ts +3 -1
  114. package/components/segmented-control/segmented-control.js +5 -2
  115. package/components/segmented-control/segmented-control.module.css +9 -4
  116. package/components/select/select.module.css +5 -5
  117. package/components/shortcut-recorder/shortcut-recorder.js +5 -5
  118. package/components/shortcut-recorder/shortcut-recorder.module.css +16 -16
  119. package/components/signature-pad/signature-pad.module.css +11 -11
  120. package/components/slider/slider.module.css +4 -4
  121. package/components/slope-chart/slope-chart.module.css +7 -7
  122. package/components/slot-text/slot-text.js +2 -1
  123. package/components/sortable-data-table/sortable-data-table.module.css +4 -4
  124. package/components/sparkline/sparkline.js +1 -1
  125. package/components/split-button/split-button.module.css +6 -6
  126. package/components/stepper/stepper.module.css +3 -3
  127. package/components/streamgraph/streamgraph.module.css +7 -7
  128. package/components/swipe-actions/swipe-actions.js +2 -2
  129. package/components/swipe-actions/swipe-actions.module.css +5 -5
  130. package/components/switch/switch.js +2 -2
  131. package/components/switch/switch.module.css +1 -1
  132. package/components/tabs/tabs.d.ts +9 -2
  133. package/components/tabs/tabs.js +11 -5
  134. package/components/tabs/tabs.module.css +10 -3
  135. package/components/tag-input/tag-input.module.css +4 -4
  136. package/components/text-reveal/text-reveal.js +1 -1
  137. package/components/time-picker/time-picker.js +4 -2
  138. package/components/time-picker/time-picker.module.css +3 -3
  139. package/components/timeline/timeline.js +4 -4
  140. package/components/timeline/timeline.module.css +3 -3
  141. package/components/toast/toast.js +1 -1
  142. package/components/toast/toast.module.css +5 -5
  143. package/components/toast-stack/toast-stack.js +1 -1
  144. package/components/toast-stack/toast-stack.module.css +5 -5
  145. package/components/tooltip/tooltip.module.css +1 -1
  146. package/components/tree-view/tree-view.js +16 -12
  147. package/components/tree-view/tree-view.module.css +10 -8
  148. package/components/treemap/treemap.module.css +13 -13
  149. package/components/usage-meter/usage-meter.js +1 -1
  150. package/components/usage-meter/usage-meter.module.css +5 -5
  151. package/components/user-menu/user-menu.js +16 -14
  152. package/components/user-menu/user-menu.module.css +22 -22
  153. package/components/waffle-chart/waffle-chart.module.css +7 -7
  154. package/components.json +416 -1
  155. package/eslint.js +35 -3
  156. package/lib/fluid-hover.d.ts +83 -0
  157. package/lib/fluid-hover.js +184 -0
  158. package/lib/menu-highlight.d.ts +24 -11
  159. package/lib/menu-highlight.js +29 -28
  160. package/lib/motion-tokens.d.ts +76 -18
  161. package/lib/motion-tokens.js +45 -10
  162. package/package.json +1 -1
  163. package/styles.css +50 -7
package/eslint.js CHANGED
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * Options:
9
9
  * files globs the rules apply to (default: every .tsx and .jsx file)
10
+ * allowExperimental components whose experimental parts or prop values this app may use (a deliberate, reviewable opt-in)
10
11
  * ignores globs they skip (for example a vendored exception)
11
12
  * elements override the element → component table below
12
13
  *
@@ -14,6 +15,17 @@
14
15
  * if your own config also sets either rule, put this one last and move your entries into `extraSyntax` / `extraImports`.
15
16
  */
16
17
 
18
+ import fs from "node:fs";
19
+
20
+ /** This release's catalog (components.json next to this file): which components, parts or prop values are experimental. */
21
+ function catalog() {
22
+ try {
23
+ return JSON.parse(fs.readFileSync(new URL("./components.json", import.meta.url), "utf8"));
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
17
29
  /** Raw elements Silicon UI already has a component for. */
18
30
  export const ELEMENTS = {
19
31
  button: "Use Button (actions) or ButtonLink (navigation) from @teamofsilicons/silicon-ui.",
@@ -34,7 +46,27 @@ const IMPORTS = [
34
46
  },
35
47
  ];
36
48
 
37
- export default function siliconUi({ files = ["**/*.{tsx,jsx}"], ignores = [], elements = ELEMENTS, extraSyntax = [], extraImports = [] } = {}) {
49
+ export default function siliconUi({ files = ["**/*.{tsx,jsx}"], ignores = [], elements = ELEMENTS, extraSyntax = [], extraImports = [], allowExperimental = [] } = {}) {
50
+ const entries = catalog().filter(entry => !allowExperimental.includes(entry.name));
51
+ const optIn = name => `To try it, add "${name}" to siliconUi({ allowExperimental }) in your ESLint config.`;
52
+ // A whole experimental component: any import of it.
53
+ const gated = entries.filter(entry => entry.status === "experimental").map(entry => ({
54
+ regex: `^@teamofsilicons/silicon-ui/${entry.name}$`,
55
+ message: `${entry.name} is experimental: it may still change and is not for production. ${optIn(entry.name)}`,
56
+ }));
57
+ // Experimental parts of a stable component: only those named imports.
58
+ const gatedParts = entries.filter(entry => entry.status !== "experimental" && entry.experimentalExports?.length).map(entry => ({
59
+ name: `@teamofsilicons/silicon-ui/${entry.name}`,
60
+ importNames: entry.experimentalExports,
61
+ message: `These parts of ${entry.name} are experimental: they may still change and are not for production. ${optIn(entry.name)}`,
62
+ }));
63
+ // Experimental values of a stable component's prop, such as <Button size="xs">. The value "*" gates a new prop
64
+ // whatever its value, such as <Breadcrumb maxItems={3}>.
65
+ const gatedProps = entries.flatMap(entry => Object.entries(entry.experimentalProps ?? {}).flatMap(([component, props]) =>
66
+ Object.entries(props).flatMap(([prop, values]) => values.map(value => ({
67
+ selector: `JSXOpeningElement[name.name="${component}"] > JSXAttribute[name.name="${prop}"]${value === "*" ? "" : `[value.value="${value}"]`}`,
68
+ message: `${component} ${value === "*" ? prop : `${prop}="${value}"`} is experimental: it may still change and is not for production. ${optIn(entry.name)}`,
69
+ })))));
38
70
  const syntax = Object.entries(elements).map(([name, message]) => ({
39
71
  selector: `JSXOpeningElement[name.type="JSXIdentifier"][name.name="${name}"]`,
40
72
  message: `<${name}> is not allowed here. ${message}`,
@@ -45,8 +77,8 @@ export default function siliconUi({ files = ["**/*.{tsx,jsx}"], ignores = [], el
45
77
  files,
46
78
  ignores,
47
79
  rules: {
48
- "no-restricted-syntax": ["error", ...syntax, ...extraSyntax],
49
- "no-restricted-imports": ["error", { patterns: [...IMPORTS, ...extraImports] }],
80
+ "no-restricted-syntax": ["error", ...syntax, ...gatedProps, ...extraSyntax],
81
+ "no-restricted-imports": ["error", { paths: gatedParts, patterns: [...IMPORTS, ...gated, ...extraImports] }],
50
82
  },
51
83
  },
52
84
  ];
@@ -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 {};
@@ -1,43 +1,44 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useEffect, useRef, useState } from "react";
3
+ import { useRef } from "react";
4
4
  import { motion, useReducedMotion } from "motion/react";
5
+ import { FluidHoverHighlight, useFluidHover } from "./fluid-hover.js";
5
6
  import { motionTokens } from "./motion-tokens.js";
7
+ const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"]';
6
8
  /**
7
- * One highlight that glides between the items of a Radix menu or listbox for the pointer, and jumps instantly for the keyboard.
8
- * Radix focuses the highlighted item, so the highlight follows focus inside the content. Spread `contentProps` on the content,
9
- * call `reset` when it opens, and render `<MenuHighlight state={highlight} className={styles.highlight} />` as its first child.
10
- * Items mark a destructive tone with `data-tone="danger"`.
9
+ * Fluid hover for a Radix menu or listbox (lib/fluid-hover): the item nearest the pointer is lit, gaps and padding
10
+ * included, a click in a gap picks the lit item, and the keyboard moves the highlight with Radix's focus, without travel.
11
+ * Spread `contentProps` on the content, call `reset` when it opens, and render `<MenuHighlight state={highlight} … />`
12
+ * as its first child. Items mark a destructive tone with `data-tone="danger"`.
11
13
  */
12
- export function useMenuHighlight(itemSelector = '[role="menuitem"], [role="option"]') {
13
- const [highlight, setHighlight] = useState(null);
14
- const pointer = useRef(false);
15
- const clearTimer = useRef(0);
16
- useEffect(() => () => window.clearTimeout(clearTimer.current), []);
17
- function onFocus(event) {
18
- const item = event.target instanceof HTMLElement ? event.target.closest(itemSelector) : null;
19
- window.clearTimeout(clearTimer.current);
20
- if (!item) {
21
- // A short grace period keeps the highlight gliding across separators and item gaps.
22
- clearTimer.current = window.setTimeout(() => setHighlight(null), pointer.current ? 70 : 0);
23
- return;
24
- }
25
- const next = { top: item.offsetTop, height: item.offsetHeight, danger: item.dataset.tone === "danger" };
26
- const glide = pointer.current;
27
- setHighlight(current => ({ ...next, glide: glide && current !== null }));
28
- }
14
+ export function useMenuHighlight(itemSelector = ITEMS) {
15
+ // The content element is taken from its own events, so the hook never competes with the content's ref.
16
+ const content = useRef(null);
17
+ const hover = useFluidHover(content, { items: itemSelector });
18
+ const own = (event) => { content.current = event.currentTarget; };
19
+ const { handlers } = hover;
20
+ const highlight = { rect: hover.rect, glide: hover.glide, session: hover.session, danger: hover.item?.dataset.tone === "danger" };
29
21
  return {
30
22
  highlight,
31
- reset: () => { window.clearTimeout(clearTimer.current); setHighlight(null); },
23
+ reset: hover.reset,
32
24
  contentProps: {
33
- onFocus,
34
- onPointerMoveCapture: () => { pointer.current = true; },
35
- onKeyDownCapture: () => { pointer.current = false; },
25
+ onMouseEnter: (event) => { own(event); handlers.onMouseEnter(); },
26
+ onMouseMove: (event) => { own(event); handlers.onMouseMove(event); },
27
+ onMouseLeave: handlers.onMouseLeave,
28
+ onKeyDownCapture: handlers.onKeyDownCapture,
29
+ onFocus: (event) => { own(event); handlers.onFocus(event); },
30
+ onBlur: handlers.onBlur,
31
+ onClick: (event) => { own(event); handlers.onClick(event); },
36
32
  },
37
33
  };
38
34
  }
39
- /** The gliding highlight itself. Position it absolutely inside the content, under the items. */
35
+ /** The highlight itself: the fluid hover highlight, or a marker that moves by top and height (a current-page marker). */
40
36
  export function MenuHighlight({ state, className }) {
37
+ if (state && "session" in state)
38
+ return _jsx(FluidHoverHighlight, { hover: state, className: className, tone: state.danger ? "danger" : undefined });
39
+ return _jsx(BarMarker, { state: state, className: className });
40
+ }
41
+ function BarMarker({ state, className }) {
41
42
  const reduced = useReducedMotion();
42
- return _jsx(motion.span, { className: className, "data-tone": state?.danger ? "danger" : undefined, "aria-hidden": "true", initial: false, animate: state ? { y: state.top, height: state.height, opacity: 1 } : { opacity: 0 }, transition: { default: state?.glide && !reduced ? motionTokens.spring.snappy : { duration: 0 }, opacity: { duration: reduced ? 0 : .08 } } });
43
+ return _jsx(motion.span, { className: className, "data-tone": state?.danger ? "danger" : undefined, "aria-hidden": "true", initial: false, animate: state ? { y: state.top, height: state.height, opacity: 1 } : { opacity: 0 }, transition: { default: state?.glide && !reduced ? motionTokens.spring.fast : { duration: 0 }, opacity: { duration: reduced ? 0 : motionTokens.duration.fast } } });
43
44
  }
@@ -1,11 +1,14 @@
1
- /** Motion presets are shared by the gallery and future registry consumers. */
2
1
  export declare const motionTokens: {
2
+ /** Tween durations in seconds, for opacity, colour and blur, and for CSS (the same steps as `--duration-*`). */
3
3
  readonly duration: {
4
- readonly instant: 0.12;
5
- readonly fast: 0.16;
6
- readonly exit: 0.18;
7
- readonly standard: 0.24;
8
- readonly considered: 0.48;
4
+ readonly instant: 0.06;
5
+ readonly fast: 0.08;
6
+ readonly exit: 0.12;
7
+ readonly standard: 0.16;
8
+ readonly considered: 0.24;
9
+ /** Motion that tells a story over time rather than answering a click: a chart drawing in, a counter rolling, text
10
+ * revealing, a success ring drawing, a slow image zoom. Never for a hover, press, menu or panel. */
11
+ readonly expressive: 0.48;
9
12
  };
10
13
  readonly ease: {
11
14
  readonly enter: readonly [0.16, 1, 0.3, 1];
@@ -15,30 +18,76 @@ export declare const motionTokens: {
15
18
  readonly inOut: readonly [0.65, 0, 0.35, 1];
16
19
  };
17
20
  readonly spring: {
18
- readonly responsive: {
21
+ /** Hover highlights, presses, toggles, thumbs and small indicators. */
22
+ readonly fast: {
19
23
  readonly type: "spring";
20
- readonly stiffness: 520;
21
- readonly damping: 38;
24
+ readonly visualDuration: 0.05;
25
+ readonly bounce: 0;
26
+ readonly exit: {
27
+ readonly duration: 0.06;
28
+ };
22
29
  };
23
- readonly gentle: {
30
+ /** Menus, popovers, tabs, drawers, height changes: short travel that must land exactly. */
31
+ readonly moderate: {
24
32
  readonly type: "spring";
25
- readonly stiffness: 340;
26
- readonly damping: 34;
33
+ readonly visualDuration: 0.09;
34
+ readonly bounce: 0;
35
+ readonly exit: {
36
+ readonly duration: 0.12;
37
+ };
27
38
  };
28
- /** Presses, toggles, thumbs, and small indicators. Settles fast with a hint of life. */
29
- readonly snappy: {
39
+ /** Dialogs, sheets, shape morphs and large shared-layout moves. A little bounce. */
40
+ readonly slow: {
30
41
  readonly type: "spring";
31
- readonly visualDuration: 0.26;
42
+ readonly visualDuration: 0.15;
32
43
  readonly bounce: 0.12;
44
+ readonly exit: {
45
+ readonly duration: 0.16;
46
+ };
47
+ };
48
+ /** Earlier names, kept so existing code keeps working. Prefer the tier names. */
49
+ readonly snappy: {
50
+ readonly type: "spring";
51
+ readonly visualDuration: 0.05;
52
+ readonly bounce: 0;
53
+ readonly exit: {
54
+ readonly duration: 0.06;
55
+ };
33
56
  };
34
- /** Panels, height changes, and layout shifts. Critically damped, never overshoots. */
35
57
  readonly smooth: {
36
58
  readonly type: "spring";
37
- readonly visualDuration: 0.4;
59
+ readonly visualDuration: 0.09;
38
60
  readonly bounce: 0;
61
+ readonly exit: {
62
+ readonly duration: 0.12;
63
+ };
39
64
  };
40
- /** Shape morphs, shared layout highlights, and width changes that follow new content. */
41
65
  readonly morph: {
66
+ readonly type: "spring";
67
+ readonly visualDuration: 0.15;
68
+ readonly bounce: 0.12;
69
+ readonly exit: {
70
+ readonly duration: 0.16;
71
+ };
72
+ };
73
+ readonly responsive: {
74
+ readonly type: "spring";
75
+ readonly visualDuration: 0.09;
76
+ readonly bounce: 0;
77
+ readonly exit: {
78
+ readonly duration: 0.12;
79
+ };
80
+ };
81
+ readonly gentle: {
82
+ readonly type: "spring";
83
+ readonly visualDuration: 0.09;
84
+ readonly bounce: 0;
85
+ readonly exit: {
86
+ readonly duration: 0.12;
87
+ };
88
+ };
89
+ /** The spring for expressive motion (see duration.expressive): a bar growing, a value settling into place. */
90
+ readonly expressive: {
42
91
  readonly type: "spring";
43
92
  readonly visualDuration: 0.42;
44
93
  readonly bounce: 0.16;
@@ -58,3 +107,12 @@ export declare const motionTokens: {
58
107
  readonly text: 8;
59
108
  };
60
109
  };
110
+ /**
111
+ * How long (ms) a deferred-unmount timer waits for a tier's exit before forcing it: the exit plus a safety margin, for
112
+ * a throttled background tab that never finishes the animation.
113
+ */
114
+ export declare const exitFallbackMs: (tier: {
115
+ exit: {
116
+ duration: number;
117
+ };
118
+ }) => number;