diff --git a/.changeset/chilled-dolphins-eat.md b/.changeset/chilled-dolphins-eat.md new file mode 100644 index 000000000000..1e2350c3c5c3 --- /dev/null +++ b/.changeset/chilled-dolphins-eat.md @@ -0,0 +1,7 @@ +--- +'@primer/react': minor +--- + +Adds components to support skeleton loading states, and uses those components to replace ad-hoc skeleton loading states in Primer React components. + + diff --git a/e2e/components/Skeletons.test.ts b/e2e/components/Skeletons.test.ts new file mode 100644 index 000000000000..ca058804731a --- /dev/null +++ b/e2e/components/Skeletons.test.ts @@ -0,0 +1,694 @@ +import {test, expect} from '@playwright/test' +import {visit} from '../test-helpers/storybook' +import {themes} from '../test-helpers/themes' + +test.describe('Skeleton', () => { + // + // SkeletonAvatar + // + test.describe('SkeletonAvatar - Default', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar--default', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonAvatar.Default.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar--default', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonAvatar - In A Stack', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--in-a-stack', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonAvatar.InAStack.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--in-a-stack', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonAvatar - In An AvatarPair', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--in-an-avatar-pair', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonAvatar.InAnAvatarPair.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--in-an-avatar-pair', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonAvatar - Size', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--size', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonAvatar.Size.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--size', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonAvatar - Size Responsive', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--size-responsive', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonAvatar.SizeResponsive.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--size-responsive', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonAvatar - Square', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--square', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonAvatar.Square.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--square', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + // + // SkeletonBone + // + test.describe('SkeletonBone - Default', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonbone--default', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonBone.Default.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonbone--default', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonBone - Height', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--custom-height', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonBone.Height.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--custom-height', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonBone - Width', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--custom-width', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonBone.Width.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletonavatar-features--custom-width', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + // + // SkeletonText + // + test.describe('SkeletonText - Default', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext--default', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonText.Default.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext--default', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonText - Body Large', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--body-large', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonText.BodyLarge.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--body-large', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonText - Body Medium', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--body-medium', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonText.BodyMedium.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--body-medium', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonText - Body Small', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--body-small', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonText.BodySmall.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--body-small', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonText - Display', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--display', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonText.Display.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--display', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonText - Subtitle', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--subtitle', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonText.Subtitle.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--subtitle', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonText - Title Large', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--title-large', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonText.TitleLarge.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--title-large', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonText - Title Medium', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--title-medium', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonText.TitleMedium.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--title-medium', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonText - Title Small', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--title-small', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonText.TitleSmall.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--title-small', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonText - With Max Width', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--with-max-width', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonText.WithMaxWidth.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--with-max-width', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) + + test.describe('SkeletonText - With Multiple Lines', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--with-multiple-lines', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`SkeletonText.WithMultipleLines.${theme}.png`) + }) + + test('axe @aat', async ({page}) => { + await visit(page, { + id: 'drafts-components-skeleton-skeletontext-features--with-multiple-lines', + globals: { + colorScheme: theme, + }, + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, + }, + }) + }) + }) + } + }) +}) diff --git a/src/Avatar/Avatar.tsx b/src/Avatar/Avatar.tsx index 200594b864a6..69f1ae1c5308 100644 --- a/src/Avatar/Avatar.tsx +++ b/src/Avatar/Avatar.tsx @@ -52,7 +52,9 @@ const Avatar = React.forwardRef(function Avatar( sxProp as SxProp, ) : merge({'--avatar-size': `${size}px`} as React.CSSProperties, sxProp as SxProp) - return + return ( + + ) }) if (__DEV__) { diff --git a/src/AvatarPair/AvatarPair.tsx b/src/AvatarPair/AvatarPair.tsx index b9aa132148a6..44bd89cf9f5a 100644 --- a/src/AvatarPair/AvatarPair.tsx +++ b/src/AvatarPair/AvatarPair.tsx @@ -3,12 +3,23 @@ import styled from 'styled-components' import Avatar, {AvatarProps} from '../Avatar' import {get} from '../constants' import Box, {BoxProps} from '../Box' - -const ChildAvatar = styled(Avatar)` - position: absolute; - right: -15%; - bottom: -9%; - box-shadow: ${get('shadows.avatar.childShadow')}; +import {SkeletonAvatar} from '../drafts/Skeleton/SkeletonAvatar' + +const StyledAvatarPair = styled(Box)` + position: relative; + display: inline-flex; + + [data-component='Avatar']:last-child, + [data-component='SkeletonAvatar']:last-child { + position: absolute; + right: -15%; + bottom: -9%; + box-shadow: ${get('shadows.avatar.childShadow')}; + } + + [data-component='SkeletonAvatar']:last-child { + box-shadow: inset ${get('shadows.avatar.childShadow')}; + } ` export type AvatarPairProps = BoxProps @@ -23,14 +34,14 @@ const AvatarPair = ({children, ...rest}: AvatarPairProps) => { return React.cloneElement(child as React.ReactElement, {size: 40}) } - return + if (child.type === SkeletonAvatar) { + return + } + + return }) - return ( - - {avatars} - - ) + return {avatars} } // styled() changes this diff --git a/src/AvatarStack/AvatarStack.tsx b/src/AvatarStack/AvatarStack.tsx index 2351eb5b5888..b3c0b73f9b8f 100644 --- a/src/AvatarStack/AvatarStack.tsx +++ b/src/AvatarStack/AvatarStack.tsx @@ -137,7 +137,7 @@ const AvatarStackWrapper = styled.span` margin-left: ${get('space.1')}; opacity: 100%; visibility: visible; - ${props => (props.count === 1 ? '' : `box-shadow: 0 0 0 4px ${get('colors.canvas.default')};`)} + ${props => (props.count === 1 ? '' : `box-shadow: inset 0 0 0 4px ${get('colors.canvas.default')};`)} transition: margin 0.2s ease-in-out, opacity 0.2s ease-in-out, diff --git a/src/DataTable/Table.tsx b/src/DataTable/Table.tsx index af65ba9aedb3..5ae8f2e9f68d 100644 --- a/src/DataTable/Table.tsx +++ b/src/DataTable/Table.tsx @@ -1,7 +1,7 @@ import {SortAscIcon, SortDescIcon} from '@primer/octicons-react' import clsx from 'clsx' import React from 'react' -import styled, {keyframes} from 'styled-components' +import styled from 'styled-components' import Box from '../Box' import Text from '../Text' import {get} from '../constants' @@ -12,15 +12,12 @@ import {UniqueRow} from './row' import {SortDirection} from './sorting' import {useTableLayout} from './useTable' import {useOverflow} from '../internal/hooks/useOverflow' +import {SkeletonText} from '../drafts/Skeleton/SkeletonText' // ---------------------------------------------------------------------------- // Table // ---------------------------------------------------------------------------- -const shimmer = keyframes` - from { mask-position: 200%; } - to { mask-position: 0%; } -` const StyledTable = styled.table>` /* Default table styles */ --table-border-radius: 0.375rem; @@ -198,30 +195,12 @@ const StyledTable = styled.table>` } } - .TableCellSkeletonItem:not(:last-of-type) { - border-bottom: 1px solid ${get('colors.border.default')}; + .TableCellSkeletonItem [data-component='SkeletonText'] { + width: var(--skeleton-item-width); } - .TableCellSkeletonItem::before { - display: block; - content: ''; - height: 1rem; - width: var(--skeleton-item-width, 67%); - background-color: ${get('colors.canvas.subtle')}; - border-radius: 3px; - - @media (prefers-reduced-motion: no-preference) { - mask-image: linear-gradient(75deg, #000 30%, rgba(0, 0, 0, 0.65) 80%); - mask-size: 200%; - animation: ${shimmer}; - animation-duration: 1s; - animation-iteration-count: infinite; - } - - @media (forced-colors: active) { - outline: 1px solid transparent; - outline-offset: -1px; - } + .TableCellSkeletonItem:not(:last-of-type) { + border-bottom: 1px solid ${get('colors.border.default')}; } /* Grid layout */ @@ -624,7 +603,11 @@ function TableSkeleton({cellPadding, columns, rows = 10, Loading
{Array.from({length: rows}).map((_, i) => { - return
+ return ( +
+ +
+ ) })}
diff --git a/src/Token/__tests__/__snapshots__/Token.test.tsx.snap b/src/Token/__tests__/__snapshots__/Token.test.tsx.snap index 607d9763155a..e2ed726bf51f 100644 --- a/src/Token/__tests__/__snapshots__/Token.test.tsx.snap +++ b/src/Token/__tests__/__snapshots__/Token.test.tsx.snap @@ -176,6 +176,7 @@ exports[`Token components AvatarToken renders all sizes 1`] = ` (value: T): T { return ref.current } -const shimmer = keyframes` - from { mask-position: 200%; } - to { mask-position: 0%; } -` - -const SkeletonItem = styled.span.attrs({className: 'PRIVATE_TreeView-item-skeleton'})` +const StyledSkeletonItemContainer = styled.span.attrs({className: 'PRIVATE_TreeView-item-skeleton'})` display: flex; align-items: center; column-gap: 0.5rem; @@ -677,40 +674,6 @@ const SkeletonItem = styled.span.attrs({className: 'PRIVATE_TreeView-item-skelet height: 2.75rem; } - @media (prefers-reduced-motion: no-preference) { - mask-image: linear-gradient(75deg, #000 30%, rgba(0, 0, 0, 0.65) 80%); - mask-size: 200%; - animation: ${shimmer}; - animation-duration: 1s; - animation-iteration-count: infinite; - } - - &::before { - content: ''; - display: block; - width: 1rem; - height: 1rem; - background-color: ${get('colors.neutral.subtle')}; - border-radius: 3px; - @media (forced-colors: active) { - outline: 1px solid transparent; - outline-offset: -1px; - } - } - - &::after { - content: ''; - display: block; - width: var(--tree-item-loading-width, 67%); - height: 1rem; - background-color: ${get('colors.neutral.subtle')}; - border-radius: 3px; - @media (forced-colors: active) { - outline: 1px solid transparent; - outline-offset: -1px; - } - } - &:nth-of-type(5n + 1) { --tree-item-loading-width: 67%; } @@ -732,6 +695,19 @@ const SkeletonItem = styled.span.attrs({className: 'PRIVATE_TreeView-item-skelet } ` +const StyledSkeletonText = styled(SkeletonText)` + width: var(--tree-item-loading-width, 67%); +` + +const SkeletonItem = () => { + return ( + + + + + ) +} + type LoadingItemProps = { count?: number } diff --git a/src/__tests__/__snapshots__/Avatar.test.tsx.snap b/src/__tests__/__snapshots__/Avatar.test.tsx.snap index b2e0a81ff117..f2b8f505615d 100644 --- a/src/__tests__/__snapshots__/Avatar.test.tsx.snap +++ b/src/__tests__/__snapshots__/Avatar.test.tsx.snap @@ -16,6 +16,7 @@ exports[`Avatar renders consistently 1`] = ` > + +const CommentCard = ({children}: {children: React.ReactNode}) => ( + + {children} + +) + +const CommentCardHeading = ({children}: {children: React.ReactNode}) => ( + + {children} + +) + +export const CommentsLoading = () => { + const [loading, setLoading] = React.useState(true) + const [loadingFinished, setLoadingFinished] = React.useState(false) + + const toggleLoadingState = () => { + setLoading(!loading) + setLoadingFinished(loading) + } + + return ( + <> + {/** read by screen readers in place of the comments in a skeleton loading state */} + {loading ? Comments are loading : null} + {/** when loading is completed, it should be announced by the screen-reader */} + {loadingFinished ? 'Comments are loaded' : null} + * + *': {marginBlockStart: '1rem'}}}> + + {Array.from({length: 3}, (_, index) => ( + /* aria-busy is passed so the screenreader doesn't announce the skeleton state */ + + + {loading ? ( + <> + + + + ) : ( + <> + + + monalisa + + on Jan 1 + + {/* buttons and interactive elements should not be represented as skeleton items or shown in any way until they're ready to accept input */} + + + + )} + + {loading ? ( + + ) : ( + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the + industry standard dummy text ever since the 1500s, when an unknown printer took a galley of type and + scrambled it to make a type specimen book. + + )} + + ))} + + + ) +} diff --git a/src/drafts/Skeleton/SkeletonAvatar.docs.json b/src/drafts/Skeleton/SkeletonAvatar.docs.json new file mode 100644 index 000000000000..1eeb62e8f6b8 --- /dev/null +++ b/src/drafts/Skeleton/SkeletonAvatar.docs.json @@ -0,0 +1,27 @@ +{ + "id": "skeleton_avatar", + "name": "SkeletonAvatar", + "status": "draft", + "a11yReviewed": false, + "stories": [], + "props": [ + { + "name": "size", + "type": "number | { narrow?: number; regular?: number; wide?: number; }", + "defaultValue": "20", + "description": "The size of the avatar in pixels." + }, + { + "name": "square", + "type": "boolean", + "defaultValue": "false", + "description": "If true, the avatar will be square instead of circular." + }, + { + "name": "sx", + "type": "SystemStyleObject" + } + ], + "subcomponents": [] + } + \ No newline at end of file diff --git a/src/drafts/Skeleton/SkeletonAvatar.features.stories.tsx b/src/drafts/Skeleton/SkeletonAvatar.features.stories.tsx new file mode 100644 index 000000000000..e78ff2039b97 --- /dev/null +++ b/src/drafts/Skeleton/SkeletonAvatar.features.stories.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import {Meta} from '@storybook/react' +import {ComponentProps} from '../../utils/types' +import {SkeletonAvatar} from './SkeletonAvatar' +import {AvatarStack, AvatarPair} from '../../' + +export default { + title: 'Drafts/Components/Skeleton/SkeletonAvatar/Features', + component: SkeletonAvatar, +} as Meta> + +export const Square = () => + +export const Size = () => ( +
+ + + + + + + + + + + + +
+) + +export const SizeResponsive = () => ( +
+ + + + + + + + + + +
+) + +export const InAStack = () => ( + + + + + + +) + +export const InAnAvatarPair = () => ( + + + + +) diff --git a/src/drafts/Skeleton/SkeletonAvatar.stories.tsx b/src/drafts/Skeleton/SkeletonAvatar.stories.tsx new file mode 100644 index 000000000000..cbf77a5750e5 --- /dev/null +++ b/src/drafts/Skeleton/SkeletonAvatar.stories.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import {Meta, Story} from '@storybook/react' +import {ComponentProps} from '../../utils/types' +import {SkeletonAvatar, SkeletonAvatarProps} from './SkeletonAvatar' +import {parseSizeFromArgs} from '../../Avatar/storyHelpers' +import {DEFAULT_AVATAR_SIZE} from '../../Avatar/Avatar' + +export default { + title: 'Drafts/Components/Skeleton/SkeletonAvatar', + component: SkeletonAvatar, +} as Meta> + +type Args = { + size?: number + sizeAtNarrow?: number + sizeAtRegular?: number + sizeAtWide?: number +} & Omit + +export const Default = () => + +export const Playground: Story = args => { + return +} + +Playground.args = { + size: DEFAULT_AVATAR_SIZE, +} + +Playground.argTypes = { + size: { + control: { + type: 'number', + }, + }, + sizeAtNarrow: { + name: 'size.narrow', + control: { + type: 'number', + }, + }, + sizeAtRegular: { + name: 'size.regular', + control: { + type: 'number', + }, + }, + sizeAtWide: { + name: 'size.wide', + control: { + type: 'number', + }, + }, +} diff --git a/src/drafts/Skeleton/SkeletonAvatar.tsx b/src/drafts/Skeleton/SkeletonAvatar.tsx new file mode 100644 index 000000000000..54bdc68bc238 --- /dev/null +++ b/src/drafts/Skeleton/SkeletonAvatar.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import {AvatarProps} from '../../Avatar' +import {DEFAULT_AVATAR_SIZE} from '../../Avatar/Avatar' +import {BaseSkeletonBone} from './_BaseSkeletonBone' +import {isResponsiveValue} from '../../hooks/useResponsiveValue' +import {BetterCssProperties, BetterSystemStyleObject, SxProp, merge} from '../../sx' +import {getBreakpointDeclarations} from '../../utils/getBreakpointDeclarations' + +export type SkeletonAvatarProps = Pick & SxProp + +export const SkeletonAvatar: React.FC = ({ + size = DEFAULT_AVATAR_SIZE, + square, + sx: sxProp = {}, + ...rest +}) => { + const avatarSx = isResponsiveValue(size) + ? merge( + getBreakpointDeclarations( + size, + '--avatar-size' as keyof React.CSSProperties, + value => `${value || DEFAULT_AVATAR_SIZE}px`, + ), + sxProp as SxProp, + ) + : merge({'--avatar-size': `${size}px`} as React.CSSProperties, sxProp as SxProp) + + return ( + + ) +} diff --git a/src/drafts/Skeleton/SkeletonBone.docs.json b/src/drafts/Skeleton/SkeletonBone.docs.json new file mode 100644 index 000000000000..b319b494e982 --- /dev/null +++ b/src/drafts/Skeleton/SkeletonBone.docs.json @@ -0,0 +1,26 @@ +{ + "id": "skeleton_bone", + "name": "SkeletonBone", + "status": "draft", + "a11yReviewed": false, + "stories": [], + "props": [ + { + "name": "width", + "type": "string", + "description": "Width of the skeleton 'bone'. Accepts any valid CSS `width` value." + }, + { + "name": "height", + "defaultValue": "1rem", + "type": "string", + "description": "Height of the skeleton 'bone'. Accepts any valid CSS `height` value." + }, + { + "name": "sx", + "type": "SystemStyleObject" + } + ], + "subcomponents": [] + } + \ No newline at end of file diff --git a/src/drafts/Skeleton/SkeletonBone.features.stories.tsx b/src/drafts/Skeleton/SkeletonBone.features.stories.tsx new file mode 100644 index 000000000000..6f113218fd33 --- /dev/null +++ b/src/drafts/Skeleton/SkeletonBone.features.stories.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import {Meta} from '@storybook/react' +import {ComponentProps} from '../../utils/types' +import {SkeletonBone} from './SkeletonBone' + +export default { + title: 'Drafts/Components/Skeleton/SkeletonBone/Features', + component: SkeletonBone, +} as Meta> + +export const CustomHeight = () => + +export const CustomWidth = () => diff --git a/src/drafts/Skeleton/SkeletonBone.stories.tsx b/src/drafts/Skeleton/SkeletonBone.stories.tsx new file mode 100644 index 000000000000..3f921117c3fd --- /dev/null +++ b/src/drafts/Skeleton/SkeletonBone.stories.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import {Meta, Story} from '@storybook/react' +import {ComponentProps} from '../../utils/types' +import {SkeletonBone} from './SkeletonBone' + +export default { + title: 'Drafts/Components/Skeleton/SkeletonBone', + component: SkeletonBone, +} as Meta> + +export const Default = () => + +export const Playground: Story> = args => + +Playground.argTypes = { + sx: { + controls: false, + table: { + disable: true, + }, + }, + height: { + type: 'string', + }, + width: { + type: 'string', + }, +} diff --git a/src/drafts/Skeleton/SkeletonBone.tsx b/src/drafts/Skeleton/SkeletonBone.tsx new file mode 100644 index 000000000000..2e9dcfb33556 --- /dev/null +++ b/src/drafts/Skeleton/SkeletonBone.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import {SxProp} from '../../sx' +import {BaseSkeletonBone} from './_BaseSkeletonBone' + +type SkeletonBoneProps = { + /** Height of the skeleton "bone". Accepts any valid CSS `height` value. */ + height?: React.CSSProperties['height'] + /** Width of the skeleton "bone". Accepts any valid CSS `width` value. */ + width?: React.CSSProperties['width'] +} + +export const SkeletonBone: React.FC = ({height = '1rem', ...rest}) => ( + +) diff --git a/src/drafts/Skeleton/SkeletonText.docs.json b/src/drafts/Skeleton/SkeletonText.docs.json new file mode 100644 index 000000000000..c11ced93c1fe --- /dev/null +++ b/src/drafts/Skeleton/SkeletonText.docs.json @@ -0,0 +1,32 @@ +{ + "id": "skeleton_text", + "name": "SkeletonText", + "status": "draft", + "a11yReviewed": false, + "stories": [], + "props": [ + { + "name": "size", + "defaultValue": "'bodyMedium'", + "type": "'display' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall' | 'subtitle'", + "description": "Size of the text that the skeleton is replacing." + }, + { + "name": "lines", + "defaultValue": "1", + "type": "number", + "description": "Number of lines of skeleton text to render." + }, + { + "name": "maxWidth", + "type": "string", + "description": "Maximum width that the line(s) of skeleton text can take up. Accepts any valid CSS `max-width` value." + }, + { + "name": "sx", + "type": "SystemStyleObject" + } + ], + "subcomponents": [] + } + \ No newline at end of file diff --git a/src/drafts/Skeleton/SkeletonText.features.stories.tsx b/src/drafts/Skeleton/SkeletonText.features.stories.tsx new file mode 100644 index 000000000000..4aa564bf34e1 --- /dev/null +++ b/src/drafts/Skeleton/SkeletonText.features.stories.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import {Meta} from '@storybook/react' +import {ComponentProps} from '../../utils/types' +import {SkeletonText} from './SkeletonText' + +export default { + title: 'Drafts/Components/Skeleton/SkeletonText/Features', + component: SkeletonText, +} as Meta> + +export const WithMaxWidth = () => + +export const WithMultipleLines = () => + +export const Display = () => + +export const Subtitle = () => + +export const TitleLarge = () => + +export const TitleMedium = () => + +export const TitleSmall = () => + +export const BodyLarge = () => + +export const BodyMedium = () => + +export const BodySmall = () => diff --git a/src/drafts/Skeleton/SkeletonText.stories.tsx b/src/drafts/Skeleton/SkeletonText.stories.tsx new file mode 100644 index 000000000000..c56fcbf5755a --- /dev/null +++ b/src/drafts/Skeleton/SkeletonText.stories.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import {Meta, Story} from '@storybook/react' +import {ComponentProps} from '../../utils/types' +import {SkeletonText} from './SkeletonText' + +export default { + title: 'Drafts/Components/Skeleton/SkeletonText', + component: SkeletonText, +} as Meta> + +export const Default = () => + +export const Playground: Story> = args => + +Playground.args = { + size: 'bodyMedium', + lines: 1, +} + +Playground.argTypes = { + sx: { + controls: false, + table: { + disable: true, + }, + }, + lines: { + type: 'number', + }, + maxWidth: { + type: 'string', + }, + size: { + type: 'string', + }, +} diff --git a/src/drafts/Skeleton/SkeletonText.tsx b/src/drafts/Skeleton/SkeletonText.tsx new file mode 100644 index 000000000000..69b818ab5f37 --- /dev/null +++ b/src/drafts/Skeleton/SkeletonText.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import {SxProp, merge} from '../../sx' +import {BaseSkeletonBone} from './_BaseSkeletonBone' +import Box from '../../Box' + +type SkeletonTextProps = { + /** Size of the text that the skeleton is replacing. */ + size?: 'display' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall' | 'subtitle' + /** Number of lines of skeleton text to render. */ + lines?: number + /** Maximum width that the line(s) of skeleton text can take up. Accepts any valid CSS `max-width` value. */ + maxWidth?: React.CSSProperties['maxWidth'] +} + +export const SkeletonText: React.FC = ({ + lines = 1, + maxWidth, + size = 'bodyMedium', + sx: sxProp = {}, + ...rest +}) => { + return lines < 2 ? ( + + ) : ( + + {Array.from({length: lines}, (_, index) => ( + + ))} + + ) +} diff --git a/src/drafts/Skeleton/_BaseSkeletonBone.tsx b/src/drafts/Skeleton/_BaseSkeletonBone.tsx new file mode 100644 index 000000000000..be733d7896b3 --- /dev/null +++ b/src/drafts/Skeleton/_BaseSkeletonBone.tsx @@ -0,0 +1,123 @@ +import React from 'react' +import styled, {keyframes} from 'styled-components' +import sx, {SxProp} from '../../sx' +import {get} from '../../constants' + +type BaseSkeletonBoneProps = { + height?: React.CSSProperties['height'] + width?: React.CSSProperties['width'] +} & SxProp + +const shimmer = keyframes` + from { mask-position: 200%; } + to { mask-position: 0%; } +` + +export const BaseSkeletonBone = styled.div` + animation: ${shimmer}; + display: block; + background-color: var(--bgColor-muted, ${get('colors.canvas.subtle')}); + border-radius: 3px; + height: ${props => props.height}; + width: ${props => props.width}; + + @media (prefers-reduced-motion: no-preference) { + mask-image: linear-gradient(75deg, #000 30%, rgba(0, 0, 0, 0.65) 80%); + mask-size: 200%; + animation: ${shimmer}; + animation-duration: 1s; + animation-iteration-count: infinite; + } + + @media (forced-colors: active) { + outline: 1px solid transparent; + outline-offset: -1px; + } + + &[data-component='SkeletonAvatar'] { + border-radius: 50%; + box-shadow: 0 0 0 1px ${get('colors.avatar.border')}; + display: inline-block; + line-height: ${get('lineHeights.condensedUltra')}; + height: var(--avatar-size); + width: var(--avatar-size); + } + + &[data-avatar-shape='square'] { + border-radius: clamp(4px, var(--avatar-size) - 24px, 6px); + } + + &[data-component='SkeletonText'] { + --font-size: var(--text-body-size-medium, 0.875rem); + --line-height: var(--text-body-lineHeight-medium, 1.4285); + --leading: calc(var(--font-size) * var(--line-height) - var(--font-size)); + border-radius: var(--borderRadius-small, 0.1875rem); + height: var(--font-size); + /* We divide the total amount of leading between the top and bottom */ + margin-block: calc(var(--leading) / 2); + } + + /* We double the margin between lines to counteract margin collapse. This keeps the spaces the skeleton lines the same way lines of text are spaced */ + &[data-in-multiline='true'] { + margin-block-end: calc(var(--leading) * 2); + } + + &[data-in-multiline='true']:last-child { + max-width: 65%; + min-width: 50px; + margin-bottom: 0; + } + + /* + * The new 'mod()' function is more straight forward than using the 'calc()' equation to calculate the + * vertical space around letters created by line-height. + * Once more browsers suppot 'mod()', we can replace the margin-block 'calc()' with this. + */ + @supports (margin-block: mod(1px, 1px)) { + &[data-component='SkeletonText'] { + --leading: mod(var(--font-size) * var(--line-height), var(--font-size)); + } + } + + &[data-text-skeleton-size='display'], + &[data-text-skeleton-size='titleLarge'] { + border-radius: var(--borderRadius-medium, 0.375rem); + } + + &[data-text-skeleton-size='display'] { + --font-size: var(--text-display-size, 2.5rem); + --line-height: var(--text-display-lineHeight, 1.4); + } + + &[data-text-skeleton-size='titleLarge'] { + --font-size: var(--text-title-size-large, 2.5rem); + --line-height: var(--text-title-lineHeight-large, 1.5); + } + + &[data-text-skeleton-size='titleMedium'] { + --font-size: var(--text-title-size-medium, 1.25rem); + --line-height: var(--text-title-lineHeight-medium, 1.6); + } + + &[data-text-skeleton-size='titleSmall'] { + --font-size: var(--text-title-size-small, 1rem); + --line-height: var(--text-title-lineHeight-small, 1.5); + } + + &[data-text-skeleton-size='subtitle'] { + --font-size: var(--text-subtitle-size, 1.25rem); + --line-height: var(--text-subtitle-lineHeight, 1.6); + } + + &[data-text-skeleton-size='bodyLarge'] { + --font-size: var(--text-body-size-large, 1rem); + --line-height: var(--text-body-lineHeight-large, 1.5); + } + + &[data-text-skeleton-size='bodySmall'] { + --font-size: var(--text-body-size-small, 0.75rem); + --line-height: var(--text-body-lineHeight-small, 1.6666); + } + + ${sx}; +`