From c324d365fe80c6ea924707707ddcaac1a65a8893 Mon Sep 17 00:00:00 2001 From: Jeremy Davis Date: Tue, 2 Jun 2026 17:43:51 +0200 Subject: [PATCH] ECHOES-1335 LoadingSkeleton component --- src/components/index.ts | 1 + .../loading-skeleton/LoadingSkeleton.tsx | 127 +++++++++++ .../LoadingSkeletonStyles.tsx | 102 +++++++++ .../loading-skeleton/LoadingSkeletonTypes.tsx | 26 +++ .../__tests__/LoadingSkeleton-test.tsx | 52 +++++ src/components/loading-skeleton/index.ts | 22 ++ src/components/typography/Heading.tsx | 2 +- stories/LoadingSkeleton-stories.tsx | 215 ++++++++++++++++++ 8 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 src/components/loading-skeleton/LoadingSkeleton.tsx create mode 100644 src/components/loading-skeleton/LoadingSkeletonStyles.tsx create mode 100644 src/components/loading-skeleton/LoadingSkeletonTypes.tsx create mode 100644 src/components/loading-skeleton/__tests__/LoadingSkeleton-test.tsx create mode 100644 src/components/loading-skeleton/index.ts create mode 100644 stories/LoadingSkeleton-stories.tsx diff --git a/src/components/index.ts b/src/components/index.ts index 00af82e99..02784bf5c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -34,6 +34,7 @@ export * from './layout'; export { DirectImportGlobalNavigation as GlobalNavigation } from './layout/global-navigation'; export * from './legacy-banner'; export * from './links'; +export * from './loading-skeleton'; export * from './logos'; export * from './margin-indicator'; export * from './messages'; diff --git a/src/components/loading-skeleton/LoadingSkeleton.tsx b/src/components/loading-skeleton/LoadingSkeleton.tsx new file mode 100644 index 000000000..d821c74ca --- /dev/null +++ b/src/components/loading-skeleton/LoadingSkeleton.tsx @@ -0,0 +1,127 @@ +/* + * 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 } from 'react'; +import { + StyledLoadingSkeletonText, + StyledLoadingSkeletonTextLastParagraphLine, + StyledLoadingSkeletonWrapper, + StyledLoadingSkeletonWrapperDisk, + StyledParagraphWrapper, +} from './LoadingSkeletonStyles'; +import { LoadingSkeletonVariety } from './LoadingSkeletonTypes'; + +export interface LoadingSkeletonProps { + className?: string; + /** + * Wrapped components will be hidden when isLoading is true. If variety is not Text or Paragraph, the LoadingSkeleton will match the size of its child. + */ + children?: ReactNode; + /** + * Displays the skeleton if `true`. + * Displays its children if `false`. + * @default true + */ + isLoading?: boolean; + /** + * The type of skeleton. The varieties fall in two categories: + * - text: Text or Paragraph, meant as a placeholder for text (either a label/heading or a paragraph of text). They are auto-sized according to the font size and line-height. + * - shapes: Rectangle or Disk. These are meant to wrap components and are auto-sized to their child's dimensions. + */ + variety: `${LoadingSkeletonVariety}`; +} + +/** + * Placeholder to display while we wait for data to be available. + * This can be used in 3 ways: + * + * 1) For text, place the LoadingSkeleton inside the text component (i.e. Text, Heading, ...), but around the contents. + * It will inherit the size of the font. This works for variety 'text' & 'paragraph'. + * ``` + * + * + * {stringDataThatNeedsLoading} + * + * + * ``` + * + * 2) Wrap components to have the LoadingSkeleton inherit its size (if already known). + * Works perfectly for fixed-size components whose state depends on async data. + * + * ``` + * + * + * + * ``` + * + * 3) You can build a loading screen or component with standalone LoadingSkeletons + * ``` + *
+ *
+ * + * + *
+ * + * + *
+ * ``` + * + * ⚠️ Accessibility ⚠️ + * + * LoadingSkeletons are `aria-hidden`. A11y must be handled outside of this component, typically by using a LoadingContainer. + * Check out LoadingContainer's tsdoc for more information. + */ +export function LoadingSkeleton(props: Readonly) { + const { children, isLoading = true, variety, ...radixProps } = props; + + if (!isLoading) { + return children; + } + + const commonProps = { ...radixProps, 'aria-hidden': true }; + + switch (variety) { + // Text v + case LoadingSkeletonVariety.Text: + return ; + case LoadingSkeletonVariety.Paragraph: + return ( + + + + + + ); + + case LoadingSkeletonVariety.Disk: + return ( + + {children} + + ); + case LoadingSkeletonVariety.Rectangle: + default: + return ( + {children} + ); + } +} + +LoadingSkeleton.displayName = 'LoadingSkeleton'; diff --git a/src/components/loading-skeleton/LoadingSkeletonStyles.tsx b/src/components/loading-skeleton/LoadingSkeletonStyles.tsx new file mode 100644 index 000000000..7e8d5cace --- /dev/null +++ b/src/components/loading-skeleton/LoadingSkeletonStyles.tsx @@ -0,0 +1,102 @@ +/* + * 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 { keyframes } from '@emotion/react'; +import styled from '@emotion/styled'; +import { cssVar } from '~utils/design-tokens'; +import { Label } from '../typography'; +import { StyledHeading } from '../typography/Heading'; + +export const DEFAULT_LOADING_SKELETON_WIDTH_LABEL = '96px'; +export const DEFAULT_LOADING_SKELETON_WIDTH_HEADING = '176px'; +export const LAST_PARAGRAPH_LINE_WIDTH = '40%'; + +const shimmer = keyframes` + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +`; + +const LoadingSkeletonBaseStyle = styled.div` + background-color: ${cssVar('color-surface-hover')}; + + border-radius: ${cssVar('border-radius-200')}; + + position: relative; // necessary so the ::after pseudoelement doesn't misbehave with the container + overflow: hidden; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + 90deg, + transparent 0%, + ${cssVar('color-surface-default')} 50%, + transparent 100% + ); + animation: ${shimmer} 2s infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } + } +`; + +export const StyledLoadingSkeletonText = styled(LoadingSkeletonBaseStyle)` + height: 1em; + margin-block: calc((1lh - 1em) / 2); // set margins to match the line-height + min-width: ${DEFAULT_LOADING_SKELETON_WIDTH_LABEL}; + max-width: ${cssVar('sizes-typography-max-width-default')}; + + ${Label} & { + width: ${DEFAULT_LOADING_SKELETON_WIDTH_LABEL}; + } + + ${StyledHeading} & { + width: ${DEFAULT_LOADING_SKELETON_WIDTH_HEADING}; + } +`; + +export const StyledLoadingSkeletonTextLastParagraphLine = styled(StyledLoadingSkeletonText)` + width: ${LAST_PARAGRAPH_LINE_WIDTH}; +`; + +export const StyledLoadingSkeletonWrapper = styled(LoadingSkeletonBaseStyle)` + width: fit-content; + height: fit-content; + + // Hide the child + & > * { + visibility: hidden; + } +`; + +export const StyledLoadingSkeletonWrapperDisk = styled(StyledLoadingSkeletonWrapper)` + border-radius: ${cssVar('border-radius-full')}; +`; + +export const StyledParagraphWrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${cssVar('dimension-space-75')}; +`; diff --git a/src/components/loading-skeleton/LoadingSkeletonTypes.tsx b/src/components/loading-skeleton/LoadingSkeletonTypes.tsx new file mode 100644 index 000000000..ca2244865 --- /dev/null +++ b/src/components/loading-skeleton/LoadingSkeletonTypes.tsx @@ -0,0 +1,26 @@ +/* + * 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 LoadingSkeletonVariety { + Text = 'text', + Rectangle = 'rectangle', + Disk = 'disk', + Paragraph = 'paragraph', +} diff --git a/src/components/loading-skeleton/__tests__/LoadingSkeleton-test.tsx b/src/components/loading-skeleton/__tests__/LoadingSkeleton-test.tsx new file mode 100644 index 000000000..cb2338c1a --- /dev/null +++ b/src/components/loading-skeleton/__tests__/LoadingSkeleton-test.tsx @@ -0,0 +1,52 @@ +/* + * 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 { matchers } from '@emotion/jest'; +import { screen } from '@testing-library/react'; +import { render } from '~common/helpers/test-utils'; +import { LoadingSkeleton, LoadingSkeletonVariety } from '..'; + +expect.extend(matchers); + +it.each([ + [LoadingSkeletonVariety.Disk], + [LoadingSkeletonVariety.Paragraph], + [LoadingSkeletonVariety.Rectangle], + [LoadingSkeletonVariety.Text], +])('%s should render correctly', async (variety) => { + const { container } = render(); + + await expect(container).toHaveNoA11yViolations(); +}); + +it('should hide text content when loading', () => { + render(content); + + expect(screen.queryByText('content')).not.toBeInTheDocument(); +}); + +it('should show text content when not loading', () => { + render( + + content + , + ); + + expect(screen.getByText('content')).toBeInTheDocument(); +}); diff --git a/src/components/loading-skeleton/index.ts b/src/components/loading-skeleton/index.ts new file mode 100644 index 000000000..7c202eb72 --- /dev/null +++ b/src/components/loading-skeleton/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { LoadingSkeleton, type LoadingSkeletonProps } from './LoadingSkeleton'; +export { LoadingSkeletonVariety } from './LoadingSkeletonTypes'; diff --git a/src/components/typography/Heading.tsx b/src/components/typography/Heading.tsx index 7e7f9589d..37d51aa8b 100644 --- a/src/components/typography/Heading.tsx +++ b/src/components/typography/Heading.tsx @@ -57,7 +57,7 @@ const defaultSizeByTag: Record = { h5: HeadingSize.ExtraSmall, }; -const StyledHeading = styled.div>>` +export const StyledHeading = styled.div>>` font: ${getHeadingFont}; letter-spacing: ${cssVar('letter-spacing-decreased')}; color: ${cssVar('color-text-strong')}; diff --git a/stories/LoadingSkeleton-stories.tsx b/stories/LoadingSkeleton-stories.tsx new file mode 100644 index 000000000..4f4be48bd --- /dev/null +++ b/stories/LoadingSkeleton-stories.tsx @@ -0,0 +1,215 @@ +/* + * 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 { css } from '@emotion/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ReactElement } from 'react'; +import { Card, Pagination, RatingBadge } from '../src/components'; +import { LoadingSkeleton, LoadingSkeletonVariety } from '../src/components/loading-skeleton'; +import { Heading, Label, Text, TextSize } from '../src/components/typography'; +import { basicWrapperDecorator } from './helpers/BasicWrapper'; + +const meta: Meta = { + component: LoadingSkeleton, + title: 'Echoes/LoadingSkeleton', + argTypes: {}, + decorators: [basicWrapperDecorator], + parameters: { + controls: { exclude: ['children'] }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children:
, + isLoading: true, + variety: LoadingSkeletonVariety.Text, + }, +}; + +interface WithinTextStoryArgs { + isLoading: boolean; + size: TextSize; + variety: LoadingSkeletonVariety.Text | LoadingSkeletonVariety.Paragraph; +} + +export const WithinText: StoryObj = { + parameters: { + controls: { exclude: ['children'] }, + }, + argTypes: { + size: { + control: 'select', + options: Object.values(TextSize), + }, + variety: { options: [LoadingSkeletonVariety.Text, LoadingSkeletonVariety.Paragraph] }, + }, + args: { + isLoading: true, + size: TextSize.Default, + variety: LoadingSkeletonVariety.Text, + }, + render(args) { + const { size, ...skeletonArgs } = args; + return ( +
+ + Nostradamus! + +
+ ); + }, +}; + +export const ShowcaseTextDefaultWidths: StoryObj = { + parameters: { + controls: { exclude: ['children'] }, + }, + render() { + return ( + <> +

Text

+ + Nostradamus! + + +

Text in a container

+
+ + Nostradamus! + +
+ +

Label

+ + +

Heading

+ + Nostradamus! + + + ); + }, +}; + +interface AsAWrapperStoryArgs { + children: ReactElement; + isLoading: boolean; + height: number; + width: number; + variety: LoadingSkeletonVariety.Disk | LoadingSkeletonVariety.Rectangle; +} + +export const AsAWrapper: StoryObj = { + parameters: { + controls: { exclude: [] }, + }, + argTypes: { + children: { + mapping: { + ratingBadge: , + pagination: {}} page={1} totalPages={1} />, + }, + options: ['ratingBadge', 'pagination'], + }, + variety: { options: [LoadingSkeletonVariety.Disk, LoadingSkeletonVariety.Rectangle] }, + }, + args: { + children: , + isLoading: true, + variety: LoadingSkeletonVariety.Disk, + }, + render(args) { + const { children, ...skeletonArgs } = args; + return {children}; + }, +}; + +export const InACard: Story = { + parameters: { + controls: { include: [] }, + }, + render() { + return ( +
+ + + + +
+
+ + + +
+
+ + + + +
+
+
+
+
+ ); + }, +};