bits-ui 2.16.4 → 2.17.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.
Files changed (42) 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/date-field/date-field.svelte.d.ts +5 -0
  7. package/dist/bits/date-field/date-field.svelte.js +5 -0
  8. package/dist/bits/dialog/dialog.svelte.d.ts +4 -0
  9. package/dist/bits/dialog/dialog.svelte.js +3 -1
  10. package/dist/bits/link-preview/link-preview.svelte.d.ts +5 -3
  11. package/dist/bits/link-preview/link-preview.svelte.js +2 -1
  12. package/dist/bits/menu/components/menu-sub-content-static.svelte +12 -3
  13. package/dist/bits/menu/components/menu-sub-content.svelte +12 -3
  14. package/dist/bits/menu/components/menu-sub-trigger.svelte +1 -1
  15. package/dist/bits/menu/components/menu.svelte +5 -0
  16. package/dist/bits/menu/components/menu.svelte.d.ts +1 -0
  17. package/dist/bits/menu/menu.svelte.d.ts +8 -4
  18. package/dist/bits/menu/menu.svelte.js +636 -12
  19. package/dist/bits/menubar/components/menubar-menu.svelte +3 -0
  20. package/dist/bits/menubar/menubar.svelte.d.ts +2 -0
  21. package/dist/bits/menubar/menubar.svelte.js +22 -2
  22. package/dist/bits/navigation-menu/components/navigation-menu-content.svelte +7 -2
  23. package/dist/bits/navigation-menu/components/navigation-menu-indicator.svelte +9 -2
  24. package/dist/bits/navigation-menu/components/navigation-menu-viewport.svelte +5 -3
  25. package/dist/bits/navigation-menu/navigation-menu.svelte.js +1 -1
  26. package/dist/bits/popover/popover.svelte.d.ts +7 -3
  27. package/dist/bits/popover/popover.svelte.js +3 -1
  28. package/dist/bits/select/select.svelte.d.ts +8 -4
  29. package/dist/bits/select/select.svelte.js +20 -3
  30. package/dist/bits/tooltip/tooltip.svelte.d.ts +5 -3
  31. package/dist/bits/tooltip/tooltip.svelte.js +3 -2
  32. package/dist/bits/utilities/floating-layer/use-floating-layer.svelte.d.ts +5 -5
  33. package/dist/bits/utilities/presence-layer/presence-layer.svelte +5 -1
  34. package/dist/bits/utilities/presence-layer/presence.svelte.d.ts +3 -38
  35. package/dist/bits/utilities/presence-layer/presence.svelte.js +49 -146
  36. package/dist/bits/utilities/presence-layer/types.d.ts +7 -3
  37. package/dist/internal/animations-complete.js +64 -9
  38. package/dist/internal/attrs.d.ts +5 -0
  39. package/dist/internal/attrs.js +8 -2
  40. package/dist/internal/presence-manager.svelte.d.ts +4 -1
  41. package/dist/internal/presence-manager.svelte.js +42 -1
  42. package/package.json +1 -1
@@ -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
  };
@@ -2,18 +2,22 @@ import { afterTick, onDestroyEffect } from "svelte-toolbelt";
2
2
  export class AnimationsComplete {
3
3
  #opts;
4
4
  #currentFrame = null;
5
+ #observer = null;
6
+ #runId = 0;
5
7
  constructor(opts) {
6
8
  this.#opts = opts;
7
9
  onDestroyEffect(() => this.#cleanup());
8
10
  }
9
11
  #cleanup() {
10
- if (!this.#currentFrame)
11
- return;
12
- window.cancelAnimationFrame(this.#currentFrame);
13
- this.#currentFrame = null;
12
+ if (this.#currentFrame !== null) {
13
+ window.cancelAnimationFrame(this.#currentFrame);
14
+ this.#currentFrame = null;
15
+ }
16
+ this.#observer?.disconnect();
17
+ this.#observer = null;
18
+ this.#runId++;
14
19
  }
15
20
  run(fn) {
16
- // if already running, cleanup and restart
17
21
  this.#cleanup();
18
22
  const node = this.#opts.ref.current;
19
23
  if (!node)
@@ -22,14 +26,65 @@ export class AnimationsComplete {
22
26
  this.#executeCallback(fn);
23
27
  return;
24
28
  }
25
- this.#currentFrame = window.requestAnimationFrame(() => {
29
+ const runId = this.#runId;
30
+ const executeIfCurrent = () => {
31
+ if (runId !== this.#runId)
32
+ return;
33
+ this.#executeCallback(fn);
34
+ };
35
+ const waitForAnimations = () => {
36
+ if (runId !== this.#runId)
37
+ return;
26
38
  const animations = node.getAnimations();
27
39
  if (animations.length === 0) {
28
- this.#executeCallback(fn);
40
+ executeIfCurrent();
29
41
  return;
30
42
  }
31
- Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
32
- this.#executeCallback(fn);
43
+ Promise.all(animations.map((animation) => animation.finished))
44
+ .then(() => {
45
+ executeIfCurrent();
46
+ })
47
+ .catch(() => {
48
+ if (runId !== this.#runId)
49
+ return;
50
+ const currentAnimations = node.getAnimations();
51
+ const hasRunningAnimations = currentAnimations.some((animation) => animation.pending || animation.playState !== "finished");
52
+ if (hasRunningAnimations) {
53
+ waitForAnimations();
54
+ return;
55
+ }
56
+ executeIfCurrent();
57
+ });
58
+ };
59
+ const requestWaitForAnimations = () => {
60
+ this.#currentFrame = window.requestAnimationFrame(() => {
61
+ this.#currentFrame = null;
62
+ waitForAnimations();
63
+ });
64
+ };
65
+ if (!this.#opts.afterTick.current) {
66
+ requestWaitForAnimations();
67
+ return;
68
+ }
69
+ this.#currentFrame = window.requestAnimationFrame(() => {
70
+ this.#currentFrame = null;
71
+ const startingStyleAttr = "data-starting-style";
72
+ if (!node.hasAttribute(startingStyleAttr)) {
73
+ requestWaitForAnimations();
74
+ return;
75
+ }
76
+ this.#observer = new MutationObserver(() => {
77
+ if (runId !== this.#runId)
78
+ return;
79
+ if (node.hasAttribute(startingStyleAttr))
80
+ return;
81
+ this.#observer?.disconnect();
82
+ this.#observer = null;
83
+ requestWaitForAnimations();
84
+ });
85
+ this.#observer.observe(node, {
86
+ attributes: true,
87
+ attributeFilter: [startingStyleAttr],
33
88
  });
34
89
  });
35
90
  }
@@ -4,6 +4,11 @@ export declare function boolToEmptyStrOrUndef(condition: boolean): "" | undefine
4
4
  export declare function boolToTrueOrUndef(condition: boolean): true | undefined;
5
5
  export declare function getDataOpenClosed(condition: boolean): "open" | "closed";
6
6
  export declare function getDataChecked(condition: boolean): "checked" | "unchecked";
7
+ export type TransitionState = "starting" | "ending" | "idle" | undefined;
8
+ export declare function getDataTransitionAttrs(state: TransitionState): {
9
+ "data-starting-style"?: "";
10
+ "data-ending-style"?: "";
11
+ };
7
12
  export declare function getAriaChecked(checked: boolean, indeterminate: boolean): "true" | "false" | "mixed";
8
13
  export type BitsAttrsConfig<T extends readonly string[]> = {
9
14
  component: string;
@@ -16,10 +16,16 @@ export function getDataOpenClosed(condition) {
16
16
  export function getDataChecked(condition) {
17
17
  return condition ? "checked" : "unchecked";
18
18
  }
19
+ export function getDataTransitionAttrs(state) {
20
+ if (state === "starting")
21
+ return { "data-starting-style": "" };
22
+ if (state === "ending")
23
+ return { "data-ending-style": "" };
24
+ return {};
25
+ }
19
26
  export function getAriaChecked(checked, indeterminate) {
20
- if (indeterminate) {
27
+ if (indeterminate)
21
28
  return "mixed";
22
- }
23
29
  return checked ? "true" : "false";
24
30
  }
25
31
  export class BitsAttrs {
@@ -1,14 +1,17 @@
1
- import type { ReadableBoxedValues } from "svelte-toolbelt";
1
+ import { type ReadableBoxedValues } from "svelte-toolbelt";
2
+ import type { TransitionState } from "./attrs.js";
2
3
  interface PresenceManagerOpts extends ReadableBoxedValues<{
3
4
  open: boolean;
4
5
  ref: HTMLElement | null;
5
6
  }> {
6
7
  onComplete?: () => void;
7
8
  enabled?: boolean;
9
+ shouldSkipExitAnimation?: () => boolean;
8
10
  }
9
11
  export declare class PresenceManager {
10
12
  #private;
11
13
  constructor(opts: PresenceManagerOpts);
12
14
  get shouldRender(): boolean;
15
+ get transitionStatus(): TransitionState;
13
16
  }
14
17
  export {};
@@ -1,10 +1,14 @@
1
1
  import { watch } from "runed";
2
+ import { onDestroyEffect } from "svelte-toolbelt";
2
3
  import { AnimationsComplete } from "./animations-complete.js";
3
4
  export class PresenceManager {
4
5
  #opts;
5
6
  #enabled;
6
7
  #afterAnimations;
7
8
  #shouldRender = $state(false);
9
+ #transitionStatus = $state(undefined);
10
+ #hasMounted = false;
11
+ #transitionFrame = null;
8
12
  constructor(opts) {
9
13
  this.#opts = opts;
10
14
  this.#shouldRender = opts.open.current;
@@ -13,16 +17,44 @@ export class PresenceManager {
13
17
  ref: this.#opts.ref,
14
18
  afterTick: this.#opts.open,
15
19
  });
20
+ onDestroyEffect(() => this.#clearTransitionFrame());
16
21
  watch(() => this.#opts.open.current, (isOpen) => {
22
+ if (!this.#hasMounted) {
23
+ this.#hasMounted = true;
24
+ return;
25
+ }
26
+ this.#clearTransitionFrame();
27
+ if (!isOpen && this.#opts.shouldSkipExitAnimation?.()) {
28
+ this.#shouldRender = false;
29
+ this.#transitionStatus = undefined;
30
+ this.#opts.onComplete?.();
31
+ return;
32
+ }
17
33
  if (isOpen)
18
34
  this.#shouldRender = true;
19
- if (!this.#enabled)
35
+ this.#transitionStatus = isOpen ? "starting" : "ending";
36
+ if (isOpen) {
37
+ this.#transitionFrame = window.requestAnimationFrame(() => {
38
+ this.#transitionFrame = null;
39
+ if (this.#opts.open.current) {
40
+ this.#transitionStatus = undefined;
41
+ }
42
+ });
43
+ }
44
+ if (!this.#enabled) {
45
+ if (!isOpen) {
46
+ this.#shouldRender = false;
47
+ }
48
+ this.#transitionStatus = undefined;
49
+ this.#opts.onComplete?.();
20
50
  return;
51
+ }
21
52
  this.#afterAnimations.run(() => {
22
53
  if (isOpen === this.#opts.open.current) {
23
54
  if (!this.#opts.open.current) {
24
55
  this.#shouldRender = false;
25
56
  }
57
+ this.#transitionStatus = undefined;
26
58
  this.#opts.onComplete?.();
27
59
  }
28
60
  });
@@ -31,4 +63,13 @@ export class PresenceManager {
31
63
  get shouldRender() {
32
64
  return this.#shouldRender;
33
65
  }
66
+ get transitionStatus() {
67
+ return this.#transitionStatus;
68
+ }
69
+ #clearTransitionFrame() {
70
+ if (this.#transitionFrame === null)
71
+ return;
72
+ window.cancelAnimationFrame(this.#transitionFrame);
73
+ this.#transitionFrame = null;
74
+ }
34
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bits-ui",
3
- "version": "2.16.4",
3
+ "version": "2.17.0",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",