diff --git a/.changeset/crazy-vans-follow.md b/.changeset/crazy-vans-follow.md new file mode 100644 index 000000000..501cb584d --- /dev/null +++ b/.changeset/crazy-vans-follow.md @@ -0,0 +1,5 @@ +--- +"bits-ui": minor +--- + +feat: support CSS transitions diff --git a/docs/content/styling.md b/docs/content/styling.md index 8c065e87f..e4ae17f30 100644 --- a/docs/content/styling.md +++ b/docs/content/styling.md @@ -224,6 +224,30 @@ Here's an example styling an accordion with different states: ## Advanced Styling Techniques +### CSS Transitions on Mount-Managed Surfaces + +Components that manage their mount lifecycle for animations expose transient +`data-starting-style` and `data-ending-style` attributes on their animated +surfaces. This is useful for popup content, overlays, and similar parts where +you want enter and exit transitions without losing the close animation during +unmount. + +```css +[data-popover-content] { + opacity: 1; + transform: scale(1); + transition: + opacity 150ms ease, + transform 150ms ease; +} + +[data-popover-content][data-starting-style], +[data-popover-content][data-ending-style] { + opacity: 0; + transform: scale(0.96); +} +``` + ### Combining Data Attributes with CSS Variables You can combine data attributes with CSS variables to create dynamic styles based on component state. Here's how to animate the accordion content using the `--bits-accordion-content-height` variable and the `data-state` attribute: diff --git a/docs/src/lib/content/api-reference/accordion.api.ts b/docs/src/lib/content/api-reference/accordion.api.ts index 7ed7f8747..b6e881657 100644 --- a/docs/src/lib/content/api-reference/accordion.api.ts +++ b/docs/src/lib/content/api-reference/accordion.api.ts @@ -5,7 +5,13 @@ import type { AccordionRootPropsWithoutHTML, AccordionTriggerPropsWithoutHTML, } from "bits-ui"; -import { disabledDataAttr, forceMountProp, orientationDataAttr, withChildProps } from "./shared.js"; +import { + disabledDataAttr, + forceMountProp, + orientationDataAttr, + transitionStyleDataAttrs, + withChildProps, +} from "./shared.js"; import { HeaderLevelProp, OnChangeStringOrArrayProp, @@ -150,6 +156,7 @@ const content = defineComponentApiSchema({ dataAttributes: [ orientationDataAttr, disabledDataAttr, + ...transitionStyleDataAttrs, defineSimpleDataAttr({ name: "accordion-content", description: "Present on the content element.", diff --git a/docs/src/lib/content/api-reference/alert-dialog.api.ts b/docs/src/lib/content/api-reference/alert-dialog.api.ts index d4a3113e7..9ad19a099 100644 --- a/docs/src/lib/content/api-reference/alert-dialog.api.ts +++ b/docs/src/lib/content/api-reference/alert-dialog.api.ts @@ -27,6 +27,7 @@ import { preventOverflowTextSelectionProp, preventScrollProp, restoreScrollDelayProp, + transitionStyleDataAttrs, withChildProps, } from "$lib/content/api-reference/shared.js"; import { @@ -104,6 +105,7 @@ const content = defineComponentApiSchema({ }, dataAttributes: [ stateDataAttr, + ...transitionStyleDataAttrs, defineSimpleDataAttr({ name: "alert-dialog-content", description: "Present on the content element.", @@ -177,6 +179,7 @@ const overlay = defineComponentApiSchema({ }, dataAttributes: [ stateDataAttr, + ...transitionStyleDataAttrs, defineSimpleDataAttr({ name: "alert-dialog-overlay", description: "Present on the overlay element.", diff --git a/docs/src/lib/content/api-reference/collapsible.api.ts b/docs/src/lib/content/api-reference/collapsible.api.ts index 1050bdd06..50d9c3c8e 100644 --- a/docs/src/lib/content/api-reference/collapsible.api.ts +++ b/docs/src/lib/content/api-reference/collapsible.api.ts @@ -7,6 +7,7 @@ import { forceMountProp, onOpenChangeCompleteProp, onOpenChangeProp, + transitionStyleDataAttrs, withChildProps, } from "./shared.js"; import { CollapsibleContentChildSnippetProps } from "./extended-types/collapsible/index.js"; @@ -111,6 +112,7 @@ export const content = defineComponentApiSchema }, dataAttributes: [ stateDataAttr, + ...transitionStyleDataAttrs, defineSimpleDataAttr({ name: "combobox-content", description: "Present on the content element.", @@ -194,6 +196,7 @@ export const contentStatic = defineComponentApiSchema({ }, dataAttributes: [ stateDataAttr, + ...transitionStyleDataAttrs, defineSimpleDataAttr({ name: "dialog-content", description: "Present on the content.", @@ -175,6 +177,7 @@ export const overlay = defineComponentApiSchema({ }, dataAttributes: [ stateDataAttr, + ...transitionStyleDataAttrs, defineSimpleDataAttr({ name: "dialog-overlay", description: "Present on the overlay.", diff --git a/docs/src/lib/content/api-reference/link-preview.api.ts b/docs/src/lib/content/api-reference/link-preview.api.ts index 5a6d3def9..9794d8857 100644 --- a/docs/src/lib/content/api-reference/link-preview.api.ts +++ b/docs/src/lib/content/api-reference/link-preview.api.ts @@ -20,6 +20,7 @@ import { onOpenChangeCompleteProp, onOpenChangeProp, portalProps, + transitionStyleDataAttrs, withChildProps, } from "$lib/content/api-reference/shared.js"; import { @@ -98,6 +99,7 @@ export const content = defineComponentApiSchema({ @@ -160,6 +169,13 @@ export const indicator = defineComponentApiSchema({ @@ -170,6 +186,13 @@ export const viewport = defineComponentApiSchema( }, dataAttributes: [ openClosedDataAttr, + ...transitionStyleDataAttrs, defineSimpleDataAttr({ name: "popover-content", description: "Present on the content element.", @@ -141,6 +143,7 @@ export const contentStatic = defineComponentApiSchema( description: "Present on the overlay element.", }), openClosedDataAttr, + ...transitionStyleDataAttrs, ], }); diff --git a/docs/src/lib/content/api-reference/select.api.ts b/docs/src/lib/content/api-reference/select.api.ts index a26366c61..0fa39a640 100644 --- a/docs/src/lib/content/api-reference/select.api.ts +++ b/docs/src/lib/content/api-reference/select.api.ts @@ -16,6 +16,7 @@ import { portalProps, preventOverflowTextSelectionProp, preventScrollProp, + transitionStyleDataAttrs, typeSingleOrMultipleProp, withChildProps, } from "$lib/content/api-reference/shared.js"; @@ -153,6 +154,7 @@ export const content = defineComponentApiSchema({ }, dataAttributes: [ stateDataAttr, + ...transitionStyleDataAttrs, defineSimpleDataAttr({ name: "select-content", description: "Present on the content element.", @@ -184,6 +186,7 @@ export const contentStatic = defineComponentApiSchema( }, dataAttributes: [ openClosedDataAttr, + ...transitionStyleDataAttrs, defineSimpleDataAttr({ name: "tooltip-content", description: "Present on the tooltip content element.", @@ -194,6 +196,7 @@ export const contentStatic = defineComponentApiSchema 0), "data-nested": boolToEmptyStrOrUndef(this.root.parent !== null), + ...getDataTransitionAttrs(this.root.contentPresence.transitionStatus), ...this.root.sharedProps, ...this.attachment, }) as const @@ -428,6 +430,7 @@ export class DialogOverlayState { }, "data-nested-open": boolToEmptyStrOrUndef(this.root.nestedOpenCount > 0), "data-nested": boolToEmptyStrOrUndef(this.root.parent !== null), + ...getDataTransitionAttrs(this.root.overlayPresence.transitionStatus), ...this.root.sharedProps, ...this.attachment, }) as const diff --git a/packages/bits-ui/src/lib/bits/link-preview/link-preview.svelte.ts b/packages/bits-ui/src/lib/bits/link-preview/link-preview.svelte.ts index 1b726865d..cceabf5fb 100644 --- a/packages/bits-ui/src/lib/bits/link-preview/link-preview.svelte.ts +++ b/packages/bits-ui/src/lib/bits/link-preview/link-preview.svelte.ts @@ -9,7 +9,12 @@ import { } from "svelte-toolbelt"; import { Context, watch } from "runed"; import { on } from "svelte/events"; -import { createBitsAttrs, boolToStr, getDataOpenClosed } from "$lib/internal/attrs.js"; +import { + createBitsAttrs, + boolToStr, + getDataOpenClosed, + getDataTransitionAttrs, +} from "$lib/internal/attrs.js"; import { isElement, isFocusVisible, isTouch } from "$lib/internal/is.js"; import type { BitsFocusEvent, @@ -306,6 +311,7 @@ export class LinkPreviewContentState { id: this.opts.id.current, tabindex: -1, "data-state": getDataOpenClosed(this.root.opts.open.current), + ...getDataTransitionAttrs(this.root.contentPresence.transitionStatus), [linkPreviewAttrs.content]: "", onpointerdown: this.onpointerdown, onpointerenter: this.onpointerenter, diff --git a/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts b/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts index b6065b721..053363f8a 100644 --- a/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts @@ -40,6 +40,7 @@ import { boolToStr, getDataOpenClosed, boolToEmptyStrOrUndef, + getDataTransitionAttrs, } from "$lib/internal/attrs.js"; import type { Direction } from "$lib/shared/index.js"; import { IsUsingKeyboard } from "$lib/bits/utilities/is-using-keyboard/is-using-keyboard.svelte.js"; @@ -435,6 +436,7 @@ export class MenuContentState { "aria-orientation": "vertical" as const, [this.parentMenu.root.getBitsAttr("content")]: "", "data-state": getDataOpenClosed(this.parentMenu.opts.open.current), + ...getDataTransitionAttrs(this.parentMenu.contentPresence.transitionStatus), onkeydown: this.onkeydown, onblur: this.onblur, onfocus: this.onfocus, diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte index ae44dc6c1..447734fc4 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte @@ -3,6 +3,7 @@ import { NavigationMenuContentState } from "../navigation-menu.svelte.js"; import NavigationMenuContentImpl from "./navigation-menu-content-impl.svelte"; import { createId } from "$lib/internal/create-id.js"; + import { getDataTransitionAttrs } from "$lib/internal/attrs.js"; import type { NavigationMenuContentProps } from "$lib/types.js"; import Portal from "$lib/bits/utilities/portal/portal.svelte"; import PresenceLayer from "$lib/bits/utilities/presence-layer/presence-layer.svelte"; @@ -38,8 +39,12 @@ open={forceMount || contentState.open || contentState.isLastActiveValue} ref={contentState.opts.ref} > - {#snippet presence()} - + {#snippet presence({ transitionStatus })} + {/snippet} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte index 16134e8f3..c10988e3a 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte @@ -4,6 +4,7 @@ import { NavigationMenuIndicatorState } from "../navigation-menu.svelte.js"; import NavigationMenuIndicatorImpl from "./navigation-menu-indicator-impl.svelte"; import { createId } from "$lib/internal/create-id.js"; + import { getDataTransitionAttrs } from "$lib/internal/attrs.js"; import PresenceLayer from "$lib/bits/utilities/presence-layer/presence-layer.svelte"; import Portal from "$lib/bits/utilities/portal/portal.svelte"; @@ -25,8 +26,14 @@ {#if indicatorState.context.indicatorTrackRef.current} ref)}> - {#snippet presence()} - + {#snippet presence({ transitionStatus })} + {/snippet} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte index 3e6b9e16b..d1557e122 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte @@ -2,6 +2,7 @@ import type { NavigationMenuViewportProps } from "../types.js"; import { NavigationMenuViewportState } from "../navigation-menu.svelte.js"; import { createId } from "$lib/internal/create-id.js"; + import { getDataTransitionAttrs } from "$lib/internal/attrs.js"; import PresenceLayer from "$lib/bits/utilities/presence-layer/presence-layer.svelte"; import { boxWith, mergeProps } from "svelte-toolbelt"; import { Mounted } from "$lib/bits/utilities/index.js"; @@ -29,11 +30,12 @@ - {#snippet presence()} + {#snippet presence({ transitionStatus })} + {@const presenceProps = getDataTransitionAttrs(transitionStatus)} {#if child} - {@render child({ props: mergedProps })} + {@render child({ props: mergeProps(mergedProps, presenceProps) })} {:else} -
+
{@render children?.()}
{/if} diff --git a/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts b/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts index 4e637dbbe..850a6dc67 100644 --- a/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts +++ b/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts @@ -7,7 +7,12 @@ import { } from "svelte-toolbelt"; import { Context, watch } from "runed"; import { kbd } from "$lib/internal/kbd.js"; -import { createBitsAttrs, boolToStr, getDataOpenClosed } from "$lib/internal/attrs.js"; +import { + createBitsAttrs, + boolToStr, + getDataOpenClosed, + getDataTransitionAttrs, +} from "$lib/internal/attrs.js"; import type { BitsFocusEvent, BitsKeyboardEvent, @@ -411,6 +416,7 @@ export class PopoverContentState { id: this.opts.id.current, tabindex: -1, "data-state": getDataOpenClosed(this.root.opts.open.current), + ...getDataTransitionAttrs(this.root.contentPresence.transitionStatus), [popoverAttrs.content]: "", style: { pointerEvents: "auto", @@ -505,6 +511,7 @@ export class PopoverOverlayState { pointerEvents: "auto", }, "data-state": getDataOpenClosed(this.root.opts.open.current), + ...getDataTransitionAttrs(this.root.overlayPresence.transitionStatus), ...this.attachment, }) as const ); diff --git a/packages/bits-ui/src/lib/bits/select/select.svelte.ts b/packages/bits-ui/src/lib/bits/select/select.svelte.ts index 00a6635b5..3541fe44c 100644 --- a/packages/bits-ui/src/lib/bits/select/select.svelte.ts +++ b/packages/bits-ui/src/lib/bits/select/select.svelte.ts @@ -18,6 +18,7 @@ import { boolToEmptyStrOrUndef, getDataOpenClosed, boolToTrueOrUndef, + getDataTransitionAttrs, } from "$lib/internal/attrs.js"; import { kbd } from "$lib/internal/kbd.js"; import type { @@ -962,6 +963,7 @@ export class SelectContentState { role: "listbox", "aria-multiselectable": this.root.isMulti ? "true" : undefined, "data-state": getDataOpenClosed(this.root.opts.open.current), + ...getDataTransitionAttrs(this.root.contentPresence.transitionStatus), [this.root.getBitsAttr("content")]: "", style: { display: "flex", diff --git a/packages/bits-ui/src/lib/bits/tooltip/tooltip.svelte.ts b/packages/bits-ui/src/lib/bits/tooltip/tooltip.svelte.ts index a032ce66a..89503ae75 100644 --- a/packages/bits-ui/src/lib/bits/tooltip/tooltip.svelte.ts +++ b/packages/bits-ui/src/lib/bits/tooltip/tooltip.svelte.ts @@ -10,7 +10,11 @@ import { import { on } from "svelte/events"; import { Context, watch } from "runed"; import { isElement, isFocusVisible } from "$lib/internal/is.js"; -import { createBitsAttrs, boolToEmptyStrOrUndef } from "$lib/internal/attrs.js"; +import { + createBitsAttrs, + boolToEmptyStrOrUndef, + getDataTransitionAttrs, +} from "$lib/internal/attrs.js"; import type { OnChangeFn, RefAttachment, WithRefOpts } from "$lib/internal/types.js"; import type { FocusEventHandler, MouseEventHandler, PointerEventHandler } from "svelte/elements"; import { TimeoutFn } from "$lib/internal/timeout-fn.js"; @@ -844,6 +848,7 @@ export class TooltipContentState { id: this.opts.id.current, "data-state": this.root.stateAttr, "data-disabled": boolToEmptyStrOrUndef(this.root.disabled), + ...getDataTransitionAttrs(this.root.contentPresence.transitionStatus), style: { outline: "none", }, diff --git a/packages/bits-ui/src/lib/bits/utilities/presence-layer/presence-layer.svelte b/packages/bits-ui/src/lib/bits/utilities/presence-layer/presence-layer.svelte index 06d125596..4e2c95144 100644 --- a/packages/bits-ui/src/lib/bits/utilities/presence-layer/presence-layer.svelte +++ b/packages/bits-ui/src/lib/bits/utilities/presence-layer/presence-layer.svelte @@ -12,5 +12,9 @@ {#if forceMount || open || presenceState.isPresent} - {@render presence?.({ present: presenceState.isPresent })} + {@render + presence?.({ + present: presenceState.isPresent, + transitionStatus: presenceState.transitionStatus, + })} {/if} diff --git a/packages/bits-ui/src/lib/bits/utilities/presence-layer/presence.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/presence-layer/presence.svelte.ts index 27e333f3f..b4e6fa9c5 100644 --- a/packages/bits-ui/src/lib/bits/utilities/presence-layer/presence.svelte.ts +++ b/packages/bits-ui/src/lib/bits/utilities/presence-layer/presence.svelte.ts @@ -1,7 +1,7 @@ -import { type ReadableBox, type ReadableBoxedValues, executeCallbacks } from "svelte-toolbelt"; -import { Previous, watch } from "runed"; -import { on } from "svelte/events"; -import { StateMachine } from "$lib/internal/state-machine.js"; +import { onDestroyEffect, type ReadableBox, type ReadableBoxedValues } from "svelte-toolbelt"; +import { watch } from "runed"; +import { AnimationsComplete } from "$lib/internal/animations-complete.js"; +import type { TransitionState } from "$lib/internal/attrs.js"; export interface PresenceOptions extends ReadableBoxedValues<{ @@ -9,197 +9,71 @@ export interface PresenceOptions ref: HTMLElement | null; }> {} -type PresenceStatus = "unmounted" | "mounted" | "unmountSuspended"; - -/** - * Cached style properties to avoid storing live CSSStyleDeclaration - * which triggers style recalculations when accessed. - */ -interface CachedStyles { - display: string; - animationName: string; -} - -/** - * Cache for animation names with TTL to reduce getComputedStyle calls. - * Uses WeakMap to avoid memory leaks when elements are removed. - */ -const animationNameCache = new WeakMap(); -const ANIMATION_NAME_CACHE_TTL_MS = 16; // One frame at 60fps - -const presenceMachine = { - mounted: { - UNMOUNT: "unmounted", - ANIMATION_OUT: "unmountSuspended", - }, - unmountSuspended: { - MOUNT: "mounted", - ANIMATION_END: "unmounted", - }, - unmounted: { - MOUNT: "mounted", - }, -} as const; - -type PresenceMachine = StateMachine; - export class Presence { readonly opts: PresenceOptions; - prevAnimationNameState = $state("none"); - styles = $state({ display: "", animationName: "none" }); - initialStatus: PresenceStatus; - previousPresent: Previous; - machine: PresenceMachine; present: ReadableBox; + #afterAnimations: AnimationsComplete; + #isPresent = $state(false); + #hasMounted = false; + #transitionStatus = $state(undefined); + #transitionFrame: number | null = null; constructor(opts: PresenceOptions) { this.opts = opts; this.present = this.opts.open; + this.#isPresent = opts.open.current; + this.#afterAnimations = new AnimationsComplete({ + ref: this.opts.ref, + afterTick: this.opts.open, + }); + onDestroyEffect(() => this.#clearTransitionFrame()); + + watch( + () => this.present.current, + (isOpen) => { + if (!this.#hasMounted) { + this.#hasMounted = true; + return; + } - this.initialStatus = opts.open.current ? "mounted" : "unmounted"; - this.previousPresent = new Previous(() => this.present.current); - this.machine = new StateMachine(this.initialStatus, presenceMachine); - - this.handleAnimationEnd = this.handleAnimationEnd.bind(this); - this.handleAnimationStart = this.handleAnimationStart.bind(this); - - watchPresenceChange(this); - watchStatusChange(this); - watchRefChange(this); - } + this.#clearTransitionFrame(); - /** - * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel` - * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we - * make sure we only trigger ANIMATION_END for the currently active animation. - */ - handleAnimationEnd(event: AnimationEvent) { - if (!this.opts.ref.current) return; - // Use cached animation name from styles when available to avoid getComputedStyle - const currAnimationName = - this.styles.animationName || getAnimationName(this.opts.ref.current); - const isCurrentAnimation = - currAnimationName.includes(event.animationName) || currAnimationName === "none"; + if (isOpen) { + this.#isPresent = true; + } - if (event.target === this.opts.ref.current && isCurrentAnimation) { - this.machine.dispatch("ANIMATION_END"); - } - } + this.#transitionStatus = isOpen ? "starting" : "ending"; + if (isOpen) { + this.#transitionFrame = window.requestAnimationFrame(() => { + this.#transitionFrame = null; + if (this.present.current) { + this.#transitionStatus = undefined; + } + }); + } - handleAnimationStart(event: AnimationEvent) { - if (!this.opts.ref.current) return; - if (event.target === this.opts.ref.current) { - // Force refresh cache on animation start to get accurate animation name - const animationName = getAnimationName(this.opts.ref.current, true); - this.prevAnimationNameState = animationName; - // Update styles cache for subsequent reads - this.styles.animationName = animationName; - } + this.#afterAnimations.run(() => { + if (isOpen !== this.present.current) return; + if (!isOpen) { + this.#isPresent = false; + } + this.#transitionStatus = undefined; + }); + } + ); } isPresent = $derived.by(() => { - return ["mounted", "unmountSuspended"].includes(this.machine.state.current); + return this.#isPresent; }); -} -function watchPresenceChange(state: Presence) { - watch( - () => state.present.current, - () => { - if (!state.opts.ref.current) return; - const hasPresentChanged = state.present.current !== state.previousPresent.current; - if (!hasPresentChanged) return; - - const prevAnimationName = state.prevAnimationNameState; - // Force refresh on state change to get accurate current animation - const currAnimationName = getAnimationName(state.opts.ref.current, true); - // Update styles cache for subsequent reads - state.styles.animationName = currAnimationName; - - if (state.present.current) { - state.machine.dispatch("MOUNT"); - } else if (currAnimationName === "none" || state.styles.display === "none") { - // If there is no exit animation or the element is hidden, animations won't run - // so we unmount instantly - state.machine.dispatch("UNMOUNT"); - } else { - /** - * When `present` changes to `false`, we check changes to animation-name to - * determine whether an animation has started. We chose this approach (reading - * computed styles) because there is no `animationrun` event and `animationstart` - * fires after `animation-delay` has expired which would be too late. - */ - const isAnimating = prevAnimationName !== currAnimationName; - - if (state.previousPresent.current && isAnimating) { - state.machine.dispatch("ANIMATION_OUT"); - } else { - state.machine.dispatch("UNMOUNT"); - } - } - } - ); -} - -function watchStatusChange(state: Presence) { - watch( - () => state.machine.state.current, - () => { - if (!state.opts.ref.current) return; - // Use cached animation name first, only force refresh if needed for mounted state - const currAnimationName = - state.machine.state.current === "mounted" - ? getAnimationName(state.opts.ref.current, true) - : "none"; - state.prevAnimationNameState = currAnimationName; - // Update styles cache - state.styles.animationName = currAnimationName; - } - ); -} - -function watchRefChange(state: Presence) { - watch( - () => state.opts.ref.current, - () => { - if (!state.opts.ref.current) return; - // Snapshot only needed style properties instead of storing live CSSStyleDeclaration - // This avoids triggering style recalculations when accessing the cached object - const computed = getComputedStyle(state.opts.ref.current); - state.styles = { - display: computed.display, - animationName: computed.animationName || "none", - }; - - 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) - ); - } - ); -} - -/** - * Gets the animation name from computed styles with optional caching. - * - * @param node - The HTML element to get animation name from - * @param forceRefresh - If true, bypasses the cache and forces a fresh getComputedStyle call - * @returns The animation name or "none" if not animating - */ -function getAnimationName(node?: HTMLElement, forceRefresh = false): string { - if (!node) return "none"; - - const now = performance.now(); - const cached = animationNameCache.get(node); - - // Return cached value if still valid and not forced to refresh - if (!forceRefresh && cached && now - cached.timestamp < ANIMATION_NAME_CACHE_TTL_MS) { - return cached.value; + get transitionStatus(): TransitionState { + return this.#transitionStatus; } - // Compute and cache the new value - const value = getComputedStyle(node).animationName || "none"; - animationNameCache.set(node, { value, timestamp: now }); - return value; + #clearTransitionFrame(): void { + if (this.#transitionFrame === null) return; + window.cancelAnimationFrame(this.#transitionFrame); + this.#transitionFrame = null; + } } diff --git a/packages/bits-ui/src/lib/bits/utilities/presence-layer/types.ts b/packages/bits-ui/src/lib/bits/utilities/presence-layer/types.ts index b56dc5792..ca8cb5b12 100644 --- a/packages/bits-ui/src/lib/bits/utilities/presence-layer/types.ts +++ b/packages/bits-ui/src/lib/bits/utilities/presence-layer/types.ts @@ -1,5 +1,6 @@ import type { Snippet } from "svelte"; import type { ReadableBox } from "svelte-toolbelt"; +import type { TransitionState } from "$lib/internal/attrs.js"; export type PresenceLayerProps = { /** @@ -14,7 +15,14 @@ export type PresenceLayerImplProps = PresenceLayerProps & { */ open: boolean; - presence?: Snippet<[{ present: boolean }]>; + presence?: Snippet< + [ + { + present: boolean; + transitionStatus: TransitionState; + }, + ] + >; ref: ReadableBox; }; diff --git a/packages/bits-ui/src/lib/internal/animations-complete.ts b/packages/bits-ui/src/lib/internal/animations-complete.ts index 9c1c35ef7..f52af6dc4 100644 --- a/packages/bits-ui/src/lib/internal/animations-complete.ts +++ b/packages/bits-ui/src/lib/internal/animations-complete.ts @@ -9,19 +9,25 @@ interface AnimationsCompleteOpts export class AnimationsComplete { #opts: AnimationsCompleteOpts; #currentFrame: number | null = null; + #observer: MutationObserver | null = null; + #runId = 0; constructor(opts: AnimationsCompleteOpts) { this.#opts = opts; onDestroyEffect(() => this.#cleanup()); } - #cleanup() { - if (!this.#currentFrame) return; - window.cancelAnimationFrame(this.#currentFrame); - this.#currentFrame = null; + #cleanup(): void { + if (this.#currentFrame !== null) { + window.cancelAnimationFrame(this.#currentFrame); + this.#currentFrame = null; + } + this.#observer?.disconnect(); + this.#observer = null; + this.#runId++; } - run(fn: () => void | Promise) { + run(fn: () => void | Promise): void { // if already running, cleanup and restart this.#cleanup(); @@ -33,21 +39,80 @@ export class AnimationsComplete { return; } - this.#currentFrame = window.requestAnimationFrame(() => { + const runId = this.#runId; + + const executeIfCurrent = (): void => { + if (runId !== this.#runId) return; + this.#executeCallback(fn); + }; + + const waitForAnimations = (): void => { + if (runId !== this.#runId) return; const animations = node.getAnimations(); if (animations.length === 0) { - this.#executeCallback(fn); + executeIfCurrent(); + return; + } + + Promise.all(animations.map((animation) => animation.finished)) + .then(() => { + executeIfCurrent(); + }) + .catch(() => { + if (runId !== this.#runId) return; + const currentAnimations = node.getAnimations(); + const hasRunningAnimations = currentAnimations.some( + (animation) => animation.pending || animation.playState !== "finished" + ); + + if (hasRunningAnimations) { + waitForAnimations(); + return; + } + + executeIfCurrent(); + }); + }; + + const requestWaitForAnimations = (): void => { + this.#currentFrame = window.requestAnimationFrame(() => { + this.#currentFrame = null; + waitForAnimations(); + }); + }; + + if (!this.#opts.afterTick.current) { + requestWaitForAnimations(); + return; + } + + this.#currentFrame = window.requestAnimationFrame(() => { + this.#currentFrame = null; + const startingStyleAttr = "data-starting-style"; + + if (!node.hasAttribute(startingStyleAttr)) { + requestWaitForAnimations(); return; } - Promise.allSettled(animations.map((animation) => animation.finished)).then(() => { - this.#executeCallback(fn); + this.#observer = new MutationObserver(() => { + if (runId !== this.#runId) return; + if (node.hasAttribute(startingStyleAttr)) return; + + this.#observer?.disconnect(); + this.#observer = null; + requestWaitForAnimations(); + }); + + this.#observer.observe(node, { + attributes: true, + attributeFilter: [startingStyleAttr], }); }); } - #executeCallback(fn: () => void | Promise) { + #executeCallback(fn: () => void | Promise): void { const execute = () => { fn(); }; diff --git a/packages/bits-ui/src/lib/internal/attrs.ts b/packages/bits-ui/src/lib/internal/attrs.ts index 1bf3edea6..51df88830 100644 --- a/packages/bits-ui/src/lib/internal/attrs.ts +++ b/packages/bits-ui/src/lib/internal/attrs.ts @@ -22,13 +22,22 @@ export function getDataChecked(condition: boolean): "checked" | "unchecked" { return condition ? "checked" : "unchecked"; } +export type TransitionState = "starting" | "ending" | "idle" | undefined; + +export function getDataTransitionAttrs(state: TransitionState): { + "data-starting-style"?: ""; + "data-ending-style"?: ""; +} { + if (state === "starting") return { "data-starting-style": "" }; + if (state === "ending") return { "data-ending-style": "" }; + return {}; +} + export function getAriaChecked( checked: boolean, indeterminate: boolean ): "true" | "false" | "mixed" { - if (indeterminate) { - return "mixed"; - } + if (indeterminate) return "mixed"; return checked ? "true" : "false"; } diff --git a/packages/bits-ui/src/lib/internal/presence-manager.svelte.ts b/packages/bits-ui/src/lib/internal/presence-manager.svelte.ts index 9d2d16500..91441a2f8 100644 --- a/packages/bits-ui/src/lib/internal/presence-manager.svelte.ts +++ b/packages/bits-ui/src/lib/internal/presence-manager.svelte.ts @@ -1,6 +1,7 @@ import { watch } from "runed"; -import type { ReadableBoxedValues } from "svelte-toolbelt"; +import { onDestroyEffect, type ReadableBoxedValues } from "svelte-toolbelt"; import { AnimationsComplete } from "./animations-complete.js"; +import type { TransitionState } from "./attrs.js"; interface PresenceManagerOpts extends ReadableBoxedValues<{ @@ -16,6 +17,9 @@ export class PresenceManager { #enabled: boolean; #afterAnimations: AnimationsComplete; #shouldRender = $state(false); + #transitionStatus = $state(undefined); + #hasMounted = false; + #transitionFrame: number | null = null; constructor(opts: PresenceManagerOpts) { this.#opts = opts; @@ -25,18 +29,44 @@ export class PresenceManager { ref: this.#opts.ref, afterTick: this.#opts.open, }); + onDestroyEffect(() => this.#clearTransitionFrame()); watch( () => this.#opts.open.current, (isOpen) => { + if (!this.#hasMounted) { + this.#hasMounted = true; + return; + } + + this.#clearTransitionFrame(); + if (isOpen) this.#shouldRender = true; - if (!this.#enabled) return; + this.#transitionStatus = isOpen ? "starting" : "ending"; + if (isOpen) { + this.#transitionFrame = window.requestAnimationFrame(() => { + this.#transitionFrame = null; + if (this.#opts.open.current) { + this.#transitionStatus = undefined; + } + }); + } + + if (!this.#enabled) { + if (!isOpen) { + this.#shouldRender = false; + } + this.#transitionStatus = undefined; + this.#opts.onComplete?.(); + return; + } this.#afterAnimations.run(() => { if (isOpen === this.#opts.open.current) { if (!this.#opts.open.current) { this.#shouldRender = false; } + this.#transitionStatus = undefined; this.#opts.onComplete?.(); } }); @@ -47,4 +77,14 @@ export class PresenceManager { get shouldRender() { return this.#shouldRender; } + + get transitionStatus(): TransitionState { + return this.#transitionStatus; + } + + #clearTransitionFrame(): void { + if (this.#transitionFrame === null) return; + window.cancelAnimationFrame(this.#transitionFrame); + this.#transitionFrame = null; + } } diff --git a/tests/src/tests/accordion/accordion.browser.test.ts b/tests/src/tests/accordion/accordion.browser.test.ts index 46fb6881c..9d2cbf18b 100644 --- a/tests/src/tests/accordion/accordion.browser.test.ts +++ b/tests/src/tests/accordion/accordion.browser.test.ts @@ -11,7 +11,7 @@ import AccordionHiddenUntilFoundTest from "./accordion-hidden-until-found-test.s import AccordionMultiHiddenUntilFoundTest from "./accordion-multi-hidden-until-found-test.svelte"; import type { ComponentProps } from "svelte"; import { getTestKbd } from "../utils.js"; -import { expectNotExists } from "../browser-utils"; +import { expectNotExists, observeTransitionAttrs } from "../browser-utils"; export type Item = { value: string; @@ -323,6 +323,26 @@ describe("type='single'", () => { } }); + it("should apply transition attrs to content during open and close", async () => { + const t = setupSingleForceMount({ + items: ITEMS_WITH_DISABLED, + }); + const content = page.getByTestId(`${ITEMS[0]!.value}-content`); + const observer = observeTransitionAttrs(content.element()); + + await t.triggerEls[0]!.click(); + await vi.waitFor(() => + expect(observer.history.some((entry) => entry.starting)).toBe(true) + ); + + await t.triggerEls[0]!.click(); + await vi.waitFor(() => + expect(observer.history.some((entry) => entry.ending)).toBe(true) + ); + + observer.disconnect(); + }); + it("works properly when `forceMount` is true and the `open` snippet prop is used to conditionally render the content", async () => { const t = setupSingleForceMount({ items: ITEMS_WITH_DISABLED, diff --git a/tests/src/tests/browser-utils.ts b/tests/src/tests/browser-utils.ts index 721047013..a595838c4 100644 --- a/tests/src/tests/browser-utils.ts +++ b/tests/src/tests/browser-utils.ts @@ -17,3 +17,50 @@ export async function focusAndExpectToHaveFocus(loc: Locator) { (loc.element() as HTMLElement).focus(); await expect.element(loc).toHaveFocus(); } + +export type TransitionAttrSnapshot = { + starting: boolean; + ending: boolean; +}; + +export type TransitionAttrObserver = { + history: TransitionAttrSnapshot[]; + disconnect: () => void; +}; + +export function observeTransitionAttrs(node: Element): TransitionAttrObserver { + const history: TransitionAttrSnapshot[] = []; + const capture = (): void => { + history.push({ + starting: node.hasAttribute("data-starting-style"), + ending: node.hasAttribute("data-ending-style"), + }); + }; + + capture(); + + const observer = new MutationObserver((mutations) => { + if ( + !mutations.some( + (mutation) => + mutation.type === "attributes" && + (mutation.attributeName === "data-starting-style" || + mutation.attributeName === "data-ending-style") + ) + ) { + return; + } + + capture(); + }); + + observer.observe(node, { + attributes: true, + attributeFilter: ["data-starting-style", "data-ending-style"], + }); + + return { + history, + disconnect: (): void => observer.disconnect(), + }; +} diff --git a/tests/src/tests/collapsible/collapsible.browser.test.ts b/tests/src/tests/collapsible/collapsible.browser.test.ts index 2496ffbbe..0dd25f7de 100644 --- a/tests/src/tests/collapsible/collapsible.browser.test.ts +++ b/tests/src/tests/collapsible/collapsible.browser.test.ts @@ -6,7 +6,7 @@ import type { Collapsible } from "bits-ui"; import CollapsibleTest from "./collapsible-test.svelte"; import CollapsibleForceMountTest from "./collapsible-force-mount-test.svelte"; import CollapsibleHiddenUntilFoundTest from "./collapsible-hidden-until-found-test.svelte"; -import { expectExists, expectNotExists } from "../browser-utils"; +import { expectExists, expectNotExists, observeTransitionAttrs } from "../browser-utils"; function setup( props: Collapsible.RootProps & { withOpenCheck?: boolean } = {}, @@ -76,6 +76,19 @@ describe("Collapsible ", () => { await expect.element(content).toBeVisible(); }); + it("should apply transition attrs to content during open and close", async () => { + const t = setup({}, CollapsibleForceMountTest); + const observer = observeTransitionAttrs(t.content.element()); + + await t.trigger.click(); + await vi.waitFor(() => expect(observer.history.some((entry) => entry.starting)).toBe(true)); + + await t.trigger.click(); + await vi.waitFor(() => expect(observer.history.some((entry) => entry.ending)).toBe(true)); + + observer.disconnect(); + }); + it("should forceMount the content when `forceMount` is true and the `open` snippet prop is used", async () => { const t = setup({ withOpenCheck: true }, CollapsibleForceMountTest); await expectNotExists(page.getByTestId("content")); diff --git a/tests/src/tests/dialog/dialog.browser.test.ts b/tests/src/tests/dialog/dialog.browser.test.ts index 446e32a4f..f7dc32872 100644 --- a/tests/src/tests/dialog/dialog.browser.test.ts +++ b/tests/src/tests/dialog/dialog.browser.test.ts @@ -5,7 +5,7 @@ import type { Component } from "svelte"; import { getTestKbd } from "../utils.js"; import DialogTest, { type DialogTestProps } from "./dialog-test.svelte"; import DialogNestedTest from "./dialog-nested-test.svelte"; -import { expectExists, expectNotExists } from "../browser-utils"; +import { expectExists, expectNotExists, observeTransitionAttrs } from "../browser-utils"; import DialogForceMountTest from "./dialog-force-mount-test.svelte"; import DialogIntegrationTest from "./dialog-integration-test.svelte"; import DialogTooltipTest from "./dialog-tooltip-test.svelte"; @@ -50,6 +50,24 @@ describe("Data Attributes", () => { const content = page.getByTestId("content"); await expect.element(content).toHaveAttribute("data-state", "open"); }); + + it("should apply transition attrs to content and overlay during open and close", async () => { + await setup({}, DialogForceMountTest); + const trigger = page.getByTestId("trigger"); + const contentObserver = observeTransitionAttrs(page.getByTestId("content").element()); + const overlayObserver = observeTransitionAttrs(page.getByTestId("overlay").element()); + + await trigger.click(); + await vi.waitFor(() => expect(contentObserver.history.some((entry) => entry.starting)).toBe(true)); + await vi.waitFor(() => expect(overlayObserver.history.some((entry) => entry.starting)).toBe(true)); + + await userEvent.keyboard(kbd.ESCAPE); + await vi.waitFor(() => expect(contentObserver.history.some((entry) => entry.ending)).toBe(true)); + await vi.waitFor(() => expect(overlayObserver.history.some((entry) => entry.ending)).toBe(true)); + + contentObserver.disconnect(); + overlayObserver.disconnect(); + }); }); describe("Open/Close Behavior", () => { diff --git a/tests/src/tests/dropdown-menu/dropdown-menu.browser.test.ts b/tests/src/tests/dropdown-menu/dropdown-menu.browser.test.ts index acc4fcfa5..e85e76967 100644 --- a/tests/src/tests/dropdown-menu/dropdown-menu.browser.test.ts +++ b/tests/src/tests/dropdown-menu/dropdown-menu.browser.test.ts @@ -58,8 +58,7 @@ async function openWithKbd(props: DropdownMenuSetupProps = {}, key: string = kbd async function openSubmenu(props: Awaited>) { const t = props; - await userEvent.keyboard(kbd.ARROW_DOWN); - await expect.element(page.getByTestId("sub-trigger")).toHaveFocus(); + await focusSubTrigger(); await expectNotExists(t.getSubContent()); await userEvent.keyboard(kbd.ARROW_RIGHT); @@ -71,6 +70,15 @@ async function openSubmenu(props: Awaited>) { }; } +async function focusSubTrigger(): Promise { + const subtrigger = page.getByTestId("sub-trigger"); + if (subtrigger.element() === document.activeElement) return; + await userEvent.keyboard(kbd.ARROW_DOWN); + if (subtrigger.element() === document.activeElement) return; + await userEvent.keyboard(kbd.ARROW_DOWN); + await expect.element(subtrigger).toHaveFocus(); +} + afterEach(() => { vi.resetAllMocks(); }); @@ -145,9 +153,7 @@ it("should manage focus correctly when opened with keyboard", async () => { it("should open submenu with keyboard on subtrigger", async () => { await openWithKbd(); - await userEvent.keyboard(kbd.ARROW_DOWN); - const subtrigger = page.getByTestId("sub-trigger"); - await expect.element(subtrigger).toHaveFocus(); + await focusSubTrigger(); await expectNotExists(page.getByTestId("sub-content")); await userEvent.keyboard(kbd.ARROW_RIGHT); await expectExists(page.getByTestId("sub-content")); @@ -201,8 +207,7 @@ it("should check the radio item when clicked & respects binding", async () => { it("should skip over disabled items when navigating with the keyboard", async () => { await openWithKbd(); await expect.element(page.getByTestId("item")).toHaveFocus(); - await userEvent.keyboard(kbd.ARROW_DOWN); - await expect.element(page.getByTestId("sub-trigger")).toHaveFocus(); + await focusSubTrigger(); await userEvent.keyboard(kbd.ARROW_DOWN); await expect.element(page.getByTestId("checkbox-item")).toHaveFocus(); await expect.element(page.getByTestId("disabled-item")).not.toHaveFocus(); @@ -215,8 +220,7 @@ it("should not loop through the menu items when the `loop` prop is set to false/ loop: false, }, }); - await userEvent.keyboard(kbd.ARROW_DOWN); - await expect.element(page.getByTestId("sub-trigger")).toHaveFocus(); + await focusSubTrigger(); await userEvent.keyboard(kbd.ARROW_DOWN); await expect.element(page.getByTestId("checkbox-item")).toHaveFocus(); await userEvent.keyboard(kbd.ARROW_DOWN); @@ -235,8 +239,7 @@ it("should loop through the menu items when the `loop` prop is set to true", asy loop: true, }, }); - await userEvent.keyboard(kbd.ARROW_DOWN); - await expect.element(page.getByTestId("sub-trigger")).toHaveFocus(); + await focusSubTrigger(); await userEvent.keyboard(kbd.ARROW_DOWN); await expect.element(page.getByTestId("checkbox-item")).toHaveFocus(); await userEvent.keyboard(kbd.ARROW_DOWN); diff --git a/tests/src/tests/link-preview/link-preview.browser.test.ts b/tests/src/tests/link-preview/link-preview.browser.test.ts index e3f7cf061..2af16d135 100644 --- a/tests/src/tests/link-preview/link-preview.browser.test.ts +++ b/tests/src/tests/link-preview/link-preview.browser.test.ts @@ -5,7 +5,7 @@ import { getTestKbd } from "../utils.js"; import LinkPreviewTest, { type LinkPreviewTestProps } from "./link-preview-test.svelte"; import type { LinkPreviewForceMountTestProps } from "./link-preview-force-mount-test.svelte"; import LinkPreviewForceMountTest from "./link-preview-force-mount-test.svelte"; -import { expectExists, expectNotExists } from "../browser-utils"; +import { expectExists, expectNotExists, observeTransitionAttrs } from "../browser-utils"; import { page, userEvent } from "@vitest/browser/context"; const kbd = getTestKbd(); @@ -147,6 +147,19 @@ it("should forceMount the content when `forceMount` is true", async () => { await expectExists(page.getByTestId("content")); }); +it("should apply transition attrs to content during open and close", async () => { + const t = setup({}, LinkPreviewForceMountTest); + const observer = observeTransitionAttrs(page.getByTestId("content").element()); + + await t.trigger.hover(); + await vi.waitFor(() => expect(observer.history.some((entry) => entry.starting)).toBe(true)); + + await page.getByTestId("outside").hover(); + await vi.waitFor(() => expect(observer.history.some((entry) => entry.ending)).toBe(true)); + + observer.disconnect(); +}); + it("should forceMount the content when `forceMount` is true and the `open` snippet prop is used to conditionally render the content", async () => { const t = setup({ withOpenCheck: true }, LinkPreviewForceMountTest); await expectNotExists(page.getByTestId("content")); diff --git a/tests/src/tests/navigation-menu/navigation-menu-test.svelte b/tests/src/tests/navigation-menu/navigation-menu-test.svelte index 54aaf02cd..2a72e1f59 100644 --- a/tests/src/tests/navigation-menu/navigation-menu-test.svelte +++ b/tests/src/tests/navigation-menu/navigation-menu-test.svelte @@ -2,6 +2,9 @@ export type NavigationMenuTestProps = NavigationMenu.RootProps & { noViewport?: boolean; noSubViewport?: boolean; + contentForceMount?: boolean; + viewportForceMount?: boolean; + indicatorForceMount?: boolean; groupItemProps?: NavigationMenu.ItemProps; subGroupItemProps?: NavigationMenu.ItemProps; subGroupItem1Props?: NavigationMenu.ItemProps; @@ -15,6 +18,9 @@ let { noViewport, noSubViewport, + contentForceMount = false, + viewportForceMount = false, + indicatorForceMount = false, groupItemProps, subGroupItemProps, subGroupItem1Props, @@ -31,7 +37,7 @@ trigger - + @@ -45,7 +51,10 @@ sub - + diff --git a/tests/src/tests/navigation-menu/navigation-menu.browser.test.ts b/tests/src/tests/navigation-menu/navigation-menu.browser.test.ts index 63fa07807..f4a8a6702 100644 --- a/tests/src/tests/navigation-menu/navigation-menu.browser.test.ts +++ b/tests/src/tests/navigation-menu/navigation-menu.browser.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { render } from "vitest-browser-svelte"; import NavigationMenuTest, { type NavigationMenuTestProps } from "./navigation-menu-test.svelte"; import { getTestKbd } from "../utils"; @@ -91,6 +91,119 @@ it("should show indicator when hovering trigger", async () => { await expectExists(page.getByTestId("indicator")); }); +it("should apply transition attrs to content during open and close without a viewport", async () => { + setup({ noViewport: true }); + + type TransitionRecord = { starting: boolean; ending: boolean }; + const history: TransitionRecord[] = []; + + const capture = (element: Element): void => { + history.push({ + starting: element.hasAttribute("data-starting-style"), + ending: element.hasAttribute("data-ending-style"), + }); + }; + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === "attributes") { + const element = mutation.target as HTMLElement; + if (element.dataset.testid === "group-item-content") { + capture(element); + } + continue; + } + + for (const node of mutation.addedNodes) { + if (!(node instanceof Element)) continue; + if ((node as HTMLElement).dataset.testid === "group-item-content") { + capture(node); + } + } + } + }); + + observer.observe(document.body, { + subtree: true, + attributes: true, + childList: true, + attributeFilter: ["data-starting-style", "data-ending-style"], + }); + + const trigger = page.getByTestId("group-item-trigger"); + + await trigger.hover(); + await vi.waitFor(() => expect(history.some((entry) => entry.starting)).toBe(true)); + + await page.getByTestId("next-button").hover(); + await vi.waitFor(() => expect(history.some((entry) => entry.ending)).toBe(true)); + + observer.disconnect(); +}); + +it("should apply transition attrs to viewport and indicator during open and close", async () => { + setup(); + + type TransitionRecord = { starting: boolean; ending: boolean }; + const history = { + viewport: [] as TransitionRecord[], + indicator: [] as TransitionRecord[], + }; + + const capture = (key: keyof typeof history, element: Element): void => { + history[key].push({ + starting: element.hasAttribute("data-starting-style"), + ending: element.hasAttribute("data-ending-style"), + }); + }; + + const captureIfTracked = (element: Element): void => { + const testId = (element as HTMLElement).dataset.testid; + + if (testId === "viewport") { + capture("viewport", element); + } else if (testId === "indicator") { + capture("indicator", element); + } + }; + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === "attributes") { + captureIfTracked(mutation.target as Element); + continue; + } + + for (const node of mutation.addedNodes) { + if (!(node instanceof Element)) continue; + captureIfTracked(node); + node.querySelectorAll("[data-testid]").forEach((element) => + captureIfTracked(element) + ); + } + } + }); + + observer.observe(document.body, { + subtree: true, + attributes: true, + childList: true, + attributeFilter: ["data-starting-style", "data-ending-style"], + }); + + const trigger = page.getByTestId("group-item-trigger"); + + await trigger.hover(); + await vi.waitFor(() => expect(history.viewport.some((entry) => entry.starting)).toBe(true)); + await vi.waitFor(() => expect(history.indicator.some((entry) => entry.starting)).toBe(true)); + + await page.getByTestId("next-button").hover(); + await vi.waitFor(() => expect(history.viewport.some((entry) => entry.ending)).toBe(true)); + await vi.waitFor(() => expect(history.indicator.some((entry) => entry.ending)).toBe(true)); + + observer.disconnect(); +}); + it("should receive focus on the first item", async () => { setup(); (page.getByTestId("previous-button").element() as HTMLElement).focus(); diff --git a/tests/src/tests/popover/popover.browser.test.ts b/tests/src/tests/popover/popover.browser.test.ts index 34e7c3fcc..d0c79ed80 100644 --- a/tests/src/tests/popover/popover.browser.test.ts +++ b/tests/src/tests/popover/popover.browser.test.ts @@ -7,7 +7,7 @@ import PopoverForceMountTest, { type PopoverForceMountTestProps, } from "./popover-force-mount-test.svelte"; import PopoverSiblingsTest from "./popover-siblings-test.svelte"; -import { expectExists, expectNotExists } from "../browser-utils"; +import { expectExists, expectNotExists, observeTransitionAttrs } from "../browser-utils"; import { page, userEvent } from "@vitest/browser/context"; import PopoverMultipleTriggersTest from "./popover-multiple-triggers-test.svelte"; import PopoverOverlayTest from "./popover-overlay-test.svelte"; @@ -83,6 +83,37 @@ it("should have bits data attrs for overlay", async () => { await expect.element(overlay).toHaveAttribute("data-popover-overlay"); }); +it("should apply transition attrs to content and overlay during open and close", async () => { + render(PopoverTest, { + withOverlay: true, + contentProps: { forceMount: true }, + overlayProps: { forceMount: true }, + }); + + const trigger = page.getByTestId("trigger"); + const contentObserver = observeTransitionAttrs(page.getByTestId("content").element()); + const overlayObserver = observeTransitionAttrs(page.getByTestId("overlay").element()); + + await trigger.click(); + await vi.waitFor(() => + expect(contentObserver.history.some((entry) => entry.starting)).toBe(true) + ); + await vi.waitFor(() => + expect(overlayObserver.history.some((entry) => entry.starting)).toBe(true) + ); + + await trigger.click(); + await vi.waitFor(() => + expect(contentObserver.history.some((entry) => entry.ending)).toBe(true) + ); + await vi.waitFor(() => + expect(overlayObserver.history.some((entry) => entry.ending)).toBe(true) + ); + + contentObserver.disconnect(); + overlayObserver.disconnect(); +}); + it("should open on click", async () => { await open(); }); diff --git a/tests/src/tests/select/select.browser.test.ts b/tests/src/tests/select/select.browser.test.ts index 82fe87956..cf3f10157 100644 --- a/tests/src/tests/select/select.browser.test.ts +++ b/tests/src/tests/select/select.browser.test.ts @@ -10,7 +10,7 @@ import SelectMultiTest from "./select-multi-test.svelte"; import type { Item, SelectSingleTestProps } from "./select-test.svelte"; import SelectTest from "./select-test.svelte"; import SelectViewportTest from "./select-viewport-test.svelte"; -import { expectExists, expectNotExists } from "../browser-utils"; +import { expectExists, expectNotExists, observeTransitionAttrs } from "../browser-utils"; import { page, userEvent } from "@vitest/browser/context"; const kbd = getTestKbd(); @@ -168,6 +168,19 @@ describe("select - single", () => { expect(contentEl.style.backgroundColor).toBe("rgb(255, 0, 0)"); }); + it("should apply transition attrs to content during open and close", async () => { + const t = setupSingle({}, testItems, SelectForceMountTest); + const observer = observeTransitionAttrs(t.getContent().element()); + + await t.trigger.click(); + await vi.waitFor(() => expect(observer.history.some((entry) => entry.starting)).toBe(true)); + + await t.outside.click({ force: true }); + await vi.waitFor(() => expect(observer.history.some((entry) => entry.ending)).toBe(true)); + + observer.disconnect(); + }); + it("should apply the appropriate `aria-labelledby` attribute to the group", async () => { const t = await openSingle(); diff --git a/tests/src/tests/tooltip/tooltip.browser.test.ts b/tests/src/tests/tooltip/tooltip.browser.test.ts index 9b5b0bc62..5bbe951b9 100644 --- a/tests/src/tests/tooltip/tooltip.browser.test.ts +++ b/tests/src/tests/tooltip/tooltip.browser.test.ts @@ -7,7 +7,9 @@ import type { TooltipForceMountTestProps } from "./tooltip-force-mount-test.svel import TooltipForceMountTest from "./tooltip-force-mount-test.svelte"; import TooltipPopoverTest from "./tooltip-popover-test.svelte"; import TooltipManyTest from "./tooltip-many-test.svelte"; -import TooltipSingletonTest, { type TooltipSingletonTestProps } from "./tooltip-singleton-test.svelte"; +import TooltipSingletonTest, { + type TooltipSingletonTestProps, +} from "./tooltip-singleton-test.svelte"; import TooltipTetherTest from "./tooltip-tether-test.svelte"; import TooltipSingletonControlledTest from "./tooltip-singleton-controlled-test.svelte"; import TooltipSingletonForceMountTest, { @@ -16,7 +18,7 @@ import TooltipSingletonForceMountTest, { import TooltipSingletonEdgeTest from "./tooltip-singleton-edge-test.svelte"; import TooltipSafePolygonIntermediateTargetTest from "./tooltip-safe-polygon-intermediate-target-test.svelte"; import TooltipTetherGapTest from "./tooltip-tether-gap-test.svelte"; -import { expectExists, expectNotExists } from "../browser-utils"; +import { expectExists, expectNotExists, observeTransitionAttrs } from "../browser-utils"; import { page, userEvent } from "@vitest/browser/context"; const kbd = getTestKbd(); @@ -161,6 +163,19 @@ it("should forceMount the content when `forceMount` is true", async () => { expect(page.getByTestId("content")).toBeVisible(); }); +it("should apply transition attrs to content during open and close", async () => { + const t = setup({}, TooltipForceMountTest); + const observer = observeTransitionAttrs(page.getByTestId("content").element()); + + await t.trigger.hover(); + await vi.waitFor(() => expect(observer.history.some((entry) => entry.starting)).toBe(true)); + + await page.getByTestId("outside").hover(); + await vi.waitFor(() => expect(observer.history.some((entry) => entry.ending)).toBe(true)); + + observer.disconnect(); +}); + it("should forceMount the content when `forceMount` is true and the `open` snippet prop is used to conditionally render the content", async () => { const t = setup({ withOpenCheck: true }, TooltipForceMountTest); await expectNotExists(page.getByTestId("content"));