diff --git a/i18n/keys.json b/i18n/keys.json index 6ff1ae4ae..c7bef42e7 100644 --- a/i18n/keys.json +++ b/i18n/keys.json @@ -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", diff --git a/src/components/messages/MessageCallout.tsx b/src/components/messages/MessageCallout.tsx index 85ba44db4..3042122d3 100644 --- a/src/components/messages/MessageCallout.tsx +++ b/src/components/messages/MessageCallout.tsx @@ -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, @@ -39,6 +41,14 @@ 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; @@ -46,9 +56,16 @@ export interface MessageCalloutProps extends PropsWithChildren { 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((props, ref) => { const { action, + announcementMode, children, className, onDismiss, @@ -72,7 +89,7 @@ export const MessageCallout = forwardRef((p {MESSAGE_VARIETY_ICON[variety]} - + {isDefined(title) && {title}} @@ -85,7 +102,7 @@ export const MessageCallout = forwardRef((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} /> diff --git a/src/components/messages/MessageInline.tsx b/src/components/messages/MessageInline.tsx index 8b6cc9d34..e57f0c27c 100644 --- a/src/components/messages/MessageInline.tsx +++ b/src/components/messages/MessageInline.tsx @@ -17,8 +17,10 @@ * 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'; @@ -26,6 +28,14 @@ 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; @@ -34,9 +44,24 @@ export interface MessageInlineProps { variety: `${MessageVariety}`; } -export const MessageInline = forwardRef>( +/** + * 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>( (props, ref) => { - const { children, className, screenReaderPrefix, size, variety, ...radixProps } = props; + const { + announcementMode, + children, + className, + screenReaderPrefix, + size, + variety, + ...radixProps + } = props; + return ( {MESSAGE_VARIETY_ICON[variety]} - + + + {children} diff --git a/src/components/messages/__tests__/MessageCallout-test.tsx b/src/components/messages/__tests__/MessageCallout-test.tsx index d773d0cb4..473f49025 100644 --- a/src/components/messages/__tests__/MessageCallout-test.tsx +++ b/src/components/messages/__tests__/MessageCallout-test.tsx @@ -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: , + 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:'], diff --git a/src/components/messages/__tests__/MessageInline-test.tsx b/src/components/messages/__tests__/MessageInline-test.tsx index fdcc03597..52b06c0ed 100644 --- a/src/components/messages/__tests__/MessageInline-test.tsx +++ b/src/components/messages/__tests__/MessageInline-test.tsx @@ -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:'], diff --git a/stories/message/MessageCallout-stories.tsx b/stories/message/MessageCallout-stories.tsx index 78792c38b..0b578bf14 100644 --- a/stories/message/MessageCallout-stories.tsx +++ b/stories/message/MessageCallout-stories.tsx @@ -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 = { 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: { @@ -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); diff --git a/stories/message/MessageInline-stories.tsx b/stories/message/MessageInline-stories.tsx index b881f2b72..99527ca42 100644 --- a/stories/message/MessageInline-stories.tsx +++ b/stories/message/MessageInline-stories.tsx @@ -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 = { 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) }, }, @@ -58,3 +78,11 @@ export const InAParagraph: Story = { ); }, }; + +export const AlertAnnouncement: Story = { + args: { + announcementMode: LiveRegionAnnouncementMode.Alert, + children: 'This field is required.', + variety: MessageVariety.Danger, + }, +};