@teamofsilicons/silicon-ui 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/animated-counter/animated-counter.js +1 -1
- package/components/bar-chart/bar-chart.js +1 -1
- package/components/billing-toggle/billing-toggle.js +2 -2
- package/components/bottom-sheet/bottom-sheet.js +1 -1
- package/components/button-group/button-group.module.css +1 -1
- package/components/calendar/calendar.js +15 -5
- package/components/calendar/calendar.module.css +6 -3
- package/components/card/card.js +4 -4
- package/components/card-stack/card-stack.js +2 -2
- package/components/carousel/carousel.module.css +1 -1
- package/components/chip-group/chip-group.js +1 -1
- package/components/combobox/combobox.js +5 -1
- package/components/command-palette/command-palette.js +18 -11
- package/components/confirm-morph/confirm-morph.js +5 -5
- package/components/context-menu/context-menu.js +10 -28
- package/components/copy-button/copy-button.js +4 -4
- package/components/date-picker/date-picker.js +3 -3
- package/components/date-picker/date-picker.module.css +1 -1
- package/components/date-range-picker/date-range-picker.js +13 -10
- package/components/date-range-picker/date-range-picker.module.css +2 -1
- package/components/donut-chart/donut-chart.module.css +1 -1
- package/components/drawer/drawer.module.css +2 -2
- package/components/elastic-slider/elastic-slider.js +2 -2
- package/components/expandable-card/expandable-card.js +1 -1
- package/components/expanding-button-group/expanding-button-group.module.css +2 -2
- package/components/expanding-search/expanding-search.js +3 -3
- package/components/file-dropzone/file-dropzone.js +3 -3
- package/components/filter-toolbar/filter-toolbar.js +7 -3
- package/components/floating-button-group/floating-button-group.module.css +1 -1
- package/components/gauge/gauge.js +1 -1
- package/components/hover-card/hover-card.js +2 -2
- package/components/line-chart/line-chart.js +1 -1
- package/components/mention-input/mention-input.js +4 -2
- package/components/money-input/money-input.js +6 -3
- package/components/money-input/money-input.module.css +1 -1
- package/components/morph-select/morph-select.js +7 -5
- package/components/multi-select/multi-select.js +4 -2
- package/components/nav-list/nav-list.d.ts +2 -2
- package/components/nav-list/nav-list.js +6 -13
- package/components/number-field/number-field.js +3 -3
- package/components/number-field/number-field.module.css +1 -1
- package/components/pagination/pagination.module.css +1 -1
- package/components/password-field/password-field.module.css +1 -1
- package/components/password-strength/password-strength.module.css +1 -1
- package/components/phone-input/phone-input.js +9 -7
- package/components/radio-group/radio-group.js +4 -1
- package/components/radio-group/radio-group.module.css +3 -2
- package/components/rich-text-editor/rich-text-editor.js +3 -3
- package/components/segmented-control/segmented-control.js +4 -1
- package/components/segmented-control/segmented-control.module.css +3 -1
- package/components/shortcut-recorder/shortcut-recorder.js +5 -5
- package/components/shortcut-recorder/shortcut-recorder.module.css +1 -1
- package/components/slot-text/slot-text.js +2 -1
- package/components/sparkline/sparkline.js +1 -1
- package/components/split-button/split-button.module.css +1 -1
- package/components/swipe-actions/swipe-actions.js +2 -2
- package/components/switch/switch.js +2 -2
- package/components/tabs/tabs.js +7 -3
- package/components/tabs/tabs.module.css +3 -1
- package/components/text-reveal/text-reveal.js +1 -1
- package/components/time-picker/time-picker.js +4 -2
- package/components/timeline/timeline.js +4 -4
- package/components/toast/toast.js +1 -1
- package/components/toast-stack/toast-stack.js +1 -1
- package/components/tree-view/tree-view.js +16 -12
- package/components/tree-view/tree-view.module.css +4 -2
- package/components/usage-meter/usage-meter.js +1 -1
- package/components/user-menu/user-menu.js +16 -14
- package/lib/fluid-hover.d.ts +83 -0
- package/lib/fluid-hover.js +184 -0
- package/lib/menu-highlight.d.ts +24 -11
- package/lib/menu-highlight.js +29 -28
- package/lib/motion-tokens.d.ts +76 -18
- package/lib/motion-tokens.js +45 -10
- package/package.json +1 -1
- package/styles.css +6 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { FluidHoverHighlight, useFluidHover } from "../../lib/fluid-hover.js";
|
|
4
5
|
import { MenuHighlight } from "../../lib/menu-highlight.js";
|
|
5
6
|
import { SiliconLink } from "../../lib/silicon-provider.js";
|
|
6
7
|
import { Badge } from "../badge/badge.js";
|
|
@@ -10,14 +11,14 @@ const boxOf = (item) => ({ top: item.offsetTop, height: item.offsetHeight, dange
|
|
|
10
11
|
/** Lets NavItem reach its list without prop drilling: the active marker's position, and reporting a choice. */
|
|
11
12
|
const NavContext = createContext(null);
|
|
12
13
|
/**
|
|
13
|
-
* A sidebar's list of destinations: a `<nav>` with a list of NavItem rows, optionally grouped by NavSection.
|
|
14
|
-
*
|
|
14
|
+
* A sidebar's list of destinations: a `<nav>` with a list of NavItem rows, optionally grouped by NavSection. The row nearest
|
|
15
|
+
* the pointer is lit (fluid hover), and the current page's marker glides when the page changes.
|
|
15
16
|
*/
|
|
16
17
|
export function NavList({ label, children, className, ref, onNavigate }) {
|
|
17
18
|
const root = useRef(null);
|
|
18
|
-
const [hover, setHover] = useState(null);
|
|
19
19
|
const [active, setActive] = useState(null);
|
|
20
|
-
|
|
20
|
+
// The row nearest the pointer is lit, gaps included, and a click in a gap follows the row (lib/fluid-hover).
|
|
21
|
+
const hover = useFluidHover(root, { items: ITEM });
|
|
21
22
|
// The latest handler, read at choice time, so a new function each render never changes the context.
|
|
22
23
|
const navigateHandler = useRef(onNavigate);
|
|
23
24
|
useEffect(() => { navigateHandler.current = onNavigate; });
|
|
@@ -56,16 +57,8 @@ export function NavList({ label, children, className, ref, onNavigate }) {
|
|
|
56
57
|
observer.observe(node);
|
|
57
58
|
return () => observer.disconnect();
|
|
58
59
|
}, [measureActive]);
|
|
59
|
-
function show(target, glide) {
|
|
60
|
-
const item = target instanceof HTMLElement ? target.closest(ITEM) : null;
|
|
61
|
-
if (!item || item.getAttribute("aria-disabled") === "true")
|
|
62
|
-
return setHover(null);
|
|
63
|
-
setHover(current => ({ ...boxOf(item), glide: glide && current !== null }));
|
|
64
|
-
}
|
|
65
60
|
const context = useMemo(() => ({ activeChanged: measureActive, navigate: (choice) => navigateHandler.current?.(choice) }), [measureActive]);
|
|
66
|
-
return (_jsx(NavContext.Provider, { value: context, children: _jsxs("nav", { ref: setRoot, "aria-label": label, className: [styles.list, className].filter(Boolean).join(" "),
|
|
67
|
-
show(event.target, false); }, onBlur: (event) => { if (!pointer.current && !event.currentTarget.contains(event.relatedTarget))
|
|
68
|
-
setHover(null); }, children: [_jsx(MenuHighlight, { state: hover, className: styles.highlight }), _jsx(MenuHighlight, { state: active, className: styles.active }), _jsx("ul", { className: styles.items, children: children })] }) }));
|
|
61
|
+
return (_jsx(NavContext.Provider, { value: context, children: _jsxs("nav", { ref: setRoot, "aria-label": label, className: [styles.list, className].filter(Boolean).join(" "), ...hover.handlers, children: [_jsx(FluidHoverHighlight, { hover: hover, className: styles.highlight }), _jsx(MenuHighlight, { state: active, className: styles.active }), _jsx("ul", { className: styles.items, children: children })] }) }));
|
|
69
62
|
}
|
|
70
63
|
/** A labelled group of NavItem rows inside a NavList. */
|
|
71
64
|
export function NavSection({ label, children }) {
|
|
@@ -42,7 +42,7 @@ function partsOf(value, format) {
|
|
|
42
42
|
Scale and opacity ride the same spring as width, so a digit grows with its slot instead of landing on its neighbours. */
|
|
43
43
|
const slot = {
|
|
44
44
|
enter: (direction) => ({ width: 0, scale: .6, opacity: 0, y: `${direction * .3}em`, filter: `blur(${blur.soft}px)` }),
|
|
45
|
-
center: { width: "auto", scale: 1, opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" }, transition: { width: spring.morph, scale: spring.morph, opacity: spring.morph, y: spring.snappy, filter: { duration: .
|
|
45
|
+
center: { width: "auto", scale: 1, opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" }, transition: { width: spring.morph, scale: spring.morph, opacity: spring.morph, y: spring.snappy, filter: { duration: motionTokens.duration.standard, ease: exitEase } } },
|
|
46
46
|
exit: (direction) => ({ width: 0, scale: .6, opacity: 0, y: `${direction * -.3}em`, filter: `blur(${blur.subtle}px)`, transition: { width: spring.smooth, scale: spring.smooth, y: { duration: duration.fast, ease: exitEase }, opacity: { duration: duration.instant }, filter: { duration: duration.instant } } }),
|
|
47
47
|
};
|
|
48
48
|
/* Reduced motion keeps a short fade and no travel. While typing, columns follow the draft at once so they never lag the caret.
|
|
@@ -99,8 +99,8 @@ const directionsBetween = (from, to) => { const before = from.split(" "); return
|
|
|
99
99
|
const wordRise = {
|
|
100
100
|
enter: (direction) => ({ opacity: 0, y: `${direction * .3}em`, filter: `blur(${blur.soft}px)` }),
|
|
101
101
|
// Opacity follows the slot's width spring, so a longer word never shows before its room has opened.
|
|
102
|
-
center: { opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" }, transition: { duration: .
|
|
103
|
-
exit: (direction) => ({ opacity: 0, y: `${direction * -.3}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: .
|
|
102
|
+
center: { opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" }, transition: { duration: motionTokens.duration.exit, ease: enterEase, opacity: spring.morph } },
|
|
103
|
+
exit: (direction) => ({ opacity: 0, y: `${direction * -.3}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: exitEase } }),
|
|
104
104
|
};
|
|
105
105
|
const wordFade = { enter: { opacity: 0, y: 0, filter: "none" }, center: { opacity: 1, y: 0, filter: "none", transition: { duration: duration.instant } }, exit: { opacity: 0, transition: { duration: 0 } } };
|
|
106
106
|
/** A word's width follows its new copy on a spring, so a price gaining a digit eases the rest of the line along instead of shoving it. */
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
@media (hover: hover) and (pointer: fine) { .step:hover:not(:disabled, [aria-disabled="true"]) { background: var(--surface-muted); color: var(--foreground); } }
|
|
27
27
|
/* Quick press, spring release: the fill deepens and the glyph sinks. A held button stays pressed for as long as it repeats. */
|
|
28
28
|
.step:is(:active, [data-pressed]):not(:disabled, [aria-disabled="true"]) { background: color-mix(in oklab, var(--foreground) 10%, var(--surface)); color: var(--foreground); transition: background-color 60ms linear, color 60ms linear; }
|
|
29
|
-
.step:is(:active, [data-pressed]):not(:disabled, [aria-disabled="true"]) svg { transform: scale(.82); transition: transform
|
|
29
|
+
.step:is(:active, [data-pressed]):not(:disabled, [aria-disabled="true"]) svg { transform: scale(.82); transition: transform var(--duration-fast) var(--ease-standard); }
|
|
30
30
|
.step:focus-visible { outline: none; background: var(--surface-muted); color: var(--foreground); }
|
|
31
31
|
/* At a limit the button dims and refuses, but stays in the tab order; pressing it strains the value toward the limit and it shakes once. */
|
|
32
32
|
.step[aria-disabled="true"] { opacity: .32; cursor: not-allowed; }
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
.nav:not([data-mark-ready]) button[aria-current="page"] { border-color: var(--border); background: var(--surface); box-shadow: var(--shadow-resting); }
|
|
8
8
|
/* Only the arrows press in. Page numbers move with Motion, so they keep CSS off transform and opacity. */
|
|
9
9
|
.nav .step { transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
|
|
10
|
-
.nav .step:active:not(:disabled) { transform: scale(.96); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast),
|
|
10
|
+
.nav .step:active:not(:disabled) { transform: scale(.96); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), var(--duration-fast); transition-timing-function: var(--ease-standard), var(--ease-standard), var(--ease-standard), var(--ease-standard); }
|
|
11
11
|
.nav button:disabled { opacity: .4; cursor: not-allowed; }
|
|
12
12
|
@media (hover: hover) and (pointer: fine) {
|
|
13
13
|
.nav button:hover:not(:disabled) { background: var(--surface-muted); color: var(--foreground); }
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
@keyframes resolve-hidden { from { opacity: .35; filter: blur(2px); } }
|
|
16
16
|
.shell button { display: grid; width: 30px; height: 30px; flex: 0 0 auto; place-items: center; border: 0; border-radius: var(--radius-control); background: transparent; color: var(--text-muted); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
|
|
17
17
|
@media (hover: hover) and (pointer: fine) { .shell button:hover { background: var(--surface-muted); color: var(--foreground); } }
|
|
18
|
-
.shell button:active { transform: scale(.96); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform
|
|
18
|
+
.shell button:active { transform: scale(.96); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }
|
|
19
19
|
.shell button[aria-pressed="true"] { color: var(--foreground); }
|
|
20
20
|
/* The row's space is padding inside the clipped slot, so the height animates from a true zero. */
|
|
21
21
|
.messageSlot { display: block; overflow: hidden; }
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
@keyframes resolve-hidden { from { opacity: .35; filter: blur(2px); } }
|
|
21
21
|
.reveal { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; padding: 0; border: 0; border-radius: calc(var(--radius-control) - var(--space-tight)); corner-shape: var(--corner-shape); background: transparent; color: var(--text-muted); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
|
|
22
22
|
@media (hover: hover) and (pointer: fine) { .reveal:hover { background: var(--surface-muted); color: var(--foreground); } }
|
|
23
|
-
.reveal:active { transform: scale(.94); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform
|
|
23
|
+
.reveal:active { transform: scale(.94); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }
|
|
24
24
|
.reveal[aria-pressed="true"] { color: var(--foreground); }
|
|
25
25
|
/* The row's space is padding inside the clipped slot, so its height opens from a true zero. */
|
|
26
26
|
.errorSlot { display: block; overflow: hidden; }
|
|
@@ -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: .
|
|
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: .
|
|
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: .
|
|
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: .
|
|
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); },
|
|
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: .
|
|
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;
|
|
@@ -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,7 +21,7 @@
|
|
|
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
|
|
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; }
|
|
@@ -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: .
|
|
690
|
-
gone: (direction) => ({ opacity: 0, x: direction * -TRAVEL, filter: `blur(${blur.subtle}px)`, transition: { x: MORPH, opacity: { duration: .
|
|
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: .
|
|
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);
|
|
@@ -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
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, "data-size": size, children: _jsx(LayoutGroup, { id: id, children:
|
|
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,7 +1,7 @@
|
|
|
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: 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); }
|
|
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; }
|
|
@@ -9,6 +9,8 @@
|
|
|
9
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
15
|
.label { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: var(--space-related); }
|
|
14
16
|
/* Compact: 24px segments in a thinner track, at the small type step. */
|
|
@@ -185,10 +185,10 @@ const RESERVED = [
|
|
|
185
185
|
];
|
|
186
186
|
const chip = {
|
|
187
187
|
enter: { opacity: 0, scale: .72, y: 4, filter: `blur(${motionTokens.blur.subtle}px)` },
|
|
188
|
-
rest: (index) => ({ opacity: 1, scale: 1, y: 0, filter: "blur(0px)", transition: { ...motionTokens.spring.snappy, delay: index * motionTokens.stagger.item, opacity: { duration: .
|
|
189
|
-
exit: { opacity: 0, scale: .8, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: .
|
|
188
|
+
rest: (index) => ({ opacity: 1, scale: 1, y: 0, filter: "blur(0px)", transition: { ...motionTokens.spring.snappy, delay: index * motionTokens.stagger.item, opacity: { duration: motionTokens.duration.fast, delay: index * motionTokens.stagger.item }, filter: { duration: motionTokens.duration.fast, delay: index * motionTokens.stagger.item } } }),
|
|
189
|
+
exit: { opacity: 0, scale: .8, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant } },
|
|
190
190
|
};
|
|
191
|
-
const chipFade = { enter: { opacity: 0 }, rest: { opacity: 1, scale: 1, y: 0, filter: "none", transition: { duration: .
|
|
191
|
+
const chipFade = { enter: { opacity: 0 }, rest: { opacity: 1, scale: 1, y: 0, filter: "none", transition: { duration: motionTokens.duration.instant } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
|
|
192
192
|
/**
|
|
193
193
|
* A field that records a keyboard shortcut. Press it, then press the combination: held modifiers light up as caps while you
|
|
194
194
|
* build the chord, the finished shortcut settles into place, and a combination that is already taken asks before it is used.
|
|
@@ -339,7 +339,7 @@ export function ShortcutRecorder({ label, hideLabel = false, value, defaultValue
|
|
|
339
339
|
} start(); }, onKeyDown: onKeyDown, onKeyUp: onKeyUp, onBlur: () => { if (recording) {
|
|
340
340
|
stop();
|
|
341
341
|
setNudge(null);
|
|
342
|
-
} }, children: [_jsx("span", { className: styles.srOnly, children: recording ? "Recording" : shown ? spoken(shown) : placeholder }), _jsxs("span", { className: styles.row, "aria-hidden": "true", children: [recording && _jsx("span", { className: styles.dot, "data-reduced": reduced || undefined }), _jsxs(AnimatePresence, { initial: false, mode: "popLayout", children: [tokens.map((token, index) => _jsx(motion.span, { layout: reduced ? false : "position", custom: recording ? 0 : index, variants: reduced ? chipFade : chip, initial: "enter", animate: "rest", exit: "exit", transition: reduced ? { duration: 0 } : { layout: motionTokens.spring.snappy }, className: styles.chipSlot, children: _jsx(ShortcutKbd, { pressed: recording || (settled > 0 && !pending), "data-tone": pending ? "warning" : undefined, children: token.label }) }, `${token.id}`)), tokens.length === 0 && _jsx(motion.span, { className: styles.placeholder, "data-recording": recording || undefined, initial: reduced ? { opacity: 0 } : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: .
|
|
342
|
+
} }, children: [_jsx("span", { className: styles.srOnly, children: recording ? "Recording" : shown ? spoken(shown) : placeholder }), _jsxs("span", { className: styles.row, "aria-hidden": "true", children: [recording && _jsx("span", { className: styles.dot, "data-reduced": reduced || undefined }), _jsxs(AnimatePresence, { initial: false, mode: "popLayout", children: [tokens.map((token, index) => _jsx(motion.span, { layout: reduced ? false : "position", custom: recording ? 0 : index, variants: reduced ? chipFade : chip, initial: "enter", animate: "rest", exit: "exit", transition: reduced ? { duration: 0 } : { layout: motionTokens.spring.snappy }, className: styles.chipSlot, children: _jsx(ShortcutKbd, { pressed: recording || (settled > 0 && !pending), "data-tone": pending ? "warning" : undefined, children: token.label }) }, `${token.id}`)), tokens.length === 0 && _jsx(motion.span, { className: styles.placeholder, "data-recording": recording || undefined, initial: reduced ? { opacity: 0 } : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } }, transition: reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, children: recording ? "Press keys" : placeholder }, recording ? "prompt" : "placeholder")] })] })] }), _jsx("span", { className: styles.actions, children: _jsxs(AnimatePresence, { initial: false, children: [canReset && _jsx(motion.button, { type: "button", className: styles.action, "aria-label": `Reset to ${restoreTo ? formatShortcut(restoreTo, platform).spoken : "none"}`, onClick: () => { commit(restoreTo ?? null); buttonRef.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 } : motionTokens.spring.snappy, children: _jsx(RotateCcw, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }, "reset"), canClear && _jsx(motion.button, { type: "button", className: styles.action, "aria-label": "Clear shortcut", onClick: () => { commit(null); buttonRef.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 } : motionTokens.spring.snappy, children: _jsx(X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }, "clear")] }) })] }), _jsx(AnimatePresence, { initial: false, children: message && _jsx(motion.div, { className: styles.messageSlot, initial: reduced ? false : { height: 0, opacity: 0 }, animate: { height: messageHeight ?? "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("div", { ref: measureMessage, children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsxs(motion.div, { id: messageId, className: styles.message, "data-tone": message.tone, initial: reduced ? { opacity: 0 } : { opacity: 0, y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } }, transition: { duration: reduced ? .1 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: [_jsx("span", { className: styles.messageText, children: message.body }), pending && _jsxs("span", { className: styles.messageActions, children: [_jsx("button", { type: "button", className: styles.link, onClick: () => { const taken = pending; commit(taken.shortcut, taken.reserved ? undefined : taken.conflict); buttonRef.current?.focus(); }, children: "Use anyway" }), _jsx("button", { type: "button", className: styles.link, "data-quiet": "", onClick: () => { setPending(null); setAnnouncement("Kept the previous shortcut"); buttonRef.current?.focus(); }, children: "Cancel" })] })] }, message.key) }) }) }, "slot") }), _jsx("span", { className: styles.srOnly, role: "status", "aria-live": "polite", children: announcement })] });
|
|
343
343
|
}
|
|
344
344
|
/**
|
|
345
345
|
* A searchable, grouped cheatsheet. Holding a modifier lights that cap in every row and fades rows that do not use it, so people
|
|
@@ -369,7 +369,7 @@ export function ShortcutList({ groups, label = "Keyboard shortcuts", searchable
|
|
|
369
369
|
return _jsxs("section", { className: [styles.list, className].filter(Boolean).join(" "), "aria-label": label, children: [searchable && _jsxs("div", { className: styles.searchRow, children: [_jsx(Search, { className: styles.searchIcon, size: 16, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { ref: searchRef, className: styles.search, type: "search", placeholder: searchPlaceholder, value: query, "aria-label": searchPlaceholder, onChange: event => setQuery(event.target.value), onKeyDown: event => { if (event.key === "Escape" && query) {
|
|
370
370
|
event.preventDefault();
|
|
371
371
|
setQuery("");
|
|
372
|
-
} }, autoComplete: "off", spellCheck: false }), _jsx("span", { className: styles.count, "aria-live": "polite", children: query ? `${total} ${total === 1 ? "match" : "matches"}` : "" }), _jsx(AnimatePresence, { initial: false, children: query && _jsx(motion.button, { type: "button", className: styles.clear, "aria-label": "Clear search", onClick: () => { setQuery(""); searchRef.current?.focus(); }, initial: { opacity: 0, scale: .6 }, animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: .6, transition: { duration: .
|
|
372
|
+
} }, autoComplete: "off", spellCheck: false }), _jsx("span", { className: styles.count, "aria-live": "polite", children: query ? `${total} ${total === 1 ? "match" : "matches"}` : "" }), _jsx(AnimatePresence, { initial: false, children: query && _jsx(motion.button, { type: "button", className: styles.clear, "aria-label": "Clear search", onClick: () => { setQuery(""); 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 } : motionTokens.spring.snappy, children: _jsx(X, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }) }, "clear") })] }), _jsxs("div", { className: styles.groups, children: [_jsx(AnimatePresence, { initial: false, children: filtered.map(group => _jsxs(motion.section, { className: styles.group, "aria-label": group.label, initial: reduced ? { opacity: 0 } : { opacity: 0, height: 0 }, animate: { opacity: 1, height: "auto" }, exit: reduced ? { opacity: 0 } : { opacity: 0, height: 0 }, transition: rowTransition, children: [_jsx("h4", { className: styles.groupLabel, children: group.label }), _jsx("ul", { className: styles.rows, children: _jsx(AnimatePresence, { initial: false, children: group.items.map(item => {
|
|
373
373
|
const tokens = shortcutTokens(item.shortcut, platform);
|
|
374
374
|
const ids = tokens.map(token => token.id);
|
|
375
375
|
const match = held.size > 0 && ids.every(key => held.has(key));
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/* A key cap: a one pixel edge with a slightly heavier bottom, so it reads as a key without a shadow. Held, it drops a pixel and takes the accent. */
|
|
4
4
|
.kbd { display: inline-grid; min-width: 26px; height: 26px; box-sizing: border-box; place-items: center; padding: 0 var(--space-related) 1px; border: 1px solid var(--border-strong); border-bottom-width: 2px; border-radius: var(--radius-detail); corner-shape: var(--corner-shape); background: var(--surface-raised); color: var(--foreground); font-family: var(--font-body); font-size: var(--text-xs); font-weight: 500; line-height: 1; letter-spacing: 0; font-variant-numeric: tabular-nums; white-space: nowrap; transition: transform var(--duration-spring) var(--ease-spring), border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), border-bottom-width var(--duration-fast) var(--ease-standard); }
|
|
5
5
|
.kbd[data-size="sm"] { min-width: 22px; height: 22px; padding: 0 var(--space-tight) 1px; border-radius: var(--radius-detail); corner-shape: var(--corner-shape); font-size: var(--text-xs); }
|
|
6
|
-
.kbd[data-pressed] { transform: translateY(1px); border-color: color-mix(in oklab, var(--accent) 55%, var(--border-strong)); border-bottom-width: 1px; background: var(--accent-subtle); color: var(--accent-strong); transition-duration:
|
|
6
|
+
.kbd[data-pressed] { transform: translateY(1px); border-color: color-mix(in oklab, var(--accent) 55%, var(--border-strong)); border-bottom-width: 1px; background: var(--accent-subtle); color: var(--accent-strong); transition-duration: var(--duration-fast); }
|
|
7
7
|
.kbd[data-tone="warning"] { border-color: color-mix(in oklab, var(--warning) 55%, var(--border-strong)); background: color-mix(in oklab, var(--warning) 12%, var(--surface-raised)); color: var(--foreground); }
|
|
8
8
|
.keys { display: inline-flex; flex: none; align-items: center; gap: var(--space-tight); }
|
|
9
9
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import { AnimatePresence, animate, motion, useMotionValue, usePresence, useReducedMotion, useTransform, useVelocity } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
5
6
|
import styles from "./slot-text.module.css";
|
|
6
7
|
const DIGITS = "0123456789";
|
|
7
8
|
const LOWER = "abcdefghijklmnopqrstuvwxyz";
|
|
@@ -94,7 +95,7 @@ function Reel({ char, order, entering, rising, duration, stagger, spins, reduced
|
|
|
94
95
|
safeToRemove();
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
|
-
const exit = animate(width, 0, { type: "spring", visualDuration: .
|
|
98
|
+
const exit = animate(width, 0, { type: "spring", visualDuration: motionTokens.spring.moderate.visualDuration, bounce: 0, onComplete: safeToRemove });
|
|
98
99
|
return () => exit.stop();
|
|
99
100
|
}, [isPresent, safeToRemove, width, reduced]);
|
|
100
101
|
return _jsxs(motion.span, { className: styles.slot, style: { width }, "data-exiting": isPresent ? undefined : "", children: [_jsx("span", { ref: sizer, className: styles.sizer, children: char === " " ? "\u00a0" : char }), _jsx("span", { className: styles.window, children: _jsx(motion.span, { className: styles.strip, style: { transform: y, filter: blur }, children: reel.strip.map((cell, i) => _jsx("span", { className: styles.cell, children: cell === " " ? "\u00a0" : cell }, i)) }) })] });
|
|
@@ -10,7 +10,7 @@ const fade = { hidden: { opacity: 0, y: 0, filter: "blur(0px)" }, shown: { opaci
|
|
|
10
10
|
const amountIn = (text) => Number(text.replace(/,/g, "").replace(/−/g, "-").match(/-?\d+(?:\.\d+)?/)?.[0] ?? NaN);
|
|
11
11
|
const grouped = new Intl.NumberFormat("en-US");
|
|
12
12
|
/** The first draw: one smooth stroke, slow at both ends like a pen. */
|
|
13
|
-
const draw = { duration: motionTokens.duration.
|
|
13
|
+
const draw = { duration: motionTokens.duration.expressive * 1.75, ease: [...motionTokens.ease.inOut] };
|
|
14
14
|
const quick = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
|
|
15
15
|
/** A number keeps its unchanged digits still, keyed by place from the right. Words key on everything up to them, so only the first changed word and what follows it roll. */
|
|
16
16
|
function chunksOf(text, whole) {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
.menu[data-state="open"] .item { opacity: 0; transform: translate(calc(var(--menu-x) * .4), calc(var(--menu-y) * .4)); }
|
|
36
36
|
}
|
|
37
37
|
/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */
|
|
38
|
-
.menu[data-state="closed"] { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); transition: opacity
|
|
38
|
+
.menu[data-state="closed"] { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); animation: menu-exit var(--duration-instant) linear both; pointer-events: none; }
|
|
39
39
|
.highlight { position: absolute; top: 0; right: var(--space-tight); left: var(--space-tight); border-radius: calc(var(--radius-container) - var(--space-related)); background: var(--surface-muted); opacity: 0; pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard); }
|
|
40
40
|
.highlight[data-tone="danger"] { background: color-mix(in oklab, var(--danger) 8%, var(--surface)); }
|
|
41
41
|
/* One highlight glides between items for the pointer and jumps instantly for the keyboard (lib/menu-highlight). */
|
|
@@ -281,9 +281,9 @@ export function SwipeActionsRow({ label, leading = [], trailing = [], fullSwipe
|
|
|
281
281
|
returnHome(); }, [present]);
|
|
282
282
|
const menuItems = [...leading.map((action, index) => ({ action, side: "leading", index })), ...trailing.map((action, index) => ({ action, side: "trailing", index }))];
|
|
283
283
|
const collapse = reduced
|
|
284
|
-
? { opacity: 0, transition: { duration: .
|
|
284
|
+
? { opacity: 0, transition: { duration: motionTokens.duration.fast } }
|
|
285
285
|
: { height: 0, opacity: 0, transition: { height: { ...motionTokens.spring.smooth, delay: .12 }, opacity: { duration: motionTokens.duration.instant, delay: .34 } } };
|
|
286
|
-
return _jsxs(motion.li, { ref: rowRef, className: [styles.row, className].filter(Boolean).join(" "), style: { "--swipe-action-width": `${ACTION}px` }, initial: reduced ? { opacity: 0 } : { height: 0, opacity: 0 }, animate: { height: "auto", opacity: 1 }, exit: collapse, transition: reduced ? { duration: .
|
|
286
|
+
return _jsxs(motion.li, { ref: rowRef, className: [styles.row, className].filter(Boolean).join(" "), style: { "--swipe-action-width": `${ACTION}px` }, initial: reduced ? { opacity: 0 } : { height: 0, opacity: 0 }, animate: { height: "auto", opacity: 1 }, exit: collapse, transition: reduced ? { duration: motionTokens.duration.fast } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, children: [["leading", "trailing"].map(side => actionsOf(side).map((action, index) => {
|
|
287
287
|
const count = actionsOf(side).length;
|
|
288
288
|
const rank = side === "leading" ? count - 1 - index : index;
|
|
289
289
|
const coverRank = covering?.side === side ? (side === "leading" ? count - 1 - covering.index : covering.index) : null;
|
|
@@ -11,7 +11,7 @@ const travel = 18;
|
|
|
11
11
|
/** How far the thumb widens toward the other side while pressed. */
|
|
12
12
|
const stretch = 5;
|
|
13
13
|
/** Critically damped: the thumb lands on its end without overshooting the state it reports. */
|
|
14
|
-
const glide = { type: "spring", visualDuration:
|
|
14
|
+
const glide = { type: "spring", visualDuration: motionTokens.spring.fast.visualDuration, bounce: 0 };
|
|
15
15
|
export const Switch = forwardRef(function Switch({ label, className, checked, defaultChecked, onCheckedChange, onPointerDown, onPointerUp, onPointerLeave, onPointerCancel, onKeyDown, onKeyUp, onBlur, ...props }, ref) {
|
|
16
16
|
const reduceMotion = useReducedMotion();
|
|
17
17
|
const [internal, setInternal] = useState(defaultChecked ?? false);
|
|
@@ -29,7 +29,7 @@ export const Switch = forwardRef(function Switch({ label, className, checked, de
|
|
|
29
29
|
const fromPress = performance.now() - releasedAt.current < 250;
|
|
30
30
|
if (reduceMotion || fromPress)
|
|
31
31
|
return;
|
|
32
|
-
const controls = animate(scaleX, [1, 1.16, 1], { duration:
|
|
32
|
+
const controls = animate(scaleX, [1, 1.16, 1], { duration: motionTokens.duration.standard, times: [0, 0.4, 1], ease: ["easeOut", "easeInOut"] });
|
|
33
33
|
return () => controls.stop();
|
|
34
34
|
}, [on, reduceMotion, scaleX]);
|
|
35
35
|
const extra = pressed && !reduceMotion && !props.disabled ? stretch : 0;
|
package/components/tabs/tabs.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createContext, useCallback, useContext, useId, useLayoutEffect, useRef,
|
|
|
6
6
|
import { ChevronLeft as NavArrowLeft, ChevronRight as NavArrowRight } from "lucide-react";
|
|
7
7
|
import { AnimatePresence, LayoutGroup, animate, motion, useReducedMotion } from "motion/react";
|
|
8
8
|
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
9
|
+
import { FluidHoverHighlight, useFluidHover } from "../../lib/fluid-hover.js";
|
|
9
10
|
import styles from "./tabs.module.css";
|
|
10
11
|
/** `direction` is +1 when the new tab sits after the old one; `panelHeightRef` holds the visible panel height so the next panel can morph from it; `leavingRectRef` pins the outgoing panel where it was on screen. */
|
|
11
12
|
const TabsContext = createContext({ active: "", layoutId: "tabs", direction: 1, panelHeightRef: { current: null }, leavingRectRef: { current: null } });
|
|
@@ -37,6 +38,9 @@ export function TabsList({ className, ...props }) {
|
|
|
37
38
|
const viewport = useRef(null);
|
|
38
39
|
const list = useRef(null);
|
|
39
40
|
const [edges, setEdges] = useState({ overflow: false, left: false, right: false });
|
|
41
|
+
// Fluid hover: the nearest tab is lit, gaps included. Radix selects on mouse-down, so a gap click stays inert, and focus
|
|
42
|
+
// already shows the selection, so the keyboard leaves the hover fill alone.
|
|
43
|
+
const hover = useFluidHover(list, { items: '[role="tab"]', axis: "x", gapClick: false, followFocus: false });
|
|
40
44
|
const update = useCallback(() => {
|
|
41
45
|
const frame = shell.current;
|
|
42
46
|
const scroll = viewport.current;
|
|
@@ -75,8 +79,8 @@ export function TabsList({ className, ...props }) {
|
|
|
75
79
|
}, [update]);
|
|
76
80
|
useLayoutEffect(() => { reveal(list.current?.querySelector('[role="tab"][data-state="active"]') ?? null); }, [active, reveal]);
|
|
77
81
|
const scrollTabs = (direction) => viewport.current?.scrollBy({ left: direction * (viewport.current?.clientWidth ?? 0) * .75, behavior: reduced ? "instant" : "smooth" });
|
|
78
|
-
return _jsxs("div", { ref: shell, className: styles.listShell, "data-overflow": edges.overflow, "data-left": edges.left, "data-right": edges.right, children: [edges.overflow && _jsx("button", { type: "button", className: `${styles.scrollButton} ${styles.scrollLeft}`, "aria-label": "Scroll tabs left", disabled: !edges.left, onClick: () => scrollTabs(-1), children: _jsx(NavArrowLeft, { width: 17, height: 17, "aria-hidden": "true" }) }), _jsx(motion.div, { ref: viewport, layoutScroll: true, className: styles.viewport, onFocusCapture: event => { if (event.target instanceof HTMLElement && event.target.getAttribute("role") === "tab")
|
|
79
|
-
reveal(event.target); }, children:
|
|
82
|
+
return _jsxs("div", { ref: shell, className: styles.listShell, "data-overflow": edges.overflow, "data-left": edges.left, "data-right": edges.right, children: [edges.overflow && _jsx("button", { type: "button", className: `${styles.scrollButton} ${styles.scrollLeft}`, "aria-label": "Scroll tabs left", disabled: !edges.left, onClick: () => scrollTabs(-1), children: _jsx(NavArrowLeft, { width: 17, height: 17, "aria-hidden": "true" }) }), _jsx(motion.div, { ref: viewport, layoutScroll: true, className: styles.viewport, ...hover.handlers, onFocusCapture: event => { if (event.target instanceof HTMLElement && event.target.getAttribute("role") === "tab")
|
|
83
|
+
reveal(event.target); }, children: _jsxs(TabsPrimitive.List, { ...props, ref: list, className: [styles.list, className].filter(Boolean).join(" "), children: [_jsx(FluidHoverHighlight, { hover: hover, className: styles.hover }), props.children] }) }), edges.overflow && _jsx("button", { type: "button", className: `${styles.scrollButton} ${styles.scrollRight}`, "aria-label": "Scroll tabs right", disabled: !edges.right, onClick: () => scrollTabs(1), children: _jsx(NavArrowRight, { width: 17, height: 17, "aria-hidden": "true" }) })] });
|
|
80
84
|
}
|
|
81
85
|
export function TabsTrigger({ className, children, value, icon, ...props }) {
|
|
82
86
|
const { active } = useContext(TabsContext);
|
|
@@ -95,7 +99,7 @@ const panelMotion = {
|
|
|
95
99
|
const panelFade = {
|
|
96
100
|
enter: { opacity: 0, x: 0 },
|
|
97
101
|
center: { opacity: 1, x: 0, transition: { duration: motionTokens.duration.instant } },
|
|
98
|
-
exit: { opacity: 0, x: 0, transition: { duration: .
|
|
102
|
+
exit: { opacity: 0, x: 0, transition: { duration: motionTokens.duration.instant } },
|
|
99
103
|
};
|
|
100
104
|
export function TabsContent({ className, value, forceMount, children, ...props }) {
|
|
101
105
|
const { active, direction, panelHeightRef, leavingRectRef } = useContext(TabsContext);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
.listShell[data-right="true"] .viewport { mask-image: linear-gradient(to right, black, black calc(100% - 35px), transparent); }
|
|
8
8
|
.listShell[data-left="true"][data-right="true"] .viewport { mask-image: linear-gradient(to right, transparent, black 35px, black calc(100% - 35px), transparent); }
|
|
9
9
|
/* The list owns the stacking context so the gliding highlight passes under every label, not over earlier ones. */
|
|
10
|
-
.list { isolation: isolate; display: inline-flex; width: max-content; align-items: center; gap: var(--space-tight); padding: var(--space-tight); }
|
|
10
|
+
.list { position: relative; isolation: isolate; display: inline-flex; width: max-content; align-items: center; gap: var(--space-tight); padding: var(--space-tight); }
|
|
11
11
|
.scrollButton { position: absolute; z-index: 2; top: 0; bottom: 0; display: grid; width: 33px; place-items: center; border: 0; background: var(--surface-muted); color: var(--foreground); }
|
|
12
12
|
.scrollLeft { left: 0; border-radius: var(--radius-control) 0 0 var(--radius-control); }
|
|
13
13
|
.scrollRight { right: 0; border-radius: 0 var(--radius-control) var(--radius-control) 0; }
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
.trigger { position: relative; -webkit-tap-highlight-color: transparent; display: inline-grid; min-width: 5.5rem; place-items: center; 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; transition: color var(--duration-fast) var(--ease-standard); }
|
|
17
17
|
@media (hover: hover) and (pointer: fine) { .trigger:hover { color: var(--foreground); } }
|
|
18
18
|
.trigger:active { color: var(--foreground); }.trigger[data-state="active"] { color: var(--accent-strong); }
|
|
19
|
+
/* The hover fill glides under the tabs and under the selection, so the selected tab never changes on hover. */
|
|
20
|
+
.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); }
|
|
19
21
|
.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); will-change: transform; }
|
|
20
22
|
.triggerLabel { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: var(--space-related); }
|
|
21
23
|
/* An icon before the label: one size for every tab set, coloured with the label so it follows hover and selection. */
|
|
@@ -4,7 +4,7 @@ import { Fragment } from "react";
|
|
|
4
4
|
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
5
5
|
import styles from "./text-reveal.module.css";
|
|
6
6
|
/** Total stagger stays under this many seconds, however long the text is. */
|
|
7
|
-
const MAX_STAGGER = motionTokens.duration.
|
|
7
|
+
const MAX_STAGGER = motionTokens.duration.expressive;
|
|
8
8
|
export function TextReveal({ text, as = "h2", className, id, delay = 0 }) {
|
|
9
9
|
const Tag = as;
|
|
10
10
|
const lines = text.split("\n").map(line => line.split(" ").filter(Boolean));
|