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
12 changes: 10 additions & 2 deletions src/components/layout/banner/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,7 +33,14 @@ import {
import { BannerProps } from './BannerTypes';

export const Banner = forwardRef<HTMLDivElement, BannerProps>((props, ref) => {
const { children, onDismiss, screenReaderPrefix, variety, ...htmlProps } = props;
const {
announcementMode = LiveRegionAnnouncementMode.Alert,
children,
onDismiss,
screenReaderPrefix,
variety,
...htmlProps
} = props;

const intl = useIntl();

Expand All @@ -43,7 +51,7 @@ export const Banner = forwardRef<HTMLDivElement, BannerProps>((props, ref) => {
css={useMemo(() => BANNER_TYPE_STYLES[variety], [variety])}
{...htmlProps}
ref={ref}
role="alert">
role={announcementMode}>
<BannerContent>
<BannerIcon color={iconColor} />
<BannerScreenReaderPrefix screenReaderPrefix={screenReaderPrefix} variety={variety} />
Expand Down
19 changes: 16 additions & 3 deletions src/components/layout/banner/BannerTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/

import { MouseEvent } from 'react';
import { LiveRegionAnnouncementMode } from '~types/LiveRegionAnnouncementMode';
import { TextNodeOptional } from '~types/utils';

export enum BannerVariety {
Expand All @@ -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<HTMLElement>) => Promise<void>);
/**
* 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;
/**
Expand Down
15 changes: 14 additions & 1 deletion src/components/layout/banner/__tests__/Banner-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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:'],
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
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
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ export * from './utils';

export { Theme } from './generated/themes';
export { GroupAlignment } from './types/GroupAlignment';
export { LiveRegionAnnouncementMode } from './types/LiveRegionAnnouncementMode';
24 changes: 24 additions & 0 deletions src/types/LiveRegionAnnouncementMode.ts
Original file line number Diff line number Diff line change
@@ -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',
}
Loading
Loading