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 void;
unregisterFocusTarget: (element: HTMLElement) => void;
}
export const FlowStepContext = createContext(null);
+export function useFlowStepActive(): boolean {
+ return useContext(FlowStepContext)?.active ?? true;
+}
+
/**
* Marks an element as the one to focus when the enclosing `Flow.Step` enters.
* When several mounted elements are marked, the first in DOM order is focused. Outside a
diff --git a/packages/mosaic/src/primitives/flow/flow-step.tsx b/packages/mosaic/src/primitives/flow/flow-step.tsx
index bbe6c145361..bd6c8c9b835 100644
--- a/packages/mosaic/src/primitives/flow/flow-step.tsx
+++ b/packages/mosaic/src/primitives/flow/flow-step.tsx
@@ -6,7 +6,7 @@ import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
import { useTransition } from '../hooks/use-transition';
import { type ComponentProps, mergeProps, useRender } from '../utils';
import { useFlowContext } from './flow-context';
-import { FlowStepContext, type FlowStepContextValue } from './flow-step-context';
+import { FlowStepContext, type FlowStepContextValue, useFlowStepActive } from './flow-step-context';
export interface FlowStepProps extends ComponentProps<'div'> {
ids: readonly string[];
@@ -29,6 +29,7 @@ function firstInDocumentOrder(elements: Iterable): HTMLElement | nu
export const FlowStep = React.forwardRef(function FlowStep(props, forwardedRef) {
const { render, ids, children, ...otherProps } = props;
+ const parentActive = useFlowStepActive();
const {
value,
direction,
@@ -98,8 +99,8 @@ export const FlowStep = React.forwardRef(function
focusTargetsRef.current.delete(element);
}, []);
const stepContext = useMemo(
- () => ({ registerFocusTarget, unregisterFocusTarget }),
- [registerFocusTarget, unregisterFocusTarget],
+ () => ({ active: open && parentActive, registerFocusTarget, unregisterFocusTarget }),
+ [open, parentActive, registerFocusTarget, unregisterFocusTarget],
);
const effectiveTransitionProps = !hasBeenClosed.current
diff --git a/packages/mosaic/src/primitives/flow/index.ts b/packages/mosaic/src/primitives/flow/index.ts
index 15479cf7891..200f8b9a5a7 100644
--- a/packages/mosaic/src/primitives/flow/index.ts
+++ b/packages/mosaic/src/primitives/flow/index.ts
@@ -1,4 +1,4 @@
export * as Flow from './parts';
-export { useFlowAutoFocus } from './flow-step-context';
+export { useFlowAutoFocus, useFlowStepActive } from './flow-step-context';
export type { FlowDirection, FlowRootProps, FlowStepProps } from './parts';