@teamofsilicons/silicon-ui 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/check.mjs +14 -5
- package/components/activity-heatmap/activity-heatmap.module.css +4 -4
- package/components/alert/alert.module.css +1 -1
- package/components/animated-counter/animated-counter.js +1 -1
- package/components/announcement-bar/announcement-bar.module.css +6 -6
- package/components/avatar/avatar.d.ts +2 -2
- package/components/avatar/avatar.js +1 -1
- package/components/avatar/avatar.module.css +5 -2
- package/components/avatar-group/avatar-group.module.css +4 -4
- package/components/badge/badge.module.css +4 -4
- package/components/bar-chart/bar-chart.js +1 -1
- package/components/bar-chart/bar-chart.module.css +4 -4
- package/components/billing-toggle/billing-toggle.js +2 -2
- package/components/billing-toggle/billing-toggle.module.css +10 -10
- package/components/bottom-sheet/bottom-sheet.js +1 -1
- package/components/bottom-sheet/bottom-sheet.module.css +4 -5
- package/components/breadcrumb/breadcrumb.d.ts +8 -2
- package/components/breadcrumb/breadcrumb.js +21 -9
- package/components/breadcrumb/breadcrumb.module.css +7 -3
- package/components/brush-chart/brush-chart.module.css +9 -9
- package/components/button/button.d.ts +7 -2
- package/components/button/button.js +3 -1
- package/components/button/button.module.css +10 -1
- package/components/button-group/button-group.module.css +7 -7
- package/components/calendar/calendar.js +15 -5
- package/components/calendar/calendar.module.css +13 -10
- package/components/card/card.js +4 -4
- package/components/card/card.module.css +2 -2
- package/components/card-stack/card-stack.js +2 -2
- package/components/card-stack/card-stack.module.css +2 -3
- package/components/carousel/carousel.module.css +3 -3
- package/components/chat-thread/chat-thread.module.css +41 -41
- package/components/checkbox/checkbox.module.css +1 -1
- package/components/chip-group/chip-group.js +1 -1
- package/components/chip-group/chip-group.module.css +2 -2
- package/components/code-block/code-block.module.css +7 -14
- package/components/color-picker/color-picker.module.css +20 -21
- package/components/combobox/combobox.js +5 -1
- package/components/combobox/combobox.module.css +2 -2
- package/components/command-palette/command-palette.js +18 -11
- package/components/command-palette/command-palette.module.css +18 -18
- package/components/comment-thread/comment-thread.module.css +35 -35
- package/components/confirm-morph/confirm-morph.js +5 -5
- package/components/confirm-morph/confirm-morph.module.css +9 -9
- package/components/context-menu/context-menu.js +10 -28
- package/components/context-menu/context-menu.module.css +3 -3
- package/components/copy-button/copy-button.js +4 -4
- package/components/countdown/countdown.module.css +4 -4
- package/components/date-picker/date-picker.js +3 -3
- package/components/date-picker/date-picker.module.css +7 -7
- package/components/date-range-picker/date-range-picker.js +13 -10
- package/components/date-range-picker/date-range-picker.module.css +23 -22
- package/components/dialog/dialog.module.css +2 -2
- package/components/donut-chart/donut-chart.module.css +5 -5
- package/components/drawer/drawer.module.css +7 -7
- package/components/dropdown-menu/dropdown-menu.d.ts +71 -1
- package/components/dropdown-menu/dropdown-menu.js +100 -6
- package/components/dropdown-menu/dropdown-menu.module.css +15 -5
- package/components/elastic-slider/elastic-slider.js +2 -2
- package/components/elastic-slider/elastic-slider.module.css +4 -4
- package/components/expandable-card/expandable-card.js +1 -1
- package/components/expandable-card/expandable-card.module.css +1 -1
- package/components/expanding-button-group/expanding-button-group.module.css +3 -3
- package/components/expanding-search/expanding-search.js +3 -3
- package/components/expanding-search/expanding-search.module.css +9 -9
- package/components/file-dropzone/file-dropzone.js +3 -3
- package/components/file-dropzone/file-dropzone.module.css +21 -21
- package/components/file-upload/file-upload.module.css +7 -7
- package/components/filter-toolbar/filter-toolbar.js +7 -3
- package/components/filter-toolbar/filter-toolbar.module.css +9 -9
- package/components/floating-button-group/floating-button-group.module.css +7 -7
- package/components/gauge/gauge.js +1 -1
- package/components/gauge/gauge.module.css +2 -2
- package/components/hover-card/hover-card.js +2 -2
- package/components/hover-card/hover-card.module.css +2 -2
- package/components/image-compare/image-compare.module.css +4 -4
- package/components/inline-edit/inline-edit.module.css +12 -12
- package/components/json-viewer/json-viewer.module.css +12 -12
- package/components/line-chart/line-chart.js +1 -1
- package/components/line-chart/line-chart.module.css +8 -8
- package/components/mention-input/mention-input.js +4 -2
- package/components/mention-input/mention-input.module.css +8 -8
- package/components/metric-card/metric-card.module.css +1 -1
- package/components/money-input/money-input.js +6 -3
- package/components/money-input/money-input.module.css +10 -10
- package/components/morph-select/morph-select.js +7 -5
- package/components/morph-select/morph-select.module.css +13 -13
- package/components/multi-select/multi-select.js +4 -2
- package/components/multi-select/multi-select.module.css +6 -6
- package/components/nav-list/nav-list.d.ts +64 -0
- package/components/nav-list/nav-list.js +96 -0
- package/components/nav-list/nav-list.module.css +25 -0
- package/components/notification-center/notification-center.module.css +27 -27
- package/components/number-field/number-field.js +3 -3
- package/components/number-field/number-field.module.css +3 -3
- package/components/otp-input/otp-input.module.css +4 -4
- package/components/pagination/pagination.module.css +1 -1
- package/components/password-field/password-field.module.css +2 -2
- package/components/password-strength/password-strength.module.css +8 -8
- package/components/phone-input/phone-input.js +9 -7
- package/components/phone-input/phone-input.module.css +19 -19
- package/components/popover/popover.module.css +2 -2
- package/components/progress/progress.module.css +1 -1
- package/components/radio-cards/radio-cards.module.css +8 -8
- package/components/radio-group/radio-group.js +4 -1
- package/components/radio-group/radio-group.module.css +4 -3
- package/components/resizable-panels/resizable-panels.module.css +5 -5
- package/components/rich-text-editor/rich-text-editor.js +3 -3
- package/components/rich-text-editor/rich-text-editor.module.css +12 -12
- package/components/ridgeline/ridgeline.module.css +7 -7
- package/components/scroll-area/scroll-area.module.css +8 -8
- package/components/segmented-control/segmented-control.d.ts +3 -1
- package/components/segmented-control/segmented-control.js +5 -2
- package/components/segmented-control/segmented-control.module.css +9 -4
- package/components/select/select.module.css +5 -5
- package/components/shortcut-recorder/shortcut-recorder.js +5 -5
- package/components/shortcut-recorder/shortcut-recorder.module.css +16 -16
- package/components/signature-pad/signature-pad.module.css +11 -11
- package/components/slider/slider.module.css +4 -4
- package/components/slope-chart/slope-chart.module.css +7 -7
- package/components/slot-text/slot-text.js +2 -1
- package/components/sortable-data-table/sortable-data-table.module.css +4 -4
- package/components/sparkline/sparkline.js +1 -1
- package/components/split-button/split-button.module.css +6 -6
- package/components/stepper/stepper.module.css +3 -3
- package/components/streamgraph/streamgraph.module.css +7 -7
- package/components/swipe-actions/swipe-actions.js +2 -2
- package/components/swipe-actions/swipe-actions.module.css +5 -5
- package/components/switch/switch.js +2 -2
- package/components/switch/switch.module.css +1 -1
- package/components/tabs/tabs.d.ts +9 -2
- package/components/tabs/tabs.js +11 -5
- package/components/tabs/tabs.module.css +10 -3
- package/components/tag-input/tag-input.module.css +4 -4
- package/components/text-reveal/text-reveal.js +1 -1
- package/components/time-picker/time-picker.js +4 -2
- package/components/time-picker/time-picker.module.css +3 -3
- package/components/timeline/timeline.js +4 -4
- package/components/timeline/timeline.module.css +3 -3
- package/components/toast/toast.js +1 -1
- package/components/toast/toast.module.css +5 -5
- package/components/toast-stack/toast-stack.js +1 -1
- package/components/toast-stack/toast-stack.module.css +5 -5
- package/components/tooltip/tooltip.module.css +1 -1
- package/components/tree-view/tree-view.js +16 -12
- package/components/tree-view/tree-view.module.css +10 -8
- package/components/treemap/treemap.module.css +13 -13
- package/components/usage-meter/usage-meter.js +1 -1
- package/components/usage-meter/usage-meter.module.css +5 -5
- package/components/user-menu/user-menu.js +16 -14
- package/components/user-menu/user-menu.module.css +22 -22
- package/components/waffle-chart/waffle-chart.module.css +7 -7
- package/components.json +416 -1
- package/eslint.js +35 -3
- 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 +50 -7
package/eslint.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Options:
|
|
9
9
|
* files globs the rules apply to (default: every .tsx and .jsx file)
|
|
10
|
+
* allowExperimental components whose experimental parts or prop values this app may use (a deliberate, reviewable opt-in)
|
|
10
11
|
* ignores globs they skip (for example a vendored exception)
|
|
11
12
|
* elements override the element → component table below
|
|
12
13
|
*
|
|
@@ -14,6 +15,17 @@
|
|
|
14
15
|
* if your own config also sets either rule, put this one last and move your entries into `extraSyntax` / `extraImports`.
|
|
15
16
|
*/
|
|
16
17
|
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
|
|
20
|
+
/** This release's catalog (components.json next to this file): which components, parts or prop values are experimental. */
|
|
21
|
+
function catalog() {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(new URL("./components.json", import.meta.url), "utf8"));
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
/** Raw elements Silicon UI already has a component for. */
|
|
18
30
|
export const ELEMENTS = {
|
|
19
31
|
button: "Use Button (actions) or ButtonLink (navigation) from @teamofsilicons/silicon-ui.",
|
|
@@ -34,7 +46,27 @@ const IMPORTS = [
|
|
|
34
46
|
},
|
|
35
47
|
];
|
|
36
48
|
|
|
37
|
-
export default function siliconUi({ files = ["**/*.{tsx,jsx}"], ignores = [], elements = ELEMENTS, extraSyntax = [], extraImports = [] } = {}) {
|
|
49
|
+
export default function siliconUi({ files = ["**/*.{tsx,jsx}"], ignores = [], elements = ELEMENTS, extraSyntax = [], extraImports = [], allowExperimental = [] } = {}) {
|
|
50
|
+
const entries = catalog().filter(entry => !allowExperimental.includes(entry.name));
|
|
51
|
+
const optIn = name => `To try it, add "${name}" to siliconUi({ allowExperimental }) in your ESLint config.`;
|
|
52
|
+
// A whole experimental component: any import of it.
|
|
53
|
+
const gated = entries.filter(entry => entry.status === "experimental").map(entry => ({
|
|
54
|
+
regex: `^@teamofsilicons/silicon-ui/${entry.name}$`,
|
|
55
|
+
message: `${entry.name} is experimental: it may still change and is not for production. ${optIn(entry.name)}`,
|
|
56
|
+
}));
|
|
57
|
+
// Experimental parts of a stable component: only those named imports.
|
|
58
|
+
const gatedParts = entries.filter(entry => entry.status !== "experimental" && entry.experimentalExports?.length).map(entry => ({
|
|
59
|
+
name: `@teamofsilicons/silicon-ui/${entry.name}`,
|
|
60
|
+
importNames: entry.experimentalExports,
|
|
61
|
+
message: `These parts of ${entry.name} are experimental: they may still change and are not for production. ${optIn(entry.name)}`,
|
|
62
|
+
}));
|
|
63
|
+
// Experimental values of a stable component's prop, such as <Button size="xs">. The value "*" gates a new prop
|
|
64
|
+
// whatever its value, such as <Breadcrumb maxItems={3}>.
|
|
65
|
+
const gatedProps = entries.flatMap(entry => Object.entries(entry.experimentalProps ?? {}).flatMap(([component, props]) =>
|
|
66
|
+
Object.entries(props).flatMap(([prop, values]) => values.map(value => ({
|
|
67
|
+
selector: `JSXOpeningElement[name.name="${component}"] > JSXAttribute[name.name="${prop}"]${value === "*" ? "" : `[value.value="${value}"]`}`,
|
|
68
|
+
message: `${component} ${value === "*" ? prop : `${prop}="${value}"`} is experimental: it may still change and is not for production. ${optIn(entry.name)}`,
|
|
69
|
+
})))));
|
|
38
70
|
const syntax = Object.entries(elements).map(([name, message]) => ({
|
|
39
71
|
selector: `JSXOpeningElement[name.type="JSXIdentifier"][name.name="${name}"]`,
|
|
40
72
|
message: `<${name}> is not allowed here. ${message}`,
|
|
@@ -45,8 +77,8 @@ export default function siliconUi({ files = ["**/*.{tsx,jsx}"], ignores = [], el
|
|
|
45
77
|
files,
|
|
46
78
|
ignores,
|
|
47
79
|
rules: {
|
|
48
|
-
"no-restricted-syntax": ["error", ...syntax, ...extraSyntax],
|
|
49
|
-
"no-restricted-imports": ["error", { patterns: [...IMPORTS, ...extraImports] }],
|
|
80
|
+
"no-restricted-syntax": ["error", ...syntax, ...gatedProps, ...extraSyntax],
|
|
81
|
+
"no-restricted-imports": ["error", { paths: gatedParts, patterns: [...IMPORTS, ...gated, ...extraImports] }],
|
|
50
82
|
},
|
|
51
83
|
},
|
|
52
84
|
];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { FocusEvent, MouseEvent, PointerEvent, RefObject } from "react";
|
|
2
|
+
/** An item's box in the container's own layout space (unaffected by transforms or scrolling). */
|
|
3
|
+
export interface FluidRect {
|
|
4
|
+
top: number;
|
|
5
|
+
left: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
export interface FluidHoverState {
|
|
10
|
+
/** Where the highlight sits, or null when nothing is lit. */
|
|
11
|
+
rect: FluidRect | null;
|
|
12
|
+
/** The lit item. */
|
|
13
|
+
item: HTMLElement | null;
|
|
14
|
+
/** False when the highlight should jump (keyboard, a resize) rather than travel. */
|
|
15
|
+
glide: boolean;
|
|
16
|
+
/** Steps on each new pointer entry, so the highlight fades in where it lands. */
|
|
17
|
+
session: number;
|
|
18
|
+
}
|
|
19
|
+
export interface FluidHoverOptions {
|
|
20
|
+
/** Selector for the items inside the container. */
|
|
21
|
+
items: string;
|
|
22
|
+
/** "y" for lists (default), "x" for a row of tabs, "xy" for grids. */
|
|
23
|
+
axis?: "x" | "y" | "xy";
|
|
24
|
+
/** Items the highlight skips (and a gap click never activates). Defaults to disabled or aria-disabled items. */
|
|
25
|
+
isItemDisabled?: (item: HTMLElement) => boolean;
|
|
26
|
+
/** "skip" (default) lights the nearest enabled item even over a disabled one; "clear" lights nothing over a disabled item (a calendar's unavailable days). */
|
|
27
|
+
disabled?: "skip" | "clear";
|
|
28
|
+
/** Route a click in a gap to the lit item. On by default; turn it off where empty space should stay inert. */
|
|
29
|
+
gapClick?: boolean;
|
|
30
|
+
/** Light the item that receives keyboard focus. On by default. */
|
|
31
|
+
followFocus?: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** The item's box relative to the container, summing offsets through positioned ancestors in between. */
|
|
34
|
+
export declare function rectOf(item: HTMLElement, container: HTMLElement): FluidRect;
|
|
35
|
+
/**
|
|
36
|
+
* The item the pointer is inside, or else the one whose edge is nearest along the axis (both axes for a grid). Edges, not
|
|
37
|
+
* centres, so a gap next to a wide item still lights the item the pointer is closer to.
|
|
38
|
+
*/
|
|
39
|
+
export declare function pickNearest(items: HTMLElement[], x: number, y: number, axis: "x" | "y" | "xy"): HTMLElement | null;
|
|
40
|
+
export declare function useFluidHover<T extends HTMLElement>(container: RefObject<T | null>, options: FluidHoverOptions): {
|
|
41
|
+
handlers: {
|
|
42
|
+
onMouseEnter: () => void;
|
|
43
|
+
onMouseMove: (event: MouseEvent<T>) => void;
|
|
44
|
+
onMouseLeave: () => void;
|
|
45
|
+
onKeyDownCapture: () => void;
|
|
46
|
+
onFocus: (event: FocusEvent<T>) => void;
|
|
47
|
+
onBlur: (event: FocusEvent<T>) => void;
|
|
48
|
+
onClick: (event: MouseEvent<T>) => void;
|
|
49
|
+
};
|
|
50
|
+
reset: () => void;
|
|
51
|
+
light: (item: HTMLElement | null, glide: boolean) => void;
|
|
52
|
+
/** Where the highlight sits, or null when nothing is lit. */
|
|
53
|
+
rect: FluidRect | null;
|
|
54
|
+
/** The lit item. */
|
|
55
|
+
item: HTMLElement | null;
|
|
56
|
+
/** False when the highlight should jump (keyboard, a resize) rather than travel. */
|
|
57
|
+
glide: boolean;
|
|
58
|
+
/** Steps on each new pointer entry, so the highlight fades in where it lands. */
|
|
59
|
+
session: number;
|
|
60
|
+
};
|
|
61
|
+
export interface NearestItemOptions extends Omit<FluidHoverOptions, "followFocus"> {
|
|
62
|
+
/** Called with the nearest enabled item as the mouse moves over the container. */
|
|
63
|
+
onPick: (item: HTMLElement) => void;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fluid hover's picking without its highlight, for a list that already keeps its own active item and highlight (a listbox
|
|
67
|
+
* with aria-activedescendant, a roving list): the mouse picks the nearest enabled item, gaps and padding included, and a
|
|
68
|
+
* click in a gap goes to that item. Spread the result on the container.
|
|
69
|
+
*/
|
|
70
|
+
export declare function useNearestItem<T extends HTMLElement>({ items: selector, axis, isItemDisabled, gapClick, onPick }: NearestItemOptions): {
|
|
71
|
+
onPointerMove: (event: PointerEvent<T>) => void;
|
|
72
|
+
onClick: (event: MouseEvent<T>) => void;
|
|
73
|
+
};
|
|
74
|
+
export interface FluidHoverHighlightProps {
|
|
75
|
+
/** The state from useFluidHover (or any { rect, glide, session }). */
|
|
76
|
+
hover: Pick<FluidHoverState, "rect" | "glide" | "session">;
|
|
77
|
+
/** Radius, fill, z-index. The container must be position: relative; the highlight is placed at its top-left corner. */
|
|
78
|
+
className?: string;
|
|
79
|
+
/** Extra attributes for styling, such as data-tone. */
|
|
80
|
+
tone?: string;
|
|
81
|
+
}
|
|
82
|
+
/** The one highlight a fluid hover set renders: it springs between items on transforms, and fades in and out. */
|
|
83
|
+
export declare function FluidHoverHighlight({ hover, className, tone }: FluidHoverHighlightProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Fluid hover: in a set of items (a menu, a list, tabs, a grid), the item nearest the pointer is lit, even while the
|
|
5
|
+
* pointer is in a gap, the padding or past the last item, and one highlight springs between items. A click in a gap goes
|
|
6
|
+
* to the lit item, so what is lit is what a click hits. A new pointer entry fades the highlight in where it lands instead
|
|
7
|
+
* of sliding it over from the last item. The keyboard moves it without travel, and reduced motion keeps the fade but
|
|
8
|
+
* drops the travel.
|
|
9
|
+
*
|
|
10
|
+
* const list = useRef<HTMLUListElement>(null);
|
|
11
|
+
* const hover = useFluidHover(list, { items: "[data-item]" });
|
|
12
|
+
* <ul ref={list} className={styles.list} {...hover.handlers}> // .list { position: relative }
|
|
13
|
+
* <FluidHoverHighlight hover={hover} className={styles.highlight} />
|
|
14
|
+
* …items…
|
|
15
|
+
* </ul>
|
|
16
|
+
*
|
|
17
|
+
* Adapted from Fluid Functionalism's use-fluid-hover and fluid-hover-highlight by Micka Touillaud (MIT),
|
|
18
|
+
* https://github.com/mickadesign/fluid-functionalism. Items are found by a selector instead of registering themselves,
|
|
19
|
+
* so a component adopts it without changing its items.
|
|
20
|
+
*/
|
|
21
|
+
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
|
22
|
+
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
23
|
+
import { motionTokens } from "./motion-tokens.js";
|
|
24
|
+
const defaultDisabled = (item) => item.matches(":disabled, [aria-disabled='true'], [data-disabled]");
|
|
25
|
+
/** Controls that sit between items keep their own clicks. */
|
|
26
|
+
const CONTROL = "input, textarea, select, button, a, summary, [contenteditable], [role='textbox'], [role='searchbox'], [role='button']";
|
|
27
|
+
/** The item's box relative to the container, summing offsets through positioned ancestors in between. */
|
|
28
|
+
export function rectOf(item, container) {
|
|
29
|
+
let top = item.offsetTop, left = item.offsetLeft;
|
|
30
|
+
let parent = item.offsetParent;
|
|
31
|
+
while (parent && parent !== container && container.contains(parent)) {
|
|
32
|
+
top += parent.offsetTop + parent.clientTop;
|
|
33
|
+
left += parent.offsetLeft + parent.clientLeft;
|
|
34
|
+
parent = parent.offsetParent;
|
|
35
|
+
}
|
|
36
|
+
return { top, left, width: item.offsetWidth, height: item.offsetHeight };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The item the pointer is inside, or else the one whose edge is nearest along the axis (both axes for a grid). Edges, not
|
|
40
|
+
* centres, so a gap next to a wide item still lights the item the pointer is closer to.
|
|
41
|
+
*/
|
|
42
|
+
export function pickNearest(items, x, y, axis) {
|
|
43
|
+
let nearest = null, best = Infinity;
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
const r = item.getBoundingClientRect();
|
|
46
|
+
if (!r.width && !r.height)
|
|
47
|
+
continue;
|
|
48
|
+
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom)
|
|
49
|
+
return item;
|
|
50
|
+
const dx = Math.max(r.left - x, 0, x - r.right), dy = Math.max(r.top - y, 0, y - r.bottom);
|
|
51
|
+
const distance = axis === "x" ? dx : axis === "y" ? dy : Math.hypot(dx, dy);
|
|
52
|
+
if (distance < best) {
|
|
53
|
+
best = distance;
|
|
54
|
+
nearest = item;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return nearest;
|
|
58
|
+
}
|
|
59
|
+
/** Hands a gap click to the item as a real mouse click (detail, modifiers and coordinates kept), so the item reads it as a pointer press, not a keyboard one. */
|
|
60
|
+
function forwardClick(item, event) {
|
|
61
|
+
const { detail, clientX, clientY, screenX, screenY, ctrlKey, shiftKey, altKey, metaKey, button, buttons } = event;
|
|
62
|
+
item.dispatchEvent(new globalThis.MouseEvent("click", { bubbles: true, cancelable: true, composed: true, view: window, detail, clientX, clientY, screenX, screenY, ctrlKey, shiftKey, altKey, metaKey, button, buttons }));
|
|
63
|
+
}
|
|
64
|
+
/** Whether an event happened inside the element whose handler hears it, not in a portalled child (a submenu, a tooltip). */
|
|
65
|
+
const isOwn = (event) => !(event.currentTarget instanceof Node) || !(event.target instanceof Node) || event.currentTarget.contains(event.target);
|
|
66
|
+
export function useFluidHover(container, options) {
|
|
67
|
+
const { items: selector, axis = "y", isItemDisabled = defaultDisabled, disabled = "skip", gapClick = true, followFocus = true } = options;
|
|
68
|
+
const [state, setState] = useState({ rect: null, item: null, glide: false, session: 0 });
|
|
69
|
+
const current = useRef(state);
|
|
70
|
+
const pointer = useRef(false);
|
|
71
|
+
const entering = useRef(false);
|
|
72
|
+
const frame = useRef(0);
|
|
73
|
+
function commit(next) {
|
|
74
|
+
const prev = current.current;
|
|
75
|
+
if (prev.item === next.item && prev.session === next.session && sameRect(prev.rect, next.rect))
|
|
76
|
+
return;
|
|
77
|
+
current.current = next;
|
|
78
|
+
setState(next);
|
|
79
|
+
}
|
|
80
|
+
function light(item, glide) {
|
|
81
|
+
const root = container.current;
|
|
82
|
+
if (!root || !item)
|
|
83
|
+
return commit({ ...current.current, rect: null, item: null, glide });
|
|
84
|
+
let session = current.current.session;
|
|
85
|
+
if (entering.current) {
|
|
86
|
+
entering.current = false;
|
|
87
|
+
session += 1;
|
|
88
|
+
}
|
|
89
|
+
commit({ rect: rectOf(item, root), item, glide: glide && session === current.current.session, session });
|
|
90
|
+
}
|
|
91
|
+
const enabledItems = () => {
|
|
92
|
+
const root = container.current;
|
|
93
|
+
return root ? [...root.querySelectorAll(selector)].filter(item => !isItemDisabled(item)) : [];
|
|
94
|
+
};
|
|
95
|
+
// The lit item can move under a still highlight (a resize, content above it changing): follow it without travel.
|
|
96
|
+
const follow = useEffectEvent(() => { const { item } = current.current; if (item?.isConnected)
|
|
97
|
+
light(item, false); });
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const root = container.current;
|
|
100
|
+
if (!root || typeof ResizeObserver === "undefined")
|
|
101
|
+
return;
|
|
102
|
+
const observer = new ResizeObserver(() => follow());
|
|
103
|
+
observer.observe(root);
|
|
104
|
+
return () => observer.disconnect();
|
|
105
|
+
}, [container]);
|
|
106
|
+
useEffect(() => () => cancelAnimationFrame(frame.current), []);
|
|
107
|
+
const handlers = {
|
|
108
|
+
onMouseEnter: () => { entering.current = true; },
|
|
109
|
+
onMouseMove: (event) => {
|
|
110
|
+
if (!isOwn(event))
|
|
111
|
+
return;
|
|
112
|
+
pointer.current = true;
|
|
113
|
+
const { clientX, clientY } = event;
|
|
114
|
+
cancelAnimationFrame(frame.current);
|
|
115
|
+
frame.current = requestAnimationFrame(() => {
|
|
116
|
+
const over = disabled === "clear" ? document.elementFromPoint(clientX, clientY)?.closest(selector) : null;
|
|
117
|
+
light(over && isItemDisabled(over) ? null : pickNearest(enabledItems(), clientX, clientY, axis), true);
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
onMouseLeave: () => { cancelAnimationFrame(frame.current); entering.current = false; light(null, false); },
|
|
121
|
+
onKeyDownCapture: () => { pointer.current = false; },
|
|
122
|
+
onFocus: (event) => {
|
|
123
|
+
if (!followFocus || pointer.current || !isOwn(event))
|
|
124
|
+
return;
|
|
125
|
+
const item = event.target instanceof HTMLElement ? event.target.closest(selector) : null;
|
|
126
|
+
if (item && container.current?.contains(item) && !isItemDisabled(item))
|
|
127
|
+
light(item, false);
|
|
128
|
+
},
|
|
129
|
+
onBlur: (event) => {
|
|
130
|
+
if (!pointer.current && !event.currentTarget.contains(event.relatedTarget))
|
|
131
|
+
light(null, false);
|
|
132
|
+
},
|
|
133
|
+
onClick: (event) => {
|
|
134
|
+
if (!gapClick || !isOwn(event))
|
|
135
|
+
return;
|
|
136
|
+
const target = event.target;
|
|
137
|
+
if (!target.isConnected || target.closest(selector) || target.closest(CONTROL))
|
|
138
|
+
return;
|
|
139
|
+
const { item } = current.current;
|
|
140
|
+
if (item?.isConnected && !isItemDisabled(item))
|
|
141
|
+
forwardClick(item, event);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
/** Clears the highlight at once, such as when a menu opens again. */
|
|
145
|
+
const reset = () => { cancelAnimationFrame(frame.current); entering.current = true; light(null, false); };
|
|
146
|
+
return { ...state, handlers, reset, light };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Fluid hover's picking without its highlight, for a list that already keeps its own active item and highlight (a listbox
|
|
150
|
+
* with aria-activedescendant, a roving list): the mouse picks the nearest enabled item, gaps and padding included, and a
|
|
151
|
+
* click in a gap goes to that item. Spread the result on the container.
|
|
152
|
+
*/
|
|
153
|
+
// A hook only so callbacks that touch refs can be passed during render; it keeps no state.
|
|
154
|
+
export function useNearestItem({ items: selector, axis = "y", isItemDisabled = defaultDisabled, gapClick = true, onPick }) {
|
|
155
|
+
const nearest = (event) => {
|
|
156
|
+
if (!isOwn(event))
|
|
157
|
+
return null;
|
|
158
|
+
const items = [...event.currentTarget.querySelectorAll(selector)].filter(item => !isItemDisabled(item));
|
|
159
|
+
return pickNearest(items, event.clientX, event.clientY, axis);
|
|
160
|
+
};
|
|
161
|
+
return {
|
|
162
|
+
onPointerMove: (event) => { if (event.pointerType !== "mouse")
|
|
163
|
+
return; const item = nearest(event); if (item)
|
|
164
|
+
onPick(item); },
|
|
165
|
+
onClick: (event) => {
|
|
166
|
+
if (!gapClick)
|
|
167
|
+
return;
|
|
168
|
+
const target = event.target;
|
|
169
|
+
if (!target.isConnected || target.closest(selector) || target.closest(CONTROL))
|
|
170
|
+
return;
|
|
171
|
+
const item = nearest(event);
|
|
172
|
+
if (item)
|
|
173
|
+
forwardClick(item, event);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const sameRect = (a, b) => a === b || (!!a && !!b && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height);
|
|
178
|
+
/** The one highlight a fluid hover set renders: it springs between items on transforms, and fades in and out. */
|
|
179
|
+
export function FluidHoverHighlight({ hover, className, tone }) {
|
|
180
|
+
const reduced = useReducedMotion() ?? false;
|
|
181
|
+
const { rect, glide, session } = hover;
|
|
182
|
+
const place = rect ? { x: rect.left, y: rect.top, width: rect.width, height: rect.height } : undefined;
|
|
183
|
+
return (_jsx(AnimatePresence, { initial: false, children: place ? (_jsx(motion.span, { className: className, "data-tone": tone, "aria-hidden": "true", style: { position: "absolute", top: 0, left: 0, pointerEvents: "none" }, initial: { opacity: 0, ...place }, animate: { opacity: 1, ...place }, exit: { opacity: 0, transition: motionTokens.spring.fast.exit }, transition: { default: glide && !reduced ? motionTokens.spring.fast : { duration: 0 }, opacity: { duration: motionTokens.duration.fast } } }, session)) : null }));
|
|
184
|
+
}
|
package/lib/menu-highlight.d.ts
CHANGED
|
@@ -1,28 +1,41 @@
|
|
|
1
|
-
import type { FocusEvent } from "react";
|
|
2
|
-
type
|
|
1
|
+
import type { FocusEvent, MouseEvent } from "react";
|
|
2
|
+
import { type FluidRect } from "./fluid-hover.js";
|
|
3
|
+
/** A fluid hover highlight in a menu: where it sits, whether it travels, its pointer session, and its tone. */
|
|
4
|
+
type FluidHighlight = {
|
|
5
|
+
rect: FluidRect | null;
|
|
6
|
+
glide: boolean;
|
|
7
|
+
session: number;
|
|
8
|
+
danger: boolean;
|
|
9
|
+
};
|
|
10
|
+
/** A marker positioned by top and height only (a list's current-page marker). */
|
|
11
|
+
type BarHighlight = {
|
|
3
12
|
top: number;
|
|
4
13
|
height: number;
|
|
5
14
|
danger: boolean;
|
|
6
15
|
glide: boolean;
|
|
7
16
|
};
|
|
8
17
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* call `reset` when it opens, and render `<MenuHighlight state={highlight}
|
|
12
|
-
* Items mark a destructive tone with `data-tone="danger"`.
|
|
18
|
+
* Fluid hover for a Radix menu or listbox (lib/fluid-hover): the item nearest the pointer is lit, gaps and padding
|
|
19
|
+
* included, a click in a gap picks the lit item, and the keyboard moves the highlight with Radix's focus, without travel.
|
|
20
|
+
* Spread `contentProps` on the content, call `reset` when it opens, and render `<MenuHighlight state={highlight} … />`
|
|
21
|
+
* as its first child. Items mark a destructive tone with `data-tone="danger"`.
|
|
13
22
|
*/
|
|
14
23
|
export declare function useMenuHighlight(itemSelector?: string): {
|
|
15
|
-
highlight:
|
|
24
|
+
highlight: FluidHighlight;
|
|
16
25
|
reset: () => void;
|
|
17
26
|
contentProps: {
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
onMouseEnter: (event: MouseEvent<HTMLElement>) => void;
|
|
28
|
+
onMouseMove: (event: MouseEvent<HTMLElement>) => void;
|
|
29
|
+
onMouseLeave: () => void;
|
|
20
30
|
onKeyDownCapture: () => void;
|
|
31
|
+
onFocus: (event: FocusEvent<HTMLElement>) => void;
|
|
32
|
+
onBlur: (event: FocusEvent<HTMLElement, Element>) => void;
|
|
33
|
+
onClick: (event: MouseEvent<HTMLElement>) => void;
|
|
21
34
|
};
|
|
22
35
|
};
|
|
23
|
-
/** The
|
|
36
|
+
/** The highlight itself: the fluid hover highlight, or a marker that moves by top and height (a current-page marker). */
|
|
24
37
|
export declare function MenuHighlight({ state, className }: {
|
|
25
|
-
state:
|
|
38
|
+
state: FluidHighlight | BarHighlight | null;
|
|
26
39
|
className?: string;
|
|
27
40
|
}): import("react").JSX.Element;
|
|
28
41
|
export {};
|
package/lib/menu-highlight.js
CHANGED
|
@@ -1,43 +1,44 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { useRef } from "react";
|
|
4
4
|
import { motion, useReducedMotion } from "motion/react";
|
|
5
|
+
import { FluidHoverHighlight, useFluidHover } from "./fluid-hover.js";
|
|
5
6
|
import { motionTokens } from "./motion-tokens.js";
|
|
7
|
+
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"]';
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* call `reset` when it opens, and render `<MenuHighlight state={highlight}
|
|
10
|
-
* Items mark a destructive tone with `data-tone="danger"`.
|
|
9
|
+
* Fluid hover for a Radix menu or listbox (lib/fluid-hover): the item nearest the pointer is lit, gaps and padding
|
|
10
|
+
* included, a click in a gap picks the lit item, and the keyboard moves the highlight with Radix's focus, without travel.
|
|
11
|
+
* Spread `contentProps` on the content, call `reset` when it opens, and render `<MenuHighlight state={highlight} … />`
|
|
12
|
+
* as its first child. Items mark a destructive tone with `data-tone="danger"`.
|
|
11
13
|
*/
|
|
12
|
-
export function useMenuHighlight(itemSelector =
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
window.clearTimeout(clearTimer.current);
|
|
20
|
-
if (!item) {
|
|
21
|
-
// A short grace period keeps the highlight gliding across separators and item gaps.
|
|
22
|
-
clearTimer.current = window.setTimeout(() => setHighlight(null), pointer.current ? 70 : 0);
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
const next = { top: item.offsetTop, height: item.offsetHeight, danger: item.dataset.tone === "danger" };
|
|
26
|
-
const glide = pointer.current;
|
|
27
|
-
setHighlight(current => ({ ...next, glide: glide && current !== null }));
|
|
28
|
-
}
|
|
14
|
+
export function useMenuHighlight(itemSelector = ITEMS) {
|
|
15
|
+
// The content element is taken from its own events, so the hook never competes with the content's ref.
|
|
16
|
+
const content = useRef(null);
|
|
17
|
+
const hover = useFluidHover(content, { items: itemSelector });
|
|
18
|
+
const own = (event) => { content.current = event.currentTarget; };
|
|
19
|
+
const { handlers } = hover;
|
|
20
|
+
const highlight = { rect: hover.rect, glide: hover.glide, session: hover.session, danger: hover.item?.dataset.tone === "danger" };
|
|
29
21
|
return {
|
|
30
22
|
highlight,
|
|
31
|
-
reset:
|
|
23
|
+
reset: hover.reset,
|
|
32
24
|
contentProps: {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
onMouseEnter: (event) => { own(event); handlers.onMouseEnter(); },
|
|
26
|
+
onMouseMove: (event) => { own(event); handlers.onMouseMove(event); },
|
|
27
|
+
onMouseLeave: handlers.onMouseLeave,
|
|
28
|
+
onKeyDownCapture: handlers.onKeyDownCapture,
|
|
29
|
+
onFocus: (event) => { own(event); handlers.onFocus(event); },
|
|
30
|
+
onBlur: handlers.onBlur,
|
|
31
|
+
onClick: (event) => { own(event); handlers.onClick(event); },
|
|
36
32
|
},
|
|
37
33
|
};
|
|
38
34
|
}
|
|
39
|
-
/** The
|
|
35
|
+
/** The highlight itself: the fluid hover highlight, or a marker that moves by top and height (a current-page marker). */
|
|
40
36
|
export function MenuHighlight({ state, className }) {
|
|
37
|
+
if (state && "session" in state)
|
|
38
|
+
return _jsx(FluidHoverHighlight, { hover: state, className: className, tone: state.danger ? "danger" : undefined });
|
|
39
|
+
return _jsx(BarMarker, { state: state, className: className });
|
|
40
|
+
}
|
|
41
|
+
function BarMarker({ state, className }) {
|
|
41
42
|
const reduced = useReducedMotion();
|
|
42
|
-
return _jsx(motion.span, { className: className, "data-tone": state?.danger ? "danger" : undefined, "aria-hidden": "true", initial: false, animate: state ? { y: state.top, height: state.height, opacity: 1 } : { opacity: 0 }, transition: { default: state?.glide && !reduced ? motionTokens.spring.
|
|
43
|
+
return _jsx(motion.span, { className: className, "data-tone": state?.danger ? "danger" : undefined, "aria-hidden": "true", initial: false, animate: state ? { y: state.top, height: state.height, opacity: 1 } : { opacity: 0 }, transition: { default: state?.glide && !reduced ? motionTokens.spring.fast : { duration: 0 }, opacity: { duration: reduced ? 0 : motionTokens.duration.fast } } });
|
|
43
44
|
}
|
package/lib/motion-tokens.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
/** Motion presets are shared by the gallery and future registry consumers. */
|
|
2
1
|
export declare const motionTokens: {
|
|
2
|
+
/** Tween durations in seconds, for opacity, colour and blur, and for CSS (the same steps as `--duration-*`). */
|
|
3
3
|
readonly duration: {
|
|
4
|
-
readonly instant: 0.
|
|
5
|
-
readonly fast: 0.
|
|
6
|
-
readonly exit: 0.
|
|
7
|
-
readonly standard: 0.
|
|
8
|
-
readonly considered: 0.
|
|
4
|
+
readonly instant: 0.06;
|
|
5
|
+
readonly fast: 0.08;
|
|
6
|
+
readonly exit: 0.12;
|
|
7
|
+
readonly standard: 0.16;
|
|
8
|
+
readonly considered: 0.24;
|
|
9
|
+
/** Motion that tells a story over time rather than answering a click: a chart drawing in, a counter rolling, text
|
|
10
|
+
* revealing, a success ring drawing, a slow image zoom. Never for a hover, press, menu or panel. */
|
|
11
|
+
readonly expressive: 0.48;
|
|
9
12
|
};
|
|
10
13
|
readonly ease: {
|
|
11
14
|
readonly enter: readonly [0.16, 1, 0.3, 1];
|
|
@@ -15,30 +18,76 @@ export declare const motionTokens: {
|
|
|
15
18
|
readonly inOut: readonly [0.65, 0, 0.35, 1];
|
|
16
19
|
};
|
|
17
20
|
readonly spring: {
|
|
18
|
-
|
|
21
|
+
/** Hover highlights, presses, toggles, thumbs and small indicators. */
|
|
22
|
+
readonly fast: {
|
|
19
23
|
readonly type: "spring";
|
|
20
|
-
readonly
|
|
21
|
-
readonly
|
|
24
|
+
readonly visualDuration: 0.05;
|
|
25
|
+
readonly bounce: 0;
|
|
26
|
+
readonly exit: {
|
|
27
|
+
readonly duration: 0.06;
|
|
28
|
+
};
|
|
22
29
|
};
|
|
23
|
-
|
|
30
|
+
/** Menus, popovers, tabs, drawers, height changes: short travel that must land exactly. */
|
|
31
|
+
readonly moderate: {
|
|
24
32
|
readonly type: "spring";
|
|
25
|
-
readonly
|
|
26
|
-
readonly
|
|
33
|
+
readonly visualDuration: 0.09;
|
|
34
|
+
readonly bounce: 0;
|
|
35
|
+
readonly exit: {
|
|
36
|
+
readonly duration: 0.12;
|
|
37
|
+
};
|
|
27
38
|
};
|
|
28
|
-
/**
|
|
29
|
-
readonly
|
|
39
|
+
/** Dialogs, sheets, shape morphs and large shared-layout moves. A little bounce. */
|
|
40
|
+
readonly slow: {
|
|
30
41
|
readonly type: "spring";
|
|
31
|
-
readonly visualDuration: 0.
|
|
42
|
+
readonly visualDuration: 0.15;
|
|
32
43
|
readonly bounce: 0.12;
|
|
44
|
+
readonly exit: {
|
|
45
|
+
readonly duration: 0.16;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
/** Earlier names, kept so existing code keeps working. Prefer the tier names. */
|
|
49
|
+
readonly snappy: {
|
|
50
|
+
readonly type: "spring";
|
|
51
|
+
readonly visualDuration: 0.05;
|
|
52
|
+
readonly bounce: 0;
|
|
53
|
+
readonly exit: {
|
|
54
|
+
readonly duration: 0.06;
|
|
55
|
+
};
|
|
33
56
|
};
|
|
34
|
-
/** Panels, height changes, and layout shifts. Critically damped, never overshoots. */
|
|
35
57
|
readonly smooth: {
|
|
36
58
|
readonly type: "spring";
|
|
37
|
-
readonly visualDuration: 0.
|
|
59
|
+
readonly visualDuration: 0.09;
|
|
38
60
|
readonly bounce: 0;
|
|
61
|
+
readonly exit: {
|
|
62
|
+
readonly duration: 0.12;
|
|
63
|
+
};
|
|
39
64
|
};
|
|
40
|
-
/** Shape morphs, shared layout highlights, and width changes that follow new content. */
|
|
41
65
|
readonly morph: {
|
|
66
|
+
readonly type: "spring";
|
|
67
|
+
readonly visualDuration: 0.15;
|
|
68
|
+
readonly bounce: 0.12;
|
|
69
|
+
readonly exit: {
|
|
70
|
+
readonly duration: 0.16;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
readonly responsive: {
|
|
74
|
+
readonly type: "spring";
|
|
75
|
+
readonly visualDuration: 0.09;
|
|
76
|
+
readonly bounce: 0;
|
|
77
|
+
readonly exit: {
|
|
78
|
+
readonly duration: 0.12;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
readonly gentle: {
|
|
82
|
+
readonly type: "spring";
|
|
83
|
+
readonly visualDuration: 0.09;
|
|
84
|
+
readonly bounce: 0;
|
|
85
|
+
readonly exit: {
|
|
86
|
+
readonly duration: 0.12;
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
/** The spring for expressive motion (see duration.expressive): a bar growing, a value settling into place. */
|
|
90
|
+
readonly expressive: {
|
|
42
91
|
readonly type: "spring";
|
|
43
92
|
readonly visualDuration: 0.42;
|
|
44
93
|
readonly bounce: 0.16;
|
|
@@ -58,3 +107,12 @@ export declare const motionTokens: {
|
|
|
58
107
|
readonly text: 8;
|
|
59
108
|
};
|
|
60
109
|
};
|
|
110
|
+
/**
|
|
111
|
+
* How long (ms) a deferred-unmount timer waits for a tier's exit before forcing it: the exit plus a safety margin, for
|
|
112
|
+
* a throttled background tab that never finishes the animation.
|
|
113
|
+
*/
|
|
114
|
+
export declare const exitFallbackMs: (tier: {
|
|
115
|
+
exit: {
|
|
116
|
+
duration: number;
|
|
117
|
+
};
|
|
118
|
+
}) => number;
|