Skip to content
Draft
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
4 changes: 4 additions & 0 deletions docs/src/app/(docs)/react/components/combobox/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,10 @@ The typed filter still resets once the popup closes.

`<Combobox.Input>` can be rendered inside `<Combobox.Popup>` to create a searchable select popup.

In this layout, focus returns to the trigger as soon as the popup closes, so the input can no
longer be typed into while the popup plays its exit animation. Reopen from the trigger — the typed
filter resets. When the input sits outside the popup instead, it keeps both focus and its filter.

import { DemoComboboxInputInsidePopup } from './demos/input-inside-popup';

<DemoComboboxInputInsidePopup />
Expand Down
30 changes: 30 additions & 0 deletions docs/src/app/(docs)/react/handbook/animation/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,36 @@

Base UI components can be animated using CSS transitions, CSS animations, or JavaScript animation libraries. Each component provides a number of data attributes to target its states, as well as a few attributes specifically for animation.

## Behavior while animating out

A popup that is still in the DOM after it closes is already closed as far as the user is
concerned. Base UI marks that subtree with the
[`inert`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert)
attribute for the rest of the exit animation, so it is neither exposed to assistive technology
nor reachable with the <kbd>Tab</kbd> key.

This applies to [Dialog](/react/components/dialog),
[Alert Dialog](/react/components/alert-dialog), [Drawer](/react/components/drawer),
[Popover](/react/components/popover), [Menu](/react/components/menu) — including
[Context Menu](/react/components/context-menu) and the menus in a
[Menubar](/react/components/menubar) — [Select](/react/components/select),
[Combobox](/react/components/combobox), [Autocomplete](/react/components/autocomplete), and
[Preview Card](/react/components/preview-card). [Tooltip](/react/components/tooltip) and
[Navigation Menu](/react/components/navigation-menu) keep their own handling: their closing
positioners stop receiving pointer events, but never become inert.

Focus is handed back when the component closes, not when the exit animation finishes. A popup
closed while focus was inside it moves focus to its trigger, or to whatever was focused before it
opened. `finalFocus` overrides that choice, including over a focus guard that would otherwise pick
the destination itself, and `finalFocus={false}` opts out entirely — focus is then yours to move.
Preview Card has no focus manager of its own and so names no destination; focus simply leaves the
closed card.

Because `inert` is an attribute rather than a style, a descendant cannot opt back in: no part of a
closing popup can stay interactive. Drawer is the exception — while a swipe is in progress its
popup stays interactive so the gesture can finish. Keep exit animations short if a stray click in
that window would be disruptive.

## CSS transitions

Use the following Base UI attributes for creating transitions when a component becomes visible or hidden:
Expand Down
1 change: 1 addition & 0 deletions docs/src/app/(docs)/react/handbook/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ A guide to animating Base UI components.

- Keywords: Base UI Animation, React Component Animations, CSS Transition Guide, Motion Framer Integration, Animation Data Attributes, Handbook Animation, Spring Animations, Enter Exit Animations, Transition Hooks, Animated Components, Keyframe Animations
- Sections:
- Behavior while animating out
- CSS transitions
- CSS animations
- JavaScript animations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export const ComboboxPositioner = React.forwardRef(function ComboboxPositioner(
refs: [forwardedRef, setPositionerElement],
hidden: !mounted,
inert: !open,
closed: !open,
});

return (
Expand Down
27 changes: 22 additions & 5 deletions packages/react/src/combobox/root/ComboboxRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ describe('<Combobox.Root />', () => {
);

it.skipIf(isJSDOM)(
'preserves a typed query when input reopens single-select during the close animation',
'makes a single-select popup inert while it closes and reopens from the trigger',
async ({ onTestFinished }) => {
globalThis.BASE_UI_ANIMATIONS_DISABLED = false;

Expand Down Expand Up @@ -529,11 +529,19 @@ describe('<Combobox.Root />', () => {
const popup = screen.getByTestId('popup');
await waitFor(() => expect(popup).toHaveAttribute('data-ending-style'));

// Once logically closed, the still-mounted subtree is inert: it is out of the tab order
// and cannot be typed into, even though it is visible for the exit animation.
expect(popup.closest('[inert]')).not.toBe(null);
input.focus();
await user.type(input, 'b', { skipClick: true });
expect(input).not.toHaveFocus();

// Reopening goes through the trigger, and the popup is interactive again afterwards.
await user.click(screen.getByTestId('trigger'));
await waitFor(() => expect(popup).not.toHaveAttribute('data-ending-style'));
expect(input).toHaveValue('apb');
expect(popup.closest('[inert]')).toBe(null);

await user.type(input, 'zz');
expect(input).toHaveValue('zz');
expect(screen.getByRole('status')).toHaveTextContent('No matches');
expect(screen.queryByRole('option')).toBe(null);
},
Expand Down Expand Up @@ -7044,7 +7052,7 @@ describe('<Combobox.Root />', () => {
);

it.skipIf(isJSDOM)(
'keeps filtered popup content stable when input changes during the close animation',
'keeps filtered popup content stable while the popup closes',
async ({ onTestFinished }) => {
globalThis.BASE_UI_ANIMATIONS_DISABLED = false;

Expand Down Expand Up @@ -7096,7 +7104,16 @@ describe('<Combobox.Root />', () => {
const popup = screen.getByTestId('popup');
await waitFor(() => expect(popup).toHaveAttribute('data-ending-style'));

await user.clear(input);
// The input is inside the closing popup, which is now inert, so a user can no longer
// change the filter while it animates out.
expect(popup.closest('[inert]')).not.toBe(null);
await act(async () => input.focus());
expect(input).not.toHaveFocus();

// `inert` blocks user interaction, not dispatched events, so the deferred filter can
// still be perturbed programmatically — the rendered list must stay as it was.
fireEvent.input(input, { target: { value: '' } });
await flushMicrotasks();

expect(screen.getByText('apple')).not.toBe(null);
expect(screen.getByText('apricot')).not.toBe(null);
Expand Down
123 changes: 123 additions & 0 deletions packages/react/src/dialog/popup/DialogPopup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -996,4 +996,127 @@ describe('<Dialog.Popup />', () => {
expect(nestedDialog).not.toHaveAttribute('data-nested-dialog-open');
});
});

// Dialog has no Positioner, so `inert` goes on the Popup itself and the focus manager's own
// guards are rendered as its siblings — outside that inert subtree. Gating the guards on `open`
// is therefore the only thing keeping them out of the tab order while a Dialog animates out.
it.skipIf(isJSDOM)(
'renders no tabbable focus guards while closing',
async ({ onTestFinished }) => {
globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
onTestFinished(() => {
globalThis.BASE_UI_ANIMATIONS_DISABLED = true;
});

const style = `
@keyframes dialog-close-test {
to {
opacity: 0;
}
}

.closing-test-dialog[data-ending-style] {
animation: dialog-close-test 5s linear;
}
`;

const { user } = await render(
<React.Fragment>
{/* eslint-disable-next-line react/no-danger */}
<style dangerouslySetInnerHTML={{ __html: style }} />
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Popup data-testid="popup" className="closing-test-dialog">
<Dialog.Close>Close</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</React.Fragment>,
);

await user.click(screen.getByRole('button', { name: 'Open' }));
const popup = screen.getByTestId('popup');

const openGuards = document.querySelectorAll('[data-base-ui-focus-guard]');
expect(openGuards.length).toBeGreaterThan(0);

await user.click(screen.getByRole('button', { name: 'Close' }));
await waitFor(() => expect(popup).toHaveAttribute('data-ending-style'));

expect(popup).toHaveAttribute('inert');

const reachableGuards = (
Array.from(document.querySelectorAll('[data-base-ui-focus-guard]')) as HTMLElement[]
).filter((guard) => guard.tabIndex >= 0 && guard.closest('[inert]') === null);
expect(reachableGuards).toHaveLength(0);

await act(async () => {
popup.getAnimations().forEach((animation) => animation.finish());
});
},
);

// `finalFocus` forms that resolve to the DEFAULT target — a callback returning `true` or
// `null`, or an empty ref — must not be treated as an explicit destination. Only a genuinely
// named element may override focus the user has already moved elsewhere.
describe.skipIf(isJSDOM)('fallback finalFocus does not override outside focus', () => {
function Test({
open,
finalFocus,
}: {
open: boolean;
finalFocus?: Dialog.Popup.Props['finalFocus'];
}) {
return (
<div>
<button data-testid="outside">outside</button>
<Dialog.Root open={open} modal={false} disablePointerDismissal>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Popup finalFocus={finalFocus}>
<button data-testid="inside">Inside</button>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</div>
);
}

async function openMoveFocusOutThenClose(finalFocus?: Dialog.Popup.Props['finalFocus']) {
const { setProps } = await render(<Test open finalFocus={finalFocus} />);
const outside = screen.getByTestId('outside');

await waitFor(() => expect(screen.getByTestId('inside')).toBeVisible());
// `disablePointerDismissal` turns off close-on-focus-out, so focus can legitimately
// move outside while the dialog stays open.
await act(async () => outside.focus());
expect(outside).toHaveFocus();

await setProps({ open: false });
await waitFor(() => expect(screen.queryByTestId('inside')).toBe(null));
return outside;
}

it('leaves focus alone for finalFocus={true}', async () => {
const outside = await openMoveFocusOutThenClose(true);
expect(outside).toHaveFocus();
});

it('leaves focus alone for a callback returning true', async () => {
const outside = await openMoveFocusOutThenClose(() => true);
expect(outside).toHaveFocus();
});

it('leaves focus alone for a callback returning null', async () => {
const outside = await openMoveFocusOutThenClose(() => null);
expect(outside).toHaveFocus();
});

it('leaves focus alone for an empty ref', async () => {
const emptyRef = { current: null } as React.RefObject<HTMLElement | null>;
const outside = await openMoveFocusOutThenClose(emptyRef);
expect(outside).toHaveFocus();
});
});
});
4 changes: 4 additions & 0 deletions packages/react/src/dialog/popup/DialogPopup.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { InteractionType } from '@base-ui/utils/useEnhancedClickHandler';
import { inertValue } from '@base-ui/utils/inertValue';
import { FloatingFocusManager } from '../../floating-ui-react';
import { useDialogRootContext } from '../root/DialogRootContext';
import { useRenderElement } from '../../internals/useRenderElement';
Expand Down Expand Up @@ -86,6 +87,9 @@ export const DialogPopup = React.forwardRef(function DialogPopup(
style: {
'--nested-dialogs': nestedOpenDialogCount,
} as React.CSSProperties,
// Dialogs have no Positioner, so the popup itself carries `inert` while it is logically
// closed but still mounted for its exit animation.
inert: inertValue(!open),
},
elementProps,
],
Expand Down
79 changes: 78 additions & 1 deletion packages/react/src/drawer/popup/DrawerPopup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Dialog } from '@base-ui/react/dialog';
import { Drawer } from '@base-ui/react/drawer';
import { SafeReact } from '@base-ui/utils/safeReact';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { act, fireEvent, screen, waitFor } from '@mui/internal-test-utils';
import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils';
import { createRenderer, describeConformance, isJSDOM } from '#test-utils';
import { useDialogRootContext } from '../../dialog/root/DialogRootContext';
import { useDrawerRootContext } from '../root/DrawerRootContext';
Expand Down Expand Up @@ -916,4 +916,81 @@ describe('<Drawer.Popup />', () => {
}
},
);

// `inert` on a closing Drawer is scoped to `!swiping` because the swipe handlers live on
// `Drawer.Viewport`, inside the popup. Going inert mid-gesture would kill a dismissal the user
// is still performing.
it.skipIf(isJSDOM)('stays interactive while a swipe is in progress after closing', async () => {
function createTouch(target: EventTarget, point: { clientX: number; clientY: number }) {
if (typeof Touch === 'function') {
return new Touch({ identifier: 1, target, ...point });
}
return point;
}

function TestCase({ open }: { open: boolean }) {
return (
<Drawer.Root open={open}>
<Drawer.Portal>
<Drawer.Viewport data-testid="viewport">
<Drawer.Popup data-testid="popup">
<button type="button" data-testid="inside">
Action
</button>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
);
}

const { setProps } = await render(<TestCase open />);
const popup = screen.getByTestId('popup');
const inside = screen.getByTestId('inside');

expect(popup).not.toHaveAttribute('inert');

const originalElementFromPoint = document.elementFromPoint;
document.elementFromPoint = () => inside;

try {
fireEvent.touchStart(inside, {
touches: [createTouch(inside, { clientX: 0, clientY: 0 })],
});
await flushMicrotasks();
expect(popup).toHaveAttribute('data-swiping', '');

// Logically closed, but the gesture is still live: the popup must remain interactive.
await setProps({ open: false });
expect(popup).toHaveAttribute('data-swiping', '');
expect(popup).not.toHaveAttribute('inert');

// That a closed Drawer with no gesture in flight *does* go inert is covered by the
// sibling test below; ending a touch gesture deterministically here would mean driving
// the snap-point settle.
} finally {
document.elementFromPoint = originalElementFromPoint;
}
});

it.skipIf(isJSDOM)('makes the popup inert once closed', async () => {
function TestCase({ open }: { open: boolean }) {
return (
<Drawer.Root open={open}>
<Drawer.Portal>
<Drawer.Viewport>
<Drawer.Popup data-testid="popup">Drawer</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
);
}

const { setProps } = await render(<TestCase open />);
const popup = screen.getByTestId('popup');
expect(popup).not.toHaveAttribute('inert');

await setProps({ open: false });
await waitFor(() => expect(popup).toHaveAttribute('inert'));
});
});
6 changes: 6 additions & 0 deletions packages/react/src/drawer/popup/DrawerPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { InteractionType } from '@base-ui/utils/useEnhancedClickHandler';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { EMPTY_OBJECT } from '@base-ui/utils/empty';
import { inertValue } from '@base-ui/utils/inertValue';
import { FloatingFocusManager } from '../../floating-ui-react';
import { useDialogRootContext } from '../../dialog/root/DialogRootContext';
import { useRenderElement } from '../../internals/useRenderElement';
Expand Down Expand Up @@ -365,6 +366,11 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup(
role,
...FOCUSABLE_POPUP_PROPS,
hidden: !mounted,
// Drawers have no Positioner, so the popup carries `inert` itself. Scoped to `!swiping`
// because `Drawer.Viewport` binds the swipe listeners to this very element
// (`useSwipeDismiss({ elementRef: popupRef })`): going inert mid-gesture would stop it
// being a pointer-event target and kill the dismissal the user is still performing.
inert: inertValue(!open && !swiping),
onKeyDown(event: React.KeyboardEvent) {
if (COMPOSITE_KEYS.has(event.key)) {
event.stopPropagation();
Expand Down
Loading
Loading