bits-ui 2.16.5 → 2.17.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 (39) hide show
  1. package/dist/bits/accordion/accordion.svelte.d.ts +4 -2
  2. package/dist/bits/accordion/accordion.svelte.js +2 -1
  3. package/dist/bits/collapsible/collapsible.svelte.d.ts +3 -1
  4. package/dist/bits/collapsible/collapsible.svelte.js +2 -1
  5. package/dist/bits/context-menu/components/context-menu.svelte +3 -1
  6. package/dist/bits/dialog/dialog.svelte.d.ts +4 -0
  7. package/dist/bits/dialog/dialog.svelte.js +3 -1
  8. package/dist/bits/link-preview/link-preview.svelte.d.ts +5 -3
  9. package/dist/bits/link-preview/link-preview.svelte.js +2 -1
  10. package/dist/bits/menu/components/menu-sub-content-static.svelte +12 -3
  11. package/dist/bits/menu/components/menu-sub-content.svelte +12 -3
  12. package/dist/bits/menu/components/menu-sub-trigger.svelte +1 -1
  13. package/dist/bits/menu/components/menu.svelte +5 -0
  14. package/dist/bits/menu/components/menu.svelte.d.ts +1 -0
  15. package/dist/bits/menu/menu.svelte.d.ts +8 -4
  16. package/dist/bits/menu/menu.svelte.js +642 -13
  17. package/dist/bits/menubar/components/menubar-menu.svelte +3 -0
  18. package/dist/bits/menubar/menubar.svelte.d.ts +2 -0
  19. package/dist/bits/menubar/menubar.svelte.js +22 -2
  20. package/dist/bits/navigation-menu/components/navigation-menu-content.svelte +7 -2
  21. package/dist/bits/navigation-menu/components/navigation-menu-indicator.svelte +9 -2
  22. package/dist/bits/navigation-menu/components/navigation-menu-viewport.svelte +5 -3
  23. package/dist/bits/popover/popover.svelte.d.ts +7 -3
  24. package/dist/bits/popover/popover.svelte.js +3 -1
  25. package/dist/bits/select/select.svelte.d.ts +6 -4
  26. package/dist/bits/select/select.svelte.js +2 -1
  27. package/dist/bits/tooltip/tooltip.svelte.d.ts +5 -3
  28. package/dist/bits/tooltip/tooltip.svelte.js +2 -1
  29. package/dist/bits/utilities/floating-layer/use-floating-layer.svelte.d.ts +5 -5
  30. package/dist/bits/utilities/presence-layer/presence-layer.svelte +5 -1
  31. package/dist/bits/utilities/presence-layer/presence.svelte.d.ts +3 -38
  32. package/dist/bits/utilities/presence-layer/presence.svelte.js +49 -146
  33. package/dist/bits/utilities/presence-layer/types.d.ts +7 -3
  34. package/dist/internal/animations-complete.js +64 -9
  35. package/dist/internal/attrs.d.ts +5 -0
  36. package/dist/internal/attrs.js +8 -2
  37. package/dist/internal/presence-manager.svelte.d.ts +4 -1
  38. package/dist/internal/presence-manager.svelte.js +42 -1
  39. package/package.json +1 -1
@@ -24,4 +24,7 @@
24
24
  dir={menuState.root.opts.dir.current}
25
25
  _internal_variant="menubar"
26
26
  {...restProps}
27
+ _internal_should_skip_exit_animation={() =>
28
+ menuState.root.skipExitAnimationForMenuValue === menuState.opts.value.current
29
+ }
27
30
  />
@@ -18,6 +18,8 @@ export declare class MenubarRootState {
18
18
  readonly attachment: RefAttachment;
19
19
  wasOpenedByKeyboard: boolean;
20
20
  triggerIds: string[];
21
+ /** Outgoing menu id when swapping to another top-level menu... skip exit animation wait only then */
22
+ skipExitAnimationForMenuValue: string | null;
21
23
  valueToChangeHandler: Map<string, ReadableBox<OnChangeFn<boolean>>>;
22
24
  constructor(opts: MenubarRootStateOpts);
23
25
  /**
@@ -21,6 +21,8 @@ export class MenubarRootState {
21
21
  attachment;
22
22
  wasOpenedByKeyboard = $state(false);
23
23
  triggerIds = $state([]);
24
+ /** Outgoing menu id when swapping to another top-level menu... skip exit animation wait only then */
25
+ skipExitAnimationForMenuValue = $state(null);
24
26
  valueToChangeHandler = new Map();
25
27
  constructor(opts) {
26
28
  this.opts = opts;
@@ -55,6 +57,10 @@ export class MenubarRootState {
55
57
  };
56
58
  updateValue = (value) => {
57
59
  const currValue = this.opts.value.current;
60
+ const switchingMenus = Boolean(currValue && value && currValue !== value);
61
+ if (switchingMenus) {
62
+ this.skipExitAnimationForMenuValue = currValue;
63
+ }
58
64
  const currHandler = this.valueToChangeHandler.get(currValue)?.current;
59
65
  const nextHandler = this.valueToChangeHandler.get(value)?.current;
60
66
  this.opts.value.current = value;
@@ -64,6 +70,11 @@ export class MenubarRootState {
64
70
  if (nextHandler) {
65
71
  nextHandler(true);
66
72
  }
73
+ if (switchingMenus) {
74
+ afterTick(() => {
75
+ this.skipExitAnimationForMenuValue = null;
76
+ });
77
+ }
67
78
  };
68
79
  getTriggers = () => {
69
80
  const node = this.opts.ref.current;
@@ -271,13 +282,22 @@ export class MenubarContentState {
271
282
  if (isPrevKey)
272
283
  candidates.reverse();
273
284
  const candidateValues = candidates.map(({ value }) => value);
274
- const currentIndex = candidateValues.indexOf(this.menu.opts.value.current);
285
+ // use the root's open menu id — during rapid switching, stale content can still be
286
+ // focused while another menu is already open; per-menu value would navigate from the wrong index
287
+ const openMenuValue = this.root.opts.value.current;
288
+ if (!openMenuValue)
289
+ return;
290
+ const currentIndex = candidateValues.indexOf(openMenuValue);
291
+ if (currentIndex === -1)
292
+ return;
275
293
  candidates = this.root.opts.loop.current
276
294
  ? wrapArray(candidates, currentIndex + 1)
277
295
  : candidates.slice(currentIndex + 1);
278
296
  const [nextValue] = candidates;
279
- if (nextValue)
297
+ if (nextValue) {
280
298
  this.menu.root.onMenuOpen(nextValue.value, nextValue.triggerId);
299
+ e.preventDefault();
300
+ }
281
301
  };
282
302
  props = $derived.by(() => ({
283
303
  id: this.opts.id.current,
@@ -3,6 +3,7 @@
3
3
  import { NavigationMenuContentState } from "../navigation-menu.svelte.js";
4
4
  import NavigationMenuContentImpl from "./navigation-menu-content-impl.svelte";
5
5
  import { createId } from "../../../internal/create-id.js";
6
+ import { getDataTransitionAttrs } from "../../../internal/attrs.js";
6
7
  import type { NavigationMenuContentProps } from "../../../types.js";
7
8
  import Portal from "../../utilities/portal/portal.svelte";
8
9
  import PresenceLayer from "../../utilities/presence-layer/presence-layer.svelte";
@@ -38,8 +39,12 @@
38
39
  open={forceMount || contentState.open || contentState.isLastActiveValue}
39
40
  ref={contentState.opts.ref}
40
41
  >
41
- {#snippet presence()}
42
- <NavigationMenuContentImpl {...mergedProps} {children} {child} />
42
+ {#snippet presence({ transitionStatus })}
43
+ <NavigationMenuContentImpl
44
+ {...mergeProps(mergedProps, getDataTransitionAttrs(transitionStatus))}
45
+ {children}
46
+ {child}
47
+ />
43
48
  <Mounted bind:mounted={contentState.mounted} />
44
49
  {/snippet}
45
50
  </PresenceLayer>
@@ -4,6 +4,7 @@
4
4
  import { NavigationMenuIndicatorState } from "../navigation-menu.svelte.js";
5
5
  import NavigationMenuIndicatorImpl from "./navigation-menu-indicator-impl.svelte";
6
6
  import { createId } from "../../../internal/create-id.js";
7
+ import { getDataTransitionAttrs } from "../../../internal/attrs.js";
7
8
  import PresenceLayer from "../../utilities/presence-layer/presence-layer.svelte";
8
9
  import Portal from "../../utilities/portal/portal.svelte";
9
10
 
@@ -25,8 +26,14 @@
25
26
  {#if indicatorState.context.indicatorTrackRef.current}
26
27
  <Portal to={indicatorState.context.indicatorTrackRef.current}>
27
28
  <PresenceLayer open={forceMount || indicatorState.isVisible} ref={boxWith(() => ref)}>
28
- {#snippet presence()}
29
- <NavigationMenuIndicatorImpl {...mergedProps} {children} {child} {id} bind:ref />
29
+ {#snippet presence({ transitionStatus })}
30
+ <NavigationMenuIndicatorImpl
31
+ {...mergeProps(mergedProps, getDataTransitionAttrs(transitionStatus))}
32
+ {children}
33
+ {child}
34
+ {id}
35
+ bind:ref
36
+ />
30
37
  {/snippet}
31
38
  </PresenceLayer>
32
39
  </Portal>
@@ -2,6 +2,7 @@
2
2
  import type { NavigationMenuViewportProps } from "../types.js";
3
3
  import { NavigationMenuViewportState } from "../navigation-menu.svelte.js";
4
4
  import { createId } from "../../../internal/create-id.js";
5
+ import { getDataTransitionAttrs } from "../../../internal/attrs.js";
5
6
  import PresenceLayer from "../../utilities/presence-layer/presence-layer.svelte";
6
7
  import { boxWith, mergeProps } from "svelte-toolbelt";
7
8
  import { Mounted } from "../../utilities/index.js";
@@ -29,11 +30,12 @@
29
30
  </script>
30
31
 
31
32
  <PresenceLayer open={forceMount || viewportState.open} ref={viewportState.opts.ref}>
32
- {#snippet presence()}
33
+ {#snippet presence({ transitionStatus })}
34
+ {@const presenceProps = getDataTransitionAttrs(transitionStatus)}
33
35
  {#if child}
34
- {@render child({ props: mergedProps })}
36
+ {@render child({ props: mergeProps(mergedProps, presenceProps) })}
35
37
  {:else}
36
- <div {...mergedProps}>
38
+ <div {...mergeProps(mergedProps, presenceProps)}>
37
39
  {@render children?.()}
38
40
  </div>
39
41
  {/if}
@@ -87,9 +87,6 @@ export declare class PopoverContentState {
87
87
  open: boolean;
88
88
  };
89
89
  readonly props: {
90
- readonly id: string;
91
- readonly tabindex: -1;
92
- readonly "data-state": "open" | "closed";
93
90
  readonly style: {
94
91
  readonly pointerEvents: "auto";
95
92
  readonly contain: "layout style";
@@ -98,6 +95,11 @@ export declare class PopoverContentState {
98
95
  readonly onfocusin: (e: BitsFocusEvent) => void;
99
96
  readonly onpointerenter: (e: BitsPointerEvent) => void;
100
97
  readonly onpointerleave: (e: BitsPointerEvent) => void;
98
+ readonly "data-starting-style"?: "";
99
+ readonly "data-ending-style"?: "";
100
+ readonly id: string;
101
+ readonly tabindex: -1;
102
+ readonly "data-state": "open" | "closed";
101
103
  };
102
104
  readonly popperProps: {
103
105
  onInteractOutside: (e: PointerEvent) => void;
@@ -134,6 +136,8 @@ export declare class PopoverOverlayState {
134
136
  open: boolean;
135
137
  };
136
138
  readonly props: {
139
+ readonly "data-starting-style"?: "";
140
+ readonly "data-ending-style"?: "";
137
141
  readonly id: string;
138
142
  readonly style: {
139
143
  readonly pointerEvents: "auto";
@@ -1,7 +1,7 @@
1
1
  import { attachRef, boxWith, DOMContext, } from "svelte-toolbelt";
2
2
  import { Context, watch } from "runed";
3
3
  import { kbd } from "../../internal/kbd.js";
4
- import { createBitsAttrs, boolToStr, getDataOpenClosed } from "../../internal/attrs.js";
4
+ import { createBitsAttrs, boolToStr, getDataOpenClosed, getDataTransitionAttrs, } from "../../internal/attrs.js";
5
5
  import { isElement, isTouch } from "../../internal/is.js";
6
6
  import { PresenceManager } from "../../internal/presence-manager.svelte.js";
7
7
  import { SafePolygon } from "../../internal/safe-polygon.svelte.js";
@@ -325,6 +325,7 @@ export class PopoverContentState {
325
325
  id: this.opts.id.current,
326
326
  tabindex: -1,
327
327
  "data-state": getDataOpenClosed(this.root.opts.open.current),
328
+ ...getDataTransitionAttrs(this.root.contentPresence.transitionStatus),
328
329
  [popoverAttrs.content]: "",
329
330
  style: {
330
331
  pointerEvents: "auto",
@@ -397,6 +398,7 @@ export class PopoverOverlayState {
397
398
  pointerEvents: "auto",
398
399
  },
399
400
  "data-state": getDataOpenClosed(this.root.opts.open.current),
401
+ ...getDataTransitionAttrs(this.root.overlayPresence.transitionStatus),
400
402
  ...this.attachment,
401
403
  }));
402
404
  }
@@ -213,10 +213,6 @@ export declare class SelectContentState {
213
213
  open: boolean;
214
214
  };
215
215
  readonly props: {
216
- readonly id: string;
217
- readonly role: "listbox";
218
- readonly "aria-multiselectable": "true" | undefined;
219
- readonly "data-state": "open" | "closed";
220
216
  readonly style: {
221
217
  readonly display: "flex";
222
218
  readonly flexDirection: "column";
@@ -225,6 +221,12 @@ export declare class SelectContentState {
225
221
  readonly pointerEvents: "auto";
226
222
  };
227
223
  readonly onpointermove: (_: BitsPointerEvent) => void;
224
+ readonly "data-starting-style"?: "";
225
+ readonly "data-ending-style"?: "";
226
+ readonly id: string;
227
+ readonly role: "listbox";
228
+ readonly "aria-multiselectable": "true" | undefined;
229
+ readonly "data-state": "open" | "closed";
228
230
  };
229
231
  readonly popperProps: {
230
232
  onInteractOutside: (e: PointerEvent) => void;
@@ -2,7 +2,7 @@ import { Context, Previous, watch } from "runed";
2
2
  import { afterSleep, afterTick, onDestroyEffect, attachRef, DOMContext, boxWith, } from "svelte-toolbelt";
3
3
  import { on } from "svelte/events";
4
4
  import { backward, forward, next, prev } from "../../internal/arrays.js";
5
- import { boolToStr, boolToStrTrueOrUndef, boolToEmptyStrOrUndef, getDataOpenClosed, boolToTrueOrUndef, } from "../../internal/attrs.js";
5
+ import { boolToStr, boolToStrTrueOrUndef, boolToEmptyStrOrUndef, getDataOpenClosed, boolToTrueOrUndef, getDataTransitionAttrs, } from "../../internal/attrs.js";
6
6
  import { kbd } from "../../internal/kbd.js";
7
7
  import { noop } from "../../internal/noop.js";
8
8
  import { isIOS } from "../../internal/is.js";
@@ -772,6 +772,7 @@ export class SelectContentState {
772
772
  role: "listbox",
773
773
  "aria-multiselectable": this.root.isMulti ? "true" : undefined,
774
774
  "data-state": getDataOpenClosed(this.root.opts.open.current),
775
+ ...getDataTransitionAttrs(this.root.contentPresence.transitionStatus),
775
776
  [this.root.getBitsAttr("content")]: "",
776
777
  style: {
777
778
  display: "flex",
@@ -153,12 +153,14 @@ export declare class TooltipContentState {
153
153
  open: boolean;
154
154
  };
155
155
  readonly props: {
156
- readonly id: string;
157
- readonly "data-state": "closed" | "delayed-open" | "instant-open";
158
- readonly "data-disabled": "" | undefined;
159
156
  readonly style: {
160
157
  readonly outline: "none";
161
158
  };
159
+ readonly "data-starting-style"?: "";
160
+ readonly "data-ending-style"?: "";
161
+ readonly id: string;
162
+ readonly "data-state": "closed" | "delayed-open" | "instant-open";
163
+ readonly "data-disabled": "" | undefined;
162
164
  };
163
165
  readonly popperProps: {
164
166
  onInteractOutside: (e: PointerEvent) => void;
@@ -2,7 +2,7 @@ import { onMountEffect, attachRef, DOMContext, simpleBox, boxWith, } from "svelt
2
2
  import { on } from "svelte/events";
3
3
  import { Context, watch } from "runed";
4
4
  import { isElement, isFocusVisible } from "../../internal/is.js";
5
- import { createBitsAttrs, boolToEmptyStrOrUndef } from "../../internal/attrs.js";
5
+ import { createBitsAttrs, boolToEmptyStrOrUndef, getDataTransitionAttrs, } from "../../internal/attrs.js";
6
6
  import { TimeoutFn } from "../../internal/timeout-fn.js";
7
7
  import { SafePolygon } from "../../internal/safe-polygon.svelte.js";
8
8
  import { PresenceManager } from "../../internal/presence-manager.svelte.js";
@@ -665,6 +665,7 @@ export class TooltipContentState {
665
665
  id: this.opts.id.current,
666
666
  "data-state": this.root.stateAttr,
667
667
  "data-disabled": boolToEmptyStrOrUndef(this.root.disabled),
668
+ ...getDataTransitionAttrs(this.root.contentPresence.transitionStatus),
668
669
  style: {
669
670
  outline: "none",
670
671
  },
@@ -348,7 +348,7 @@ export declare class FloatingContentState {
348
348
  readonly perspective?: import("csstype").Property.Perspective<0 | (string & {})> | undefined;
349
349
  readonly perspectiveOrigin?: import("csstype").Property.PerspectiveOrigin<0 | (string & {})> | undefined;
350
350
  readonly pointerEvents?: import("csstype").Property.PointerEvents | undefined;
351
- position: "relative" | "absolute" | "fixed" | "sticky" | "-moz-initial" | "inherit" | "initial" | "revert" | "revert-layer" | "unset" | "-webkit-sticky" | "static";
351
+ position: "relative" | "absolute" | "inherit" | "fixed" | "sticky" | "-moz-initial" | "initial" | "revert" | "revert-layer" | "unset" | "-webkit-sticky" | "static";
352
352
  readonly printColorAdjust?: import("csstype").Property.PrintColorAdjust | undefined;
353
353
  readonly quotes?: import("csstype").Property.Quotes | undefined;
354
354
  readonly resize?: import("csstype").Property.Resize | undefined;
@@ -901,10 +901,10 @@ export declare class FloatingContentState {
901
901
  readonly vectorEffect?: import("csstype").Property.VectorEffect | undefined;
902
902
  readonly "pointer-events"?: string | undefined;
903
903
  readonly "--bits-floating-transform-origin": `${any} ${any}`;
904
- readonly "--bits-floating-available-width": "undefinedpx" | `${number}px`;
905
- readonly "--bits-floating-available-height": "undefinedpx" | `${number}px`;
906
- readonly "--bits-floating-anchor-width": "undefinedpx" | `${number}px`;
907
- readonly "--bits-floating-anchor-height": "undefinedpx" | `${number}px`;
904
+ readonly "--bits-floating-available-width": `${number}px` | "undefinedpx";
905
+ readonly "--bits-floating-available-height": `${number}px` | "undefinedpx";
906
+ readonly "--bits-floating-anchor-width": `${number}px` | "undefinedpx";
907
+ readonly "--bits-floating-anchor-height": `${number}px` | "undefinedpx";
908
908
  };
909
909
  readonly dir: Direction;
910
910
  };
@@ -12,5 +12,9 @@
12
12
  </script>
13
13
 
14
14
  {#if forceMount || open || presenceState.isPresent}
15
- {@render presence?.({ present: presenceState.isPresent })}
15
+ {@render
16
+ presence?.({
17
+ present: presenceState.isPresent,
18
+ transitionStatus: presenceState.transitionStatus,
19
+ })}
16
20
  {/if}
@@ -1,50 +1,15 @@
1
1
  import { type ReadableBox, type ReadableBoxedValues } from "svelte-toolbelt";
2
- import { Previous } from "runed";
3
- import { StateMachine } from "../../../internal/state-machine.js";
2
+ import type { TransitionState } from "../../../internal/attrs.js";
4
3
  export interface PresenceOptions extends ReadableBoxedValues<{
5
4
  open: boolean;
6
5
  ref: HTMLElement | null;
7
6
  }> {
8
7
  }
9
- type PresenceStatus = "unmounted" | "mounted" | "unmountSuspended";
10
- /**
11
- * Cached style properties to avoid storing live CSSStyleDeclaration
12
- * which triggers style recalculations when accessed.
13
- */
14
- interface CachedStyles {
15
- display: string;
16
- animationName: string;
17
- }
18
- declare const presenceMachine: {
19
- readonly mounted: {
20
- readonly UNMOUNT: "unmounted";
21
- readonly ANIMATION_OUT: "unmountSuspended";
22
- };
23
- readonly unmountSuspended: {
24
- readonly MOUNT: "mounted";
25
- readonly ANIMATION_END: "unmounted";
26
- };
27
- readonly unmounted: {
28
- readonly MOUNT: "mounted";
29
- };
30
- };
31
- type PresenceMachine = StateMachine<typeof presenceMachine>;
32
8
  export declare class Presence {
9
+ #private;
33
10
  readonly opts: PresenceOptions;
34
- prevAnimationNameState: string;
35
- styles: CachedStyles;
36
- initialStatus: PresenceStatus;
37
- previousPresent: Previous<boolean>;
38
- machine: PresenceMachine;
39
11
  present: ReadableBox<boolean>;
40
12
  constructor(opts: PresenceOptions);
41
- /**
42
- * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel`
43
- * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we
44
- * make sure we only trigger ANIMATION_END for the currently active animation.
45
- */
46
- handleAnimationEnd(event: AnimationEvent): void;
47
- handleAnimationStart(event: AnimationEvent): void;
48
13
  isPresent: boolean;
14
+ get transitionStatus(): TransitionState;
49
15
  }
50
- export {};
@@ -1,158 +1,61 @@
1
- import { executeCallbacks } from "svelte-toolbelt";
2
- import { Previous, watch } from "runed";
3
- import { on } from "svelte/events";
4
- import { StateMachine } from "../../../internal/state-machine.js";
5
- /**
6
- * Cache for animation names with TTL to reduce getComputedStyle calls.
7
- * Uses WeakMap to avoid memory leaks when elements are removed.
8
- */
9
- const animationNameCache = new WeakMap();
10
- const ANIMATION_NAME_CACHE_TTL_MS = 16; // One frame at 60fps
11
- const presenceMachine = {
12
- mounted: {
13
- UNMOUNT: "unmounted",
14
- ANIMATION_OUT: "unmountSuspended",
15
- },
16
- unmountSuspended: {
17
- MOUNT: "mounted",
18
- ANIMATION_END: "unmounted",
19
- },
20
- unmounted: {
21
- MOUNT: "mounted",
22
- },
23
- };
1
+ import { onDestroyEffect } from "svelte-toolbelt";
2
+ import { watch } from "runed";
3
+ import { AnimationsComplete } from "../../../internal/animations-complete.js";
24
4
  export class Presence {
25
5
  opts;
26
- prevAnimationNameState = $state("none");
27
- styles = $state({ display: "", animationName: "none" });
28
- initialStatus;
29
- previousPresent;
30
- machine;
31
6
  present;
7
+ #afterAnimations;
8
+ #isPresent = $state(false);
9
+ #hasMounted = false;
10
+ #transitionStatus = $state(undefined);
11
+ #transitionFrame = null;
32
12
  constructor(opts) {
33
13
  this.opts = opts;
34
14
  this.present = this.opts.open;
35
- this.initialStatus = opts.open.current ? "mounted" : "unmounted";
36
- this.previousPresent = new Previous(() => this.present.current);
37
- this.machine = new StateMachine(this.initialStatus, presenceMachine);
38
- this.handleAnimationEnd = this.handleAnimationEnd.bind(this);
39
- this.handleAnimationStart = this.handleAnimationStart.bind(this);
40
- watchPresenceChange(this);
41
- watchStatusChange(this);
42
- watchRefChange(this);
43
- }
44
- /**
45
- * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel`
46
- * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we
47
- * make sure we only trigger ANIMATION_END for the currently active animation.
48
- */
49
- handleAnimationEnd(event) {
50
- if (!this.opts.ref.current)
51
- return;
52
- // Use cached animation name from styles when available to avoid getComputedStyle
53
- const currAnimationName = this.styles.animationName || getAnimationName(this.opts.ref.current);
54
- const isCurrentAnimation = currAnimationName.includes(event.animationName) || currAnimationName === "none";
55
- if (event.target === this.opts.ref.current && isCurrentAnimation) {
56
- this.machine.dispatch("ANIMATION_END");
57
- }
58
- }
59
- handleAnimationStart(event) {
60
- if (!this.opts.ref.current)
61
- return;
62
- if (event.target === this.opts.ref.current) {
63
- // Force refresh cache on animation start to get accurate animation name
64
- const animationName = getAnimationName(this.opts.ref.current, true);
65
- this.prevAnimationNameState = animationName;
66
- // Update styles cache for subsequent reads
67
- this.styles.animationName = animationName;
68
- }
69
- }
70
- isPresent = $derived.by(() => {
71
- return ["mounted", "unmountSuspended"].includes(this.machine.state.current);
72
- });
73
- }
74
- function watchPresenceChange(state) {
75
- watch(() => state.present.current, () => {
76
- if (!state.opts.ref.current)
77
- return;
78
- const hasPresentChanged = state.present.current !== state.previousPresent.current;
79
- if (!hasPresentChanged)
80
- return;
81
- const prevAnimationName = state.prevAnimationNameState;
82
- // Force refresh on state change to get accurate current animation
83
- const currAnimationName = getAnimationName(state.opts.ref.current, true);
84
- // Update styles cache for subsequent reads
85
- state.styles.animationName = currAnimationName;
86
- if (state.present.current) {
87
- state.machine.dispatch("MOUNT");
88
- }
89
- else if (currAnimationName === "none" || state.styles.display === "none") {
90
- // If there is no exit animation or the element is hidden, animations won't run
91
- // so we unmount instantly
92
- state.machine.dispatch("UNMOUNT");
93
- }
94
- else {
95
- /**
96
- * When `present` changes to `false`, we check changes to animation-name to
97
- * determine whether an animation has started. We chose this approach (reading
98
- * computed styles) because there is no `animationrun` event and `animationstart`
99
- * fires after `animation-delay` has expired which would be too late.
100
- */
101
- const isAnimating = prevAnimationName !== currAnimationName;
102
- if (state.previousPresent.current && isAnimating) {
103
- state.machine.dispatch("ANIMATION_OUT");
15
+ this.#isPresent = opts.open.current;
16
+ this.#afterAnimations = new AnimationsComplete({
17
+ ref: this.opts.ref,
18
+ afterTick: this.opts.open,
19
+ });
20
+ onDestroyEffect(() => this.#clearTransitionFrame());
21
+ watch(() => this.present.current, (isOpen) => {
22
+ if (!this.#hasMounted) {
23
+ this.#hasMounted = true;
24
+ return;
104
25
  }
105
- else {
106
- state.machine.dispatch("UNMOUNT");
26
+ this.#clearTransitionFrame();
27
+ if (isOpen) {
28
+ this.#isPresent = true;
107
29
  }
108
- }
109
- });
110
- }
111
- function watchStatusChange(state) {
112
- watch(() => state.machine.state.current, () => {
113
- if (!state.opts.ref.current)
114
- return;
115
- // Use cached animation name first, only force refresh if needed for mounted state
116
- const currAnimationName = state.machine.state.current === "mounted"
117
- ? getAnimationName(state.opts.ref.current, true)
118
- : "none";
119
- state.prevAnimationNameState = currAnimationName;
120
- // Update styles cache
121
- state.styles.animationName = currAnimationName;
30
+ this.#transitionStatus = isOpen ? "starting" : "ending";
31
+ if (isOpen) {
32
+ this.#transitionFrame = window.requestAnimationFrame(() => {
33
+ this.#transitionFrame = null;
34
+ if (this.present.current) {
35
+ this.#transitionStatus = undefined;
36
+ }
37
+ });
38
+ }
39
+ this.#afterAnimations.run(() => {
40
+ if (isOpen !== this.present.current)
41
+ return;
42
+ if (!isOpen) {
43
+ this.#isPresent = false;
44
+ }
45
+ this.#transitionStatus = undefined;
46
+ });
47
+ });
48
+ }
49
+ isPresent = $derived.by(() => {
50
+ return this.#isPresent;
122
51
  });
123
- }
124
- function watchRefChange(state) {
125
- watch(() => state.opts.ref.current, () => {
126
- if (!state.opts.ref.current)
52
+ get transitionStatus() {
53
+ return this.#transitionStatus;
54
+ }
55
+ #clearTransitionFrame() {
56
+ if (this.#transitionFrame === null)
127
57
  return;
128
- // Snapshot only needed style properties instead of storing live CSSStyleDeclaration
129
- // This avoids triggering style recalculations when accessing the cached object
130
- const computed = getComputedStyle(state.opts.ref.current);
131
- state.styles = {
132
- display: computed.display,
133
- animationName: computed.animationName || "none",
134
- };
135
- return executeCallbacks(on(state.opts.ref.current, "animationstart", state.handleAnimationStart), on(state.opts.ref.current, "animationcancel", state.handleAnimationEnd), on(state.opts.ref.current, "animationend", state.handleAnimationEnd));
136
- });
137
- }
138
- /**
139
- * Gets the animation name from computed styles with optional caching.
140
- *
141
- * @param node - The HTML element to get animation name from
142
- * @param forceRefresh - If true, bypasses the cache and forces a fresh getComputedStyle call
143
- * @returns The animation name or "none" if not animating
144
- */
145
- function getAnimationName(node, forceRefresh = false) {
146
- if (!node)
147
- return "none";
148
- const now = performance.now();
149
- const cached = animationNameCache.get(node);
150
- // Return cached value if still valid and not forced to refresh
151
- if (!forceRefresh && cached && now - cached.timestamp < ANIMATION_NAME_CACHE_TTL_MS) {
152
- return cached.value;
58
+ window.cancelAnimationFrame(this.#transitionFrame);
59
+ this.#transitionFrame = null;
153
60
  }
154
- // Compute and cache the new value
155
- const value = getComputedStyle(node).animationName || "none";
156
- animationNameCache.set(node, { value, timestamp: now });
157
- return value;
158
61
  }
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from "svelte";
2
2
  import type { ReadableBox } from "svelte-toolbelt";
3
+ import type { TransitionState } from "../../../internal/attrs.js";
3
4
  export type PresenceLayerProps = {
4
5
  /**
5
6
  * Whether to force mount the component.
@@ -11,8 +12,11 @@ export type PresenceLayerImplProps = PresenceLayerProps & {
11
12
  * The open state of the component.
12
13
  */
13
14
  open: boolean;
14
- presence?: Snippet<[{
15
- present: boolean;
16
- }]>;
15
+ presence?: Snippet<[
16
+ {
17
+ present: boolean;
18
+ transitionStatus: TransitionState;
19
+ }
20
+ ]>;
17
21
  ref: ReadableBox<HTMLElement | null>;
18
22
  };