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
2 changes: 1 addition & 1 deletion i18n/keys.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
},
"message_callout.dismiss": {
"defaultMessage": "Dismiss",
"description": "ARIA-label for the dismiss button at the top of the Modal."
"description": "ARIA-label for the dismiss button at the top of the MessageCallout."
},
"modal.close": {
"defaultMessage": "Close",
Expand Down
21 changes: 19 additions & 2 deletions src/components/messages/MessageCallout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import styled from '@emotion/styled';
import { forwardRef, PropsWithChildren, ReactNode, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { DismissButton } from '~common/components/DismissButton';
import { isDefined } from '~common/helpers/types';
import { LiveRegionAnnouncementMode } from '~types/LiveRegionAnnouncementMode';
import { MessageScreenReaderPrefix } from './MessageScreenReaderPrefix';
import {
MESSAGE_CALLOUT_VARIETY_STYLE,
Expand All @@ -39,16 +41,31 @@ import { cssVar } from '~utils/design-tokens';

export interface MessageCalloutProps extends PropsWithChildren {
action?: ReactNode;
/**
* Adds `status` or `alert` live-region semantics to the rendered message.
* Defaults to no explicit live-region semantics.
* Set `status` for contextual status updates or `alert` for urgent messages.
* Screen reader announcements are most reliable when an already-mounted
* message updates its content.
*/
announcementMode?: `${LiveRegionAnnouncementMode}`;
className?: string;
onDismiss?: VoidFunction;
screenReaderPrefix?: string;
title?: string;
variety: `${MessageVariety}`;
}

/**
* A contextual message block that stands apart from surrounding content.
* Use it for section-level feedback that may need a title, an action, or a
* dismiss button. Set `announcementMode` only when the message should also be
* announced by assistive technology.
*/
export const MessageCallout = forwardRef<HTMLDivElement, MessageCalloutProps>((props, ref) => {
const {
action,
announcementMode,
children,
className,
onDismiss,
Expand All @@ -72,7 +89,7 @@ export const MessageCallout = forwardRef<HTMLDivElement, MessageCalloutProps>((p
{MESSAGE_VARIETY_ICON[variety]}
</MessageCalloutIconWrapper>

<MessageCalloutTextWrapper>
<MessageCalloutTextWrapper role={announcementMode}>
<MessageScreenReaderPrefix screenReaderPrefix={screenReaderPrefix} variety={variety} />

{isDefined(title) && <MessageCalloutTitleWrapper>{title}</MessageCalloutTitleWrapper>}
Expand All @@ -85,7 +102,7 @@ export const MessageCallout = forwardRef<HTMLDivElement, MessageCalloutProps>((p
ariaLabel={intl.formatMessage({
id: 'message_callout.dismiss',
defaultMessage: 'Dismiss',
description: 'ARIA-label for the dismiss button at the top of the Modal.',
description: 'ARIA-label for the dismiss button at the top of the MessageCallout.',
})}
onClick={onDismiss}
/>
Expand Down
33 changes: 30 additions & 3 deletions src/components/messages/MessageInline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,25 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import styled from '@emotion/styled';
import { forwardRef, PropsWithChildren, useMemo } from 'react';
import { LiveRegionAnnouncementMode } from '~types/LiveRegionAnnouncementMode';
import { MessageScreenReaderPrefix } from './MessageScreenReaderPrefix';
import { MESSAGE_VARIETY_ICON } from './MessageStyles';
import { MessageInlineSize, MessageVariety } from './MessageTypes';

import { cssVar } from '~utils/design-tokens';

export interface MessageInlineProps {
/**
* Adds `status` or `alert` live-region semantics to the rendered message.
* Defaults to no explicit live-region semantics.
* Set `status` for contextual status updates or `alert` for urgent messages.
* Screen reader announcements are most reliable when an already-mounted
* message updates its content.
*/
announcementMode?: `${LiveRegionAnnouncementMode}`;
as?: 'div' | 'span';
className?: string;
id?: string;
Expand All @@ -34,9 +44,24 @@ export interface MessageInlineProps {
variety: `${MessageVariety}`;
}

export const MessageInline = forwardRef<HTMLDivElement, PropsWithChildren<MessageInlineProps>>(
/**
* A compact message that flows inline with surrounding text or controls.
* Use it for short contextual feedback tied to nearby content. Set
* `announcementMode` only when the message should also be announced by
* assistive technology.
*/
export const MessageInline = forwardRef<HTMLElement, PropsWithChildren<MessageInlineProps>>(
(props, ref) => {
const { children, className, screenReaderPrefix, size, variety, ...radixProps } = props;
const {
announcementMode,
children,
className,
screenReaderPrefix,
size,
variety,
...radixProps
} = props;

return (
<MessageInlineContainer
className={className}
Expand All @@ -45,8 +70,10 @@ export const MessageInline = forwardRef<HTMLDivElement, PropsWithChildren<Messag
size={size}
{...radixProps}>
<span>{MESSAGE_VARIETY_ICON[variety]}</span>
<MessageInlineTextWrapper>

<MessageInlineTextWrapper role={announcementMode}>
<MessageScreenReaderPrefix screenReaderPrefix={screenReaderPrefix} variety={variety} />

{children}
</MessageInlineTextWrapper>
</MessageInlineContainer>
Expand Down
34 changes: 32 additions & 2 deletions src/components/messages/__tests__/MessageCallout-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,51 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import { screen } from '@testing-library/react';
import { screen, within } from '@testing-library/react';
import { ComponentProps } from 'react';
import { render } from '~common/helpers/test-utils';
import { LiveRegionAnnouncementMode } from '~types/LiveRegionAnnouncementMode';
import { Button } from '../../buttons';
import { Tooltip } from '../../tooltip';
import { MessageCallout } from '../MessageCallout';
import { MessageVariety } from '../MessageTypes';

it('should display a message', async () => {
it('should display a message without live-region semantics by default', async () => {
const { container } = setupMessageCallout({ children: 'Fancy Content' });

expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.getByText('Fancy Content')).toBeInTheDocument();
await expect(container).toHaveNoA11yViolations();
});

it.each([LiveRegionAnnouncementMode.Alert, LiveRegionAnnouncementMode.Status])(
'should support %s announcement mode',
(announcementMode) => {
setupMessageCallout({
announcementMode,
children: 'Fancy Content',
});

expect(screen.getByRole(announcementMode)).toHaveTextContent(/Information:\s*Fancy Content/);
},
);

it('should keep dismiss and action controls outside the live region', () => {
setupMessageCallout({
action: <Button>Nice button</Button>,
announcementMode: LiveRegionAnnouncementMode.Status,
children: 'Fancy Content',
onDismiss: jest.fn(),
title: 'Fancy Title',
});

const liveRegion = screen.getByRole(LiveRegionAnnouncementMode.Status);
expect(liveRegion).toHaveTextContent(/Information:\s*Fancy Title\s*Fancy Content/);
expect(within(liveRegion).queryByRole('button', { name: 'Dismiss' })).not.toBeInTheDocument();
expect(within(liveRegion).queryByRole('button', { name: 'Nice button' })).not.toBeInTheDocument();
});

it.each([
[MessageVariety.Danger, 'Error:'],
[MessageVariety.Discover, 'Hint:'],
Expand Down
17 changes: 16 additions & 1 deletion src/components/messages/__tests__/MessageInline-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,32 @@
import { screen } from '@testing-library/react';
import { ComponentProps } from 'react';
import { render } from '~common/helpers/test-utils';
import { LiveRegionAnnouncementMode } from '~types/LiveRegionAnnouncementMode';
import { Tooltip } from '../../tooltip';
import { MessageInline } from '../MessageInline';
import { MessageVariety } from '../MessageTypes';

it('should show a message', async () => {
it('should show a message without live-region semantics by default', async () => {
const { container } = setupMessageInline({ children: 'Fancy Content' });

expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.getByText('Fancy Content')).toBeInTheDocument();
await expect(container).toHaveNoA11yViolations();
});

it.each([LiveRegionAnnouncementMode.Alert, LiveRegionAnnouncementMode.Status])(
'should support %s announcement mode',
(announcementMode) => {
setupMessageInline({
announcementMode,
children: 'Fancy Content',
});

expect(screen.getByRole(announcementMode)).toHaveTextContent(/Information:\s*Fancy Content/);
},
);

it.each([
[MessageVariety.Danger, 'Error:'],
[MessageVariety.Discover, 'Hint:'],
Expand Down
32 changes: 31 additions & 1 deletion stories/message/MessageCallout-stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,34 @@
/* eslint-disable no-console */
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useCallback, useState } from 'react';
import { Button, ButtonVariety, MessageCallout, MessageVariety } from '../../src';
import {
Button,
ButtonVariety,
MessageCallout,
MessageVariety,
LiveRegionAnnouncementMode,
} from '../../src';
import { basicWrapperDecorator } from '../helpers/BasicWrapper';

const meta: Meta<typeof MessageCallout> = {
component: MessageCallout,
title: 'Echoes/Messages/MessageCallout',
argTypes: {
announcementMode: {
control: { type: 'select' },
description:
'Adds status/alert live-region semantics. Defaults to none. Announcements are most ' +
'reliable when an already-mounted message updates its content.',
mapping: {
alert: LiveRegionAnnouncementMode.Alert,
none: undefined,
status: LiveRegionAnnouncementMode.Status,
},
options: ['none', ...Object.values(LiveRegionAnnouncementMode)],
table: {
defaultValue: { summary: 'none' },
},
},
variety: { control: { type: 'select' }, options: Object.values(MessageVariety) },
},
parameters: {
Expand Down Expand Up @@ -77,6 +98,15 @@ export const DismissableWithAction: Story = {
},
};

export const StatusAnnouncement: Story = {
args: {
announcementMode: LiveRegionAnnouncementMode.Status,
children: 'Your background synchronization finished successfully.',
title: 'Sync complete',
variety: MessageVariety.Success,
},
};

function DismissingContainer(args: Story['args']) {
const [show, setShow] = useState(true);

Expand Down
30 changes: 29 additions & 1 deletion stories/message/MessageInline-stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,33 @@

/* eslint-disable no-console */
import type { Meta, StoryObj } from '@storybook/react-vite';
import { MessageInline, MessageInlineSize, MessageVariety } from '../../src';
import {
MessageInline,
MessageInlineSize,
MessageVariety,
LiveRegionAnnouncementMode,
} from '../../src';
import { basicWrapperDecorator } from '../helpers/BasicWrapper';

const meta: Meta<typeof MessageInline> = {
component: MessageInline,
title: 'Echoes/Messages/MessageInline',
argTypes: {
announcementMode: {
control: { type: 'select' },
description:
'Adds status/alert live-region semantics. Defaults to none. Announcements are most ' +
'reliable when an already-mounted message updates its content.',
mapping: {
alert: LiveRegionAnnouncementMode.Alert,
none: undefined,
status: LiveRegionAnnouncementMode.Status,
},
options: ['none', ...Object.values(LiveRegionAnnouncementMode)],
table: {
defaultValue: { summary: 'none' },
},
},
size: { control: { type: 'select' }, options: Object.values(MessageInlineSize) },
variety: { control: { type: 'select' }, options: Object.values(MessageVariety) },
},
Expand Down Expand Up @@ -58,3 +78,11 @@ export const InAParagraph: Story = {
);
},
};

export const AlertAnnouncement: Story = {
args: {
announcementMode: LiveRegionAnnouncementMode.Alert,
children: 'This field is required.',
variety: MessageVariety.Danger,
},
};
Loading