@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
|
@@ -7,7 +7,7 @@ import styles from "./animated-counter.module.css";
|
|
|
7
7
|
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
8
8
|
const rise = { hidden: { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } } };
|
|
9
9
|
const fade = { hidden: { opacity: 0, y: 0, filter: "blur(0px)" }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } } };
|
|
10
|
-
const reveal = { ...motionTokens.spring.smooth, visualDuration: motionTokens.duration.
|
|
10
|
+
const reveal = { ...motionTokens.spring.smooth, visualDuration: motionTokens.duration.expressive };
|
|
11
11
|
/** Split a formatted number into columns keyed by place value, so 999 → 1,000 keeps the ones column the ones column. */
|
|
12
12
|
function partsFor(value, decimals, locale) {
|
|
13
13
|
const parts = new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, numberingSystem: "latn" }).formatToParts(value);
|
|
@@ -8,7 +8,7 @@ const { spring, duration, ease, blur, stagger } = motionTokens;
|
|
|
8
8
|
/** Motion drops inherited velocity on time-defined springs, so values that retarget mid-flight run the same springs written as stiffness and damping. */
|
|
9
9
|
const physical = ({ visualDuration, bounce }, restDelta = .01) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };
|
|
10
10
|
const settle = physical(spring.smooth);
|
|
11
|
-
const grow = physical(spring.
|
|
11
|
+
const grow = physical(spring.expressive);
|
|
12
12
|
const glide = physical(spring.snappy);
|
|
13
13
|
/** The zoom runs in fractions of the plot, so it rests far below a pixel. */
|
|
14
14
|
const zoom = physical(spring.smooth, .00001);
|
|
@@ -7,7 +7,7 @@ import { motionTokens } from "../../lib/motion-tokens.js";
|
|
|
7
7
|
import styles from "./billing-toggle.module.css";
|
|
8
8
|
const { duration, ease, blur } = motionTokens;
|
|
9
9
|
/** Critically damped: the thumb glides and lands without overshoot. */
|
|
10
|
-
const glide = { type: "spring", visualDuration:
|
|
10
|
+
const glide = { type: "spring", visualDuration: motionTokens.spring.moderate.visualDuration, bounce: 0 };
|
|
11
11
|
const DEFAULT_OPTIONS = [
|
|
12
12
|
{ value: "monthly", label: "Monthly" },
|
|
13
13
|
{ value: "yearly", label: "Yearly", badge: "Save 20%" },
|
|
@@ -66,7 +66,7 @@ export function BillingToggle({ value, onValueChange, options = DEFAULT_OPTIONS,
|
|
|
66
66
|
return (_jsxs("button", { ref: node => { refs.current[index] = node; }, type: "button", role: "radio", "aria-checked": selected, tabIndex: selected ? 0 : -1, className: styles.option, "data-selected": selected || undefined, onClick: () => onValueChange(option.value), onKeyDown: event => onKeyDown(event, index), children: [selected && !thumb ? _jsx("span", { className: styles.thumbStatic, "aria-hidden": "true" }) : null, _jsxs("span", { className: styles.content, children: [_jsx("span", { className: styles.label, children: option.label }), badgeText ? (_jsx("span", { className: styles.badge, "data-active": selected || undefined, children: _jsx(StableSwap, { text: badgeText, candidates: candidates, reduced: reduced }) })) : null] })] }, option.value));
|
|
67
67
|
})] }));
|
|
68
68
|
}
|
|
69
|
-
const clip = { type: "spring", visualDuration:
|
|
69
|
+
const clip = { type: "spring", visualDuration: motionTokens.spring.moderate.visualDuration, bounce: 0 };
|
|
70
70
|
/** A price that rolls to its new amount. The old price and the period ease their width, so nothing beside them jumps. */
|
|
71
71
|
export function BillingPrice({ amount, currency = "$", period, was, decimals = 0, className }) {
|
|
72
72
|
const reduced = !!useReducedMotion();
|
|
@@ -22,7 +22,7 @@ const STRETCH = 120;
|
|
|
22
22
|
const LOW_DIM = .78;
|
|
23
23
|
const settle = motionTokens.spring.smooth;
|
|
24
24
|
/** Leaving is shorter than arriving; a flick keeps its velocity through the same spring. */
|
|
25
|
-
const leave = { ...motionTokens.spring.smooth, visualDuration: .
|
|
25
|
+
const leave = { ...motionTokens.spring.smooth, visualDuration: motionTokens.spring.moderate.visualDuration };
|
|
26
26
|
const fade = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
|
|
27
27
|
/** iOS-style resistance: follows the finger at first, then approaches STRETCH. */
|
|
28
28
|
const rubber = (distance) => (1 - 1 / (distance * .55 / STRETCH + 1)) * STRETCH;
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
.menu[data-state="open"] .item { opacity: 0; transform: translate(calc(var(--menu-x) * .4), calc(var(--menu-y) * .4)); }
|
|
70
70
|
}
|
|
71
71
|
/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */
|
|
72
|
-
.menu[data-state="closed"] { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); transition: opacity
|
|
72
|
+
.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; }
|
|
73
73
|
.item { display: flex; min-height: 36px; align-items: center; gap: var(--space-group); padding: 0 var(--space-group); border-radius: calc(var(--radius-container) - var(--space-related)); corner-shape: var(--corner-shape); color: inherit; font-size: var(--text-sm); text-decoration: none; cursor: pointer; outline: none; transition: opacity var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms), transform var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms); }
|
|
74
74
|
.item[data-highlighted] { background: var(--surface-muted); }
|
|
75
75
|
.item[data-disabled] { cursor: default; opacity: .45; }
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
5
5
|
import { animate, AnimatePresence, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from "motion/react";
|
|
6
6
|
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
7
|
import { useToday } from "../../lib/use-today.js";
|
|
8
|
+
import { FluidHoverHighlight, useFluidHover } from "../../lib/fluid-hover.js";
|
|
8
9
|
import styles from "./calendar.module.css";
|
|
9
10
|
const startOfDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
10
11
|
const monthStart = (date) => new Date(date.getFullYear(), date.getMonth(), 1);
|
|
@@ -29,14 +30,14 @@ const serverSnapshot = () => false;
|
|
|
29
30
|
export { useToday } from "../../lib/use-today.js";
|
|
30
31
|
const { spring, duration, ease } = motionTokens;
|
|
31
32
|
/** Months sit side by side on one strip. Rapid clicks retarget the same spring, so the strip never queues or stacks panes. */
|
|
32
|
-
const stripSpring = { type: "spring", visualDuration:
|
|
33
|
+
const stripSpring = { type: "spring", visualDuration: motionTokens.spring.moderate.visualDuration, bounce: 0, restDelta: 0.002 };
|
|
33
34
|
const monthIndex = (date) => date.getFullYear() * 12 + date.getMonth();
|
|
34
35
|
const fromIndex = (index) => new Date(Math.floor(index / 12), ((index % 12) + 12) % 12, 1);
|
|
35
36
|
/** The title crossfades in place: the old month leaves toward the side the strip travels to while the new one arrives from the other. */
|
|
36
37
|
const titleSlide = {
|
|
37
38
|
enter: (direction) => ({ opacity: 0, x: direction * 12 }),
|
|
38
39
|
center: { opacity: 1, x: 0, transition: { duration: duration.standard, ease: ease.enter } },
|
|
39
|
-
exit: (direction) => ({ opacity: 0, x: direction * -12, transition: { duration:
|
|
40
|
+
exit: (direction) => ({ opacity: 0, x: direction * -12, transition: { duration: motionTokens.duration.fast, ease: ease.standard } }),
|
|
40
41
|
};
|
|
41
42
|
/** Reduced motion swaps the title in place, with no travel and no blank frame. */
|
|
42
43
|
const titleFade = { enter: { opacity: 1, x: 0 }, center: { opacity: 1, x: 0, transition: { duration: 0 } }, exit: { opacity: 0, transition: { duration: 0 } } };
|
|
@@ -57,7 +58,7 @@ function SelectionDisc({ cell, reduced }) {
|
|
|
57
58
|
useLayoutEffect(() => {
|
|
58
59
|
if (!visible) {
|
|
59
60
|
hidden.current = true;
|
|
60
|
-
const fade = reduced ? { duration: 0 } : { duration:
|
|
61
|
+
const fade = reduced ? { duration: 0 } : { duration: motionTokens.duration.fast, ease: ease.standard };
|
|
61
62
|
const controls = [animate(opacity, 0, fade), animate(scale, 0.6, fade)];
|
|
62
63
|
return () => controls.forEach((control) => control.stop());
|
|
63
64
|
}
|
|
@@ -75,6 +76,15 @@ function SelectionDisc({ cell, reduced }) {
|
|
|
75
76
|
}, [cell, visible, reduced, col, row, opacity, scale]);
|
|
76
77
|
return _jsx(motion.span, { className: styles.highlight, style: { transform, opacity }, "aria-hidden": "true", children: _jsx("span", { className: styles.highlightFill }) });
|
|
77
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Fluid hover for one month's days: one fill glides to the day nearest the pointer, under the selected disc. Unavailable
|
|
81
|
+
* days light nothing, a gap between days stays inert, and focus keeps its own ring.
|
|
82
|
+
*/
|
|
83
|
+
function MonthDays({ children }) {
|
|
84
|
+
const box = useRef(null);
|
|
85
|
+
const hover = useFluidHover(box, { items: `.${styles.day}`, axis: "xy", disabled: "clear", gapClick: false, followFocus: false });
|
|
86
|
+
return _jsxs("div", { ref: box, className: styles.days, ...hover.handlers, children: [_jsx(FluidHoverHighlight, { hover: hover, className: styles.dayHover }), children] });
|
|
87
|
+
}
|
|
78
88
|
/** One month on the strip. Only the month being navigated to is focusable and exposed; the one sliding past is inert. */
|
|
79
89
|
function MonthPane({ index, position, present, children }) {
|
|
80
90
|
const x = useTransform(position, (value) => `calc(${(index - value) * 100}% + ${(index - value) * 16}px)`);
|
|
@@ -239,7 +249,7 @@ export function Calendar({ value, onChange, month: controlledMonth, onMonthChang
|
|
|
239
249
|
const index = Math.round((startOfDay(value).getTime() - first.getTime()) / 864e5);
|
|
240
250
|
return index >= 0 && index < 42 ? index : -1;
|
|
241
251
|
};
|
|
242
|
-
const renderMonth = (paneMonth, present) => _jsxs(
|
|
252
|
+
const renderMonth = (paneMonth, present) => _jsxs(MonthDays, { children: [_jsx(SelectionDisc, { cell: cellOf(paneMonth), reduced: reducedMotion }), _jsx("div", { className: styles.grid, role: "grid", "aria-label": formatter.format(paneMonth), children: makeWeeks(paneMonth).map((week) => _jsx("div", { className: styles.week, role: "row", children: week.map((date) => {
|
|
243
253
|
const key = dateKey(date);
|
|
244
254
|
const selected = sameDay(date, value);
|
|
245
255
|
const isToday = sameDay(date, today);
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
.navButton { width: 32px; }
|
|
16
16
|
.todayButton { padding: 0 var(--space-group); font-size: var(--text-sm); font-weight: 500; line-height: 1; }
|
|
17
17
|
@media (hover: hover) and (pointer: fine) { .navButton:hover:not([aria-disabled]), .todayButton:hover:not([aria-disabled]) { background: var(--surface-muted); color: var(--foreground); } }
|
|
18
|
-
.navButton:active:not([aria-disabled]), .todayButton:active:not([aria-disabled]) { scale: .96; transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast),
|
|
18
|
+
.navButton:active:not([aria-disabled]), .todayButton:active:not([aria-disabled]) { scale: .96; transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), var(--duration-fast); transition-timing-function: var(--ease-standard); }
|
|
19
19
|
.navButton[aria-disabled], .todayButton[aria-disabled] { cursor: default; opacity: .36; }
|
|
20
20
|
.weekdays, .week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); column-gap: var(--space-tight); }
|
|
21
21
|
.weekdays { margin-bottom: var(--space-related); color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; line-height: 24px; text-align: center; }
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
.monthViewport { position: relative; display: grid; overflow: hidden; contain: paint; }
|
|
25
25
|
.monthBody { position: relative; grid-area: 1 / 1; min-width: 0; will-change: transform; }
|
|
26
26
|
.monthBody[inert] { pointer-events: none; }
|
|
27
|
+
/* One month's days: the hover fill and the selected disc are placed against this box. */
|
|
28
|
+
.days { position: relative; }
|
|
29
|
+
.dayHover { border-radius: 50%; corner-shape: round; background: var(--surface-muted); }
|
|
27
30
|
.grid { display: grid; row-gap: var(--space-tight); }
|
|
28
31
|
.placeholderDay { aspect-ratio: 1; }
|
|
29
32
|
.day { position: relative; display: grid; width: 100%; aspect-ratio: 1; min-width: 0; place-items: center; border: 0; border-radius: 50%; corner-shape: round; padding: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 400; line-height: 1; font-variant-numeric: tabular-nums; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard) var(--number-delay, 0ms); }
|
|
@@ -33,8 +36,8 @@
|
|
|
33
36
|
/* The selected disc is one element per month, one cell in size, placed by week and weekday under the day buttons. It glides on a transform, so the strip sliding around it never bends its path. Press scales the inner fill so it never fights the glide. */
|
|
34
37
|
.highlight { position: absolute; top: 0; left: 0; width: calc((100% - 24px) / 7); aspect-ratio: 1; pointer-events: none; will-change: transform; }
|
|
35
38
|
.highlightFill { position: absolute; inset: 0; border-radius: 50%; corner-shape: round; background: var(--accent); transition: background-color var(--duration-fast) var(--ease-standard), scale var(--duration-spring) var(--ease-spring); }
|
|
36
|
-
@media (hover: hover) and (pointer: fine) { .
|
|
37
|
-
.day:active:not(:disabled)::before, .day:active:not(:disabled) .dayNumber, .monthBody:has(.day.selected:active:not(:disabled)) .highlightFill { scale: .94; transition-duration: var(--duration-fast),
|
|
39
|
+
@media (hover: hover) and (pointer: fine) { .monthBody:has(.day.selected:hover) .highlightFill { background: var(--accent-strong); } }
|
|
40
|
+
.day:active:not(:disabled)::before, .day:active:not(:disabled) .dayNumber, .monthBody:has(.day.selected:active:not(:disabled)) .highlightFill { scale: .94; transition-duration: var(--duration-fast), var(--duration-fast); transition-timing-function: var(--ease-standard); }
|
|
38
41
|
/* Outside days dim their number, not the button, so a disc gliding onto one keeps its full strength. */
|
|
39
42
|
.day.outside { color: var(--text-muted); }
|
|
40
43
|
.day.outside:not(.selected) .dayNumber { opacity: .8; }
|
package/components/card/card.js
CHANGED
|
@@ -8,14 +8,14 @@ import { motionTokens } from "../../lib/motion-tokens.js";
|
|
|
8
8
|
import styles from "./card.module.css";
|
|
9
9
|
const { blur, duration, ease, spring, stagger } = motionTokens;
|
|
10
10
|
/** One critically damped spring carries the surface both ways, so it never overshoots and can reverse mid-flight. Closing is a touch quicker. */
|
|
11
|
-
const grow = { ...spring.smooth, visualDuration: .
|
|
12
|
-
const settle = { ...spring.smooth, visualDuration: .
|
|
11
|
+
const grow = { ...spring.smooth, visualDuration: motionTokens.spring.slow.visualDuration };
|
|
12
|
+
const settle = { ...spring.smooth, visualDuration: motionTokens.spring.moderate.visualDuration };
|
|
13
13
|
const RETURN_MS = 300;
|
|
14
14
|
const fade = { duration: duration.instant };
|
|
15
15
|
/** The photo drifts in slowly while the card is pointed at, and eases back a little faster. */
|
|
16
16
|
const ZOOM = 1.04;
|
|
17
|
-
const zoomIn = { duration: duration.
|
|
18
|
-
const zoomOut = { duration: duration.
|
|
17
|
+
const zoomIn = { duration: duration.expressive * 2, ease: [...ease.standard] };
|
|
18
|
+
const zoomOut = { duration: duration.expressive, ease: [...ease.standard] };
|
|
19
19
|
/** Quick look extras arrive once the surface has mostly grown, so they never ride the stretch. */
|
|
20
20
|
const reveal = { delay: duration.instant, duration: duration.standard, ease: [...ease.enter] };
|
|
21
21
|
const closeIn = { delay: duration.instant, duration: duration.fast, ease: [...ease.enter] };
|
|
@@ -35,8 +35,8 @@ const unresistY = (shown) => {
|
|
|
35
35
|
};
|
|
36
36
|
const clampUnit = (value) => Math.min(1, Math.max(0, value));
|
|
37
37
|
const throwSpring = { type: "spring", visualDuration: .5, bounce: 0 };
|
|
38
|
-
const fadeOut = { duration: .
|
|
39
|
-
const reducedFade = { duration: .
|
|
38
|
+
const fadeOut = { duration: motionTokens.duration.exit, delay: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] };
|
|
39
|
+
const reducedFade = { duration: motionTokens.duration.fast, ease: "linear" };
|
|
40
40
|
function velocityOf(samples, now) {
|
|
41
41
|
const recent = samples.filter(sample => now - sample.t <= 80);
|
|
42
42
|
const first = recent[0], last = recent[recent.length - 1];
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
.arrows { display: flex; flex: none; gap: var(--space-related); margin-left: auto; }
|
|
26
26
|
.control { display: grid; width: var(--control-height-sm); height: var(--control-height-sm); flex: none; place-items: center; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; padding: 0; background: var(--surface); color: var(--foreground); cursor: pointer; -webkit-tap-highlight-color: transparent; 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); }
|
|
27
27
|
@media (hover: hover) and (pointer: fine) { .control:hover:not([aria-disabled="true"]) { background: var(--surface-muted); } }
|
|
28
|
-
.control:active:not([aria-disabled="true"]) { transform: scale(.97); transition: transform
|
|
28
|
+
.control:active:not([aria-disabled="true"]) { transform: scale(.97); transition: transform var(--duration-fast) var(--ease-standard); }
|
|
29
29
|
.control[aria-disabled="true"] { color: var(--text-muted); cursor: default; opacity: .5; }
|
|
30
30
|
.iconSlot { display: grid; width: 16px; height: 16px; place-items: center; }
|
|
31
31
|
.icon { grid-area: 1 / 1; display: grid; place-items: center; }
|
|
@@ -9,7 +9,7 @@ const MORE = "\u0000more";
|
|
|
9
9
|
const standard = [...motionTokens.ease.standard];
|
|
10
10
|
const enterEase = [...motionTokens.ease.enter];
|
|
11
11
|
const fade = { duration: motionTokens.duration.fast, ease: standard };
|
|
12
|
-
const reducedFade = { duration: .
|
|
12
|
+
const reducedFade = { duration: motionTokens.duration.fast, ease: standard };
|
|
13
13
|
const exitFast = { duration: motionTokens.duration.fast, ease: standard };
|
|
14
14
|
const shown = { opacity: 1, y: "0em", scale: 1, filter: "blur(0px)" };
|
|
15
15
|
const textIn = { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` };
|
|
@@ -4,6 +4,7 @@ import { animate, AnimatePresence, motion, useReducedMotion } from "motion/react
|
|
|
4
4
|
import { Check, ChevronDown, Search, X } from "lucide-react";
|
|
5
5
|
import { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from "react";
|
|
6
6
|
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import { useNearestItem } from "../../lib/fluid-hover.js";
|
|
7
8
|
import styles from "./combobox.module.css";
|
|
8
9
|
/** Follows the listbox height with a critically damped spring, so filtering never snaps the menu. */
|
|
9
10
|
function AutoHeight({ children, reduceMotion }) {
|
|
@@ -32,6 +33,9 @@ export const Combobox = forwardRef(function Combobox({ label, options, value: co
|
|
|
32
33
|
const [open, setOpen] = useState(false);
|
|
33
34
|
const [query, setQuery] = useState("");
|
|
34
35
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
36
|
+
// Fluid hover: the mouse makes the nearest option active, gaps and padding included, and a click there picks it.
|
|
37
|
+
// The listbox keeps focus in the input, so a press in its padding never closes the popover.
|
|
38
|
+
const nearest = useNearestItem({ items: '[role="option"]', onPick: (item) => setActiveIndex(Number(item.dataset.index)) });
|
|
35
39
|
const selectedValue = controlledValue ?? uncontrolledValue;
|
|
36
40
|
const selectedOption = options.find((option) => option.value === selectedValue);
|
|
37
41
|
useImperativeHandle(forwardedRef, () => inputRef.current);
|
|
@@ -162,6 +166,6 @@ export const Combobox = forwardRef(function Combobox({ label, options, value: co
|
|
|
162
166
|
setQuery(event.target.value);
|
|
163
167
|
setOpen(true);
|
|
164
168
|
setActiveIndex(-1);
|
|
165
|
-
}, onKeyDown: handleKeyDown }), _jsx(AnimatePresence, { initial: false, children: selectedOption && !disabled && (_jsx(motion.button, { type: "button", className: styles.clear, "aria-label": "Clear selection", onMouseDown: event => event.preventDefault(), onClick: clear, initial: reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transition: reduceMotion ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast } } }, exit: { opacity: 0, ...(reduceMotion ? {} : { scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }), transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, whileTap: { scale: reduceMotion ? 1 : 0.96, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, children: _jsx(X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })) }), _jsx(ChevronDown, { className: styles.chevron, size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }), description && _jsx("span", { id: hintId, className: styles.hint, children: description }), _jsx(AnimatePresence, { initial: false, children: open && (_jsx(motion.div, { className: styles.popover, initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }, animate: { opacity: 1, y: 0, scale: 1, transition: reduceMotion ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } }, exit: { opacity: 0, ...(reduceMotion ? {} : { y: -4, scale: 0.98 }), transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, role: "presentation", children: _jsx(AutoHeight, { reduceMotion: reduceMotion, children: _jsx("div", { id: listboxId, className: styles.listbox, role: "listbox", "aria-label": `${label} options`, tabIndex: -1, children: filteredOptions.length ? filteredOptions.map((option, index) => (_jsxs("div", { ref: (element) => { optionRefs.current[option.value] = element; }, id: `${controlId}-option-${option.value}`, className: styles.option, "data-active": index === activeIndex ? "true" : undefined, "data-disabled": option.disabled ? "true" : undefined, role: "option", "aria-selected": option.value === selectedValue, "aria-disabled": option.disabled || undefined, onMouseDown: (event) => event.preventDefault(),
|
|
169
|
+
}, onKeyDown: handleKeyDown }), _jsx(AnimatePresence, { initial: false, children: selectedOption && !disabled && (_jsx(motion.button, { type: "button", className: styles.clear, "aria-label": "Clear selection", onMouseDown: event => event.preventDefault(), onClick: clear, initial: reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transition: reduceMotion ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast } } }, exit: { opacity: 0, ...(reduceMotion ? {} : { scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }), transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, whileTap: { scale: reduceMotion ? 1 : 0.96, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, children: _jsx(X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })) }), _jsx(ChevronDown, { className: styles.chevron, size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }), description && _jsx("span", { id: hintId, className: styles.hint, children: description }), _jsx(AnimatePresence, { initial: false, children: open && (_jsx(motion.div, { className: styles.popover, initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }, animate: { opacity: 1, y: 0, scale: 1, transition: reduceMotion ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } }, exit: { opacity: 0, ...(reduceMotion ? {} : { y: -4, scale: 0.98 }), transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, role: "presentation", children: _jsx(AutoHeight, { reduceMotion: reduceMotion, children: _jsx("div", { id: listboxId, className: styles.listbox, role: "listbox", "aria-label": `${label} options`, tabIndex: -1, onMouseDown: (event) => event.preventDefault(), ...nearest, children: filteredOptions.length ? filteredOptions.map((option, index) => (_jsxs("div", { ref: (element) => { optionRefs.current[option.value] = element; }, id: `${controlId}-option-${option.value}`, className: styles.option, "data-active": index === activeIndex ? "true" : undefined, "data-disabled": option.disabled ? "true" : undefined, role: "option", "aria-selected": option.value === selectedValue, "aria-disabled": option.disabled || undefined, onMouseDown: (event) => event.preventDefault(), "data-index": index, onClick: () => choose(option), children: [_jsx("span", { children: option.label }), option.value === selectedValue && _jsx(Check, { className: styles.check, size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }, option.value))) : _jsx(motion.div, { className: styles.empty, role: "status", initial: reduceMotion ? false : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: emptyMessage }) }) }) })) })] }));
|
|
166
170
|
});
|
|
167
171
|
Combobox.displayName = "Combobox";
|
|
@@ -4,6 +4,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "re
|
|
|
4
4
|
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
5
|
import { Command as CommandIcon, 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 "./command-palette.module.css";
|
|
8
9
|
const enter = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
|
|
9
10
|
const leave = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] };
|
|
@@ -74,6 +75,21 @@ export function CommandPalette({ items, placeholder = "Search commands", onSelec
|
|
|
74
75
|
observer.observe(list);
|
|
75
76
|
return () => observer.disconnect();
|
|
76
77
|
}, []);
|
|
78
|
+
// Fluid hover: the mouse makes the nearest result active, group headings and padding included, and a click there runs it.
|
|
79
|
+
const nearest = useNearestItem({ items: '[role="option"]', onPick: item => {
|
|
80
|
+
const index = Number(item.dataset.index);
|
|
81
|
+
if (index !== safeActiveIndex) {
|
|
82
|
+
pointer.current = true;
|
|
83
|
+
setActiveIndex(index);
|
|
84
|
+
}
|
|
85
|
+
} });
|
|
86
|
+
function onListPointerMove(event) {
|
|
87
|
+
// Ignore the synthetic moves browsers send while the list scrolls under a still pointer.
|
|
88
|
+
if (event.clientX === lastPointer.current.x && event.clientY === lastPointer.current.y)
|
|
89
|
+
return;
|
|
90
|
+
lastPointer.current = { x: event.clientX, y: event.clientY };
|
|
91
|
+
nearest.onPointerMove(event);
|
|
92
|
+
}
|
|
77
93
|
// One highlight follows the active result: a spring for the pointer, a 70ms slide for arrow keys, a fade when it first appears.
|
|
78
94
|
useLayoutEffect(() => {
|
|
79
95
|
const node = activeId ? document.getElementById(`${inputId}-${activeId}`) : null;
|
|
@@ -82,7 +98,7 @@ export function CommandPalette({ items, placeholder = "Search commands", onSelec
|
|
|
82
98
|
animate(highlightOpacity, 0, { duration: reduced ? 0 : .1 });
|
|
83
99
|
return;
|
|
84
100
|
}
|
|
85
|
-
const move = !highlightShown.current || reduced ? { duration: 0 } : pointer.current ? motionTokens.spring.snappy : { duration: .
|
|
101
|
+
const move = !highlightShown.current || reduced ? { duration: 0 } : pointer.current ? motionTokens.spring.snappy : { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.enter] };
|
|
86
102
|
// Rows sit inside positioned groups, so their offset is summed up to the list (layout transforms stay out of it).
|
|
87
103
|
let top = 0;
|
|
88
104
|
for (let element = node; element && element !== listRef.current; element = element.offsetParent)
|
|
@@ -137,15 +153,6 @@ export function CommandPalette({ items, placeholder = "Search commands", onSelec
|
|
|
137
153
|
onClose?.();
|
|
138
154
|
}
|
|
139
155
|
}
|
|
140
|
-
return _jsxs(motion.div, { className: styles.palette, initial: reduced ? false : { opacity: 0, y: 6, scale: .98 }, animate: { opacity: 1, y: 0, scale: 1 }, transition: reduced ? { duration: 0 } : { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } }, children: [_jsxs("div", { className: styles.searchRow, children: [_jsx("span", { className: styles.searchIcon, children: _jsx(Search, { width: 18, height: 18, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("label", { className: styles.visuallyHidden, htmlFor: inputId, children: label }), _jsx("input", { ref: inputRef, id: inputId, role: "combobox", "aria-autocomplete": "list", "aria-expanded": "true", "aria-controls": `${inputId}-results`, "aria-activedescendant": activeId ? `${inputId}-${activeId}` : undefined, value: query, onChange: event => { setQuery(event.target.value); setActiveIndex(null); }, onKeyDown: handleKeyDown, placeholder: placeholder, autoComplete: "off" }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: query ? _jsx(motion.button, { className: styles.clearButton, type: "button", "aria-label": "Clear search", onClick: () => { setQuery(""); setActiveIndex(null); inputRef.current?.focus(); }, initial: reduced ? false : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: leave }, transition: reduced ? { duration: 0 } : { default: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.instant } }, children: _jsx(X, { width: 15, height: 15, "aria-hidden": "true" }) }, "clear") : null }), onClose ? _jsx("button", { className: styles.closeButton, type: "button", "aria-label": "Close command palette", onClick: onClose, children: "Esc" }) : null, _jsx("kbd", { className: styles.commandKey, children: "\u2318 K" })] }), _jsx(motion.div, { className: styles.resultsFrame, initial: false, animate: { height: listHeight }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, children: _jsxs(motion.div, { ref: listRef, layoutScroll: true, id: `${inputId}-results`, className: styles.results, role: "listbox", "aria-label": "Command results", children: [_jsx(motion.span, { className: styles.highlight, style: { y: highlightY, height: highlightHeight, opacity: highlightOpacity }, "aria-hidden": "true" }), _jsx(AnimatePresence, { mode: "wait", initial: false, children: filtered.length ? _jsx(motion.div, { initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transition: leave }, transition: enter, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: glide, children: groupedItems.map(([group, entries]) => _jsxs(motion.div, { layout: glide ? "position" : false, className: styles.group, role: "group", "aria-label": group, variants: rowExit, initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: "exit", transition: reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }, children: [_jsx("span", { className: styles.groupHeading, "aria-hidden": "true", children: group }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: glide, children: entries.map(({ item, index }) => _jsxs(motion.button, { layout: glide ? "position" : false, id: `${inputId}-${item.id}`, className: `${styles.result} ${index === safeActiveIndex ? styles.activeResult : ""}`, type: "button", role: "option", "aria-selected": index === safeActiveIndex, onClick: () => choose(item),
|
|
141
|
-
// Ignore the synthetic moves browsers send while the list scrolls under a still pointer.
|
|
142
|
-
if (event.clientX === lastPointer.current.x && event.clientY === lastPointer.current.y)
|
|
143
|
-
return;
|
|
144
|
-
lastPointer.current = { x: event.clientX, y: event.clientY };
|
|
145
|
-
if (index !== safeActiveIndex) {
|
|
146
|
-
pointer.current = true;
|
|
147
|
-
setActiveIndex(index);
|
|
148
|
-
}
|
|
149
|
-
}, variants: rowExit, initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: "exit", transition: reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }, children: [_jsx("span", { className: styles.itemIcon, "aria-hidden": "true", children: item.icon ?? _jsx(CommandIcon, { width: 16, height: 16 }) }), _jsxs("span", { className: styles.resultCopy, children: [_jsx("strong", { children: item.label }), item.description && _jsx("small", { children: item.description })] }), item.shortcut && _jsx("kbd", { className: styles.shortcut, children: item.shortcut })] }, item.id)) })] }, group)) }) }, "results") : _jsxs(motion.div, { className: styles.empty, initial: reduced ? false : { opacity: 0, y: 6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, transition: leave }, transition: enter, children: [_jsx("span", { children: _jsx(Search, { width: 20, height: 20, "aria-hidden": "true" }) }), _jsx("strong", { children: "No matching actions" }), _jsx("small", { children: "Try a different word or clear the search." })] }, "empty") })] }) })] });
|
|
156
|
+
return _jsxs(motion.div, { className: styles.palette, initial: reduced ? false : { opacity: 0, y: 6, scale: .98 }, animate: { opacity: 1, y: 0, scale: 1 }, transition: reduced ? { duration: 0 } : { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } }, children: [_jsxs("div", { className: styles.searchRow, children: [_jsx("span", { className: styles.searchIcon, children: _jsx(Search, { width: 18, height: 18, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("label", { className: styles.visuallyHidden, htmlFor: inputId, children: label }), _jsx("input", { ref: inputRef, id: inputId, role: "combobox", "aria-autocomplete": "list", "aria-expanded": "true", "aria-controls": `${inputId}-results`, "aria-activedescendant": activeId ? `${inputId}-${activeId}` : undefined, value: query, onChange: event => { setQuery(event.target.value); setActiveIndex(null); }, onKeyDown: handleKeyDown, placeholder: placeholder, autoComplete: "off" }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: query ? _jsx(motion.button, { className: styles.clearButton, type: "button", "aria-label": "Clear search", onClick: () => { setQuery(""); setActiveIndex(null); inputRef.current?.focus(); }, initial: reduced ? false : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: leave }, transition: reduced ? { duration: 0 } : { default: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.instant } }, children: _jsx(X, { width: 15, height: 15, "aria-hidden": "true" }) }, "clear") : null }), onClose ? _jsx("button", { className: styles.closeButton, type: "button", "aria-label": "Close command palette", onClick: onClose, children: "Esc" }) : null, _jsx("kbd", { className: styles.commandKey, children: "\u2318 K" })] }), _jsx(motion.div, { className: styles.resultsFrame, initial: false, animate: { height: listHeight }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, children: _jsxs(motion.div, { ref: listRef, layoutScroll: true, id: `${inputId}-results`, className: styles.results, role: "listbox", "aria-label": "Command results", onPointerMove: onListPointerMove, onClick: nearest.onClick, children: [_jsx(motion.span, { className: styles.highlight, style: { y: highlightY, height: highlightHeight, opacity: highlightOpacity }, "aria-hidden": "true" }), _jsx(AnimatePresence, { mode: "wait", initial: false, children: filtered.length ? _jsx(motion.div, { initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transition: leave }, transition: enter, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: glide, children: groupedItems.map(([group, entries]) => _jsxs(motion.div, { layout: glide ? "position" : false, className: styles.group, role: "group", "aria-label": group, variants: rowExit, initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: "exit", transition: reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }, children: [_jsx("span", { className: styles.groupHeading, "aria-hidden": "true", children: group }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: glide, children: entries.map(({ item, index }) => _jsxs(motion.button, { layout: glide ? "position" : false, id: `${inputId}-${item.id}`, className: `${styles.result} ${index === safeActiveIndex ? styles.activeResult : ""}`, type: "button", role: "option", "aria-selected": index === safeActiveIndex, onClick: () => choose(item), "data-index": index, variants: rowExit, initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: "exit", transition: reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }, children: [_jsx("span", { className: styles.itemIcon, "aria-hidden": "true", children: item.icon ?? _jsx(CommandIcon, { width: 16, height: 16 }) }), _jsxs("span", { className: styles.resultCopy, children: [_jsx("strong", { children: item.label }), item.description && _jsx("small", { children: item.description })] }), item.shortcut && _jsx("kbd", { className: styles.shortcut, children: item.shortcut })] }, item.id)) })] }, group)) }) }, "results") : _jsxs(motion.div, { className: styles.empty, initial: reduced ? false : { opacity: 0, y: 6, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, transition: leave }, transition: enter, children: [_jsx("span", { children: _jsx(Search, { width: 20, height: 20, "aria-hidden": "true" }) }), _jsx("strong", { children: "No matching actions" }), _jsx("small", { children: "Try a different word or clear the search." })] }, "empty") })] }) })] });
|
|
150
157
|
}
|
|
151
158
|
export default CommandPalette;
|
|
@@ -34,9 +34,9 @@ const faceVariants = {
|
|
|
34
34
|
};
|
|
35
35
|
const partVariants = {
|
|
36
36
|
hidden: { opacity: 0, filter: `blur(${blur.soft}px)` },
|
|
37
|
-
shown: { opacity: 1, filter: "blur(0px)", transition: { duration: .
|
|
37
|
+
shown: { opacity: 1, filter: "blur(0px)", transition: { duration: motionTokens.duration.exit, ease: enter } },
|
|
38
38
|
};
|
|
39
|
-
const reducedFace = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: .
|
|
39
|
+
const reducedFace = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.fast } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
|
|
40
40
|
const reducedPart = { hidden: { opacity: 1 }, shown: { opacity: 1 } };
|
|
41
41
|
function Face({ id, reduced, onSize, children, labelledBy }) {
|
|
42
42
|
const ref = useRef(null);
|
|
@@ -61,7 +61,7 @@ function Part({ reduced, className, children }) {
|
|
|
61
61
|
}
|
|
62
62
|
/** A success disc that pops in with a tick drawing across it, the moment the action lands. */
|
|
63
63
|
function Check({ reduced }) {
|
|
64
|
-
return _jsxs("svg", { className: styles.check, viewBox: "0 0 18 18", fill: "none", "aria-hidden": "true", children: [_jsx(motion.circle, { className: styles.checkDisc, cx: "9", cy: "9", r: "8", style: { transformOrigin: "9px 9px" }, initial: reduced ? false : { scale: .5, opacity: 0 }, animate: { scale: 1, opacity: 1 }, transition: { scale: physical(motionTokens.spring.snappy), opacity: { duration: .
|
|
64
|
+
return _jsxs("svg", { className: styles.check, viewBox: "0 0 18 18", fill: "none", "aria-hidden": "true", children: [_jsx(motion.circle, { className: styles.checkDisc, cx: "9", cy: "9", r: "8", style: { transformOrigin: "9px 9px" }, initial: reduced ? false : { scale: .5, opacity: 0 }, animate: { scale: 1, opacity: 1 }, transition: { scale: physical(motionTokens.spring.snappy), opacity: { duration: motionTokens.duration.instant } } }), _jsx(motion.path, { className: styles.checkTick, d: "M5.6 9.3 7.8 11.4 12.4 6.7", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", initial: reduced ? false : { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration: .3, ease: enter, delay: .14 } })] });
|
|
65
65
|
}
|
|
66
66
|
/** A ring that drains with the countdown. */
|
|
67
67
|
function Ring({ progress }) {
|
|
@@ -69,7 +69,7 @@ function Ring({ progress }) {
|
|
|
69
69
|
}
|
|
70
70
|
/** Seconds that swap in place: the old digit fades up and out, the new one rises in, on tabular figures so the width holds. */
|
|
71
71
|
function Seconds({ value, reduced }) {
|
|
72
|
-
return _jsx("span", { className: styles.seconds, children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(motion.span, { className: styles.digit, initial: reduced ? { opacity: 0 } : { opacity: 0, y: 6, filter: `blur(${blur.subtle}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0 } : { opacity: 0, y: -6, filter: `blur(${blur.subtle}px)` }, transition: { duration: .
|
|
72
|
+
return _jsx("span", { className: styles.seconds, children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(motion.span, { className: styles.digit, initial: reduced ? { opacity: 0 } : { opacity: 0, y: 6, filter: `blur(${blur.subtle}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0 } : { opacity: 0, y: -6, filter: `blur(${blur.subtle}px)` }, transition: { duration: motionTokens.duration.exit, ease: enter }, children: value }, value) }) });
|
|
73
73
|
}
|
|
74
74
|
export function ConfirmMorph({ label, icon, prompt, confirmLabel = "Delete", cancelLabel = "Cancel", pendingLabel = "Deleting", doneLabel = "Deleted", errorLabel = "Couldn’t finish", retryLabel = "Retry", undoLabel = "Undo", undoingLabel = "Restoring", tone = "danger", confirmMode = "press", holdDuration = 1000, countdown = 3, countdownLabel, onConfirm, onUndo, onCancel, state: stateProp, defaultState = "idle", onStateChange, confirmTimeout = 6000, resultTimeout = 5000, cancelOnOutsidePress = true, disabled = false, className, ref, }) {
|
|
75
75
|
const reduced = useReducedFlag();
|
|
@@ -252,7 +252,7 @@ export function ConfirmMorph({ label, icon, prompt, confirmLabel = "Delete", can
|
|
|
252
252
|
holding.current = null;
|
|
253
253
|
control.stop();
|
|
254
254
|
if (hold.get() < 1) {
|
|
255
|
-
animate(hold, 0, reduced ? { duration: 0 } : { type: "spring", visualDuration: .
|
|
255
|
+
animate(hold, 0, reduced ? { duration: 0 } : { type: "spring", visualDuration: motionTokens.spring.fast.visualDuration, bounce: 0 });
|
|
256
256
|
setAnnouncement(`Keep holding to ${confirmLabel.toLowerCase()}`);
|
|
257
257
|
}
|
|
258
258
|
};
|
|
@@ -5,6 +5,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
|
5
5
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
6
6
|
import { Check, Copy, MoreHorizontal, Trash2 } from "lucide-react";
|
|
7
7
|
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
8
|
+
import { MenuHighlight, useMenuHighlight } from "../../lib/menu-highlight.js";
|
|
8
9
|
import styles from "./context-menu.module.css";
|
|
9
10
|
const MENU_WIDTH = 208;
|
|
10
11
|
// Keeps the menu 8px inside the viewport. Rows are 34px with 5px padding and a 1px border; the real size is measured before paint.
|
|
@@ -22,11 +23,10 @@ export function ContextMenu({ children, items, label = "Context menu" }) {
|
|
|
22
23
|
// Each open gets its own key, so reopening at a new point fades the old menu out in place
|
|
23
24
|
// and grows a fresh one from the pointer instead of teleporting the visible menu.
|
|
24
25
|
const [point, setPoint] = useState({ x: 0, y: 0, originX: 0, originY: 0, key: 0 });
|
|
25
|
-
|
|
26
|
+
// Fluid hover: the nearest row is lit, gaps and padding included, and a click in a gap picks it.
|
|
27
|
+
const { highlight, reset, contentProps } = useMenuHighlight();
|
|
26
28
|
const menuRef = useRef(null);
|
|
27
29
|
const targetRef = useRef(null);
|
|
28
|
-
const pointer = useRef(false);
|
|
29
|
-
const clearTimer = useRef(0);
|
|
30
30
|
// A primary press on the target while the menu is open closes it; the click that follows must not reopen it.
|
|
31
31
|
const pressedWhileOpen = useRef(false);
|
|
32
32
|
// An exiting menu keeps its last props, so its handlers check this before moving focus or selecting.
|
|
@@ -41,7 +41,6 @@ export function ContextMenu({ children, items, label = "Context menu" }) {
|
|
|
41
41
|
const next = clampTo(anchor.current.x, anchor.current.y, node.offsetWidth, node.offsetHeight);
|
|
42
42
|
setPoint(current => (current.x === next.x && current.y === next.y && current.originX === next.originX && current.originY === next.originY ? current : { ...next, key: current.key }));
|
|
43
43
|
}, [open, point.key]);
|
|
44
|
-
useEffect(() => () => window.clearTimeout(clearTimer.current), []);
|
|
45
44
|
useEffect(() => {
|
|
46
45
|
if (!open)
|
|
47
46
|
return;
|
|
@@ -68,9 +67,7 @@ export function ContextMenu({ children, items, label = "Context menu" }) {
|
|
|
68
67
|
anchor.current = { x, y };
|
|
69
68
|
const estimate = clampTo(x, y, MENU_WIDTH, Math.min(300, items.length * 34 + 12));
|
|
70
69
|
setPoint(current => ({ ...estimate, key: current.key + 1 }));
|
|
71
|
-
|
|
72
|
-
pointer.current = false;
|
|
73
|
-
setHighlight(null);
|
|
70
|
+
reset();
|
|
74
71
|
setPortal(true);
|
|
75
72
|
setOpen(true);
|
|
76
73
|
}
|
|
@@ -114,30 +111,15 @@ export function ContextMenu({ children, items, label = "Context menu" }) {
|
|
|
114
111
|
targetRef.current?.focus();
|
|
115
112
|
}
|
|
116
113
|
}
|
|
117
|
-
|
|
118
|
-
window.clearTimeout(clearTimer.current);
|
|
119
|
-
const next = { index: Number(item.dataset.index), top: item.offsetTop, height: item.offsetHeight, danger: item.dataset.tone === "danger" };
|
|
120
|
-
setHighlight(current => ({ ...next, glide: glide && current !== null }));
|
|
121
|
-
}
|
|
122
|
-
// Keyboard focus places the highlight instantly; the pointer makes it glide between rows.
|
|
114
|
+
// Keyboard focus lights its row at once. A pointer-opened menu focuses its first row without lighting it, like a native menu.
|
|
123
115
|
function onMenuFocus(event) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// A pointer-opened menu focuses its first row without showing a highlight, like a native menu.
|
|
127
|
-
if (!pointer.current && item.matches(":focus-visible"))
|
|
128
|
-
place(item, false);
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
// A short grace period keeps the highlight gliding across the gap between rows.
|
|
132
|
-
window.clearTimeout(clearTimer.current);
|
|
133
|
-
clearTimer.current = window.setTimeout(() => setHighlight(null), pointer.current ? 70 : 0);
|
|
116
|
+
if (event.target instanceof HTMLElement && event.target.matches(":focus-visible"))
|
|
117
|
+
contentProps.onFocus(event);
|
|
134
118
|
}
|
|
135
119
|
return _jsxs(_Fragment, { children: [_jsx("div", { ref: targetRef, className: styles.target, tabIndex: 0, onPointerDown: event => { pressedWhileOpen.current = open && event.button === 0; }, onClick: onTargetClick, onContextMenu: event => { event.preventDefault(); showMenu(event.clientX, event.clientY); }, onKeyDown: onTargetKeyDown, "aria-label": label, "aria-haspopup": "menu", "aria-expanded": open, children: children }), portal ? createPortal(_jsx(AnimatePresence, { children: open && _jsxs(motion.div, { ref: node => { if (node)
|
|
136
|
-
menuRef.current = node; }, className: styles.menu, role: "menu", "aria-label": label, tabIndex: -1, style: { left: point.x, top: point.y, transformOrigin: `${point.originX}px ${point.originY}px` }, onKeyDown: onMenuKeyDown, onFocus: onMenuFocus,
|
|
137
|
-
event.currentTarget.focus({ preventScroll: true }); }, initial: reduced ? { opacity: 0 } : { opacity: 0, scale: .96 }, animate: { opacity: 1, scale: 1 }, exit: reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, scale: .98, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, transition: reduced ? { duration: motionTokens.duration.instant } : { default: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } }, children: [_jsx(
|
|
138
|
-
|
|
139
|
-
event.currentTarget.focus({ preventScroll: true }); if (highlight?.index !== index)
|
|
140
|
-
place(event.currentTarget, true); }, onPointerLeave: () => { if (live.current)
|
|
120
|
+
menuRef.current = node; }, className: styles.menu, role: "menu", "aria-label": label, tabIndex: -1, style: { left: point.x, top: point.y, transformOrigin: `${point.originX}px ${point.originY}px` }, onKeyDown: onMenuKeyDown, ...contentProps, onFocus: onMenuFocus, onPointerLeave: event => { if (live.current)
|
|
121
|
+
event.currentTarget.focus({ preventScroll: true }); }, initial: reduced ? { opacity: 0 } : { opacity: 0, scale: .96 }, animate: { opacity: 1, scale: 1 }, exit: reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, scale: .98, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, transition: reduced ? { duration: motionTokens.duration.instant } : { default: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } }, children: [_jsx(MenuHighlight, { state: highlight, className: styles.highlight }), items.map((item, index) => _jsxs("button", { type: "button", role: "menuitem", tabIndex: -1, "data-index": index, "data-tone": item.destructive ? "danger" : undefined, style: { "--i": index }, disabled: item.disabled, className: [styles.item, item.destructive ? styles.destructive : ""].filter(Boolean).join(" "), onPointerMove: event => { if (live.current && document.activeElement !== event.currentTarget)
|
|
122
|
+
event.currentTarget.focus({ preventScroll: true }); }, onPointerLeave: () => { if (live.current)
|
|
141
123
|
menuRef.current?.focus({ preventScroll: true }); }, onClick: () => { if (!live.current)
|
|
142
124
|
return; live.current = false; item.onSelect?.(); setOpen(false); targetRef.current?.focus(); }, children: [_jsx("span", { className: styles.icon, "aria-hidden": "true", children: item.icon ?? (item.checked ? _jsx(Check, { size: 15 }) : _jsx(MoreHorizontal, { size: 15 })) }), _jsx("span", { children: item.label })] }, item.id))] }, point.key) }), document.body) : null] });
|
|
143
125
|
}
|
|
@@ -8,9 +8,9 @@ import { useCopyFeedback } from "../../lib/use-copy-feedback.js";
|
|
|
8
8
|
import styles from "./copy-button.module.css";
|
|
9
9
|
/** Copy feedback is deliberately unhurried: a slow, almost critically damped spring and long, soft crossfades read as calm, never busy.
|
|
10
10
|
* The same motion plays in reverse when the confirmation hands back to idle, so nothing ever snaps. */
|
|
11
|
-
const settle = { type: "spring", visualDuration: .
|
|
12
|
-
const enter = { duration: .
|
|
13
|
-
const leave = { duration: .
|
|
11
|
+
const settle = { type: "spring", visualDuration: motionTokens.spring.slow.visualDuration, bounce: .06 };
|
|
12
|
+
const enter = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
|
|
13
|
+
const leave = { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] };
|
|
14
14
|
const instant = { duration: motionTokens.duration.instant };
|
|
15
15
|
const soft = `blur(${motionTokens.blur.soft}px)`;
|
|
16
16
|
const rest = { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" };
|
|
@@ -18,7 +18,7 @@ const fadeIn = { ...rest, opacity: 0 };
|
|
|
18
18
|
const fadeOut = { opacity: 0, transition: instant };
|
|
19
19
|
/** Icons trade places in one soft breath: the old glyph shrinks into a blur while the new one grows out of it on the slow spring. */
|
|
20
20
|
const iconIn = { opacity: 0, scale: .6, filter: soft };
|
|
21
|
-
const iconOut = { opacity: 0, scale: .6, filter: soft, transition: { duration: .
|
|
21
|
+
const iconOut = { opacity: 0, scale: .6, filter: soft, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] } };
|
|
22
22
|
const iconEnter = { scale: settle, opacity: { ...enter, delay: .03 }, filter: { ...enter, delay: .03 } };
|
|
23
23
|
/** Letters rise about .3em out of a soft blur; the outgoing ones lift away a little faster. */
|
|
24
24
|
const glyphIn = { opacity: 0, y: 4, filter: soft };
|
|
@@ -13,7 +13,7 @@ const instant = { duration: duration.instant };
|
|
|
13
13
|
const valueRoll = {
|
|
14
14
|
enter: (direction) => ({ opacity: 0, y: `${direction * 0.35}em`, filter: `blur(${blur.soft}px)` }),
|
|
15
15
|
center: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.standard, ease: ease.enter } },
|
|
16
|
-
exit: (direction) => ({ opacity: 0, y: `${direction * -0.3}em`, filter: `blur(${blur.subtle}px)`, transition: { duration:
|
|
16
|
+
exit: (direction) => ({ opacity: 0, y: `${direction * -0.3}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: ease.standard } }),
|
|
17
17
|
};
|
|
18
18
|
/** Reduced motion keeps a short crossfade; resting values match the roll so server and client markup agree. */
|
|
19
19
|
const valueFade = { enter: { opacity: 0 }, center: { opacity: 1, y: 0, filter: "blur(0px)", transition: instant }, exit: { opacity: 0, transition: instant } };
|
|
@@ -21,7 +21,7 @@ const valueFade = { enter: { opacity: 0 }, center: { opacity: 1, y: 0, filter: "
|
|
|
21
21
|
const statusRise = {
|
|
22
22
|
enter: { opacity: 0, y: "0.3em", filter: `blur(${blur.soft}px)` },
|
|
23
23
|
center: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.standard, ease: ease.enter } },
|
|
24
|
-
exit: { opacity: 0, y: "-0.3em", filter: `blur(${blur.subtle}px)`, transition: { duration:
|
|
24
|
+
exit: { opacity: 0, y: "-0.3em", filter: `blur(${blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: ease.standard } },
|
|
25
25
|
};
|
|
26
26
|
export function DatePicker({ label, value, onChange, description, placeholder = "Select a date", minDate, maxDate, disabledDates, locale = "en-US", format = { month: "short", day: "numeric", year: "numeric" }, showToday, id, className, disabled, ...buttonProps }) {
|
|
27
27
|
const generatedId = useId();
|
|
@@ -96,5 +96,5 @@ export function DatePicker({ label, value, onChange, description, placeholder =
|
|
|
96
96
|
return _jsxs("div", { className: [styles.field, className].filter(Boolean).join(" "), ref: rootRef, onBlur: onBlur, children: [_jsx("label", { className: styles.label, htmlFor: controlId, children: label }), _jsxs("div", { className: styles.anchor, children: [_jsxs("button", { ...buttonProps, ref: triggerRef, id: controlId, type: "button", disabled: disabled, "aria-haspopup": "dialog", "aria-expanded": open, "aria-describedby": hintId, className: styles.trigger, onClick: () => (open ? close() : show()), onKeyDown: onTriggerKeyDown, children: [_jsx(CalendarDays, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("span", { className: styles.srOnly, children: shown }), _jsx("span", { className: styles.valueText, "aria-hidden": "true", children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: 1, children: value ? _jsx(motion.span, { className: styles.value, custom: 1, variants: reduce ? valueFade : valueRoll, initial: "enter", animate: "center", exit: "exit", children: parts.map((part, index) => _jsx(motion.span, { className: styles.part, layout: "position", layoutDependency: time, transition: partMotion, children: part.type === "literal" ? _jsx("span", { className: styles.partValue, children: part.value }) : _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: direction, children: _jsx(motion.span, { className: styles.partValue, custom: direction, variants: reduce ? valueFade : valueRoll, initial: "enter", animate: "center", exit: "exit", children: part.value }, part.value) }) }, `${index}-${part.type}`)) }, "value") : _jsx(motion.span, { className: styles.placeholder, custom: -1, variants: reduce ? valueFade : valueRoll, initial: "enter", animate: "center", exit: "exit", children: placeholder }, "placeholder") }) }), _jsx(ChevronDown, { className: styles.chevron, size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }), _jsx(AnimatePresence, { children: open && _jsxs(motion.div, { ref: popoverRef, className: styles.popover, role: "dialog", "aria-label": `${label} calendar`, onKeyDown: (event) => { if (event.key === "Escape") {
|
|
97
97
|
event.stopPropagation();
|
|
98
98
|
close();
|
|
99
|
-
} }, initial: reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: .95 }, animate: { opacity: 1, y: 0, scale: 1, transition: reduce ? instant : { ...spring.snappy, opacity: { duration: duration.fast, ease: ease.enter } } }, exit: { opacity: 0, ...(reduce ? {} : { y: -6, scale: .97 }), transition: { duration:
|
|
99
|
+
} }, initial: reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: .95 }, animate: { opacity: 1, y: 0, scale: 1, transition: reduce ? instant : { ...spring.snappy, opacity: { duration: duration.fast, ease: ease.enter } } }, exit: { opacity: 0, ...(reduce ? {} : { y: -6, scale: .97 }), transition: { duration: motionTokens.duration.fast, ease: ease.standard } }, children: [_jsx(Calendar, { value: value, onChange: selectDate, month: month, onMonthChange: setMonth, minDate: minDate, maxDate: maxDate, disabledDates: disabledDates, locale: locale, showToday: showToday }), _jsxs("div", { className: styles.footer, children: [_jsx("button", { type: "button", onClick: () => selectDate(undefined), disabled: !value, children: "Clear" }), _jsx("span", { className: styles.status, children: _jsx(AnimatePresence, { initial: false, children: _jsx(motion.span, { variants: reduce ? valueFade : statusRise, initial: "enter", animate: "center", exit: "exit", children: value ? `Selected ${formatter.format(value)}` : "Choose a day" }, time ?? "none") }) })] })] }) })] }), description && _jsx("span", { id: hintId, className: styles.description, children: description })] });
|
|
100
100
|
}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
.status > span { grid-area: 1 / 1; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
27
27
|
.footer button { border: 0; border-radius: var(--radius-control); padding: var(--space-tight) var(--space-related); background: transparent; color: var(--text-secondary); font: inherit; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
|
|
28
28
|
@media (hover: hover) and (pointer: fine) { .footer button:hover:not(:disabled) { background: var(--surface-muted); color: var(--foreground); } }
|
|
29
|
-
.footer button:active:not(:disabled) { transform: scale(.97); transition-duration: var(--duration-fast), var(--duration-fast),
|
|
29
|
+
.footer button:active:not(:disabled) { transform: scale(.97); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast); transition-timing-function: var(--ease-standard); }
|
|
30
30
|
.footer button:disabled { cursor: not-allowed; opacity: .4; }
|
|
31
31
|
.description { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); transition: opacity var(--duration-fast) var(--ease-standard); }
|
|
32
32
|
/* The open calendar covers the hint; fading it keeps its first letters from peeking past the popover's rounded corner. */
|