Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Documentation/Display/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,24 @@ import { Message } from '@cratis/components/Display';
<Message severity='info' icon={false}>No icon is shown.</Message>
```

To place a message inside a live region that already announces it, pass `live={false}`:

```tsx
<div role='alert' aria-label='Error summary'>
<Message severity='error' live={false}>The name is required.</Message>
</div>
```

| Prop | Type | Description |
| ----------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `severity` | `'info' \| 'success' \| 'warn' \| 'error' \| 'secondary' \| 'contrast'` | Visual and semantic tone. Defaults to `'info'`. |
| `text` | `ReactNode` | Message content when `children` is not provided. |
| `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

Expand Down
19 changes: 19 additions & 0 deletions Source/Display/Message.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<div role='alert' aria-label='Error summary'>
<Story />
</div>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getAllByRole('alert')).toHaveLength(1);
},
};

export const WithChildren: Story = {
args: {
text: undefined,
Expand Down
10 changes: 9 additions & 1 deletion Source/Display/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<MessageSeverity, string> = {
Expand All @@ -37,12 +44,13 @@ export const Message = ({
children,
className,
icon,
live = true,
}: MessageProps) => (
<div
className={['cratis-message', className].filter(Boolean).join(' ')}
data-cratis-part='root'
data-severity={severity}
role={severity === 'error' ? 'alert' : 'status'}
role={live ? (severity === 'error' ? 'alert' : 'status') : undefined}
>
{icon !== false && (
<span
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// 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';

const render = (element: React.ReactElement) => {
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(<Message severity='error' text='Failed.' />).getAttribute('role')!.should.equal('alert');
});

it('should render an info message as a status', () => {
render(<Message text='Note.' />).getAttribute('role')!.should.equal('status');
});

it('should render a warning message as a status', () => {
render(<Message severity='warn' text='Careful.' />).getAttribute('role')!.should.equal('status');
});

it('should render the same role when live is explicitly true', () => {
render(<Message severity='error' text='Failed.' live />).getAttribute('role')!.should.equal('alert');
});
});
Original file line number Diff line number Diff line change
@@ -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(
<div role='alert'>
<Message severity='error' text='The name is required.' live={false} />
<Message severity='info' text='Try again.' live={false} />
</div>,
);
});

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.');
});
});
2 changes: 1 addition & 1 deletion Source/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown, string | import(\"react\").JSXElementConstructor<any>>",
"ProgressBarProps": "export interface ProgressBarProps { value?: number; mode?: 'determinate' | 'indeterminate'; showValue?: boolean; 'aria-label'?: string; 'aria-labelledby'?: string; className?: string; }",
Expand Down
1 change: 1 addition & 0 deletions Storybook/scripts/storybook-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading