diff --git a/Documentation/Display/index.md b/Documentation/Display/index.md index 9b4f508c..d9489caa 100644 --- a/Documentation/Display/index.md +++ b/Documentation/Display/index.md @@ -102,6 +102,14 @@ import { Message } from '@cratis/components/Display'; No icon is shown. ``` +To place a message inside a live region that already announces it, pass `live={false}`: + +```tsx +
+ The name is required. +
+``` + | Prop | Type | Description | | ----------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `severity` | `'info' \| 'success' \| 'warn' \| 'error' \| 'secondary' \| 'contrast'` | Visual and semantic tone. Defaults to `'info'`. | @@ -109,8 +117,9 @@ import { Message } from '@cratis/components/Display'; | `children` | `ReactNode` | Message content; takes precedence over `text`. | | `icon` | `ReactNode \| false` | Custom leading icon. Pass `false` to hide the icon; otherwise a severity symbol is used by default. | | `className` | `string` | Extra CSS class on the root. | +| `live` | `boolean` | Whether the message is its own live region. Defaults to `true`; pass `false` for no `role`. | -Error messages use `role='alert'`; every other severity uses `role='status'`. The decorative icon is hidden from assistive technology. Stable `root`, `icon`, and `text` `data-cratis-part` markers are available for styling and tests. +By default, error messages use `role='alert'` and every other severity uses `role='status'`. With `live={false}` the message renders without a `role`, so it is announced only by the live region around it; do not use it for a message that stands alone. Nesting a live message inside another live region makes screen readers announce it twice. The decorative icon is hidden from assistive technology. Stable `root`, `icon`, and `text` `data-cratis-part` markers are available for styling and tests. ## ProgressSpinner diff --git a/Source/Display/Message.stories.tsx b/Source/Display/Message.stories.tsx index c6e2d56f..7df82d74 100644 --- a/Source/Display/Message.stories.tsx +++ b/Source/Display/Message.stories.tsx @@ -33,6 +33,25 @@ export const Error: Story = { }, }; +/** + * Inside an existing live region, such as a focused error summary, pass `live={false}` so the + * message renders as a plain element and is announced once, by the outer region. + */ +export const InsideALiveRegion: Story = { + args: { severity: 'error', text: 'The name is required.', live: false }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getAllByRole('alert')).toHaveLength(1); + }, +}; + export const WithChildren: Story = { args: { text: undefined, diff --git a/Source/Display/Message.tsx b/Source/Display/Message.tsx index a2a3042b..c29f1af9 100644 --- a/Source/Display/Message.tsx +++ b/Source/Display/Message.tsx @@ -19,6 +19,13 @@ export interface MessageProps { className?: string; /** The icon shown ahead of the text. Pass `false` for no icon. */ icon?: ReactNode | false; + /** + * Whether the message is its own live region, announced by assistive technology when it + * appears. Defaults to `true`: `severity="error"` renders `role="alert"`, every other severity + * `role="status"`. Pass `false` to render a plain element with no role when the message sits + * inside a live region that already announces it, so it is not announced twice. + */ + live?: boolean; } const severitySymbols: Record = { @@ -37,12 +44,13 @@ export const Message = ({ children, className, icon, + live = true, }: MessageProps) => (
{icon !== false && ( { + const container = document.createElement('div'); + container.innerHTML = renderToStaticMarkup(element); + return container.firstElementChild as Element; +}; + +describe('when rendering with the default live region', () => { + it('should render an error message as an alert', () => { + render().getAttribute('role')!.should.equal('alert'); + }); + + it('should render an info message as a status', () => { + render().getAttribute('role')!.should.equal('status'); + }); + + it('should render a warning message as a status', () => { + render().getAttribute('role')!.should.equal('status'); + }); + + it('should render the same role when live is explicitly true', () => { + render().getAttribute('role')!.should.equal('alert'); + }); +}); diff --git a/Source/Display/for_Message/when_rendering_without_a_live_region.tsx b/Source/Display/for_Message/when_rendering_without_a_live_region.tsx new file mode 100644 index 00000000..2310f8d6 --- /dev/null +++ b/Source/Display/for_Message/when_rendering_without_a_live_region.tsx @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { renderToStaticMarkup } from 'react-dom/server'; +import { Message } from '../Message'; + +describe('when rendering without a live region', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + container.innerHTML = renderToStaticMarkup( +
+ + +
, + ); + }); + + it('should render no role on the messages', () => { + container.querySelectorAll('[data-cratis-part="root"][role]').length.should.equal(0); + }); + + it('should leave only the outer live region', () => { + container.querySelectorAll('[role]').length.should.equal(1); + }); + + it('should still render the message content and severity', () => { + const root = container.querySelector('[data-cratis-part="root"]')!; + root.getAttribute('data-severity')!.should.equal('error'); + root.textContent!.should.contain('The name is required.'); + }); +}); diff --git a/Source/api-surface.json b/Source/api-surface.json index f407eadd..6e8164d4 100644 --- a/Source/api-surface.json +++ b/Source/api-surface.json @@ -444,7 +444,7 @@ "Chip": "Chip: (props: ChipProps) => import(\"react\").JSX.Element", "ChipProps": "export interface ChipProps { label?: string; icon?: ReactNode; removable?: boolean; onRemove?: () => void; removeAriaLabel?: string; className?: string; }", "Message": "Message: (props: MessageProps) => import(\"react\").JSX.Element", - "MessageProps": "export interface MessageProps { severity?: MessageSeverity; text?: ReactNode; children?: ReactNode; className?: string; icon?: ReactNode | false; }", + "MessageProps": "export interface MessageProps { severity?: MessageSeverity; text?: ReactNode; children?: ReactNode; className?: string; icon?: ReactNode | false; live?: boolean; }", "MessageSeverity": "export type MessageSeverity = 'info' | 'success' | 'warn' | 'error' | 'secondary' | 'contrast';", "ProgressBar": "ProgressBar: (props: ProgressBarProps) => import(\"react\").ReactElement>", "ProgressBarProps": "export interface ProgressBarProps { value?: number; mode?: 'determinate' | 'indeterminate'; showValue?: boolean; 'aria-label'?: string; 'aria-labelledby'?: string; className?: string; }", diff --git a/Storybook/scripts/storybook-inventory.json b/Storybook/scripts/storybook-inventory.json index 276df28a..e54ecdc0 100644 --- a/Storybook/scripts/storybook-inventory.json +++ b/Storybook/scripts/storybook-inventory.json @@ -532,6 +532,7 @@ "display-chip--with-icon", "display-message--error", "display-message--info", + "display-message--inside-a-live-region", "display-message--no-icon", "display-message--success", "display-message--warn",