Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/react/src/combobox/root/AriaCombobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { getOverflowAncestors } from '@floating-ui/react-dom';
import type { ElementProps } from '../../utils/popups/types';
import { useDismiss } from '../../utils/popups/interactions/useDismiss';
import { useSingleAnchorPopupRoot } from '../../utils/popups/popupRoot';
import { PopupOwnerContext } from '../../utils/popups/PopupOwnerContext';
import { createInitialPopupStoreState, createPopupStoreContext } from '../../utils/popups/store';
import { useListNavigation } from '../../utils/popups/interactions/useListNavigation';
import { useClick } from '../../utils/popups/interactions/useClick';
Expand Down Expand Up @@ -1686,7 +1687,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none', I
<ComboboxHasItemsContext.Provider value={hasItems}>
<ComboboxDerivedItemsContext.Provider value={itemsContextValue}>
<ComboboxInputValueContext.Provider value={inputValue}>
{children}
<PopupOwnerContext.Provider value={store}>{children}</PopupOwnerContext.Provider>
</ComboboxInputValueContext.Provider>
</ComboboxDerivedItemsContext.Provider>
</ComboboxHasItemsContext.Provider>
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/dialog/popup/DialogPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ export const DialogPopup = React.forwardRef(function DialogPopup(
initialFocus={resolvedInitialFocus}
returnFocus={finalFocus}
modal={modal !== false}
outsideElementsInert={modal === true}
getInsideElements={() => [
store.context.backdropRef.current,
store.context.internalBackdropRef.current,
]}
restoreFocus="popup"
>
{element}
Expand Down
5 changes: 3 additions & 2 deletions packages/react/src/dialog/root/DialogRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1227,7 +1227,7 @@ describe('<Dialog.Root />', () => {
Element.prototype.requestPointerLock =
requestPointerLockSpy as typeof originalRequestPointerLock;

await render(
const { user } = await render(
<ContainedTriggerDialog
rootProps={{ defaultOpen: true, modal: false }}
popupProps={{
Expand All @@ -1251,7 +1251,8 @@ describe('<Dialog.Root />', () => {
fireEvent.mouseUp(document.body, { button: 0 });
await flushMicrotasks();

fireEvent.click(document.body);
// A new outside click includes its own press, distinct from the scrub release.
await user.click(document.body);

await waitFor(() => {
expect(screen.queryByRole('dialog')).toBe(null);
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/drawer/popup/DrawerPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,11 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup(
initialFocus={resolvedInitialFocus}
returnFocus={finalFocus}
modal={modal !== false}
outsideElementsInert={modal === true}
getInsideElements={() => [
store.context.backdropRef.current,
store.context.internalBackdropRef.current,
]}
restoreFocus="popup"
>
{element}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,12 @@ describe('<Drawer.Viewport />', () => {
}

expect(selection.rangeCount).toBe(1);
expect(selection.toString()).toBe('Outside');
const preservedRange = selection.getRangeAt(0);
expect(preservedRange.startContainer).toBe(textNode);
expect(preservedRange.startOffset).toBe(0);
expect(preservedRange.endContainer).toBe(textNode);
expect(preservedRange.endOffset).toBe(7);
expect(preservedRange.toString()).toBe('Outside');
});

it('does not clear text selection on touch swipe start', async () => {
Expand Down
67 changes: 67 additions & 0 deletions packages/react/src/internals/useAnimationsFinished.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,25 @@ function Test({ getAnimations, onFinished, signal, batch }: TestProps) {
describe('useAnimationsFinished', () => {
const { render } = createRenderer();

it.each([
{ name: 'no animations', finished: false },
{ name: 'an already-finished animation', finished: true },
])('completes $name without a rendering frame', async ({ finished }) => {
globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
const nextFrame = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(0);
const onFinished = vi.fn();
const animation = createAnimation().animation;
Object.defineProperty(animation, 'playState', { value: 'finished' });
try {
await render(
<Test getAnimations={() => (finished ? [animation] : [])} onFinished={onFinished} />,
);
expect(onFinished).toHaveBeenCalledTimes(1);
} finally {
nextFrame.mockRestore();
}
});

it.each(['finish', 'cancel'] as const)(
'ignores infinite animations when a finite animation completes via %s',
async (completion) => {
Expand Down Expand Up @@ -409,6 +428,54 @@ describe('useAnimationsFinished', () => {
}
});

describe.skipIf(isJSDOM)('closing styles', () => {
it.each(['transition', 'animation'] as const)(
'waits for a CSS %s introduced by the closing commit',
async (kind) => {
globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
const onFinished = vi.fn();
function Closing() {
const ref = React.useRef<HTMLDivElement>(null);
const [closed, setClosed] = React.useState(false);
const finish = useAnimationsFinished(ref);
React.useEffect(() => {
if (closed) {
finish(onFinished);
}
}, [closed, finish]);
return (
<React.Fragment>
<style>{`
.closing-style { opacity: 1; ${kind === 'transition' ? 'transition: opacity 60s linear;' : ''} }
.closing-style[data-closed] { opacity: 0; ${kind === 'animation' ? 'animation: close-style 60s linear;' : ''} }
@keyframes close-style { from { opacity: 1; } to { opacity: 0; } }
`}</style>
<button onClick={() => setClosed(true)}>Close animation</button>
<div
ref={ref}
data-testid="closing-style"
className="closing-style"
data-closed={closed ? '' : undefined}
/>
</React.Fragment>
);
}
const { user } = await render(<Closing />);
const target = screen.getByTestId('closing-style');
expect(getComputedStyle(target).opacity).toBe('1');
await user.click(screen.getByRole('button', { name: 'Close animation' }));
const animations = target.getAnimations();
expect(animations).toHaveLength(1);
expect(onFinished).not.toHaveBeenCalled();
await act(async () => {
animations[0].finish();
await flushMicrotasks();
});
expect(onFinished).toHaveBeenCalledTimes(1);
},
);
});

describe.skipIf(isJSDOM)('elements checked in the same frame', () => {
const FADE: Keyframe[] = [{ opacity: 1 }, { opacity: 0 }];

Expand Down
27 changes: 23 additions & 4 deletions packages/react/src/internals/useAnimationsFinished.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client';
import type * as React from 'react';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame';
import { useOnMount } from '@base-ui/utils/useOnMount';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { NOOP } from '@base-ui/utils/empty';
import { resolveRef } from '../utils/resolveRef';
Expand Down Expand Up @@ -91,6 +92,10 @@ export function useAnimationsFinished(
batch = false,
) {
const frame = useAnimationFrame();
const pendingLookup = React.useRef<((animations: Animation[]) => void) | null>(null);
useOnMount(() => () => {
pendingLookup.current = null;
});

return useStableCallback(
(
Expand All @@ -106,6 +111,7 @@ export function useAnimationsFinished(
signal: AbortSignal | null = null,
) => {
frame.cancel();
pendingLookup.current = null;

const element = resolveRef(elementOrRef);
if (element == null) {
Expand Down Expand Up @@ -141,13 +147,25 @@ export function useAnimationsFinished(
}

function exec() {
getAnimationsBatched([resolvedElement, waitForAnimations]);
const lookup = (animations: Animation[]) => {
if (pendingLookup.current !== lookup) {
return;
}
pendingLookup.current = null;
waitForAnimations(animations);
};
pendingLookup.current = lookup;
getAnimationsBatched([resolvedElement, lookup]);
}

function waitForAnimations(animations: Animation[]) {
// Discard fulfilled Animation values so unfinished animations cannot retain their targets.
Promise.all(
animations.filter(isFiniteAnimation).map((animation) => animation.finished.then(NOOP)),
animations
.filter(
(animation) => isFiniteAnimation(animation) && animation.playState !== 'finished',
)
.map((animation) => animation.finished.then(NOOP)),
).then(
() => {
if (!signal?.aborted) {
Expand Down Expand Up @@ -192,7 +210,8 @@ export function useAnimationsFinished(
return;
}

frame.request(exec);
// getAnimations() reflects pending CSS changes; closing needs no extra rendering frame.
exec();
},
);
}
28 changes: 18 additions & 10 deletions packages/react/src/internals/useTriggerSwitchTransition.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';
import * as React from 'react';
import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useAnimationsFinished } from './useAnimationsFinished';

Expand Down Expand Up @@ -49,6 +50,7 @@ export interface UseTriggerSwitchTransitionParameters {
export function useTriggerSwitchTransition(parameters: UseTriggerSwitchTransitionParameters): void {
const { store, domReference, positionerElement, open } = parameters;

const positionFrame = useAnimationFrame();
const previousTriggerRef = React.useRef<Element | null>(null);
const pendingSwitchRef = React.useRef<AbortController | null>(null);
const runOnceAnimationsFinish = useAnimationsFinished(positionerElement);
Expand All @@ -58,6 +60,7 @@ export function useTriggerSwitchTransition(parameters: UseTriggerSwitchTransitio
return;
}

positionFrame.cancel();
pendingSwitchRef.current?.abort();
pendingSwitchRef.current = null;

Expand All @@ -69,7 +72,7 @@ export function useTriggerSwitchTransition(parameters: UseTriggerSwitchTransitio
if (store.state.instantType === 'trigger-change') {
store.set('instantType', undefined);
}
}, [open, store]);
}, [open, positionFrame, store]);

useIsoLayoutEffect(() => {
const currentTrigger = domReference;
Expand All @@ -96,17 +99,22 @@ export function useTriggerSwitchTransition(parameters: UseTriggerSwitchTransitio
const abortController = new AbortController();
pendingSwitchRef.current = abortController;

runOnceAnimationsFinish(() => {
// The abort above covers a committed close, but the animations can finish
// in the same frame the close commits. Writing `trigger-change` then would
// leave stale state behind for the next open to inherit.
if (store.select('open')) {
store.set('instantType', 'trigger-change');
}
}, abortController.signal);
// Anchor positioning commits asynchronously after a trigger change. Observe
// its animations after that update, rather than completing an unstarted move.
positionFrame.request(() => {
runOnceAnimationsFinish(() => {
// The abort above covers a committed close, but the animations can finish
// in the same frame the close commits. Writing `trigger-change` then would
// leave stale state behind for the next open to inherit.
if (store.select('open')) {
store.set('instantType', 'trigger-change');
}
}, abortController.signal);
});

return () => {
positionFrame.cancel();
abortController.abort();
};
}, [domReference, runOnceAnimationsFinish, store]);
}, [domReference, positionFrame, runOnceAnimationsFinish, store]);
}
1 change: 1 addition & 0 deletions packages/react/src/popover/popup/PopoverPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const PopoverPopup = React.forwardRef(function PopoverPopup(
store={store}
openInteractionType={openMethod}
modal={focusManagerModal}
outsideElementsInert={modal === true}
disabled={!mounted || openReason === REASONS.triggerHover}
initialFocus={resolvedInitialFocus}
returnFocus={finalFocus}
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/select/root/SelectRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { mergeProps } from '../../merge-props';
import { NOOP } from '../../internals/noop';
import { getHiddenInputType } from '../../internals/getHiddenInputType';
import { useSingleAnchorPopupRoot } from '../../utils/popups/popupRoot';
import { PopupOwnerContext } from '../../utils/popups/PopupOwnerContext';
import { createInitialPopupStoreState, createPopupStoreContext } from '../../utils/popups/store';
import { handleHiddenInputInvalid } from '../../internals/handleHiddenInputInvalid';

Expand Down Expand Up @@ -451,7 +452,7 @@ export function SelectRoot<Value, Multiple extends boolean | undefined = false>(
return (
<SelectRootContext.Provider value={store}>
<SelectSelectionContext.Provider value={selection}>
{children}
<PopupOwnerContext.Provider value={store}>{children}</PopupOwnerContext.Provider>
</SelectSelectionContext.Provider>
<input
{...validation.getValidationProps(disabled, {
Expand Down
24 changes: 23 additions & 1 deletion packages/react/src/toast/useToastManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2093,19 +2093,41 @@ describe.skipIf(!isJSDOM)('useToast', () => {

await render(
<Toast.Provider>
<Toast.Viewport>
<Toast.Viewport data-testid="toast-viewport">
<ToastInDialogList />
</Toast.Viewport>
<div aria-live="polite">
<span>Background announcement</span>
<button data-testid="background-action">Background action</button>
</div>
<HighPriorityToastInDialog />
</Toast.Provider>,
);

await tick(clock, 50);
const addToastButton = screen.getByRole('button', { name: 'add' });
expect(addToastButton).toHaveFocus();
fireEvent.click(addToastButton);

const toastRoot = screen.getByTestId('toast-root');
expect(toastRoot).toHaveAttribute('aria-hidden', 'true');
expect(screen.queryByRole('alert')).not.toBe(null);

// Wait for the modal's exclusion observer to process the new announcement.
const viewport = screen.getByTestId('toast-viewport');
await flushMicrotasks();
expect(viewport.closest('[inert]')).not.toBe(null);
const announcement = screen.getByRole('alert');
expect(announcement).toHaveAttribute('aria-atomic', 'true');
expect(announcement).toHaveTextContent('High priority toast');
expect(announcement).toHaveTextContent('This is urgent');
expect(announcement.closest('[inert],[aria-hidden="true"]')).toBe(null);
expect(viewport).not.toContainElement(announcement);

const backgroundAction = screen.getByTestId('background-action');
expect(backgroundAction.closest('[inert]')).not.toBe(null);
backgroundAction.focus();
expect(addToastButton).toHaveFocus();
});
});
});
Loading
Loading