diff --git a/.changeset/mosaic-dialog-step-title.md b/.changeset/mosaic-dialog-step-title.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-dialog-step-title.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/card/card.test.tsx b/packages/mosaic/src/components/card/card.test.tsx index f169d9cef20..e218d9f8d6d 100644 --- a/packages/mosaic/src/components/card/card.test.tsx +++ b/packages/mosaic/src/components/card/card.test.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Dialog } from '../dialog'; +import { Flow } from '../flow'; import { Card } from './card'; const compactCard = '@container card (max-width: 20rem)' as const; @@ -41,6 +42,110 @@ function stubPrototype(target: object, name: string, descriptor: PropertyDescrip }); } +function StepCard({ title, description }: { title: string; description: string }) { + return ( + + + {title} + {description} + + + ); +} + +function SteppedDialog({ step }: { step: string }) { + return ( + + + + {() => ( + <> + + + + + + + + )} + + + + ); +} + +function NestedSteppedDialog({ step }: { step: string }) { + return ( + + + + {() => ( + <> + + + {() => ( + + + + )} + + + + + + + )} + + + + ); +} + +function holdExitAnimations() { + let finish = () => undefined as void; + const finished = new Promise(resolve => { + finish = resolve; + }); + const getAnimations = vi.fn(() => [{ finished }]); + stubPrototype(Element.prototype, 'getAnimations', { value: getAnimations }); + return async () => { + getAnimations.mockReturnValue([]); + await act(async () => { + finish(); + await finished; + }); + }; +} + +function expectSingleOwner(popup: HTMLElement) { + for (const attribute of ['aria-labelledby', 'aria-describedby']) { + const id = popup.getAttribute(attribute); + expect(id).toBeTruthy(); + expect(document.querySelectorAll(`[id="${id}"]`)).toHaveLength(1); + } +} + describe('Mosaic Card', () => { afterEach(() => { restores.splice(0).forEach(restore => restore()); @@ -473,6 +578,109 @@ describe('Mosaic Card', () => { expect(screen.getByTestId('title')).toHaveAttribute('id', 'custom-title'); }); + it('names the dialog after the entering step while the previous step exits', async () => { + const finishExit = holdExitAnimations(); + const { rerender } = render(); + + rerender(); + + const popup = screen.getByRole('dialog'); + expect(screen.getByText('Add new API key')).toBeInTheDocument(); + expect(popup).toHaveAccessibleName('Copy your API key'); + expect(popup).toHaveAccessibleDescription('You will not see it again.'); + expectSingleOwner(popup); + + await finishExit(); + expect(screen.queryByText('Add new API key')).toBeNull(); + expect(popup).toHaveAccessibleName('Copy your API key'); + }); + + it('names the dialog after the entering step when the exiting step holds a nested flow', async () => { + const finishExit = holdExitAnimations(); + const { rerender } = render(); + + rerender(); + + const popup = screen.getByRole('dialog'); + expect(screen.getByText('Set up SMS verification')).toBeInTheDocument(); + expect(popup).toHaveAccessibleName('Save your backup codes'); + expect(popup).toHaveAccessibleDescription('Store them somewhere safe.'); + expectSingleOwner(popup); + + await finishExit(); + }); + + it('names the dialog after the returning step when it comes back mid-exit', async () => { + const finishExit = holdExitAnimations(); + const { rerender } = render(); + + rerender(); + rerender(); + + const popup = screen.getByRole('dialog'); + expect(popup).toHaveAccessibleName('Add new API key'); + expect(popup).toHaveAccessibleDescription('Name the key.'); + expectSingleOwner(popup); + + await finishExit(); + }); + + it('names the dialog after the new step when nothing animates', () => { + const { rerender } = render(); + + rerender(); + + const popup = screen.getByRole('dialog'); + expect(screen.queryByText('Add new API key')).toBeNull(); + expect(popup).toHaveAccessibleName('Copy your API key'); + expectSingleOwner(popup); + }); + + it('leaves the stacked dialog named by its own step while the one beneath it steps', async () => { + const finishExit = holdExitAnimations(); + const outer = (step: string) => ( + + + + + + + ); + const { rerender } = render(outer('create')); + + rerender(outer('copy')); + + const [settings, stepped] = screen.getAllByRole('dialog', { hidden: true }); + expect(settings).toHaveAccessibleName('Settings'); + expect(stepped).toHaveAccessibleName('Copy your API key'); + expectSingleOwner(settings); + expectSingleOwner(stepped); + + await finishExit(); + }); + + it('leaves a card in a step outside a dialog unidentified', () => { + render( + + {() => ( + + + Add new API key + + + )} + , + ); + + expect(screen.getByTestId('title')).not.toHaveAttribute('id'); + }); + it('carries the dialog dismiss button in the header', async () => { const user = userEvent.setup(); render( diff --git a/packages/mosaic/src/components/card/card.tsx b/packages/mosaic/src/components/card/card.tsx index bf9e6c7b04a..43b71df448f 100644 --- a/packages/mosaic/src/components/card/card.tsx +++ b/packages/mosaic/src/components/card/card.tsx @@ -1,6 +1,7 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import { useFlowStepActive } from '../../primitives/flow'; import { useTransition } from '../../primitives/hooks'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps } from '../../props'; @@ -146,6 +147,7 @@ const Title = React.forwardRef>(f ref, ) { const dialog = React.useContext(DialogContext); + const stepActive = useFlowStepActive(); return useRender({ defaultTagName: 'h2', render, @@ -154,7 +156,7 @@ const Title = React.forwardRef>(f ...mergeStyleProps(themeProps('card-title'), stylex.props(reset.base, slots.header.title, xstyle), rest), // The popup points `aria-labelledby` at this id, so the surface outranks the caller: an id // that displaced it would leave the dialog with no accessible name. - ...(dialog && { id: dialog.labelId }), + ...(dialog && stepActive && { id: dialog.labelId }), }, }); }); @@ -165,6 +167,7 @@ const Description = React.forwardRef