diff --git a/i18n/keys.json b/i18n/keys.json index 6ff1ae4ae..07cb21dac 100644 --- a/i18n/keys.json +++ b/i18n/keys.json @@ -108,6 +108,30 @@ "defaultMessage": "(opens in new tab)", "description": "Screen reader-only text to indicate that the link will open in a new tab" }, + "page_content.default_loaded_message": { + "defaultMessage": "Page content loaded", + "description": "Default message to be announced by screen readers when the page content is loaded" + }, + "page_content.default_loading_message": { + "defaultMessage": "Loading page content", + "description": "Default message to be announced by screen readers when the page content is loading" + }, + "page_grid.default_loaded_message": { + "defaultMessage": "Page loaded", + "description": "Default message to be announced by screen readers when the page is loaded" + }, + "page_grid.default_loading_message": { + "defaultMessage": "Loading page", + "description": "Default message to be announced by screen readers when the page is loading" + }, + "page_header.default_loaded_message": { + "defaultMessage": "Page header loaded", + "description": "Default message to be announced by screen readers when the page header is loaded" + }, + "page_header.default_loading_message": { + "defaultMessage": "Loading page header", + "description": "Default message to be announced by screen readers when the page header is loading" + }, "pagination.next": { "defaultMessage": "Next", "description": "Label for the pagination button that takes you to the next page" diff --git a/src/common/components/LoadingStateProvider.tsx b/src/common/components/LoadingStateProvider.tsx new file mode 100644 index 000000000..38099e133 --- /dev/null +++ b/src/common/components/LoadingStateProvider.tsx @@ -0,0 +1,38 @@ +/* + * 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. + */ + +import { ReactNode, useMemo } from 'react'; +import { isDefined } from '~common/helpers/types'; +import { LoadingContext } from './LoadingContext'; + +interface LoadingStateProviderProps { + children?: ReactNode; + isLoading?: boolean; +} + +export function LoadingStateProvider({ children, isLoading }: Readonly) { + const loadingContextValue = useMemo(() => ({ isLoading: Boolean(isLoading) }), [isLoading]); + + return isDefined(isLoading) ? ( + {children} + ) : ( + children + ); +} diff --git a/src/common/components/ScreenReaderOnlyLoadingStatus.tsx b/src/common/components/ScreenReaderOnlyLoadingStatus.tsx new file mode 100644 index 000000000..3163eb6a6 --- /dev/null +++ b/src/common/components/ScreenReaderOnlyLoadingStatus.tsx @@ -0,0 +1,69 @@ +/* + * 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. + */ + +import styled from '@emotion/styled'; +import { FormattedMessage } from 'react-intl'; +import { screenReaderOnly } from '~common/helpers/styles'; +import { TextNode } from '~types/utils'; + +export interface ScreenReaderOnlyLoadingStatusProps { + isLoading: boolean; + /** + * Specify what the screen reader announces once isLoading switches to `false` + * + * @defaultValue "Content loaded" + */ + loadedMessage?: TextNode; + /** + * Specify what the screen reader announces when isLoading is `true` + * + * @defaultValue "Loading content" + */ + loadingMessage?: TextNode; +} + +export function ScreenReaderOnlyLoadingStatus(props: Readonly) { + const { isLoading, loadedMessage, loadingMessage } = props; + + return ( + + {isLoading + ? (loadingMessage ?? ( + + )) + : (loadedMessage ?? ( + + ))} + + ); +} + +const ScreenReaderOnlyLive = styled.span` + ${screenReaderOnly}; +`; +ScreenReaderOnlyLive.displayName = 'ScreenReaderOnlyLive'; diff --git a/src/components/layout/LayoutSlots.tsx b/src/components/layout/LayoutSlots.tsx index 365555e6c..185af866f 100644 --- a/src/components/layout/LayoutSlots.tsx +++ b/src/components/layout/LayoutSlots.tsx @@ -19,7 +19,18 @@ */ import styled from '@emotion/styled'; -import { CSSProperties, forwardRef, PropsWithChildren } from 'react'; +import { + CSSProperties, + DetailedHTMLProps, + forwardRef, + HTMLAttributes, + PropsWithChildren, +} from 'react'; +import { FormattedMessage } from 'react-intl'; +import { LoadingStateProvider } from '~common/components/LoadingStateProvider'; +import { ScreenReaderOnlyLoadingStatus } from '~common/components/ScreenReaderOnlyLoadingStatus'; +import { isDefined } from '~common/helpers/types'; +import { TextNode } from '~types/utils'; import { cssVar } from '~utils/design-tokens'; import { AsideSize, ContentGridArea, GlobalGridArea, PageGridArea, PageWidth } from './LayoutTypes'; @@ -92,16 +103,69 @@ StyledAsideLeft.displayName = 'StyledAside'; export interface PageGridProps { className?: string; + /** + * Setting this prop will make this component behave like a LoadingContainer. + * It will provide a LoadingContext that LoadingSkeletons can consume (automatically), + * and deal with screen readers. + * Customize the accessible status messages by specifying `loadedMessage` and `loadingMessage`. + */ + isLoading?: boolean; + /** + * Allows you to customize the screen reader-only status message announced when `isLoading` is false. + * @defaultValue Page loaded + */ + loadedMessage?: TextNode; + /** + * Allows you to customize the screen reader-only status message announced when `isLoading` is true. + * @defaultValue Loading page + */ + loadingMessage?: TextNode; width?: `${PageWidth}`; } export const PageGrid = forwardRef>( (props, ref) => { - const { children, width = 'default', ...restProps } = props; + const { + children, + isLoading, + loadedMessage, + loadingMessage, + width = 'default', + ...restProps + } = props; + return ( - - {children} - + <> + + + {children} + + + + {isDefined(isLoading) && ( + + ) + } + loadingMessage={ + loadingMessage ?? ( + + ) + } + /> + )} + ); }, ); @@ -136,13 +200,74 @@ const PAGE_WIDTH_STYLES: Record = { [PageWidth.fluid]: {}, }; -export const PageContent = styled.main` +export interface PageContentProps { + className?: string; + /** + * Setting this prop will make this component behave like a LoadingContainer. + * It will provide a LoadingContext that LoadingSkeletons can consume (automatically), + * and deal with screen readers. + * Customize the accessible status messages by specifying `loadedMessage` and `loadingMessage`. + */ + isLoading?: boolean; + /** + * Allows you to customize the screen reader-only status message announced when `isLoading` is false. + * @defaultValue Page content loaded + */ + loadedMessage?: TextNode; + /** + * Allows you to customize the screen reader-only status message announced when `isLoading` is true. + * @defaultValue Loading page content + */ + loadingMessage?: TextNode; +} + +export const PageContent = forwardRef< + HTMLElement, + PropsWithChildren, HTMLElement>> +>((props, ref) => { + const { children, isLoading, loadedMessage, loadingMessage, ...restProps } = props; + + return ( + <> + + {children} + + + {isDefined(isLoading) && ( + + ) + } + loadingMessage={ + loadingMessage ?? ( + + ) + } + /> + )} + + ); +}); +PageContent.displayName = 'PageContent'; + +const StyledPageContent = styled.main` grid-area: ${PageGridArea.main}; padding: ${cssVar('dimension-space-300')}; isolation: isolate; // Prevent content z-index values from escaping and overlapping a sticky PageHeader `; -PageContent.displayName = 'PageContent'; +StyledPageContent.displayName = 'StyledPageContent'; export const PageFooter = styled.footer` grid-area: ${PageGridArea.footer}; diff --git a/src/components/layout/__tests__/LayoutSlots-test.tsx b/src/components/layout/__tests__/LayoutSlots-test.tsx index 64787534d..f69e3c5a1 100644 --- a/src/components/layout/__tests__/LayoutSlots-test.tsx +++ b/src/components/layout/__tests__/LayoutSlots-test.tsx @@ -20,7 +20,7 @@ import { screen } from '@testing-library/react'; import { render } from '~common/helpers/test-utils'; -import { AsideLeft, PageGrid } from '../LayoutSlots'; +import { AsideLeft, PageContent, PageGrid } from '../LayoutSlots'; import { AsideSize, PageWidth } from '../LayoutTypes'; describe('PageGrid', () => { @@ -40,6 +40,57 @@ describe('PageGrid', () => { maxWidth: 'var(--echoes-layout-sizes-max-width-default)', }); }); + + it.each([ + ['loading', { isLoading: true }, 'true', 'Loading page'], + ['not loading', { isLoading: false }, 'false', 'Page loaded'], + [ + 'loading (custom message)', + { isLoading: true, loadingMessage: 'Fetching data' }, + 'true', + 'Fetching data', + ], + [ + 'not loading (custom message)', + { isLoading: false, loadedMessage: 'All done' }, + 'false', + 'All done', + ], + ])('should render correctly when %s', async (_, args, ariaBusy, expectedText) => { + const { container } = render(content); + + await expect(container).toHaveNoA11yViolations(); + + // eslint-disable-next-line testing-library/no-node-access + expect(container.firstChild).toHaveAttribute('aria-busy', ariaBusy); + expect(screen.getByText(expectedText)).toBeInTheDocument(); + }); +}); + +describe('PageContent', () => { + it.each([ + ['loading', { isLoading: true }, 'true', 'Loading page content'], + ['not loading', { isLoading: false }, 'false', 'Page content loaded'], + [ + 'loading (custom message)', + { isLoading: true, loadingMessage: 'Fetching data' }, + 'true', + 'Fetching data', + ], + [ + 'not loading (custom message)', + { isLoading: false, loadedMessage: 'All done' }, + 'false', + 'All done', + ], + ])('should render correctly when %s', async (_, args, ariaBusy, expectedText) => { + const { container } = render(content); + + await expect(container).toHaveNoA11yViolations(); + + expect(screen.getByRole('main')).toHaveAttribute('aria-busy', ariaBusy); + expect(screen.getByText(expectedText)).toBeInTheDocument(); + }); }); describe('AsideLeft', () => { diff --git a/src/components/layout/header/common/HeaderBase.tsx b/src/components/layout/header/common/HeaderBase.tsx index 9fdccbe17..e7955bf0b 100644 --- a/src/components/layout/header/common/HeaderBase.tsx +++ b/src/components/layout/header/common/HeaderBase.tsx @@ -20,6 +20,10 @@ import styled from '@emotion/styled'; import { CSSProperties, forwardRef } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { LoadingStateProvider } from '~common/components/LoadingStateProvider'; +import { ScreenReaderOnlyLoadingStatus } from '~common/components/ScreenReaderOnlyLoadingStatus'; +import { isDefined } from '~common/helpers/types'; import { cssVar } from '~utils/design-tokens'; import { StyledDivider, @@ -46,6 +50,9 @@ const HeaderBase = forwardRef((prop description, hasDivider, hasFullWidthNav, + isLoading, + loadedMessage, + loadingMessage, metadata, navigation, title, @@ -53,33 +60,61 @@ const HeaderBase = forwardRef((prop } = props; return ( -
- {callout && {callout}} + <> +
+ + {callout && {callout}} - {breadcrumbs && {breadcrumbs}} + {breadcrumbs && {breadcrumbs}} - - {title} + + {title} - {metadata} + {metadata} - {description} - + {description} + - {actions && ( - {actions} - )} + {actions && ( + {actions} + )} - {navigation && ( - <> - {navigation} + {navigation && ( + <> + {navigation} - {hasDivider && } - - )} + {hasDivider && } + + )} - {hasDivider && !navigation && } -
+ {hasDivider && !navigation && } + +
+ + {isDefined(isLoading) && ( + + ) + } + loadingMessage={ + loadingMessage ?? ( + + ) + } + /> + )} + ); }); HeaderBase.displayName = 'HeaderBase'; diff --git a/src/components/layout/header/common/HeaderTypes.ts b/src/components/layout/header/common/HeaderTypes.ts index ddb25c118..376efd9ed 100644 --- a/src/components/layout/header/common/HeaderTypes.ts +++ b/src/components/layout/header/common/HeaderTypes.ts @@ -19,6 +19,7 @@ */ import { ReactNode } from 'react'; +import { TextNode } from '~types/utils'; export enum PageHeaderArea { breadcrumbs = 'breadcrumbs', @@ -109,6 +110,23 @@ export interface HeaderProps { * @defaultValue false */ hasDivider?: boolean; + /** + * Setting this prop will make this component behave like a LoadingContainer. + * It will provide a LoadingContext that LoadingSkeletons can consume (automatically), + * and deal with screen readers. + * Customize the accessible status messages by specifying `loadedMessage` and `loadingMessage`. + */ + isLoading?: boolean; + /** + * Allows you to customize the screen reader-only status message announced when `isLoading` is false. + * @defaultValue Page header loaded + */ + loadedMessage?: TextNode; + /** + * Allows you to customize the screen reader-only status message announced when `isLoading` is true. + * @defaultValue Loading page header + */ + loadingMessage?: TextNode; /** * Metadata elements to display below the title. Use to wrap them. */ diff --git a/src/components/layout/header/content-header/__tests__/ContentHeader-test.tsx b/src/components/layout/header/content-header/__tests__/ContentHeader-test.tsx index 68c82c444..163811451 100644 --- a/src/components/layout/header/content-header/__tests__/ContentHeader-test.tsx +++ b/src/components/layout/header/content-header/__tests__/ContentHeader-test.tsx @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { screen } from '@testing-library/react'; import { renderWithMemoryRouter } from '~common/helpers/test-utils'; import { ContentHeader } from '../..'; @@ -28,3 +29,34 @@ it('should display a ContentHeader properly', async () => { await expect(container).toHaveNoA11yViolations(); }); + +describe('isLoading', () => { + it.each([ + ['loading', { isLoading: true }, 'true', 'Loading page header'], + ['not loading', { isLoading: false }, 'false', 'Page header loaded'], + [ + 'loading (custom message)', + { isLoading: true, loadingMessage: 'Fetching data' }, + 'true', + 'Fetching data', + ], + [ + 'not loading (custom message)', + { isLoading: false, loadedMessage: 'All done' }, + 'false', + 'All done', + ], + ])('should render correctly when %s', async (_, args, ariaBusy, expectedText) => { + const { container } = renderWithMemoryRouter( + Awesome content header} + {...args} + />, + ); + + await expect(container).toHaveNoA11yViolations(); + + expect(screen.getByRole('banner')).toHaveAttribute('aria-busy', ariaBusy); + expect(screen.getByText(expectedText)).toBeInTheDocument(); + }); +}); diff --git a/src/components/layout/header/page-header/__tests__/PageHeader-test.tsx b/src/components/layout/header/page-header/__tests__/PageHeader-test.tsx index b7f477498..2ec841488 100644 --- a/src/components/layout/header/page-header/__tests__/PageHeader-test.tsx +++ b/src/components/layout/header/page-header/__tests__/PageHeader-test.tsx @@ -63,6 +63,32 @@ it('should display a minimal PageHeader properly', () => { expect(screen.getByText('Page title')).toBeInTheDocument(); }); +describe('isLoading', () => { + it.each([ + ['loading', { isLoading: true }, 'true', 'Loading page header'], + ['not loading', { isLoading: false }, 'false', 'Page header loaded'], + [ + 'loading (custom message)', + { isLoading: true, loadingMessage: 'Fetching data' }, + 'true', + 'Fetching data', + ], + [ + 'not loading (custom message)', + { isLoading: false, loadedMessage: 'All done' }, + 'false', + 'All done', + ], + ])('should render correctly when %s', async (_, args, ariaBusy, expectedText) => { + const { container } = setup(args); + + await expect(container).toHaveNoA11yViolations(); + + expect(screen.getByRole('banner')).toHaveAttribute('aria-busy', ariaBusy); + expect(screen.getByText(expectedText)).toBeInTheDocument(); + }); +}); + describe('scroll behavior', () => { it.each([ [PageHeaderScrollBehavior.collapse, false, 'sticky'], diff --git a/src/components/loading-container/LoadingContainer.tsx b/src/components/loading-container/LoadingContainer.tsx index c15a81234..62408799b 100644 --- a/src/components/loading-container/LoadingContainer.tsx +++ b/src/components/loading-container/LoadingContainer.tsx @@ -18,13 +18,12 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import styled from '@emotion/styled'; import { PropsWithChildren, useMemo } from 'react'; -import { FormattedMessage } from 'react-intl'; import { LoadingContext } from '~common/components/LoadingContext'; -import { screenReaderOnly } from '~common/helpers/styles'; +import { ScreenReaderOnlyLoadingStatus } from '~common/components/ScreenReaderOnlyLoadingStatus'; export interface LoadingContainerProps { + className?: string; isLoading: boolean; /** * Specify what the screen reader announces once isLoading switches to `false` @@ -52,38 +51,21 @@ export interface LoadingContainerProps { * Both status messages can be specified. */ export function LoadingContainer(props: PropsWithChildren) { - const { children, isLoading, loadedMessage, loadingMessage } = props; + const { className, children, isLoading, loadedMessage, loadingMessage } = props; const loadingContextValue = useMemo(() => ({ isLoading }), [isLoading]); return ( <> - -
{children}
-
+
+ {children} +
- - {isLoading - ? (loadingMessage ?? ( - - )) - : (loadedMessage ?? ( - - ))} - + ); } - -export const ScreenReaderOnlyLive = styled.span` - ${screenReaderOnly}; -`; -ScreenReaderOnlyLive.displayName = 'ScreenReaderOnlyLive'; diff --git a/src/components/loading-skeleton/LoadingSkeletonStyles.tsx b/src/components/loading-skeleton/LoadingSkeletonStyles.tsx index 7e8d5cace..c345882db 100644 --- a/src/components/loading-skeleton/LoadingSkeletonStyles.tsx +++ b/src/components/loading-skeleton/LoadingSkeletonStyles.tsx @@ -34,7 +34,7 @@ const shimmer = keyframes` `; const LoadingSkeletonBaseStyle = styled.div` - background-color: ${cssVar('color-surface-hover')}; + background-color: ${cssVar('color-background-neutral-subtle-default')}; border-radius: ${cssVar('border-radius-200')}; diff --git a/stories/layout/Layout-stories.tsx b/stories/layout/Layout-stories.tsx index 23b8235ca..8cd784e03 100644 --- a/stories/layout/Layout-stories.tsx +++ b/stories/layout/Layout-stories.tsx @@ -36,6 +36,7 @@ import { IconSecurityFinding, Layout, LinkStandalone, + LoadingSkeleton, LogoSonarQubeServer, Text, TextInput, @@ -144,6 +145,8 @@ export const Default: Story = { aside: AsideSize.medium, banner: 'none', contentHeader: false, + pageLoading: true, + pageContentLoading: true, pageHeader: true, pageHeaderScrollBehavior: PageHeaderScrollBehavior.scroll, pageWidth: 'default', @@ -158,16 +161,18 @@ export const Default: Story = { {args.contentHeader} {args.aside} - + {args.pageHeader(args.pageHeaderScrollBehavior)} - +

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure - dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt - mollit anim id est laborum. + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia + deserunt mollit anim id est laborum. +