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/layout/banner/Banner.tsx b/src/components/layout/banner/Banner.tsx index bf7230497..2c3db29a3 100644 --- a/src/components/layout/banner/Banner.tsx +++ b/src/components/layout/banner/Banner.tsx @@ -21,6 +21,7 @@ import { forwardRef, useMemo } from 'react'; import { useIntl } from 'react-intl'; import { DismissButton } from '~common/components/DismissButton'; +import { LiveRegionAnnouncementMode } from '~types/LiveRegionAnnouncementMode'; import { BannerScreenReaderPrefix } from './BannerScreenReaderPrefix'; import { BANNER_TYPE_ICONS, @@ -32,7 +33,14 @@ import { import { BannerProps } from './BannerTypes'; export const Banner = forwardRef((props, ref) => { - const { children, onDismiss, screenReaderPrefix, variety, ...htmlProps } = props; + const { + announcementMode = LiveRegionAnnouncementMode.Alert, + children, + onDismiss, + screenReaderPrefix, + variety, + ...htmlProps + } = props; const intl = useIntl(); @@ -43,7 +51,7 @@ export const Banner = forwardRef((props, ref) => { css={useMemo(() => BANNER_TYPE_STYLES[variety], [variety])} {...htmlProps} ref={ref} - role="alert"> + role={announcementMode}> diff --git a/src/components/layout/banner/BannerTypes.tsx b/src/components/layout/banner/BannerTypes.tsx index 84fd5595f..0d662885b 100644 --- a/src/components/layout/banner/BannerTypes.tsx +++ b/src/components/layout/banner/BannerTypes.tsx @@ -19,6 +19,7 @@ */ import { MouseEvent } from 'react'; +import { LiveRegionAnnouncementMode } from '~types/LiveRegionAnnouncementMode'; import { TextNodeOptional } from '~types/utils'; export enum BannerVariety { @@ -30,16 +31,28 @@ export enum BannerVariety { export interface BannerProps { /** - * The content to be displayed in the banner, keep it short and concise. It can't break into multiple lines and will be ellipsized if too long. + * Controls how assistive technology announces the banner. + * Defaults to `alert` to preserve the current urgent-announcement behavior. + * Switch to `status` for page-level status banners that should be announced + * politely instead of interrupting the current screen reader announcement. + * + * @defaultValue "alert" + */ + announcementMode?: `${LiveRegionAnnouncementMode}`; + /** + * The content to be displayed in the banner. Keep it short and concise. It + * cannot break into multiple lines and will be ellipsized if too long. */ children: TextNodeOptional; className?: string; /** - * Function that will be called when the dismiss button is clicked. The dismiss button is only shown if this function is defined. + * Function that will be called when the dismiss button is clicked. The + * dismiss button is only shown if this function is defined. */ onDismiss?: VoidFunction | ((event: MouseEvent) => Promise); /** - * Prefix text for screen readers to announce before the banner content. Optional since a default value is provided. + * Prefix text for screen readers to announce before the banner content. + * Optional since a default value is provided. */ screenReaderPrefix?: TextNodeOptional; /** diff --git a/src/components/layout/banner/__tests__/Banner-test.tsx b/src/components/layout/banner/__tests__/Banner-test.tsx index d02b9004f..0dc8408c0 100644 --- a/src/components/layout/banner/__tests__/Banner-test.tsx +++ b/src/components/layout/banner/__tests__/Banner-test.tsx @@ -21,11 +21,12 @@ import { screen } from '@testing-library/react'; import { screenReaderOnly } from '~common/helpers/styles'; import { renderWithMemoryRouter } from '~common/helpers/test-utils'; +import { LiveRegionAnnouncementMode } from '~types/LiveRegionAnnouncementMode'; import { Link } from '../../../links'; import { Banner } from '../Banner'; import { BannerProps, BannerVariety } from '../BannerTypes'; -it('should display banner content', async () => { +it('should display banner content as an alert by default', async () => { setupBanner({ children: 'Banner Content' }); const banner = screen.getByRole('alert'); @@ -36,6 +37,18 @@ it('should display banner content', async () => { await expect(banner).toHaveNoA11yViolations(); }); +it('should support page-level status announcements', () => { + setupBanner({ + announcementMode: LiveRegionAnnouncementMode.Status, + children: 'Banner Content', + }); + + const banner = screen.getByRole('status'); + expect(banner).toBeInTheDocument(); + expect(banner).toHaveTextContent('Information banner: Banner Content'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + it.each([ [BannerVariety.Danger, 'Error banner:'], [BannerVariety.Info, 'Information banner:'], diff --git a/src/components/layout/banner/__tests__/__snapshots__/Banner-test.tsx.snap b/src/components/layout/banner/__tests__/__snapshots__/Banner-test.tsx.snap index a4bcceec8..4f563c6e5 100644 --- a/src/components/layout/banner/__tests__/__snapshots__/Banner-test.tsx.snap +++ b/src/components/layout/banner/__tests__/__snapshots__/Banner-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`should display banner content 1`] = ` +exports[`should display banner content as an alert by default 1`] = ` .emotion-0 { display: -webkit-box; display: -webkit-flex; 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/src/index.ts b/src/index.ts index a34f1ca7c..c350cfedb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,3 +27,4 @@ export * from './utils'; export { Theme } from './generated/themes'; export { GroupAlignment } from './types/GroupAlignment'; +export { LiveRegionAnnouncementMode } from './types/LiveRegionAnnouncementMode'; diff --git a/src/types/LiveRegionAnnouncementMode.ts b/src/types/LiveRegionAnnouncementMode.ts new file mode 100644 index 000000000..7dfe058d7 --- /dev/null +++ b/src/types/LiveRegionAnnouncementMode.ts @@ -0,0 +1,24 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +export enum LiveRegionAnnouncementMode { + Alert = 'alert', + Status = 'status', +} diff --git a/stories/layout/Banner-stories.tsx b/stories/layout/Banner-stories.tsx index 76a3cdaa4..38f12064e 100644 --- a/stories/layout/Banner-stories.tsx +++ b/stories/layout/Banner-stories.tsx @@ -20,7 +20,14 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; -import { BannerProps, BannerVariety, Button, Layout, Link } from '../../src'; +import { + BannerProps, + BannerVariety, + Button, + Layout, + Link, + LiveRegionAnnouncementMode, +} from '../../src'; import { toDisabledControlArgType, toTextControlArgTypes } from '../helpers/arg-types'; import { BasicWrapper } from '../helpers/BasicWrapper'; import { minWidthBodyDecorator } from '../helpers/decorators'; @@ -30,6 +37,16 @@ const meta: Meta = { title: 'Echoes/Layout/Banner', argTypes: { + announcementMode: { + control: { type: 'select' }, + description: + 'Defaults to alert to preserve the current behavior. Switch to status for page-level ' + + 'status banners that should be announced politely.', + options: Object.values(LiveRegionAnnouncementMode), + table: { + defaultValue: { summary: LiveRegionAnnouncementMode.Alert }, + }, + }, variety: { control: { type: 'select' }, options: Object.values(BannerVariety) }, ...toTextControlArgTypes('children', 'className', 'screenReaderPrefix'), ...toDisabledControlArgType('onDismiss'), @@ -66,6 +83,15 @@ export const WithDismiss: Story = { render: (args) => , }; +export const PageLevelStatus: Story = { + args: { + announcementMode: LiveRegionAnnouncementMode.Status, + children: 'Background synchronization completed successfully.', + onDismiss: undefined, + variety: BannerVariety.Success, + }, +}; + function BannerWithDismiss(args: Readonly) { const [dismissed, setDismissed] = useState(false); 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, + }, +};