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
24 changes: 24 additions & 0 deletions i18n/keys.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
38 changes: 38 additions & 0 deletions src/common/components/LoadingStateProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<LoadingStateProviderProps>) {
const loadingContextValue = useMemo(() => ({ isLoading: Boolean(isLoading) }), [isLoading]);

return isDefined(isLoading) ? (
<LoadingContext.Provider value={loadingContextValue}>{children}</LoadingContext.Provider>
) : (
children
);
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
69 changes: 69 additions & 0 deletions src/common/components/ScreenReaderOnlyLoadingStatus.tsx
Original file line number Diff line number Diff line change
@@ -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<ScreenReaderOnlyLoadingStatusProps>) {
const { isLoading, loadedMessage, loadingMessage } = props;

return (
<ScreenReaderOnlyLive aria-live="polite">
{isLoading
? (loadingMessage ?? (
<FormattedMessage
defaultMessage="Loading content"
description="Default message to be announced by screen readers when the LoadingContainer is loading"
id="loading_container.default_loading_message"
/>
))
: (loadedMessage ?? (
<FormattedMessage
defaultMessage="Content loaded"
description="Default message to be announced by screen readers when the LoadingContainer has finished loading"
id="loading_container.default_loaded_message"
/>
))}
</ScreenReaderOnlyLive>
);
}

const ScreenReaderOnlyLive = styled.span`
${screenReaderOnly};
`;
ScreenReaderOnlyLive.displayName = 'ScreenReaderOnlyLive';
139 changes: 132 additions & 7 deletions src/components/layout/LayoutSlots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<HTMLDivElement, PropsWithChildren<PageGridProps>>(
(props, ref) => {
const { children, width = 'default', ...restProps } = props;
const {
children,
isLoading,
loadedMessage,
loadingMessage,
width = 'default',
...restProps
} = props;

return (
<PageGridContainer {...restProps} ref={ref}>
<PageGridInner style={PAGE_WIDTH_STYLES[width]}>{children}</PageGridInner>
</PageGridContainer>
<>
<PageGridContainer {...restProps} aria-busy={isLoading} ref={ref}>
<PageGridInner style={PAGE_WIDTH_STYLES[width]}>
<LoadingStateProvider isLoading={isLoading}>{children}</LoadingStateProvider>
</PageGridInner>
</PageGridContainer>

{isDefined(isLoading) && (
<ScreenReaderOnlyLoadingStatus
Comment thread
jeremy-davis-sonarsource marked this conversation as resolved.
isLoading={isLoading}
loadedMessage={
loadedMessage ?? (
<FormattedMessage
defaultMessage="Page loaded"
description="Default message to be announced by screen readers when the page is loaded"
id="page_grid.default_loaded_message"
/>
)
}
loadingMessage={
loadingMessage ?? (
<FormattedMessage
Comment thread
jeremy-davis-sonarsource marked this conversation as resolved.
defaultMessage="Loading page"
description="Default message to be announced by screen readers when the page is loading"
id="page_grid.default_loading_message"
/>
)
Comment thread
gitar-bot[bot] marked this conversation as resolved.
}
/>
)}
</>
);
},
);
Expand Down Expand Up @@ -136,13 +200,74 @@ const PAGE_WIDTH_STYLES: Record<PageWidth, CSSProperties> = {
[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<PageContentProps & DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>>
>((props, ref) => {
const { children, isLoading, loadedMessage, loadingMessage, ...restProps } = props;

return (
<>
<StyledPageContent {...restProps} aria-busy={isLoading} ref={ref}>
<LoadingStateProvider isLoading={isLoading}>{children}</LoadingStateProvider>
</StyledPageContent>

{isDefined(isLoading) && (
<ScreenReaderOnlyLoadingStatus
isLoading={isLoading}
loadedMessage={
loadedMessage ?? (
<FormattedMessage
defaultMessage="Page content loaded"
description="Default message to be announced by screen readers when the page content is loaded"
id="page_content.default_loaded_message"
/>
)
}
loadingMessage={
loadingMessage ?? (
<FormattedMessage
defaultMessage="Loading page content"
description="Default message to be announced by screen readers when the page content is loading"
id="page_content.default_loading_message"
/>
)
}
/>
)}
</>
);
});
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};
Expand Down
53 changes: 52 additions & 1 deletion src/components/layout/__tests__/LayoutSlots-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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(<PageGrid {...args}>content</PageGrid>);

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(<PageContent {...args}>content</PageContent>);

await expect(container).toHaveNoA11yViolations();

expect(screen.getByRole('main')).toHaveAttribute('aria-busy', ariaBusy);
expect(screen.getByText(expectedText)).toBeInTheDocument();
});
});

describe('AsideLeft', () => {
Expand Down
Loading
Loading