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",