import * as stylex from '@stylexjs/stylex'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { MosaicComponentProps } from '../../../props'; import { colorVars, radiusVars, space } from '../../../tokens.stylex'; import { Card } from '../../card'; import type { DialogVariant } from '../dialog'; import { Dialog } from '../dialog'; import { styles as dialogStyles } from '../dialog.styles'; afterEach(() => cleanup()); // Every dialog brings its own surface, and the surface is what names it: `Card.Title` takes the // id the popup points `aria-labelledby` at. These tests need nothing else from it. function Surface({ title, description }: { title: string; description?: string }) { return ( {title} {description ? {description} : null} ); } // The accessible-name warning defers by a task, so the assertions have to let one elapse. const settle = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); const nativeTrigger = (label: string) => (props: MosaicComponentProps<'button'>) => ( ); describe('Mosaic Dialog', () => { it('renders the trigger and opens the dialog on click', async () => { const user = userEvent.setup(); render( Body , ); expect(screen.queryByText('Body')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Open' })); expect(screen.getByText('Body')).toBeInTheDocument(); }); it('renders the whole floating tree from the popup: backdrop, viewport and popup carry the slots', () => { render(
Body
, ); expect(document.querySelector('.cl-dialog-backdrop')).toBeInTheDocument(); expect(document.querySelector('.cl-dialog-viewport')).toBeInTheDocument(); expect(document.querySelector('.cl-dialog-track')).toBeInTheDocument(); expect(document.querySelector('.cl-dialog-popup')).toBeInTheDocument(); // Portalled: the tree lands in the body, not where the root sits. expect(screen.getByTestId('host')).not.toContainElement(document.querySelector('.cl-dialog-viewport')); }); it('defaults the popup to the card variant and reflects it as data-variant', () => { render( Body , ); expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-variant', 'card'); }); it('reflects an explicit variant as data-variant on the popup and the viewport', () => { render( Body , ); expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-variant', 'profile'); expect(document.querySelector('.cl-dialog-viewport')).toHaveAttribute('data-variant', 'profile'); }); it('reflects the fullscreen variant as data-variant on the popup and the viewport', () => { render( Body , ); expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-variant', 'fullscreen'); expect(document.querySelector('.cl-dialog-viewport')).toHaveAttribute('data-variant', 'fullscreen'); }); it('composes consumer xstyle onto the popup', () => { const caller = stylex.create({ popup: { marginTop: '8px' } }); render( Body , ); expect(document.querySelector('.cl-dialog-popup')).toHaveClass( 'cl-dialog-popup', stylex.props(caller.popup).className ?? '', ); }); it('merges the className a render source hands the popup', () => { render( }>Body , ); expect(document.querySelector('.cl-dialog-popup')).toHaveClass('cl-dialog-popup', 'from-render'); }); it('closes on Dialog.Close, reporting it through onOpenChange', async () => { const user = userEvent.setup(); const onOpenChange = vi.fn(); render( Dismiss , ); await user.click(screen.getByRole('button', { name: 'Dismiss' })); expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); expect(screen.queryByRole('button', { name: 'Dismiss' })).not.toBeInTheDocument(); }); it('names the dialog from the card inside it', () => { render( , ); expect(screen.getByRole('dialog', { name: 'Confirm action' })).toBeInTheDocument(); }); it('forwards the ref to the popup element', () => { const ref = React.createRef(); render( Body , ); expect(ref.current).toBe(document.querySelector('.cl-dialog-popup')); }); }); describe('shouldCloseOnInteractOutside', () => { function Verify({ shouldCloseOnInteractOutside }: { shouldCloseOnInteractOutside?: boolean }) { return ( ); } it('closes on an outside press by default', async () => { const user = userEvent.setup(); render(); await user.click(document.querySelector('.cl-dialog-backdrop') as HTMLElement); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('stays open on an outside press when false', async () => { const user = userEvent.setup(); render(); await user.click(document.querySelector('.cl-dialog-backdrop') as HTMLElement); expect(screen.getByRole('dialog', { name: 'Verify it is you' })).toBeInTheDocument(); }); it('still closes on Escape when false', async () => { const user = userEvent.setup(); render(); await user.keyboard('{Escape}'); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('keeps the corner dismiss when false', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: 'Close' })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); }); // A `profile` dialog (account profile) opening a `card` dialog (add an email address) is a real // shape, so the `FloatingTree` nesting the headless README claims is exercised here rather than // assumed. Dismissal must reach the topmost dialog only, and the body must stay locked until the // last one closes. describe('nested Mosaic Dialogs', () => { function Nested({ innerVariant }: { innerVariant?: DialogVariant } = {}) { return (
Outer body
Inner body
); } it('opens an inner dialog from inside an outer one', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: 'Add email' })); expect(screen.getByText('Inner body')).toBeInTheDocument(); expect(screen.getByText('Outer body')).toBeInTheDocument(); }); it('closes only the inner dialog on Escape, then the outer', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: 'Add email' })); await user.keyboard('{Escape}'); expect(screen.queryByText('Inner body')).not.toBeInTheDocument(); expect(screen.getByText('Outer body')).toBeInTheDocument(); await user.keyboard('{Escape}'); expect(screen.queryByText('Outer body')).not.toBeInTheDocument(); }); it('closes only the inner dialog when its backdrop is pressed', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: 'Add email' })); const backdrops = document.querySelectorAll('.cl-dialog-backdrop'); expect(backdrops).toHaveLength(2); await user.click(backdrops[1]); expect(screen.queryByText('Inner body')).not.toBeInTheDocument(); expect(screen.getByText('Outer body')).toBeInTheDocument(); }); it('keeps the body scroll-locked until the last dialog closes', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: 'Add email' })); expect(document.body.style.overflowY).toBe('hidden'); await user.keyboard('{Escape}'); expect(document.body.style.overflowY).toBe('hidden'); await user.keyboard('{Escape}'); expect(document.body.style.overflowY).toBe(''); }); it('warns when a profile opens inside another dialog', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: 'Add email' })); expect(warn).toHaveBeenCalledWith(expect.stringContaining('variant="profile"')); }); it('warns when a fullscreen profile opens inside another dialog', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: 'Add email' })); expect(warn).toHaveBeenCalledWith(expect.stringContaining('variant="fullscreen"')); }); // A card over a profile is the delete-account confirmation: a `Card` inside a `card` dialog. it('does not warn for a card over a profile, or for the profile itself', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: 'Add email' })); expect(warn).not.toHaveBeenCalled(); }); }); describe('stacked backdrops', () => { async function innerBackdropClasses(host: { variant?: DialogVariant }) { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole('button', { name: 'Add email' })); // The host paints one too, so the inner dialog's is the last. const backdrops = document.querySelectorAll('.cl-dialog-backdrop'); const classes = Array.from(backdrops[backdrops.length - 1].classList); cleanup(); return classes; } it('drops the scrim for a card over a card, and keeps it for one over a profile', async () => { const overCard = await innerBackdropClasses({ variant: 'card' }); const overPanel = await innerBackdropClasses({ variant: 'profile' }); expect(overCard).toEqual(expect.arrayContaining(atomFor(dialogStyles.backdropStacked))); expect(overCard).not.toEqual(expect.arrayContaining(atomFor(dialogStyles.backdrop))); expect(overPanel).toEqual(expect.arrayContaining(atomFor(dialogStyles.backdrop))); expect(overPanel).not.toEqual(expect.arrayContaining(atomFor(dialogStyles.backdropStacked))); }); it('marks the popup beneath as the stack base, so it can recede', async () => { const user = userEvent.setup(); render( , ); const outerPopup = document.querySelector('.cl-dialog-popup'); expect(outerPopup).not.toHaveAttribute('data-stack-base'); await user.click(screen.getByRole('button', { name: 'Add email' })); const popups = document.querySelectorAll('.cl-dialog-popup'); expect(popups[0]).toHaveAttribute('data-stack-base', ''); expect(popups[1]).not.toHaveAttribute('data-stack-base'); }); }); describe('Dialog.CloseButton', () => { it('closes the dialog and carries a default accessible name', async () => { const user = userEvent.setup(); render(
Body
, ); const close = screen.getByRole('button', { name: 'Close' }); expect(close).toHaveClass('cl-dialog-close-button'); await user.click(close); expect(screen.queryByText('Body')).not.toBeInTheDocument(); }); it('takes an overridable label, ready for a localized string', () => { render( , ); expect(screen.getByRole('button', { name: 'Fermer' })).toBeInTheDocument(); }); it('is the first tabbable element when rendered first — see initialFocus', async () => { render( , ); // Pinning the default: a corner X rendered before the form is what the dialog opens // focused on unless `initialFocus` on `Dialog.Popup` says otherwise (next test). // `FloatingFocusManager` moves focus in an effect, hence the wait. await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus()); }); it('warns inside an alert dialog, where a corner X is a way out without answering', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( , ); expect(warn).toHaveBeenCalledWith(expect.stringContaining('alert dialog')); }); }); describe('composition APIs', () => { it('opens from a detached trigger through a handle', async () => { const user = userEvent.setup(); const handle = Dialog.createHandle(); render( <> Open detached , ); await user.click(screen.getByRole('button', { name: 'Open detached' })); expect(screen.getByRole('dialog', { name: 'Detached' })).toBeInTheDocument(); }); it('renders per-trigger content from the payload', async () => { const user = userEvent.setup(); const handle = Dialog.createHandle(); render( <> Open A {({ payload }) => ( )} , ); await user.click(screen.getByRole('button', { name: 'Open A' })); expect(screen.getByRole('dialog', { name: 'from-a' })).toBeInTheDocument(); }); it('initialFocus on the popup redirects the open focus past the close button', async () => { function Fixture() { const inputRef = React.useRef(null); return ( ); } render(); await waitFor(() => expect(screen.getByRole('textbox', { name: 'Email' })).toHaveFocus()); }); }); // A probe gives us atoms to look for without hard-coding a hash. StyleX dedupes by property // within one `stylex.props` call, so a variant atom should REPLACE a base one rather than sit // alongside it — and a `null` should remove it outright. const atomFor = (style: Parameters[0]) => stylex .props(style) .className!.split(' ') .filter(name => !name.includes('__')); const classesOf = (selector: string) => Array.from(document.querySelector(selector)!.classList); function renderVariant(variant: DialogVariant) { return render( , ); } describe('popup padding', () => { const probe = stylex.create({ zero: { padding: space['0'] }, four: { padding: space['4'] }, six: { padding: space['6'] }, }); const popupClassesFor = (variant: DialogVariant) => { const { unmount } = renderVariant(variant); const classes = classesOf('.cl-dialog-popup'); unmount(); return classes; }; // A `card` takes its padding from the `Card` rendered inside the popup, and a `profile` from // the `Profile`, so the popup must emit NO padding atom at all — a competing value would put // two atoms for the same property on the element, and StyleX cannot dedupe across the two // `stylex.props` calls involved. it.each(['card', 'profile'] as const)('emits no padding at all for a %s, deferring to its surface', variant => { const classes = popupClassesFor(variant); for (const value of [probe.zero, probe.four, probe.six]) { expect(classes).not.toEqual(expect.arrayContaining(atomFor(value))); } }); }); describe('popup surface', () => { // `card` and `profile` are painted by what renders as the popup, so the popup itself must emit // no paint of its own — the same cross-call dedupe problem as the padding above. StyleX names // an atom from its property and value, so a probe with the popup's own values yields the very // atoms `styles.popup` declares. const probe = stylex.create({ background: { backgroundColor: colorVars['--cl-color-page'] }, radius: { borderRadius: radiusVars['--cl-radius-xl'] }, }); it.each(['card', 'profile'] as const)('emits no background for a %s, deferring to its surface', variant => { renderVariant(variant); expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.background))); }); // The radius is the exception to "the popup does not paint": the stacking veil and the // forced-colors border are drawn by the popup and have to follow the card's corners. it('keeps the radius for a card, which the veil and the forced-colors edge trace', () => { renderVariant('card'); expect(classesOf('.cl-dialog-popup')).toEqual(expect.arrayContaining(atomFor(probe.radius))); }); it('leaves the radius to the surface for a profile, which owns its corners at every band', () => { renderVariant('profile'); expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.radius))); }); }); // The sheet is the compact band's bottom anchor: the geometry that used to ride on the `prompt` // size, now asked for by name. Above the band it resolves back to a centered card, so what the // atoms pin is that the placement reaches the popup and the track at all. describe('compactPlacement', () => { // Written out rather than imported: an atom is named from its property, value AND condition, so // the probe only yields the popup's own atom if the query string matches `dialog.styles.ts` // exactly. A drift here shows up as a failing test rather than as a silently empty assertion. const SHEET = '@media (width < 40rem)'; const probe = stylex.create({ anchored: { alignSelf: { [SHEET]: 'end', default: null } }, clipped: { overflow: { [SHEET]: 'clip', default: null } }, }); const renderPlacement = (compactPlacement: 'center' | 'sheet' | undefined, variant: DialogVariant = 'card') => render( , ); it('makes a card a sheet by default', () => { renderPlacement(undefined); expect(classesOf('.cl-dialog-popup')).toEqual(expect.arrayContaining(atomFor(probe.anchored))); }); it('centers when asked, anchoring nothing to the bottom edge', () => { renderPlacement('center'); expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.anchored))); expect(classesOf('.cl-dialog-track')).not.toEqual(expect.arrayContaining(atomFor(probe.clipped))); }); it('anchors the sheet to the bottom edge and clips the track it slides through', () => { renderPlacement('sheet'); expect(classesOf('.cl-dialog-popup')).toEqual(expect.arrayContaining(atomFor(probe.anchored))); expect(classesOf('.cl-dialog-track')).toEqual(expect.arrayContaining(atomFor(probe.clipped))); }); // A profile fills the compact band, with no room to be anchored anywhere else. it('ignores a placement on a profile, and warns', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); renderPlacement('sheet', 'profile'); expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.anchored))); expect(warn).toHaveBeenCalledWith(expect.stringContaining('takes no placement')); }); it('warns when a fullscreen profile is given a placement', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); renderPlacement('sheet', 'fullscreen'); expect(warn).toHaveBeenCalledWith(expect.stringContaining('variant="fullscreen"')); }); it('keeps a profile centered by default without warning', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); renderPlacement(undefined, 'profile'); expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.anchored))); expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('takes no placement')); }); }); describe('viewport scroll behaviour', () => { // The inside/outside scroll split. A pinned `height: 100%` cannot grow, so an over-tall popup // spills past the viewport's padding box and loses the bottom inset; `min-height: 100%` lets the // box grow with it. Which one applies follows from the variant, so what this pins is that the // viewport reads the variant at all — a regression here is silent, since both values look right // until the content is taller than the screen. const probe = stylex.create({ fixed: { height: '100%' }, grows: { minHeight: '100%' }, }); const viewportClassesFor = (variant: DialogVariant) => { const { unmount } = renderVariant(variant); const classes = classesOf('.cl-dialog-viewport'); unmount(); return classes; }; it('lets the viewport grow for a card, so the inset survives', () => { const viewport = viewportClassesFor('card'); expect(viewport).toEqual(expect.arrayContaining(atomFor(probe.grows))); expect(viewport).not.toEqual(expect.arrayContaining(atomFor(probe.fixed))); }); it('pins the viewport for a profile, which scrolls inside instead', () => { const viewport = viewportClassesFor('profile'); expect(viewport).toEqual(expect.arrayContaining(atomFor(probe.fixed))); expect(viewport).not.toEqual(expect.arrayContaining(atomFor(probe.grows))); }); }); describe('sizing container', () => { // The width bands are container queries against the viewport, so the viewport has to BE a // container — drop that and every band silently stops matching, at every width. const probe = stylex.create({ container: { containerName: 'cl-dialog', containerType: 'inline-size' }, // The phone-band side inset, one of the rules that queries the container. phoneSides: { paddingInline: { default: space['4'], '@container cl-dialog (max-width: 47.99rem)': null } }, }); it('makes the viewport the named inline-size container the bands query', () => { renderVariant('card'); expect(classesOf('.cl-dialog-viewport')).toEqual(expect.arrayContaining(atomFor(probe.container))); }); // An element is never its own query container. A band declared on the viewport would resolve // against an OUTER dialog's container, or nothing — so every banded rule has to sit on the track // inside it, and none may sit on the viewport. it('keeps every banded rule inside the container, on the track', () => { renderVariant('card'); const viewport = document.querySelector('.cl-dialog-viewport')!; const track = document.querySelector('.cl-dialog-track')!; expect(viewport).toContainElement(track); expect(Array.from(track.classList)).toEqual(expect.arrayContaining(atomFor(probe.phoneSides))); expect(Array.from(viewport.classList)).not.toEqual(expect.arrayContaining(atomFor(probe.phoneSides))); }); }); describe('accessible name warning', () => { it.each([ ['card', 'Card.Title'], ['profile', 'Profile.Title'], ] as const)('recommends the title part for an unnamed %s dialog', async (variant, titlePart) => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( Body , ); await settle(); expect(warn).toHaveBeenCalledWith(expect.stringContaining('no accessible name')); expect(warn).toHaveBeenCalledWith(expect.stringContaining(`<${titlePart}>`)); }); it('does not warn when the card inside it supplies the name', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( , ); await settle(); expect(warn).not.toHaveBeenCalled(); }); // The name can come from the consumer instead of from a Title, and an `aria-label` on the popup // is the documented way to do that. it('does not warn when aria-label supplies the name', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( Body , ); await settle(); expect(warn).not.toHaveBeenCalled(); }); });