@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
@@ -4,6 +4,7 @@ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayo
4
4
  import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
5
5
  import { Check, ChevronDown, Search, X } from "lucide-react";
6
6
  import { motionTokens } from "../../lib/motion-tokens.js";
7
+ import { useNearestItem } from "../../lib/fluid-hover.js";
7
8
  import styles from "./phone-input.module.css";
8
9
  const country = (iso, name, dial, patterns, example, trunk = "0", areaCodes) => ({ iso, name, dial, patterns: Array.isArray(patterns) ? patterns : [patterns], example, trunk, areaCodes });
9
10
  const CA_AREA_CODES = "204 226 236 249 250 263 289 306 343 354 365 367 368 382 387 403 416 418 428 431 437 438 450 468 474 506 514 519 548 579 581 584 587 604 613 639 647 672 683 705 709 742 753 778 780 782 807 819 825 867 873 879 902 905".split(" ");
@@ -150,9 +151,9 @@ function useReducedFlag() {
150
151
  const layerVariants = {
151
152
  enter: (dir) => ({ opacity: 0, y: `${dir * .6}em`, filter: `blur(${blur.soft}px)` }),
152
153
  rest: { opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } },
153
- exit: (dir) => ({ opacity: 0, y: `${dir * -.5}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: .12, ease: standardEase } }),
154
+ exit: (dir) => ({ opacity: 0, y: `${dir * -.5}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standardEase } }),
154
155
  };
155
- const layerFade = { enter: { opacity: 0 }, rest: { opacity: 1, y: 0, filter: "none" }, exit: { opacity: 0, transition: { duration: .08 } } };
156
+ const layerFade = { enter: { opacity: 0 }, rest: { opacity: 1, y: 0, filter: "none" }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
156
157
  function matchesQuery(entry, needle) {
157
158
  if (!needle)
158
159
  return true;
@@ -191,7 +192,7 @@ function caretAfterDigits(text, count) {
191
192
  }
192
193
  function MessageRow({ id, text, tone }) {
193
194
  const reduced = useReducedFlag();
194
- return _jsx(motion.span, { className: styles.messageSlot, initial: reduced ? false : { height: 0, opacity: 0 }, animate: { height: "auto", opacity: 1 }, exit: { height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }, children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(motion.span, { id: id, className: tone === "error" ? styles.error : styles.hint, role: tone === "error" ? "alert" : undefined, initial: reduced ? { opacity: 0 } : { opacity: 0, y: "0.35em", filter: `blur(${blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: .1 } }, transition: { duration: reduced ? .12 : motionTokens.duration.standard, ease: enterEase }, children: text }, text) }) });
195
+ return _jsx(motion.span, { className: styles.messageSlot, initial: reduced ? false : { height: 0, opacity: 0 }, animate: { height: "auto", opacity: 1 }, exit: { height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }, children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(motion.span, { id: id, className: tone === "error" ? styles.error : styles.hint, role: tone === "error" ? "alert" : undefined, initial: reduced ? { opacity: 0 } : { opacity: 0, y: "0.35em", filter: `blur(${blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } }, transition: { duration: reduced ? .12 : motionTokens.duration.standard, ease: enterEase }, children: text }, text) }) });
195
196
  }
196
197
  /**
197
198
  * A phone number field with a country picker. The country button grows into a searchable list, the number formats as it is
@@ -339,6 +340,8 @@ export const PhoneInput = forwardRef(function PhoneInput({ label, hideLabel = fa
339
340
  const [open, setOpen] = useState(false);
340
341
  const [query, setQuery] = useState("");
341
342
  const [active, setActive] = useState(null);
343
+ // Fluid hover: the mouse makes the nearest option active, group labels and padding included, and a click there picks it.
344
+ const nearest = useNearestItem({ items: '[role="option"]', onPick: item => setActive(item.dataset.key ?? null) });
342
345
  const [roll, setRoll] = useState(1);
343
346
  const needle = query.trim().toLowerCase();
344
347
  const sections = useMemo(() => {
@@ -594,19 +597,18 @@ export const PhoneInput = forwardRef(function PhoneInput({ label, hideLabel = fa
594
597
  const describedBy = [description ? hintId : null, message ? errorId : null].filter(Boolean).join(" ") || undefined;
595
598
  const rootStyle = { "--pi-closed-r": `${CLOSED_RADIUS}px` };
596
599
  const face = (entry) => _jsxs(_Fragment, { children: [_jsx("span", { className: styles.flag, "aria-hidden": "true", children: flagOf(entry.iso) }), _jsxs("span", { className: styles.dial, children: ["+", entry.dial] })] });
597
- return _jsxs("div", { ref: rootRef, className: [styles.field, className].filter(Boolean).join(" "), style: rootStyle, "data-open": open || undefined, "data-disabled": disabled || undefined, onBlur: onRootBlur, children: [_jsx("label", { htmlFor: inputId, className: hideLabel ? styles.srOnly : styles.label, children: label }), _jsxs("div", { ref: controlRef, className: styles.control, "data-invalid": invalid || undefined, children: [_jsxs(motion.div, { className: styles.anchor, style: { width: anchorW }, children: [_jsx("span", { ref: measureRef, className: `${styles.trigger} ${styles.measure}`, "aria-hidden": "true", children: face(current) }), _jsxs(motion.div, { className: styles.shape, style: { width: shapeW, height: shapeH, borderRadius: radius }, children: [_jsxs("div", { className: styles.lid, children: [_jsx("button", { ref: triggerRef, type: "button", className: styles.trigger, disabled: disabled, inert: open || undefined, tabIndex: open ? -1 : 0, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": listId, "aria-label": `Country, ${current.name} +${current.dial}`, onClick: () => (open ? close("trigger") : openList()), onKeyDown: onTriggerKeyDown, children: _jsx("span", { className: styles.slot, "data-hidden": open || undefined, children: _jsx(AnimatePresence, { initial: false, custom: roll, children: _jsx(motion.span, { className: styles.layer, custom: roll, variants: reduced ? layerFade : layerVariants, initial: "enter", animate: "rest", exit: "exit", transition: reduced ? { duration: .12 } : { y: GLIDE, opacity: { duration: .2, ease: enterEase }, filter: { duration: .22, ease: enterEase } }, children: face(current) }, current.iso) }) }) }), _jsxs(motion.div, { className: styles.searchRow, inert: !open || undefined, initial: false, animate: open ? { opacity: 1, filter: "blur(0px)" } : { opacity: 0, filter: reduced ? "blur(0px)" : `blur(${blur.subtle}px)` }, transition: open ? { duration: .18, ease: enterEase, delay: reduced ? 0 : .05 } : { duration: .1, ease: standardEase }, children: [_jsx(Search, { className: styles.searchIcon, size: 16, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { ref: searchRef, className: styles.search, type: "text", role: "combobox", "aria-label": "Search countries or calling codes", "aria-expanded": open, "aria-controls": listId, "aria-autocomplete": "list", "aria-activedescendant": open && activeKey ? optionId(activeKey) : undefined, placeholder: "Country or code", value: query, autoComplete: "off", spellCheck: false, onChange: event => onQuery(event.target.value), onKeyDown: onSearchKeyDown }), _jsx(AnimatePresence, { initial: false, children: query && _jsx(motion.button, { type: "button", className: styles.clear, "aria-label": "Clear search", onPointerDown: event => event.preventDefault(), onClick: () => { onQuery(""); searchRef.current?.focus(); }, initial: { opacity: 0, scale: .6 }, animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: .6, transition: { duration: .1 } }, transition: reduced ? { duration: 0 } : GLIDE, children: _jsx(X, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }) }, "clear") }), _jsx("button", { type: "button", className: styles.closeHit, "aria-label": "Close country list", onClick: () => close("trigger") })] }), _jsx(motion.span, { className: styles.chevron, "aria-hidden": "true", initial: false, animate: { rotate: open ? 180 : 0 }, transition: reduced ? { duration: 0 } : GLIDE, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75 }) })] }), _jsx(motion.div, { ref: listFaceRef, className: styles.listFace, inert: !open || undefined, "aria-hidden": !open || undefined, initial: false, animate: open ? { opacity: 1, y: 0, filter: "blur(0px)" } : { opacity: 0, y: reduced ? 0 : -6, filter: reduced ? "blur(0px)" : `blur(${blur.subtle}px)` }, transition: open ? { y: GROW, opacity: { duration: .2, ease: enterEase, delay: reduced ? 0 : .04 }, filter: { duration: .22, ease: enterEase, delay: .04 } } : { duration: .1, ease: standardEase }, children: _jsx("div", { ref: scrollRef, className: styles.scroll, children: _jsxs("div", { id: listId, className: styles.options, role: "listbox", "aria-label": "Countries", children: [_jsx(motion.span, { className: styles.highlight, style: { y: hy, height: hh, opacity: ho }, "aria-hidden": "true" }), sections.map(section => {
600
+ return _jsxs("div", { ref: rootRef, className: [styles.field, className].filter(Boolean).join(" "), style: rootStyle, "data-open": open || undefined, "data-disabled": disabled || undefined, onBlur: onRootBlur, children: [_jsx("label", { htmlFor: inputId, className: hideLabel ? styles.srOnly : styles.label, children: label }), _jsxs("div", { ref: controlRef, className: styles.control, "data-invalid": invalid || undefined, children: [_jsxs(motion.div, { className: styles.anchor, style: { width: anchorW }, children: [_jsx("span", { ref: measureRef, className: `${styles.trigger} ${styles.measure}`, "aria-hidden": "true", children: face(current) }), _jsxs(motion.div, { className: styles.shape, style: { width: shapeW, height: shapeH, borderRadius: radius }, children: [_jsxs("div", { className: styles.lid, children: [_jsx("button", { ref: triggerRef, type: "button", className: styles.trigger, disabled: disabled, inert: open || undefined, tabIndex: open ? -1 : 0, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": listId, "aria-label": `Country, ${current.name} +${current.dial}`, onClick: () => (open ? close("trigger") : openList()), onKeyDown: onTriggerKeyDown, children: _jsx("span", { className: styles.slot, "data-hidden": open || undefined, children: _jsx(AnimatePresence, { initial: false, custom: roll, children: _jsx(motion.span, { className: styles.layer, custom: roll, variants: reduced ? layerFade : layerVariants, initial: "enter", animate: "rest", exit: "exit", transition: reduced ? { duration: motionTokens.duration.instant } : { y: GLIDE, opacity: { duration: motionTokens.duration.exit, ease: enterEase }, filter: { duration: motionTokens.duration.exit, ease: enterEase } }, children: face(current) }, current.iso) }) }) }), _jsxs(motion.div, { className: styles.searchRow, inert: !open || undefined, initial: false, animate: open ? { opacity: 1, filter: "blur(0px)" } : { opacity: 0, filter: reduced ? "blur(0px)" : `blur(${blur.subtle}px)` }, transition: open ? { duration: motionTokens.duration.exit, ease: enterEase, delay: reduced ? 0 : motionTokens.duration.instant } : { duration: motionTokens.duration.instant, ease: standardEase }, children: [_jsx(Search, { className: styles.searchIcon, size: 16, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { ref: searchRef, className: styles.search, type: "text", role: "combobox", "aria-label": "Search countries or calling codes", "aria-expanded": open, "aria-controls": listId, "aria-autocomplete": "list", "aria-activedescendant": open && activeKey ? optionId(activeKey) : undefined, placeholder: "Country or code", value: query, autoComplete: "off", spellCheck: false, onChange: event => onQuery(event.target.value), onKeyDown: onSearchKeyDown }), _jsx(AnimatePresence, { initial: false, children: query && _jsx(motion.button, { type: "button", className: styles.clear, "aria-label": "Clear search", onPointerDown: event => event.preventDefault(), onClick: () => { onQuery(""); searchRef.current?.focus(); }, initial: { opacity: 0, scale: .6 }, animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: .6, transition: { duration: motionTokens.duration.instant } }, transition: reduced ? { duration: 0 } : GLIDE, children: _jsx(X, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }) }, "clear") }), _jsx("button", { type: "button", className: styles.closeHit, "aria-label": "Close country list", onClick: () => close("trigger") })] }), _jsx(motion.span, { className: styles.chevron, "aria-hidden": "true", initial: false, animate: { rotate: open ? 180 : 0 }, transition: reduced ? { duration: 0 } : GLIDE, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75 }) })] }), _jsx(motion.div, { ref: listFaceRef, className: styles.listFace, inert: !open || undefined, "aria-hidden": !open || undefined, initial: false, animate: open ? { opacity: 1, y: 0, filter: "blur(0px)" } : { opacity: 0, y: reduced ? 0 : -6, filter: reduced ? "blur(0px)" : `blur(${blur.subtle}px)` }, transition: open ? { y: GROW, opacity: { duration: motionTokens.duration.exit, ease: enterEase, delay: reduced ? 0 : motionTokens.stagger.word }, filter: { duration: motionTokens.duration.exit, ease: enterEase, delay: motionTokens.stagger.word } } : { duration: motionTokens.duration.instant, ease: standardEase }, children: _jsx("div", { ref: scrollRef, className: styles.scroll, children: _jsxs("div", { id: listId, className: styles.options, role: "listbox", "aria-label": "Countries", onPointerDown: event => event.preventDefault(), ...nearest, children: [_jsx(motion.span, { className: styles.highlight, style: { y: hy, height: hh, opacity: ho }, "aria-hidden": "true" }), sections.map(section => {
598
601
  const body = section.rows.map(row => {
599
602
  const selected = row.entry.iso === current.iso;
600
603
  return _jsxs("div", { id: optionId(row.key), role: "option", "aria-selected": selected, className: styles.option, "data-active": row.key === activeKey || undefined, ref: node => { if (node)
601
604
  rowRefs.current.set(row.key, node);
602
605
  else
603
- rowRefs.current.delete(row.key); }, onPointerMove: event => { if (event.pointerType === "mouse" && row.key !== activeKey)
604
- setActive(row.key); }, onPointerDown: event => event.preventDefault(), onClick: () => pick(row.entry), children: [_jsx("span", { className: styles.flag, "aria-hidden": "true", children: flagOf(row.entry.iso) }), _jsx("span", { className: styles.name, children: row.entry.name }), _jsxs("span", { className: styles.meta, children: ["+", row.entry.dial] }), _jsx("span", { className: styles.check, "data-on": selected || undefined, "aria-hidden": "true", children: _jsx(Check, { size: 16, strokeWidth: 1.75 }) })] }, row.key);
606
+ rowRefs.current.delete(row.key); }, "data-key": row.key, onPointerDown: event => event.preventDefault(), onClick: () => pick(row.entry), children: [_jsx("span", { className: styles.flag, "aria-hidden": "true", children: flagOf(row.entry.iso) }), _jsx("span", { className: styles.name, children: row.entry.name }), _jsxs("span", { className: styles.meta, children: ["+", row.entry.dial] }), _jsx("span", { className: styles.check, "data-on": selected || undefined, "aria-hidden": "true", children: _jsx(Check, { size: 16, strokeWidth: 1.75 }) })] }, row.key);
605
607
  });
606
608
  return section.label
607
609
  ? _jsxs("div", { role: "group", "aria-labelledby": `${uid}-${section.key}`, className: styles.group, children: [_jsx("div", { id: `${uid}-${section.key}`, className: styles.groupLabel, role: "presentation", children: section.label }), body] }, section.key)
608
610
  : _jsx("div", { role: "presentation", className: styles.group, children: body }, section.key);
609
- }), rows.length === 0 && _jsxs("p", { className: styles.empty, children: ["No countries match \u201C", query.trim(), "\u201D"] })] }) }) })] })] }), _jsxs("div", { className: styles.numberWrap, children: [_jsxs("span", { className: styles.guide, "aria-hidden": "true", children: [_jsx("span", { className: styles.guideTyped, children: formatted }), guide] }), _jsx("input", { ref: inputRef, id: inputId, className: styles.number, type: "tel", inputMode: "tel", autoComplete: "tel", value: formatted, disabled: disabled, required: required, "aria-invalid": invalid || undefined, "aria-describedby": describedBy, "aria-label": hideLabel ? label : undefined, onChange: event => onNumberChange(event.currentTarget), onKeyDown: onNumberKeyDown, onPaste: onPaste, onBlur: event => { setTouched(digits.length > 0); onBlur?.(event); } }), _jsx(AnimatePresence, { initial: false, children: showCheck && _jsx(motion.span, { className: styles.valid, "aria-hidden": "true", initial: reduced ? { opacity: 0 } : { opacity: 0, scale: .6, filter: `blur(${blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: { opacity: 0, scale: reduced ? 1 : .8, transition: { duration: .1 } }, transition: reduced ? { duration: .12 } : motionTokens.spring.snappy, children: _jsx(Check, { size: 16, strokeWidth: 2 }) }, "ok") })] })] }), _jsx(AnimatePresence, { initial: false, children: description && !message && _jsx(MessageRow, { id: hintId, text: description, tone: "hint" }, "hint") }), _jsx(AnimatePresence, { initial: false, children: message && _jsx(MessageRow, { id: errorId, text: message, tone: "error" }, "error") }), name && _jsx("input", { type: "hidden", name: name, value: e164 }), _jsx("span", { className: styles.srOnly, role: "status", "aria-live": "polite", children: announcement || (showCheck ? `Valid ${current.name} number` : "") })] });
611
+ }), rows.length === 0 && _jsxs("p", { className: styles.empty, children: ["No countries match \u201C", query.trim(), "\u201D"] })] }) }) })] })] }), _jsxs("div", { className: styles.numberWrap, children: [_jsxs("span", { className: styles.guide, "aria-hidden": "true", children: [_jsx("span", { className: styles.guideTyped, children: formatted }), guide] }), _jsx("input", { ref: inputRef, id: inputId, className: styles.number, type: "tel", inputMode: "tel", autoComplete: "tel", value: formatted, disabled: disabled, required: required, "aria-invalid": invalid || undefined, "aria-describedby": describedBy, "aria-label": hideLabel ? label : undefined, onChange: event => onNumberChange(event.currentTarget), onKeyDown: onNumberKeyDown, onPaste: onPaste, onBlur: event => { setTouched(digits.length > 0); onBlur?.(event); } }), _jsx(AnimatePresence, { initial: false, children: showCheck && _jsx(motion.span, { className: styles.valid, "aria-hidden": "true", initial: reduced ? { opacity: 0 } : { opacity: 0, scale: .6, filter: `blur(${blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: { opacity: 0, scale: reduced ? 1 : .8, transition: { duration: motionTokens.duration.instant } }, transition: reduced ? { duration: motionTokens.duration.instant } : motionTokens.spring.snappy, children: _jsx(Check, { size: 16, strokeWidth: 2 }) }, "ok") })] })] }), _jsx(AnimatePresence, { initial: false, children: description && !message && _jsx(MessageRow, { id: hintId, text: description, tone: "hint" }, "hint") }), _jsx(AnimatePresence, { initial: false, children: message && _jsx(MessageRow, { id: errorId, text: message, tone: "error" }, "error") }), name && _jsx("input", { type: "hidden", name: name, value: e164 }), _jsx("span", { className: styles.srOnly, role: "status", "aria-live": "polite", children: announcement || (showCheck ? `Valid ${current.name} number` : "") })] });
610
612
  });
611
613
  PhoneInput.displayName = "PhoneInput";
612
614
  export default PhoneInput;
@@ -1,6 +1,6 @@
1
1
  /* No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead. */
2
2
  .field { --pi-h: var(--control-height-md); --pi-panel-w: 320px; position: relative; display: grid; min-width: 0; font-family: var(--font-body); letter-spacing: var(--tracking-body); }
3
- .field[data-open] { z-index: 30; }
3
+ .field[data-open] { z-index: var(--layer-dropdown); }
4
4
  .label { justify-self: start; margin-bottom: var(--space-related); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }
5
5
 
6
6
  /* The shell matches the labeled input: one border, one radius, and a quiet border change on hover and focus. */
@@ -12,7 +12,7 @@
12
12
 
13
13
  /* The anchor holds the closed button's place and springs to a new width when the calling code changes length. */
14
14
  .anchor { position: relative; flex: none; height: 100%; }
15
- .anchor::after { content: ""; position: absolute; top: 10px; right: 0; bottom: 10px; width: 1px; background: var(--border); transition: opacity var(--duration-fast) var(--ease-standard); }
15
+ .anchor::after { content: ""; position: absolute; top: var(--space-group); right: 0; bottom: var(--space-group); width: 1px; background: var(--border); transition: opacity var(--duration-fast) var(--ease-standard); }
16
16
  .field[data-open] .anchor::after { opacity: 0; }
17
17
 
18
18
  /* One material for both states. Open, it steps out over the shell's own border so the two edges never double up. */
@@ -24,8 +24,8 @@
24
24
 
25
25
  .lid { position: absolute; z-index: 1; top: 0; right: 0; left: 0; height: calc(var(--pi-h) - 2px); }
26
26
 
27
- .trigger { box-sizing: border-box; display: flex; height: calc(var(--pi-h) - 2px); align-items: center; padding: 0 36px 0 12px; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; }
28
- .lid .trigger { position: absolute; inset: 3px auto 3px 3px; width: calc(100% - 6px); height: auto; margin: 0; padding: 0 33px 0 9px; border: 0; border-radius: calc(var(--radius-control) - 4px); corner-shape: var(--corner-shape); background: transparent; color: var(--foreground); cursor: pointer; font-family: inherit; letter-spacing: inherit; text-align: left; -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background-color var(--duration-fast) var(--ease-standard); }
27
+ .trigger { box-sizing: border-box; display: flex; height: calc(var(--pi-h) - 2px); align-items: center; padding: 0 var(--space-block) 0 var(--space-group); font-size: var(--text-sm); font-weight: 500; white-space: nowrap; }
28
+ .lid .trigger { position: absolute; inset: var(--space-tight) auto var(--space-tight) var(--space-tight); width: calc(100% - 6px); height: auto; margin: 0; padding: 0 var(--space-block) 0 var(--space-related); border: 0; border-radius: calc(var(--radius-control) - var(--space-tight)); corner-shape: var(--corner-shape); background: transparent; color: var(--foreground); cursor: pointer; font-family: inherit; letter-spacing: inherit; text-align: left; -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background-color var(--duration-fast) var(--ease-standard); }
29
29
  @media (hover: hover) and (pointer: fine) { .field:not([data-open], [data-disabled]) .lid .trigger:hover { background: var(--surface-muted); } }
30
30
  .lid .trigger:active:not(:disabled) { background: var(--surface-muted); }
31
31
  .lid .trigger:disabled { cursor: not-allowed; }
@@ -37,14 +37,14 @@
37
37
 
38
38
  .slot { position: relative; display: block; min-width: 0; height: 100%; flex: 1; }
39
39
  .slot[data-hidden] { opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); }
40
- .layer { position: absolute; inset: 0; display: flex; align-items: center; gap: 6px; }
41
- .flag { display: inline-grid; width: 20px; flex: none; place-items: center; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", var(--font-body); font-size: 17px; font-weight: 400; line-height: 1; }
40
+ .layer { position: absolute; inset: 0; display: flex; align-items: center; gap: var(--space-related); }
41
+ .flag { display: inline-grid; width: 20px; flex: none; place-items: center; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", var(--font-body); font-size: var(--text-lg); font-weight: 400; line-height: 1; }
42
42
  .dial { font-variant-numeric: tabular-nums; }
43
43
 
44
- .chevron { position: absolute; top: 50%; right: 12px; display: grid; width: 16px; height: 16px; margin-top: -8px; place-items: center; color: var(--text-muted); pointer-events: none; }
44
+ .chevron { position: absolute; top: 50%; right: var(--space-group); display: grid; width: 16px; height: 16px; margin-top: calc(var(--space-related) * -1); place-items: center; color: var(--text-muted); pointer-events: none; }
45
45
 
46
46
  /* Search takes the lid's place while the list is open. */
47
- .searchRow { position: absolute; inset: 0; display: flex; align-items: center; gap: var(--space-related); padding: 0 2px 0 13px; }
47
+ .searchRow { position: absolute; inset: 0; display: flex; align-items: center; gap: var(--space-related); padding: 0 var(--space-hair) 0 var(--space-group); }
48
48
  .searchRow[inert] { pointer-events: none; }
49
49
  .searchIcon { flex: none; color: var(--text-muted); }
50
50
  .search { min-width: 0; height: 100%; flex: 1; padding: 0; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); letter-spacing: var(--tracking-body); outline: none; }
@@ -58,31 +58,31 @@
58
58
  }
59
59
 
60
60
  /* The list hangs under the lid at its final width, so rows never reflow while the surface springs open. */
61
- .listFace { position: absolute; top: calc(var(--pi-h) - 2px); left: 0; box-sizing: border-box; width: var(--pi-panel-w); padding: 0 6px 6px; }
62
- .listFace::before { content: ""; position: absolute; top: 0; right: 14px; left: 14px; height: 1px; background: var(--border-subtle); }
61
+ .listFace { position: absolute; top: calc(var(--pi-h) - var(--space-hair)); left: 0; box-sizing: border-box; width: var(--pi-panel-w); padding: 0 var(--space-related) var(--space-related); }
62
+ .listFace::before { content: ""; position: absolute; top: 0; right: var(--space-inset); left: var(--space-inset); height: 1px; background: var(--border-subtle); }
63
63
  .listFace[inert] { pointer-events: none; }
64
- .scroll { max-height: 280px; overflow-y: auto; overscroll-behavior: contain; padding-top: 6px; scrollbar-width: thin; }
65
- .options { position: relative; display: grid; gap: 2px; }
64
+ .scroll { max-height: 280px; overflow-y: auto; overscroll-behavior: contain; padding-top: var(--space-related); scrollbar-width: thin; }
65
+ .options { position: relative; display: grid; gap: var(--space-hair); }
66
66
  .highlight { position: absolute; top: 0; right: 0; left: 0; border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--foreground) 6.5%, transparent); pointer-events: none; }
67
- .group { display: grid; gap: 2px; }
68
- .group + .group { margin-top: 4px; }
69
- .groupLabel { padding: 8px 10px 4px; color: var(--text-muted); font-size: var(--text-xs); }
70
- .option { position: relative; display: flex; min-height: 36px; box-sizing: border-box; align-items: center; gap: 10px; padding: 0 10px; border-radius: var(--radius-control); corner-shape: var(--corner-shape); color: var(--text-secondary); cursor: pointer; font-size: var(--text-sm); user-select: none; -webkit-user-select: none; transition: color var(--duration-fast) var(--ease-standard); }
67
+ .group { display: grid; gap: var(--space-hair); }
68
+ .group + .group { margin-top: var(--space-tight); }
69
+ .groupLabel { padding: var(--space-related) var(--space-group) var(--space-tight); color: var(--text-muted); font-size: var(--text-xs); }
70
+ .option { position: relative; display: flex; min-height: 36px; box-sizing: border-box; align-items: center; gap: var(--space-group); padding: 0 var(--space-group); border-radius: var(--radius-control); corner-shape: var(--corner-shape); color: var(--text-secondary); cursor: pointer; font-size: var(--text-sm); user-select: none; -webkit-user-select: none; transition: color var(--duration-fast) var(--ease-standard); }
71
71
  .option[data-active], .option[aria-selected="true"] { color: var(--foreground); }
72
72
  .name { min-width: 0; flex: 1; overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
73
73
  .meta { flex: none; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
74
74
  .check { display: grid; width: 16px; height: 16px; flex: none; place-items: center; color: var(--foreground); opacity: 0; transform: scale(.6); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
75
75
  .check[data-on] { opacity: 1; transform: none; }
76
- .empty { margin: 0; padding: 12px 10px 14px; color: var(--text-muted); font-size: var(--text-sm); }
76
+ .empty { margin: 0; padding: var(--space-group) var(--space-group) var(--space-inset); color: var(--text-muted); font-size: var(--text-sm); }
77
77
 
78
78
  /* The number and its guide share one box. The guide paints the typed part invisibly, then the rest of the example faintly after it. */
79
79
  .numberWrap { position: relative; display: flex; min-width: 0; flex: 1; align-items: center; }
80
- .number, .guide { box-sizing: border-box; padding: 0 36px 0 12px; font-family: inherit; font-size: var(--text-sm); font-variant-numeric: tabular-nums; letter-spacing: var(--tracking-body); line-height: var(--leading-body); }
80
+ .number, .guide { box-sizing: border-box; padding: 0 var(--space-block) 0 var(--space-group); font-family: inherit; font-size: var(--text-sm); font-variant-numeric: tabular-nums; letter-spacing: var(--tracking-body); line-height: var(--leading-body); }
81
81
  .number { position: relative; z-index: 1; width: 100%; height: 100%; min-width: 0; margin: 0; border: 0; border-radius: 0 var(--radius-control) var(--radius-control) 0; corner-shape: var(--corner-shape); background: transparent; color: var(--foreground); outline: none; }
82
82
  .number:disabled { cursor: not-allowed; }
83
83
  .guide { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: color-mix(in oklab, var(--text-muted) 72%, transparent); pointer-events: none; white-space: pre; }
84
84
  .guideTyped { visibility: hidden; }
85
- .valid { position: absolute; z-index: 1; top: 50%; right: 12px; display: grid; width: 18px; height: 18px; margin-top: -9px; place-items: center; color: var(--success); pointer-events: none; }
85
+ .valid { position: absolute; z-index: 1; top: 50%; right: var(--space-group); display: grid; width: 18px; height: 18px; margin-top: calc(var(--space-related) * -1); place-items: center; color: var(--success); pointer-events: none; }
86
86
 
87
87
  /* The row's space is padding inside the clipped slot, so the height animates from a true zero. */
88
88
  .messageSlot { position: relative; display: block; overflow: hidden; }
@@ -3,10 +3,10 @@
3
3
  .content {
4
4
  --popover-x: 0px;
5
5
  --popover-y: -5px;
6
- z-index: 80;
6
+ z-index: var(--layer-popover);
7
7
  min-width: 12rem;
8
8
  max-width: min(22rem, calc(100vw - 20px));
9
- padding: 1rem;
9
+ padding: var(--space-inset);
10
10
  border: 1px solid var(--border);
11
11
  border-radius: var(--radius-container);
12
12
  background: var(--surface-raised);
@@ -1 +1 @@
1
- .progress { width: 100%; color: var(--text-secondary); font-size: var(--text-sm); } .meta { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 8px; line-height: var(--leading-body); } .label { position: relative; min-width: 0; } .line { display: block; } .value { display: inline-flex; flex: none; align-items: center; gap: 4px; color: var(--text-muted); font-variant-numeric: tabular-nums; } .count { display: inline-grid; justify-items: end; } /* A hidden "100%" row reserves the widest count, so the check never shifts as the number gains a digit. */ .count::before { content: "100%"; height: 0; overflow: hidden; visibility: hidden; } .done { display: grid; place-items: center; color: var(--success); } .track { height: 7px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface-muted); } .fill { display: block; width: 100%; height: 100%; border-radius: inherit; corner-shape: inherit; background: var(--accent); transition: background-color var(--duration-standard) var(--ease-standard); } .progress[data-complete] .fill { background: var(--success); transition-delay: 200ms; } @media (prefers-reduced-motion: reduce) { .fill, .progress[data-complete] .fill { transition: none; } }
1
+ .progress { width: 100%; color: var(--text-secondary); font-size: var(--text-sm); } .meta { display: flex; justify-content: space-between; gap: var(--space-group); margin-bottom: var(--space-related); line-height: var(--leading-body); } .label { position: relative; min-width: 0; } .line { display: block; } .value { display: inline-flex; flex: none; align-items: center; gap: var(--space-tight); color: var(--text-muted); font-variant-numeric: tabular-nums; } .count { display: inline-grid; justify-items: end; } /* A hidden "100%" row reserves the widest count, so the check never shifts as the number gains a digit. */ .count::before { content: "100%"; height: 0; overflow: hidden; visibility: hidden; } .done { display: grid; place-items: center; color: var(--success); } .track { height: 7px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface-muted); } .fill { display: block; width: 100%; height: 100%; border-radius: inherit; corner-shape: inherit; background: var(--accent); transition: background-color var(--duration-standard) var(--ease-standard); } .progress[data-complete] .fill { background: var(--success); transition-delay: 200ms; } @media (prefers-reduced-motion: reduce) { .fill, .progress[data-complete] .fill { transition: none; } }
@@ -1,7 +1,7 @@
1
1
  .root {
2
2
  position: relative;
3
3
  display: grid;
4
- gap: 10px;
4
+ gap: var(--space-group);
5
5
  color: var(--foreground);
6
6
  font-family: var(--font-body);
7
7
  font-size: var(--text-sm);
@@ -10,7 +10,7 @@
10
10
  }
11
11
  .root *, .root *::before, .root *::after { box-sizing: border-box; }
12
12
  .root[data-layout="grid"] { grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--min-column, 180px)), 1fr)); }
13
- .root[data-layout="list"] { grid-template-columns: minmax(0, 1fr); gap: 8px; }
13
+ .root[data-layout="list"] { grid-template-columns: minmax(0, 1fr); gap: var(--space-related); }
14
14
 
15
15
  /* The one moving part: a ring that springs from card to card. It sits over the card border so the card itself never changes size. */
16
16
  .ring {
@@ -28,8 +28,8 @@
28
28
  display: grid;
29
29
  min-width: 0;
30
30
  align-items: start;
31
- gap: 4px 12px;
32
- padding: 14px 16px;
31
+ gap: var(--space-tight) var(--space-group);
32
+ padding: var(--space-inset) var(--space-inset);
33
33
  border: 1px solid var(--border);
34
34
  border-radius: var(--radius-control); corner-shape: var(--corner-shape);
35
35
  background: var(--surface);
@@ -40,7 +40,7 @@
40
40
  }
41
41
  /* The body row takes any spare height, so prices sit on one baseline across a row even when a description wraps. */
42
42
  .root[data-layout="grid"] .card { grid-template-areas: "body indicator" "meta meta"; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr) auto; }
43
- .root[data-layout="list"] .card { grid-template-areas: "indicator body meta"; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; padding: 13px 16px 13px 14px; }
43
+ .root[data-layout="list"] .card { grid-template-areas: "indicator body meta"; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; padding: var(--space-group) var(--space-inset) var(--space-group) var(--space-inset); }
44
44
  .card[data-checked] { background: color-mix(in oklab, var(--control-on) 4%, var(--surface)); }
45
45
  .card[aria-disabled="true"] { background: var(--surface-muted); cursor: not-allowed; }
46
46
  .card[aria-disabled="true"] :is(.label, .meta) { color: var(--text-muted); }
@@ -61,8 +61,8 @@
61
61
  .indicatorDot { width: 6px; height: 6px; border-radius: 50%; corner-shape: round; background: var(--control-glyph); }
62
62
  .card[aria-disabled="true"] .indicator { border-color: var(--border); }
63
63
 
64
- .body { display: grid; min-width: 0; gap: 2px; grid-area: body; }
65
- .label { display: flex; min-width: 0; align-items: center; gap: 8px; font-weight: 500; }
64
+ .body { display: grid; min-width: 0; gap: var(--space-hair); grid-area: body; }
65
+ .label { display: flex; min-width: 0; align-items: center; gap: var(--space-related); font-weight: 500; }
66
66
  .labelText { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
67
67
  .icon { display: grid; width: 18px; height: 18px; flex: none; place-items: center; color: var(--text-secondary); }
68
68
  .icon > svg { width: 18px; height: 18px; }
@@ -70,7 +70,7 @@
70
70
  .description { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); text-wrap: pretty; }
71
71
 
72
72
  .meta { grid-area: meta; color: var(--foreground); font-variant-numeric: tabular-nums; white-space: nowrap; }
73
- .root[data-layout="grid"] .meta { margin-top: 10px; font-size: var(--text-lg); }
73
+ .root[data-layout="grid"] .meta { margin-top: var(--space-group); font-size: var(--text-lg); }
74
74
  .root[data-layout="list"] .meta { font-weight: 500; }
75
75
 
76
76
  @media (hover: hover) and (pointer: fine) {
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useId, useLayoutEffect, useRef } from "react";
4
4
  import { animate, motion, useReducedMotion } from "motion/react";
5
5
  import { motionTokens } from "../../lib/motion-tokens.js";
6
+ import { FluidHoverHighlight, useFluidHover } from "../../lib/fluid-hover.js";
6
7
  import styles from "./radio-group.module.css";
7
8
  /** Sizes the highlight to the chosen row. A new choice glides on the morph spring; the first paint and resizes place it at once. */
8
9
  function place(highlight, row, at, glide) {
@@ -33,6 +34,8 @@ export function RadioGroup({ label, options, value, onValueChange, name }) {
33
34
  const rows = useRef([]);
34
35
  const shown = useRef(null);
35
36
  const at = useRef("");
37
+ // Fluid hover: the nearest row is lit and a click in a gap chooses it. Arrow keys already move the selection, so focus leaves the fill alone.
38
+ const hover = useFluidHover(listRef, { items: `.${styles.option}`, followFocus: false });
36
39
  const selected = options.findIndex(option => option.value === value);
37
40
  useLayoutEffect(() => {
38
41
  place(highlightRef.current, rows.current[selected], at, shown.current !== null && shown.current !== selected && !reduced);
@@ -48,7 +51,7 @@ export function RadioGroup({ label, options, value, onValueChange, name }) {
48
51
  rows.current.forEach(row => row && observer.observe(row));
49
52
  return () => observer.disconnect();
50
53
  }, [options.length]);
51
- return _jsxs("fieldset", { className: styles.group, children: [_jsx("legend", { children: label }), _jsxs("div", { ref: listRef, className: styles.options, children: [_jsx("span", { ref: highlightRef, className: styles.highlight, "aria-hidden": "true" }), options.map((option, index) => {
54
+ return _jsxs("fieldset", { className: styles.group, children: [_jsx("legend", { children: label }), _jsxs("div", { ref: listRef, className: styles.options, ...hover.handlers, children: [_jsx(FluidHoverHighlight, { hover: hover, className: styles.hover }), _jsx("span", { ref: highlightRef, className: styles.highlight, "aria-hidden": "true" }), options.map((option, index) => {
52
55
  const checked = value === option.value;
53
56
  return _jsxs("label", { className: styles.option, ref: node => { rows.current[index] = node; }, children: [_jsx("input", { type: "radio", name: name ?? id, value: option.value, checked: checked, onChange: () => onValueChange(option.value) }), _jsx("span", { className: styles.mark, "aria-hidden": "true", children: _jsx(motion.span, { className: styles.dot, initial: false, animate: checked ? { scale: 1, opacity: 1 } : { scale: .4, opacity: 0 }, transition: reduced ? { duration: 0 } : { ...motionTokens.spring.snappy, opacity: { duration: checked ? motionTokens.duration.fast : motionTokens.duration.instant } } }) }), _jsxs("span", { children: [_jsx("strong", { children: option.label }), option.description && _jsx("small", { children: option.description })] })] }, option.value);
54
57
  })] })] });
@@ -3,10 +3,11 @@
3
3
  /* One highlight travels between the rows: above their borders and fills, below their text, so it never crosses a label. */
4
4
  .options { position: relative; display: grid; gap: var(--space-related); }
5
5
  .highlight { position: absolute; top: 0; right: 0; left: 0; z-index: 1; box-sizing: border-box; border: 1px solid var(--control-on); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--control-on) 5%, transparent); opacity: 0; pointer-events: none; }
6
+ /* The hover fill glides between rows, under their borders, the selection and the text. */
7
+ .hover { border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--surface-muted); }
6
8
  /* Rows answer a press with color only; the mark is what moves. */
7
9
  /* The mark sits centered on the left edge of the row, whether or not the option has a description. */
8
10
  .option { position: relative; display: flex; align-items: center; gap: var(--space-group); border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); padding: var(--space-group); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); }
9
- @media (hover: hover) and (pointer: fine) { .option:hover { background: var(--surface-muted); } }
10
11
  .option:active { background: var(--surface-muted); }
11
12
  /* Until the highlight is placed (server paint or no script), the checked row carries the same look itself. */
12
13
  .options:not([data-ready]) .option:has(input:checked) { border-color: var(--control-on); background: color-mix(in oklab, var(--control-on) 5%, transparent); }
@@ -20,11 +21,11 @@
20
21
  .mark::after { content: ""; opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); }
21
22
  .option:active:not(:has(input:checked)) .mark::after { opacity: .22; }
22
23
  /* The mark alone answers a press: a quick dip, then a spring back. */
23
- .option:active .mark { transform: scale(.88); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), transform 100ms var(--ease-standard); }
24
+ .option:active .mark { transform: scale(.88); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }
24
25
  .option:has(input:checked) .mark { border-color: var(--control-on); background: var(--control-on); }
25
26
  .option:has(input:checked) .dot { background: var(--control-glyph); }
26
27
  .option strong, .option small { display: block; }
27
28
  .option strong { color: var(--text-secondary); font-size: var(--text-sm); font-weight: 500; transition: color var(--duration-standard) var(--ease-standard); }
28
29
  .option:has(input:checked) strong { color: var(--foreground); }
29
- .option small { margin-top: 2px; color: var(--text-muted); font-size: var(--text-xs); }
30
+ .option small { margin-top: var(--space-hair); color: var(--text-muted); font-size: var(--text-xs); }
30
31
  @media (prefers-reduced-motion: reduce) { .option, .mark, .mark::after, .option strong { transition: none; } .option:active .mark { transform: none; } }
@@ -14,7 +14,7 @@
14
14
 
15
15
  /* The divider is a one pixel line with a 13px grab area, and a grip that forms while it is hovered, focused, or dragged. */
16
16
  .handle { position: relative; z-index: 2; flex: 0 0 1px; background: var(--border); cursor: col-resize; touch-action: none; outline: none; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }
17
- .handle::before { position: absolute; inset: 0 -6px; content: ""; }
17
+ .handle::before { position: absolute; inset: 0 calc(var(--space-related) * -1); content: ""; }
18
18
  .handle[data-collapsed] { background: transparent; cursor: pointer; }
19
19
  .grip { position: absolute; top: 50%; left: 50%; width: 4px; height: 32px; border-radius: var(--radius-full); corner-shape: round; background: var(--border-strong); opacity: 0; pointer-events: none; transform: translate(-50%, -50%) scaleY(.6); transition: opacity var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
20
20
  @media (hover: hover) and (pointer: fine) {
@@ -28,10 +28,10 @@
28
28
  .handle[data-collapsed]:focus-visible .restore { border-color: var(--border-strong); color: var(--foreground); }
29
29
 
30
30
  /* Once a neighbour is hidden, a tab on the divider points the way it will come back. */
31
- .restore { position: absolute; top: 50%; display: grid; width: 18px; height: 40px; margin-top: -20px; place-items: center; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface); box-shadow: var(--shadow-resting); color: var(--text-secondary); pointer-events: none; transition: border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
32
- .restore[data-side="before"] { left: 5px; transform-origin: 0 50%; }
33
- .restore[data-side="after"] { right: 5px; transform-origin: 100% 50%; }
34
- .handle[data-collapsed]::before { inset: 0 -30px; }
31
+ .restore { position: absolute; top: 50%; display: grid; width: 18px; height: 40px; margin-top: calc(var(--space-comfort) * -1); place-items: center; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface); box-shadow: var(--shadow-resting); color: var(--text-secondary); pointer-events: none; transition: border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
32
+ .restore[data-side="before"] { left: var(--space-tight); transform-origin: 0 50%; }
33
+ .restore[data-side="after"] { right: var(--space-tight); transform-origin: 100% 50%; }
34
+ .handle[data-collapsed]::before { inset: 0 calc(var(--space-block) * -1); }
35
35
  .handle[data-collapsed="before"]::before { left: 0; }
36
36
  .handle[data-collapsed="after"]::before { right: 0; }
37
37
 
@@ -686,10 +686,10 @@ const MORPH = physical(spring.morph.visualDuration, .12), GLIDE = physical(.3, .
686
686
  const TRAVEL = 18;
687
687
  const faceVariants = {
688
688
  hidden: (direction) => ({ opacity: 0, x: direction * TRAVEL, filter: `blur(${blur.subtle}px)` }),
689
- shown: { opacity: 1, x: 0, filter: "blur(0px)", transition: { x: MORPH, opacity: { duration: .2, ease: enter, delay: .03 }, filter: { duration: .2, ease: enter, delay: .03 } } },
690
- gone: (direction) => ({ opacity: 0, x: direction * -TRAVEL, filter: `blur(${blur.subtle}px)`, transition: { x: MORPH, opacity: { duration: .12, ease: standard }, filter: { duration: .12, ease: standard } } }),
689
+ shown: { opacity: 1, x: 0, filter: "blur(0px)", transition: { x: MORPH, opacity: { duration: motionTokens.duration.exit, ease: enter, delay: motionTokens.stagger.char }, filter: { duration: motionTokens.duration.exit, ease: enter, delay: motionTokens.stagger.char } } },
690
+ gone: (direction) => ({ opacity: 0, x: direction * -TRAVEL, filter: `blur(${blur.subtle}px)`, transition: { x: MORPH, opacity: { duration: motionTokens.duration.instant, ease: standard }, filter: { duration: motionTokens.duration.instant, ease: standard } } }),
691
691
  };
692
- const fadeVariants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: .14 } }, gone: { opacity: 0, transition: { duration: .1 } } };
692
+ const fadeVariants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.fast } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
693
693
  /** A toolbar face reports its natural size while current, so the shared surface can spring to it. */
694
694
  function Face({ direction, reduced, onSize, children }) {
695
695
  const ref = useRef(null);
@@ -10,7 +10,7 @@
10
10
  /* The writing surface. Everything inside is plain semantic HTML, styled here so pasted and typed content look the same. */
11
11
  .editor {
12
12
  min-height: 8rem;
13
- padding: 2px 0;
13
+ padding: var(--space-hair) 0;
14
14
  outline: none;
15
15
  color: var(--foreground);
16
16
  font-size: var(--text-base);
@@ -48,7 +48,7 @@
48
48
  font-size: .86em;
49
49
  }
50
50
 
51
- .editor blockquote { padding-left: 14px; border-left: 2px solid var(--border-strong); color: var(--text-secondary); }
51
+ .editor blockquote { padding-left: var(--space-inset); border-left: 2px solid var(--border-strong); color: var(--text-secondary); }
52
52
 
53
53
  .editor ul,
54
54
  .editor ol { margin-block: 0; padding-left: 1.45em; }
@@ -63,7 +63,7 @@
63
63
  .editor ol > li::marker { font-variant-numeric: tabular-nums; }
64
64
 
65
65
  .editor pre {
66
- padding: 12px 14px;
66
+ padding: var(--space-group) var(--space-inset);
67
67
  overflow-x: auto;
68
68
  border: 1px solid var(--border-subtle);
69
69
  border-radius: var(--radius-control);
@@ -91,7 +91,7 @@
91
91
  /* Floating toolbar. One surface whose size springs between its two faces. */
92
92
  .toolbar {
93
93
  position: absolute;
94
- z-index: 40;
94
+ z-index: var(--layer-popover);
95
95
  translate: -50% -100%;
96
96
  overflow: hidden;
97
97
  border: 1px solid var(--border);
@@ -104,11 +104,11 @@
104
104
  .face[inert] { pointer-events: none; }
105
105
 
106
106
  .row,
107
- .linkRow { display: flex; align-items: center; gap: 2px; padding: 4px; }
108
- .blockTools { display: flex; align-items: center; gap: 2px; }
107
+ .linkRow { display: flex; align-items: center; gap: var(--space-hair); padding: var(--space-tight); }
108
+ .blockTools { display: flex; align-items: center; gap: var(--space-hair); }
109
109
  /* Narrow editors keep the inline marks; block types stay one slash away. */
110
110
  @container (max-width: 330px) { .blockTools { display: none; } }
111
- .divider { width: 1px; height: 18px; margin: 0 4px; background: var(--border); }
111
+ .divider { width: 1px; height: 18px; margin: 0 var(--space-tight); background: var(--border); }
112
112
 
113
113
  .tool {
114
114
  display: grid;
@@ -134,7 +134,7 @@
134
134
  width: min(15rem, 60vw);
135
135
  height: 32px;
136
136
  min-width: 0;
137
- padding: 0 8px;
137
+ padding: 0 var(--space-related);
138
138
  border: 0;
139
139
  background: transparent;
140
140
  color: var(--foreground);
@@ -163,7 +163,7 @@
163
163
  /* Slash menu. */
164
164
  .slash {
165
165
  position: absolute;
166
- z-index: 40;
166
+ z-index: var(--layer-dropdown);
167
167
  width: 240px;
168
168
  max-width: calc(100% - 8px);
169
169
  overflow-x: hidden;
@@ -176,7 +176,7 @@
176
176
  box-shadow: var(--shadow-floating);
177
177
  }
178
178
  .slash[data-above] { translate: 0 -100%; }
179
- .slashBody { padding: 6px; }
179
+ .slashBody { padding: var(--space-related); }
180
180
  .slashList { position: relative; display: grid; margin: 0; padding: 0; list-style: none; }
181
181
  .slashHighlight { position: absolute; top: 0; right: 0; left: 0; border-radius: var(--radius-control); background: color-mix(in oklab, var(--foreground) 6.5%, transparent); pointer-events: none; }
182
182
  .slashOption {
@@ -184,8 +184,8 @@
184
184
  display: flex;
185
185
  height: 34px;
186
186
  align-items: center;
187
- gap: 10px;
188
- padding: 0 10px;
187
+ gap: var(--space-group);
188
+ padding: 0 var(--space-group);
189
189
  border-radius: var(--radius-control);
190
190
  color: var(--text-secondary);
191
191
  cursor: pointer;
@@ -35,19 +35,19 @@
35
35
 
36
36
  .message { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); pointer-events: none; }
37
37
 
38
- .tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 170px; max-width: min(240px, 100%); gap: 2px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }
38
+ .tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 170px; max-width: min(240px, 100%); gap: var(--space-hair); padding: var(--space-group) var(--space-group); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }
39
39
  .plot[data-active] .tooltip { opacity: 1; scale: 1; }
40
- .tipTitle { margin: 0 0 2px; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
41
- .tipValue, .tipRow { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0; font-variant-numeric: tabular-nums; white-space: nowrap; }
40
+ .tipTitle { margin: 0 0 var(--space-hair); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
41
+ .tipValue, .tipRow { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-group); margin: 0; font-variant-numeric: tabular-nums; white-space: nowrap; }
42
42
  .tipValue { color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: 1.4; }
43
43
  .tipRow { color: var(--foreground); font-size: var(--text-xs); line-height: var(--leading-body); }
44
44
  .tipKey { color: var(--text-muted); font-weight: 400; }
45
45
  .tipValue .tipKey { font-size: var(--text-xs); }
46
- .tipNote { margin: 4px 0 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
46
+ .tipNote { margin: var(--space-tight) 0 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
47
47
 
48
- .scale { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; margin-top: var(--space-group); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
49
- .scaleLabel { margin-right: 4px; color: var(--text-secondary); }
50
- .steps { display: inline-flex; gap: 2px; }
48
+ .scale { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--space-related); margin-top: var(--space-group); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
49
+ .scaleLabel { margin-right: var(--space-tight); color: var(--text-secondary); }
50
+ .steps { display: inline-flex; gap: var(--space-hair); }
51
51
  .step { width: 18px; height: 10px; border-radius: var(--radius-detail); background: var(--ridge); }
52
52
 
53
53
  .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@@ -40,10 +40,10 @@
40
40
  touch-action: none;
41
41
  transition: opacity var(--duration-considered) var(--ease-standard);
42
42
  }
43
- .track[data-axis="y"] { top: 3px; right: 1px; bottom: 3px; width: 11px; }
44
- .track[data-axis="x"] { right: 3px; bottom: 1px; left: 3px; height: 11px; }
45
- .root[data-orientation="both"] > .track[data-axis="y"] { bottom: 13px; }
46
- .root[data-orientation="both"] > .track[data-axis="x"] { right: 13px; }
43
+ .track[data-axis="y"] { top: var(--space-tight); right: 1px; bottom: var(--space-tight); width: 11px; }
44
+ .track[data-axis="x"] { right: var(--space-tight); bottom: 1px; left: var(--space-tight); height: 11px; }
45
+ .root[data-orientation="both"] > .track[data-axis="y"] { bottom: var(--space-group); }
46
+ .root[data-orientation="both"] > .track[data-axis="x"] { right: var(--space-group); }
47
47
  .track[data-hidden] { display: none; }
48
48
 
49
49
  .thumb { position: absolute; top: 0; left: 0; will-change: transform; }
@@ -56,8 +56,8 @@
56
56
  background: color-mix(in oklab, var(--foreground) 30%, transparent);
57
57
  transition: width var(--duration-fast) var(--ease-standard), height var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);
58
58
  }
59
- .track[data-axis="y"] .thumb::after { top: 0; right: 3px; bottom: 0; width: 5px; }
60
- .track[data-axis="x"] .thumb::after { right: 0; bottom: 3px; left: 0; height: 5px; }
59
+ .track[data-axis="y"] .thumb::after { top: 0; right: var(--space-tight); bottom: 0; width: 5px; }
60
+ .track[data-axis="x"] .thumb::after { right: 0; bottom: var(--space-tight); left: 0; height: 5px; }
61
61
 
62
62
  /* Visible while scrolling, while the pointer is over the area, and always when asked. Appearing is quick; leaving lingers. */
63
63
  .root[data-scrolling] > .track,
@@ -69,9 +69,9 @@
69
69
  .track:hover .thumb::after,
70
70
  .root[data-dragging] .thumb::after { background: color-mix(in oklab, var(--foreground) 46%, transparent); }
71
71
  .track[data-axis="y"]:hover .thumb::after,
72
- .root[data-dragging="y"] .track[data-axis="y"] .thumb::after { width: 7px; right: 2px; }
72
+ .root[data-dragging="y"] .track[data-axis="y"] .thumb::after { width: 7px; right: var(--space-hair); }
73
73
  .track[data-axis="x"]:hover .thumb::after,
74
- .root[data-dragging="x"] .track[data-axis="x"] .thumb::after { height: 7px; bottom: 2px; }
74
+ .root[data-dragging="x"] .track[data-axis="x"] .thumb::after { height: 7px; bottom: var(--space-hair); }
75
75
  }
76
76
 
77
77
  @media (prefers-reduced-motion: reduce) {
@@ -11,6 +11,8 @@ export interface SegmentedControlProps {
11
11
  label?: string;
12
12
  /** Called when the pointer or focus reaches an option, before it is chosen. Use it to start loading what that option shows. */
13
13
  onOptionIntent?: (value: string) => void;
14
+ /** `sm` (default) or `xs`, a compact 24px control for dense UI such as an Edit/Read toggle in a toolbar. */
15
+ size?: "sm" | "xs";
14
16
  className?: string;
15
17
  }
16
- export default function SegmentedControl({ options, value, onValueChange, label, onOptionIntent, className }: SegmentedControlProps): import("react").JSX.Element;
18
+ export default function SegmentedControl({ options, value, onValueChange, label, onOptionIntent, size, className }: SegmentedControlProps): import("react").JSX.Element;
@@ -3,11 +3,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useId, useLayoutEffect, useRef } from "react";
4
4
  import { LayoutGroup, motion, useReducedMotion } from "motion/react";
5
5
  import { motionTokens } from "../../lib/motion-tokens.js";
6
+ import { FluidHoverHighlight, useFluidHover } from "../../lib/fluid-hover.js";
6
7
  import styles from "./segmented-control.module.css";
7
- export default function SegmentedControl({ options, value, onValueChange, label, onOptionIntent, className }) {
8
+ export default function SegmentedControl({ options, value, onValueChange, label, onOptionIntent, size = "sm", className }) {
8
9
  const id = useId();
9
10
  const reduced = useReducedMotion();
10
11
  const track = useRef(null);
12
+ // Fluid hover: the nearest option is lit and a click in a gap picks it. Focus already moves the selection, so it leaves the fill alone.
13
+ const hover = useFluidHover(track, { items: "[data-value]", axis: "x", followFocus: false });
11
14
  // When the options are wider than the container, the track scrolls inside itself. Edges fade only on the side with more to see.
12
15
  useLayoutEffect(() => {
13
16
  const node = track.current;
@@ -52,5 +55,5 @@ export default function SegmentedControl({ options, value, onValueChange, label,
52
55
  onValueChange(options[target].value);
53
56
  track.current?.querySelector(`[data-value="${CSS.escape(options[target].value)}"]`)?.focus({ preventScroll: true });
54
57
  };
55
- return _jsx("div", { className: `${styles.root} ${className ?? ""}`, role: "group", "aria-label": label, children: _jsx(LayoutGroup, { id: id, children: _jsx(motion.div, { ref: track, layoutScroll: true, className: styles.track, children: options.map((option, index) => _jsxs("button", { id: `${id}-${option.value}`, className: styles.button, type: "button", "data-value": option.value, "aria-pressed": value === option.value, tabIndex: index === selectedIndex ? 0 : -1, onClick: () => onValueChange(option.value), onKeyDown: onKeyDown, onPointerEnter: onOptionIntent ? () => onOptionIntent(option.value) : undefined, onFocus: onOptionIntent ? () => onOptionIntent(option.value) : undefined, children: [value === option.value && _jsx(motion.span, { className: styles.selection, layoutId: "selection", layoutDependency: value, transition: reduced ? { duration: 0 } : motionTokens.spring.morph, "aria-hidden": "true" }), _jsxs("span", { className: styles.label, children: [option.label, option.accessory] })] }, option.value)) }) }) });
58
+ return _jsx("div", { className: `${styles.root} ${className ?? ""}`, role: "group", "aria-label": label, "data-size": size, children: _jsx(LayoutGroup, { id: id, children: _jsxs(motion.div, { ref: track, layoutScroll: true, className: styles.track, ...hover.handlers, children: [_jsx(FluidHoverHighlight, { hover: hover, className: styles.hover }), options.map((option, index) => _jsxs("button", { id: `${id}-${option.value}`, className: styles.button, type: "button", "data-value": option.value, "aria-pressed": value === option.value, tabIndex: index === selectedIndex ? 0 : -1, onClick: () => onValueChange(option.value), onKeyDown: onKeyDown, onPointerEnter: onOptionIntent ? () => onOptionIntent(option.value) : undefined, onFocus: onOptionIntent ? () => onOptionIntent(option.value) : undefined, children: [value === option.value && _jsx(motion.span, { className: styles.selection, layoutId: "selection", layoutDependency: value, transition: reduced ? { duration: 0 } : motionTokens.spring.morph, "aria-hidden": "true" }), _jsxs("span", { className: styles.label, children: [option.label, option.accessory] })] }, option.value))] }) }) });
56
59
  }
@@ -1,15 +1,20 @@
1
1
  /* The frame can shrink below its content inside flex and grid parents; the track then scrolls inside it. */
2
2
  .root { display: inline-flex; min-width: 0; max-width: 100%; flex-shrink: 1; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface-muted); }
3
3
  /* The track owns the stacking context so the gliding highlight passes under every label, not over earlier ones. */
4
- .track { --fade-start: 0px; --fade-end: 0px; isolation: isolate; display: flex; min-width: 0; gap: 2px; overflow-x: auto; overscroll-behavior-x: contain; padding: 3px; border-radius: inherit; corner-shape: inherit; scrollbar-width: none; -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); }
4
+ .track { --fade-start: 0px; --fade-end: 0px; position: relative; isolation: isolate; display: flex; min-width: 0; gap: var(--space-hair); overflow-x: auto; overscroll-behavior-x: contain; padding: var(--space-tight); border-radius: inherit; corner-shape: inherit; scrollbar-width: none; -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); }
5
5
  .track::-webkit-scrollbar { display: none; }
6
6
  .track[data-fade-start] { --fade-start: 20px; }
7
7
  .track[data-fade-end] { --fade-end: 20px; }
8
8
  /* Items never scale or change weight; only the highlight travels and the label color follows it. */
9
- .button { position: relative; flex: 0 0 auto; min-height: var(--control-height-sm); border: 0; border-radius: calc(var(--radius-control) - 4px); padding: 0 12px; background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }
9
+ .button { position: relative; flex: 0 0 auto; min-height: var(--control-height-sm); border: 0; border-radius: calc(var(--radius-control) - var(--space-tight)); padding: 0 var(--space-group); background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }
10
10
  @media (hover: hover) and (pointer: fine) { .button:hover { color: var(--foreground); } }
11
11
  .button:active, .button[aria-pressed="true"] { color: var(--foreground); }
12
+ /* The hover fill glides under the options and under the selection, so the selected option never changes on hover. */
13
+ .hover { z-index: -1; border-radius: calc(var(--radius-control) - var(--space-tight)); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--foreground) 5%, transparent); }
12
14
  .selection { position: absolute; z-index: -1; inset: 0; border: 1px solid var(--border); border-radius: inherit; corner-shape: inherit; background: var(--surface); box-shadow: var(--shadow-resting); }
13
- .label { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 6px; }
14
- @media (max-width: 380px) { .button { padding: 0 10px; } }
15
+ .label { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: var(--space-related); }
16
+ /* Compact: 24px segments in a thinner track, at the small type step. */
17
+ .root[data-size="xs"] .track { padding: var(--space-hair); }
18
+ .root[data-size="xs"] .button { min-height: var(--control-height-xs); padding: 0 var(--space-related); font-size: var(--text-xs); }
19
+ @media (max-width: 380px) { .button { padding: 0 var(--space-group); } }
15
20
  @media (prefers-reduced-motion: reduce) { .button { transition: none; } }