beez-ui 2.5.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/dist/components/alert-dialog.d.ts +15 -4
  4. package/dist/components/alert-dialog.js +84 -44
  5. package/dist/components/alert.d.ts +30 -9
  6. package/dist/components/alert.js +360 -39
  7. package/dist/components/avatar.d.ts +33 -10
  8. package/dist/components/avatar.js +110 -30
  9. package/dist/components/badge.d.ts +39 -7
  10. package/dist/components/badge.js +348 -24
  11. package/dist/components/calendar.d.ts +29 -2
  12. package/dist/components/calendar.js +1035 -223
  13. package/dist/components/card.d.ts +17 -1
  14. package/dist/components/card.js +34 -8
  15. package/dist/components/carousel.d.ts +29 -2
  16. package/dist/components/carousel.js +471 -90
  17. package/dist/components/checkbox.d.ts +13 -2
  18. package/dist/components/checkbox.js +301 -41
  19. package/dist/components/dialog-surface.d.ts +27 -0
  20. package/dist/components/dialog-surface.js +137 -0
  21. package/dist/components/dialog.d.ts +21 -4
  22. package/dist/components/dialog.js +163 -96
  23. package/dist/components/dropdown-menu.d.ts +30 -16
  24. package/dist/components/dropdown-menu.js +139 -83
  25. package/dist/components/hover-card.d.ts +46 -3
  26. package/dist/components/hover-card.js +423 -60
  27. package/dist/components/pagination.d.ts +32 -14
  28. package/dist/components/pagination.js +136 -59
  29. package/dist/components/popover.d.ts +14 -5
  30. package/dist/components/popover.js +78 -36
  31. package/dist/components/radio-group.d.ts +19 -3
  32. package/dist/components/radio-group.js +400 -57
  33. package/dist/components/select.d.ts +17 -6
  34. package/dist/components/select.js +83 -71
  35. package/dist/components/sheet.js +37 -26
  36. package/dist/components/sidebar.js +11 -0
  37. package/dist/components/skeleton.d.ts +19 -5
  38. package/dist/components/skeleton.js +434 -14
  39. package/dist/components/switch.d.ts +14 -4
  40. package/dist/components/switch.js +424 -40
  41. package/dist/components/tabs.d.ts +38 -5
  42. package/dist/components/tabs.js +564 -97
  43. package/dist/components/themed-toaster.d.ts +6 -2
  44. package/dist/components/themed-toaster.js +80 -50
  45. package/dist/components/tooltip.d.ts +33 -2
  46. package/dist/components/tooltip.js +197 -69
  47. package/dist/constants/alert.d.ts +12 -0
  48. package/dist/constants/alert.js +9 -0
  49. package/dist/constants/avatar.d.ts +10 -0
  50. package/dist/constants/avatar.js +7 -0
  51. package/dist/constants/badge.d.ts +6 -0
  52. package/dist/constants/badge.js +6 -0
  53. package/dist/constants/calendar.d.ts +24 -0
  54. package/dist/constants/calendar.js +22 -0
  55. package/dist/constants/carousel.d.ts +37 -0
  56. package/dist/constants/carousel.js +53 -0
  57. package/dist/constants/checkbox.d.ts +17 -0
  58. package/dist/constants/checkbox.js +17 -0
  59. package/dist/constants/dialog.d.ts +6 -0
  60. package/dist/constants/dialog.js +6 -0
  61. package/dist/constants/dropdown-menu.d.ts +5 -0
  62. package/dist/constants/dropdown-menu.js +5 -0
  63. package/dist/constants/hover-card.d.ts +37 -0
  64. package/dist/constants/hover-card.js +85 -0
  65. package/dist/constants/pagination.d.ts +9 -0
  66. package/dist/constants/pagination.js +9 -0
  67. package/dist/constants/popover.d.ts +7 -0
  68. package/dist/constants/popover.js +39 -0
  69. package/dist/constants/radio-group.d.ts +10 -0
  70. package/dist/constants/radio-group.js +10 -0
  71. package/dist/constants/select.d.ts +3 -0
  72. package/dist/constants/select.js +3 -0
  73. package/dist/constants/skeleton.d.ts +17 -0
  74. package/dist/constants/skeleton.js +17 -0
  75. package/dist/constants/switch.d.ts +44 -0
  76. package/dist/constants/switch.js +49 -0
  77. package/dist/constants/tabs.d.ts +17 -0
  78. package/dist/constants/tabs.js +17 -0
  79. package/dist/constants/toast.d.ts +3 -0
  80. package/dist/constants/toast.js +3 -0
  81. package/dist/constants/tooltip.d.ts +13 -0
  82. package/dist/constants/tooltip.js +52 -0
  83. package/dist/lib/toast-layer.d.ts +24 -0
  84. package/dist/lib/toast-layer.js +76 -0
  85. package/dist/motion/arc-floating-slot.d.ts +40 -0
  86. package/dist/motion/arc-floating-slot.js +167 -0
  87. package/dist/motion/glide-indicator.d.ts +6 -7
  88. package/dist/motion/glide-indicator.js +5 -3
  89. package/dist/motion/overlay-warmth.d.ts +24 -0
  90. package/dist/motion/overlay-warmth.js +70 -0
  91. package/dist/styles.css +1 -1
  92. package/package.json +1 -1
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Tells whether an event target belongs to the toast layer.
3
+ * @param target - Target of the outside interaction.
4
+ * @returns `true` when the target is a toast or one of its controls.
5
+ */
6
+ export declare function isToastLayerTarget(target: EventTarget | null): boolean;
7
+ /**
8
+ * Lets keyboard focus enter the toast layer while a modal traps focus. Radix's focus trap listens for `focusin` and
9
+ * `focusout` on the document and pulls focus back into the modal whenever it lands outside, so Sonner's hotkey could
10
+ * never move focus to a toast's controls. Focus changes that land on a toast stop at the body, after React has run
11
+ * its own handlers and before the document-level trap sees them; every other focus change still reaches the trap.
12
+ * Because the trap never sees focus leave, it cannot pull it back either: when the focused toast control is removed
13
+ * (an action or close button dismisses its toast), focus returns to the element it came from instead of the body.
14
+ * @param ownerDocument - Document whose body hosts the app and the modal portals.
15
+ * @returns Removes the listeners.
16
+ */
17
+ export declare function letFocusReachToastLayer(ownerDocument: Document): () => void;
18
+ /**
19
+ * Wraps a modal's `onInteractOutside` so presses on toasts never dismiss it. The consumer's handler still runs first
20
+ * and can keep preventing dismissal for its own reasons.
21
+ * @param handler - The consumer's `onInteractOutside`, if any.
22
+ * @returns The handler to give the Radix content.
23
+ */
24
+ export declare function keepOpenOnToastInteraction<OutsideEvent extends Event>(handler?: (event: OutsideEvent) => void): (event: OutsideEvent) => void;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Keeps toasts usable while a modal overlay is open. Toasts sit above every overlay, but a modal `Dialog` or `Sheet`
3
+ * treats a press on them as a press outside and closes, so a toast's action or close button would dismiss the form
4
+ * that raised it instead of running.
5
+ */
6
+ import { TOAST_LAYER_SELECTOR } from "../constants/toast.js";
7
+ /**
8
+ * Tells whether an event target belongs to the toast layer.
9
+ * @param target - Target of the outside interaction.
10
+ * @returns `true` when the target is a toast or one of its controls.
11
+ */
12
+ export function isToastLayerTarget(target) {
13
+ return target instanceof Element && target.closest(TOAST_LAYER_SELECTOR) !== null;
14
+ }
15
+ /**
16
+ * Lets keyboard focus enter the toast layer while a modal traps focus. Radix's focus trap listens for `focusin` and
17
+ * `focusout` on the document and pulls focus back into the modal whenever it lands outside, so Sonner's hotkey could
18
+ * never move focus to a toast's controls. Focus changes that land on a toast stop at the body, after React has run
19
+ * its own handlers and before the document-level trap sees them; every other focus change still reaches the trap.
20
+ * Because the trap never sees focus leave, it cannot pull it back either: when the focused toast control is removed
21
+ * (an action or close button dismisses its toast), focus returns to the element it came from instead of the body.
22
+ * @param ownerDocument - Document whose body hosts the app and the modal portals.
23
+ * @returns Removes the listeners.
24
+ */
25
+ export function letFocusReachToastLayer(ownerDocument) {
26
+ const { body } = ownerDocument;
27
+ let focusOrigin = null;
28
+ let focusedToastElement = null;
29
+ const restoreFocusAfterRemoval = new MutationObserver(() => {
30
+ if (focusedToastElement?.isConnected !== false) return;
31
+ const activeElement = ownerDocument.activeElement;
32
+ if (activeElement === null || activeElement === body) focusOrigin?.focus({ preventScroll: true });
33
+ stopTrackingToastFocus();
34
+ });
35
+ const stopTrackingToastFocus = () => {
36
+ restoreFocusAfterRemoval.disconnect();
37
+ focusOrigin = null;
38
+ focusedToastElement = null;
39
+ };
40
+ const stopFocusIntoToast = (event) => {
41
+ const focusedElement = event.type === "focusin" ? event.target : event.relatedTarget;
42
+ if (!isToastLayerTarget(focusedElement)) {
43
+ if (event.type === "focusin") stopTrackingToastFocus();
44
+ return;
45
+ }
46
+ event.stopPropagation();
47
+ if (event.type !== "focusin") return;
48
+ if (focusedToastElement === null) {
49
+ focusOrigin = event.relatedTarget instanceof HTMLElement ? event.relatedTarget : null;
50
+ restoreFocusAfterRemoval.observe(body, {
51
+ childList: true,
52
+ subtree: true
53
+ });
54
+ }
55
+ focusedToastElement = focusedElement;
56
+ };
57
+ body.addEventListener("focusin", stopFocusIntoToast);
58
+ body.addEventListener("focusout", stopFocusIntoToast);
59
+ return () => {
60
+ stopTrackingToastFocus();
61
+ body.removeEventListener("focusin", stopFocusIntoToast);
62
+ body.removeEventListener("focusout", stopFocusIntoToast);
63
+ };
64
+ }
65
+ /**
66
+ * Wraps a modal's `onInteractOutside` so presses on toasts never dismiss it. The consumer's handler still runs first
67
+ * and can keep preventing dismissal for its own reasons.
68
+ * @param handler - The consumer's `onInteractOutside`, if any.
69
+ * @returns The handler to give the Radix content.
70
+ */
71
+ export function keepOpenOnToastInteraction(handler) {
72
+ return (event) => {
73
+ handler?.(event);
74
+ if (isToastLayerTarget(event.target)) event.preventDefault();
75
+ };
76
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Entry and exit playback of Arc's floating layers (https://uiarc.dev, MIT License, see THIRD_PARTY_NOTICES.md):
3
+ * tooltips, popovers and hover cards start a few pixels toward their trigger, slightly scaled down, and settle in
4
+ * place. Arc drives them with CSS transitions so a close that interrupts an open, or a reopen during a close, turns
5
+ * around from where the layer is; this slot keeps that property with Motion: an interrupted playback continues from
6
+ * the values it had reached instead of jumping to rest first. It animates the primitive's own node through a Slot,
7
+ * without adding DOM wrappers, and holds the node for `AnimatePresence` until its exit finishes.
8
+ */
9
+ import { type ComponentProps, type ReactElement } from "react";
10
+ import { Slot } from "radix-ui";
11
+ import { type AnimationOptions } from "motion/react";
12
+ /** A visual state of the floating layer, relative to its resting place. */
13
+ export interface ArcFloatingPose {
14
+ /** Pixels the layer sits toward its trigger, along the side it was placed on. */
15
+ travel: number;
16
+ scale: number;
17
+ opacity: number;
18
+ }
19
+ /** The two transitions of one playback: the transform may spring while the fade stays a short tween. */
20
+ export interface ArcFloatingTransitions {
21
+ transform: AnimationOptions;
22
+ opacity: AnimationOptions;
23
+ }
24
+ /** Entry and exit of a floating layer. The entry always ends at rest: no travel, full scale and full opacity. */
25
+ export interface ArcFloatingMotion {
26
+ enterFrom: ArcFloatingPose;
27
+ enter: ArcFloatingTransitions;
28
+ exitTo: ArcFloatingPose;
29
+ exit: ArcFloatingTransitions;
30
+ }
31
+ /**
32
+ * Animates the floating layer rendered as its only child.
33
+ * @param props.motion - Returns the entry and exit of the layer. It is read once, when the layer mounts, so a layer
34
+ * revived mid-exit keeps the path it came in on.
35
+ * @returns The child with the slot's ref composed into it.
36
+ */
37
+ export declare function ArcFloatingSlot({ children, motion, ref, ...props }: Omit<ComponentProps<typeof Slot.Root>, "children"> & {
38
+ children: ReactElement;
39
+ motion: () => ArcFloatingMotion;
40
+ }): import("react").JSX.Element;
@@ -0,0 +1,167 @@
1
+ "use client";
2
+ /**
3
+ * Entry and exit playback of Arc's floating layers (https://uiarc.dev, MIT License, see THIRD_PARTY_NOTICES.md):
4
+ * tooltips, popovers and hover cards start a few pixels toward their trigger, slightly scaled down, and settle in
5
+ * place. Arc drives them with CSS transitions so a close that interrupts an open, or a reopen during a close, turns
6
+ * around from where the layer is; this slot keeps that property with Motion: an interrupted playback continues from
7
+ * the values it had reached instead of jumping to rest first. It animates the primitive's own node through a Slot,
8
+ * without adding DOM wrappers, and holds the node for `AnimatePresence` until its exit finishes.
9
+ */
10
+ import { useLayoutEffect, useRef } from "react";
11
+ import { Slot } from "radix-ui";
12
+ import { animate, frame, usePresence } from "motion/react";
13
+ import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js";
14
+ import { MILLISECONDS_PER_SECOND, MOTION_ENTERING_ATTRIBUTE } from "../constants/motion.js";
15
+ import { ARC_DURATION, ARC_EASE } from "./arc-tokens.js";
16
+ import { useAttachedElement } from "./use-attached-element.js";
17
+ import { jsx as _jsx } from "react/jsx-runtime";
18
+ const ANIMATED_PROPERTIES = ["opacity", "transform"];
19
+ const REST_POSE = {
20
+ travel: 0,
21
+ scale: 1,
22
+ opacity: 1
23
+ };
24
+ /** Reduced motion keeps the layer in place and only fades it, on Arc's instant timing. */
25
+ const REDUCED_TWEEN = {
26
+ duration: ARC_DURATION.instant,
27
+ ease: [...ARC_EASE.standard]
28
+ };
29
+ /** Grace, in ms, past the planned exit before the layer is removed even if its playback never reports completion. */
30
+ const EXIT_DEADLINE_GRACE_MS = 100;
31
+ /**
32
+ * Reads how long a transition plays, in ms: a spring's `visualDuration` (which overrides `duration`) or a tween's `duration`.
33
+ * @param transition - Motion transition options.
34
+ * @returns The planned duration, or Arc's standard duration when the transition does not state one.
35
+ */
36
+ function plannedDurationMs({ duration, visualDuration }) {
37
+ return (visualDuration ?? duration ?? ARC_DURATION.standard) * MILLISECONDS_PER_SECOND;
38
+ }
39
+ /**
40
+ * Builds the transform of a pose. Radix writes the placed side on `data-side`; the offset points toward the trigger,
41
+ * so a layer flipped above its trigger still grows from the trigger edge.
42
+ */
43
+ function poseTransform(element, { travel, scale }) {
44
+ const side = element.dataset.side;
45
+ const horizontalOffset = side === "left" ? travel : side === "right" ? -travel : 0;
46
+ const verticalOffset = side === "top" ? travel : side === "bottom" ? -travel : 0;
47
+ return `translate(${horizontalOffset}px, ${verticalOffset}px) scale(${scale})`;
48
+ }
49
+ /**
50
+ * Animates the floating layer rendered as its only child.
51
+ * @param props.motion - Returns the entry and exit of the layer. It is read once, when the layer mounts, so a layer
52
+ * revived mid-exit keeps the path it came in on.
53
+ * @returns The child with the slot's ref composed into it.
54
+ */
55
+ export function ArcFloatingSlot({ children, motion, ref, ...props }) {
56
+ const [element, attach] = useAttachedElement(ref);
57
+ const reduceMotion = usePrefersReducedMotion();
58
+ const [isPresent, safeToRemove] = usePresence();
59
+ // AnimatePresence hands out a new `safeToRemove` on every render; a ref keeps a re-render from restarting an exit.
60
+ const safeToRemoveRef = useRef(safeToRemove);
61
+ const resolveMotionRef = useRef(motion);
62
+ useLayoutEffect(() => {
63
+ safeToRemoveRef.current = safeToRemove;
64
+ resolveMotionRef.current = motion;
65
+ });
66
+ /** The motion chosen when this node mounted. */
67
+ const plannedRef = useRef(null);
68
+ /** Values an interrupted playback had reached, so the next one continues from them. */
69
+ const interruptedRef = useRef(null);
70
+ useLayoutEffect(() => {
71
+ let active = true;
72
+ // Some primitives keep children mounted in a detached portal while closed; they must not hold an exit open.
73
+ if (!element || !element.isConnected) {
74
+ if (!isPresent) queueMicrotask(() => {
75
+ if (active) safeToRemoveRef.current?.();
76
+ });
77
+ return () => {
78
+ active = false;
79
+ };
80
+ }
81
+ const isFirstEntry = plannedRef.current === null;
82
+ plannedRef.current ??= resolveMotionRef.current();
83
+ const planned = plannedRef.current;
84
+ const original = {
85
+ opacity: element.style.opacity,
86
+ transform: element.style.transform
87
+ };
88
+ const restore = () => {
89
+ ANIMATED_PROPERTIES.forEach((property) => {
90
+ element.style[property] = original[property];
91
+ });
92
+ element.removeAttribute(MOTION_ENTERING_ATTRIBUTE);
93
+ };
94
+ const interrupted = interruptedRef.current;
95
+ interruptedRef.current = null;
96
+ const startsFromEntryPose = isPresent && isFirstEntry && !interrupted;
97
+ const from = {
98
+ opacity: startsFromEntryPose ? planned.enterFrom.opacity : interrupted?.opacity ? Number.parseFloat(interrupted.opacity) : REST_POSE.opacity,
99
+ transform: startsFromEntryPose ? reduceMotion ? "none" : poseTransform(element, planned.enterFrom) : interrupted?.transform ?? "none"
100
+ };
101
+ const targetPose = isPresent ? REST_POSE : planned.exitTo;
102
+ const to = {
103
+ opacity: targetPose.opacity,
104
+ transform: reduceMotion ? "none" : poseTransform(element, targetPose)
105
+ };
106
+ const transitions = reduceMotion ? {
107
+ transform: REDUCED_TWEEN,
108
+ opacity: REDUCED_TWEEN
109
+ } : isPresent ? planned.enter : planned.exit;
110
+ // Motion starts on its next frame; painting the first keyframe now keeps that frame from flashing the rest state.
111
+ element.style.opacity = String(from.opacity);
112
+ element.style.transform = from.transform;
113
+ // A transform makes the layer the containing block of fixed descendants, so they stay hidden until it settles.
114
+ if (isPresent) element.setAttribute(MOTION_ENTERING_ATTRIBUTE, "");
115
+ const animations = [animate(element, { opacity: [from.opacity, to.opacity] }, transitions.opacity), ...from.transform === to.transform ? [] : [animate(element, { transform: [from.transform, to.transform] }, transitions.transform)]];
116
+ /*
117
+ * Playback reports completion from Motion's frame loop, which runs on animation frames. When frames stop (a hidden
118
+ * tab, or a test whose fake clock swallowed the pending frame), a leaving layer would stay mounted forever, so
119
+ * its removal also has a deadline on a plain timer, shortly after the planned exit.
120
+ */
121
+ let removed = false;
122
+ const removeLeavingLayer = () => {
123
+ if (removed || !active) return;
124
+ removed = true;
125
+ safeToRemoveRef.current?.();
126
+ };
127
+ const exitDeadline = isPresent ? undefined : setTimeout(removeLeavingLayer, Math.max(plannedDurationMs(transitions.opacity), plannedDurationMs(transitions.transform)) + EXIT_DEADLINE_GRACE_MS);
128
+ void Promise.all(animations).then(() => {
129
+ if (!active) return;
130
+ if (!isPresent) {
131
+ removeLeavingLayer();
132
+ return;
133
+ }
134
+ // Motion commits the final keyframe inline; giving the caller's styles back leaves no compositing layer.
135
+ frame.postRender(() => {
136
+ if (active) restore();
137
+ });
138
+ });
139
+ return () => {
140
+ active = false;
141
+ clearTimeout(exitDeadline);
142
+ const computed = getComputedStyle(element);
143
+ const isMidPlayback = element.hasAttribute(MOTION_ENTERING_ATTRIBUTE) || !isPresent;
144
+ // A settled entrance needs no snapshot: the next playback starts from rest.
145
+ interruptedRef.current = isMidPlayback ? {
146
+ opacity: computed.opacity,
147
+ transform: computed.transform
148
+ } : null;
149
+ animations.forEach((animation) => animation.cancel());
150
+ restore();
151
+ };
152
+ }, [
153
+ element,
154
+ isPresent,
155
+ reduceMotion
156
+ ]);
157
+ return /* @__PURE__ */ _jsx(Slot.Root, {
158
+ ref: attach,
159
+ ...props,
160
+ // A leaving layer can be neither clicked, focused nor announced while it fades out.
161
+ ...isPresent ? {} : {
162
+ inert: true,
163
+ "aria-hidden": true
164
+ },
165
+ children
166
+ });
167
+ }
@@ -1,4 +1,5 @@
1
1
  import { SPRING_HIGHLIGHT, SPRING_LAYOUT, SPRING_TABS } from "./tokens.js";
2
+ import { ARC_SPRING_MORPH } from "./arc-tokens.js";
2
3
  /** Which part of the active item carries the highlight that must travel. */
3
4
  type GlideSurface = "background" | "after";
4
5
  /** Describes the items of one container and how their active state is exposed. */
@@ -13,7 +14,7 @@ export interface GlidePreset {
13
14
  attributes: string[];
14
15
  isActive: (item: Element) => boolean;
15
16
  surface: (container: HTMLElement) => GlideSurface;
16
- spring: typeof SPRING_LAYOUT | typeof SPRING_TABS | typeof SPRING_HIGHLIGHT;
17
+ spring: typeof SPRING_LAYOUT | typeof SPRING_TABS | typeof SPRING_HIGHLIGHT | typeof ARC_SPRING_MORPH;
17
18
  /**
18
19
  * Highlights that follow the pointer (menus, listboxes) jump straight to the hovered item:
19
20
  * the pointer already marks the position, so a travelling copy would only trail behind it.
@@ -30,9 +31,8 @@ export declare const GLIDE_PRESETS: {
30
31
  surface: (container: HTMLElement) => "after" | "background";
31
32
  spring: {
32
33
  readonly type: "spring";
33
- readonly stiffness: 245;
34
- readonly damping: 36;
35
- readonly mass: 1.2;
34
+ readonly visualDuration: 0.42;
35
+ readonly bounce: 0.16;
36
36
  };
37
37
  };
38
38
  pagination: {
@@ -43,9 +43,8 @@ export declare const GLIDE_PRESETS: {
43
43
  surface: () => "background";
44
44
  spring: {
45
45
  readonly type: "spring";
46
- readonly stiffness: 360;
47
- readonly damping: 32;
48
- readonly mass: 0.6;
46
+ readonly visualDuration: 0.42;
47
+ readonly bounce: 0.16;
49
48
  };
50
49
  };
51
50
  suggestions: {
@@ -4,7 +4,8 @@
4
4
  * previous item and is removed once it lands, leaving the item's own styles in charge again.
5
5
  */
6
6
  import { animate } from "motion/react";
7
- import { GLIDE_CONTINUITY_MS, SPRING_HIGHLIGHT, SPRING_LAYOUT, SPRING_TABS } from "./tokens.js";
7
+ import { GLIDE_CONTINUITY_MS, SPRING_HIGHLIGHT } from "./tokens.js";
8
+ import { ARC_SPRING_MORPH } from "./arc-tokens.js";
8
9
  export const GLIDE_PRESETS = {
9
10
  tabs: {
10
11
  itemSelector: "[data-slot=\"tabs-trigger\"]",
@@ -12,7 +13,8 @@ export const GLIDE_PRESETS = {
12
13
  attributes: ["data-state"],
13
14
  isActive: (item) => item.getAttribute("data-state") === "active",
14
15
  surface: (container) => container.getAttribute("data-variant") === "line" ? "after" : "background",
15
- spring: SPRING_TABS
16
+ // Arc glides the tab highlight and the current page mark with its morph spring.
17
+ spring: ARC_SPRING_MORPH
16
18
  },
17
19
  pagination: {
18
20
  itemSelector: "[data-slot=\"pagination-link\"]",
@@ -20,7 +22,7 @@ export const GLIDE_PRESETS = {
20
22
  attributes: ["aria-current"],
21
23
  isActive: (item) => item.getAttribute("aria-current") === "page",
22
24
  surface: () => "background",
23
- spring: SPRING_LAYOUT
25
+ spring: ARC_SPRING_MORPH
24
26
  },
25
27
  suggestions: {
26
28
  itemSelector: "[role=\"option\"]",
@@ -0,0 +1,24 @@
1
+ /** A subscribable warm flag shared by every instance of one layer family. */
2
+ export interface OverlayWarmth {
3
+ subscribe: (listener: () => void) => () => void;
4
+ /** Whether a layer of the family is open, or closed less than the skip window ago. */
5
+ isWarm: () => boolean;
6
+ /** Server snapshot: nothing is open while rendering on the server. */
7
+ isWarmOnServer: () => boolean;
8
+ /** Marks one more layer as open and returns the call that marks it closed again. */
9
+ open: () => () => void;
10
+ }
11
+ /**
12
+ * Creates the warm flag of one layer family.
13
+ * @param skipWindowMs - Milliseconds the page stays warm after the last layer closes.
14
+ * @returns The store, meant for `useSyncExternalStore`.
15
+ */
16
+ export declare function createOverlayWarmth(skipWindowMs: number): OverlayWarmth;
17
+ /**
18
+ * Remembers whether the page was already warm when a layer opened, so its entrance picks the instant path. It is
19
+ * captured while rendering the open change, before the layer marks the page warm itself.
20
+ * @param open - Whether the layer is open.
21
+ * @param isWarm - The family's current warm flag, read through `useSyncExternalStore`.
22
+ * @returns Whether the current (or last) opening happened while warm.
23
+ */
24
+ export declare function useOpenedWhileWarm(open: boolean, isWarm: boolean): boolean;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Arc's skip window for hover-driven layers (https://uiarc.dev, MIT License, see THIRD_PARTY_NOTICES.md): while any
3
+ * layer of a family is open, and briefly after the last one closes, the page is warm and the next layer opens
4
+ * without waiting and only fades. Each family (tooltips, hover cards) keeps its own store.
5
+ */
6
+ import { useState } from "react";
7
+ /**
8
+ * Creates the warm flag of one layer family.
9
+ * @param skipWindowMs - Milliseconds the page stays warm after the last layer closes.
10
+ * @returns The store, meant for `useSyncExternalStore`.
11
+ */
12
+ export function createOverlayWarmth(skipWindowMs) {
13
+ let warm = false;
14
+ let openCount = 0;
15
+ /** When the last layer closed, so the window also expires when its timer never runs (a frozen test clock). */
16
+ let closedAt = 0;
17
+ let coolTimer;
18
+ const listeners = new Set();
19
+ const notify = () => listeners.forEach((listener) => listener());
20
+ return {
21
+ subscribe(listener) {
22
+ listeners.add(listener);
23
+ return () => {
24
+ listeners.delete(listener);
25
+ };
26
+ },
27
+ isWarm() {
28
+ if (warm && !openCount && Date.now() - closedAt >= skipWindowMs) warm = false;
29
+ return warm;
30
+ },
31
+ isWarmOnServer: () => false,
32
+ open() {
33
+ openCount += 1;
34
+ clearTimeout(coolTimer);
35
+ if (!warm) {
36
+ warm = true;
37
+ notify();
38
+ }
39
+ let closed = false;
40
+ return () => {
41
+ if (closed) return;
42
+ closed = true;
43
+ openCount = Math.max(0, openCount - 1);
44
+ if (openCount) return;
45
+ clearTimeout(coolTimer);
46
+ closedAt = Date.now();
47
+ coolTimer = setTimeout(() => {
48
+ warm = false;
49
+ notify();
50
+ }, skipWindowMs);
51
+ };
52
+ }
53
+ };
54
+ }
55
+ /**
56
+ * Remembers whether the page was already warm when a layer opened, so its entrance picks the instant path. It is
57
+ * captured while rendering the open change, before the layer marks the page warm itself.
58
+ * @param open - Whether the layer is open.
59
+ * @param isWarm - The family's current warm flag, read through `useSyncExternalStore`.
60
+ * @returns Whether the current (or last) opening happened while warm.
61
+ */
62
+ export function useOpenedWhileWarm(open, isWarm) {
63
+ const [wasOpen, setWasOpen] = useState(open);
64
+ const [openedWhileWarm, setOpenedWhileWarm] = useState(open && isWarm);
65
+ if (open !== wasOpen) {
66
+ setWasOpen(open);
67
+ if (open) setOpenedWhileWarm(isWarm);
68
+ }
69
+ return openedWhileWarm;
70
+ }