From d349cfcb3fca9982040a1389c0fabcaafca5b85e Mon Sep 17 00:00:00 2001 From: Jon Rohan Date: Fri, 1 Nov 2024 08:14:01 -0700 Subject: [PATCH] feat(SkeletonBox): Convert SkeletonBox to CSS modules behind the feature flag (#5195) * Convert SkeletonBox to CSS modules * Create few-zebras-drive.md * Convert SkeletonText to CSS modules behind feature flag * Fix the type * Update Skeleton components to CSS modules * SkeletonAvatar converted to CSS modules * Turn off lint --- .changeset/few-zebras-drive.md | 8 ++ .../Skeleton/SkeletonAvatar.module.css | 34 +++++++ .../Skeleton/SkeletonAvatar.stories.tsx | 5 + .../experimental/Skeleton/SkeletonAvatar.tsx | 40 ++++++-- .../Skeleton/SkeletonBox.module.css | 30 ++++++ .../src/experimental/Skeleton/SkeletonBox.tsx | 90 ++++++++++++----- .../Skeleton/SkeletonText.module.css | 65 ++++++++++++ .../experimental/Skeleton/SkeletonText.tsx | 99 ++++++++++++------- .../Skeleton/__tests__/SkeletonBox.test.tsx | 25 +++++ .../Skeleton/__tests__/SkeletonText.test.tsx | 25 +++++ 10 files changed, 352 insertions(+), 69 deletions(-) create mode 100644 .changeset/few-zebras-drive.md create mode 100644 packages/react/src/experimental/Skeleton/SkeletonAvatar.module.css create mode 100644 packages/react/src/experimental/Skeleton/SkeletonBox.module.css create mode 100644 packages/react/src/experimental/Skeleton/SkeletonText.module.css create mode 100644 packages/react/src/experimental/Skeleton/__tests__/SkeletonBox.test.tsx create mode 100644 packages/react/src/experimental/Skeleton/__tests__/SkeletonText.test.tsx diff --git a/.changeset/few-zebras-drive.md b/.changeset/few-zebras-drive.md new file mode 100644 index 00000000000..d283c99f98b --- /dev/null +++ b/.changeset/few-zebras-drive.md @@ -0,0 +1,8 @@ +--- +"@primer/react": minor +--- + +* Convert SkeletonAvatar to CSS modules behind the feature flag +* Convert SkeletonBox to CSS modules behind the feature flag +* Convert SkeletonText to CSS modules behind the feature flag + diff --git a/packages/react/src/experimental/Skeleton/SkeletonAvatar.module.css b/packages/react/src/experimental/Skeleton/SkeletonAvatar.module.css new file mode 100644 index 00000000000..2f2d0c20915 --- /dev/null +++ b/packages/react/src/experimental/Skeleton/SkeletonAvatar.module.css @@ -0,0 +1,34 @@ +.SkeletonAvatar { + &:where([data-component='SkeletonAvatar']) { + display: inline-block; + width: var(--avatarSize-regular); + height: var(--avatarSize-regular); + /* stylelint-disable-next-line primer/typography */ + line-height: 1; + border-radius: 50%; + /* stylelint-disable-next-line primer/box-shadow */ + box-shadow: 0 0 0 1px var(--avatar-borderColor); + } + + &:where([data-square]) { + /* stylelint-disable-next-line primer/borders */ + border-radius: clamp(4px, var(--avatarSize-regular) - 24px, var(--borderRadius-medium)); + } + + &:where([data-responsive]) { + @media screen and (--viewportRange-narrow) { + width: var(--avatarSize-narrow); + height: var(--avatarSize-narrow); + } + + @media screen and (--viewportRange-regular) { + width: var(--avatarSize-regular); + height: var(--avatarSize-regular); + } + + @media screen and (--viewportRange-wide) { + width: var(--avatarSize-wide); + height: var(--avatarSize-wide); + } + } +} diff --git a/packages/react/src/experimental/Skeleton/SkeletonAvatar.stories.tsx b/packages/react/src/experimental/Skeleton/SkeletonAvatar.stories.tsx index baf1925058a..b1e9986f7ec 100644 --- a/packages/react/src/experimental/Skeleton/SkeletonAvatar.stories.tsx +++ b/packages/react/src/experimental/Skeleton/SkeletonAvatar.stories.tsx @@ -28,6 +28,11 @@ Playground.args = { } Playground.argTypes = { + square: { + control: { + type: 'boolean', + }, + }, size: { control: { type: 'number', diff --git a/packages/react/src/experimental/Skeleton/SkeletonAvatar.tsx b/packages/react/src/experimental/Skeleton/SkeletonAvatar.tsx index 6299f2be940..8bd5a2cbb8d 100644 --- a/packages/react/src/experimental/Skeleton/SkeletonAvatar.tsx +++ b/packages/react/src/experimental/Skeleton/SkeletonAvatar.tsx @@ -1,15 +1,19 @@ -import React from 'react' +import React, {type CSSProperties} from 'react' import {getBreakpointDeclarations} from '../../utils/getBreakpointDeclarations' import {get} from '../../constants' import {isResponsiveValue} from '../../hooks/useResponsiveValue' import type {AvatarProps} from '../../Avatar' import {DEFAULT_AVATAR_SIZE} from '../../Avatar/Avatar' import {SkeletonBox} from './SkeletonBox' +import classes from './SkeletonAvatar.module.css' +import {clsx} from 'clsx' +import {useFeatureFlag} from '../../FeatureFlags' +import {merge} from '../../sx' export type SkeletonAvatarProps = Pick & { /** Class name for custom styling */ className?: string -} +} & Omit, 'size'> const avatarSkeletonStyles = { '&[data-component="SkeletonAvatar"]': { @@ -21,13 +25,22 @@ const avatarSkeletonStyles = { width: 'var(--avatar-size)', }, - '&[data-avatar-shape="square"]': { + '&[data-square]': { borderRadius: 'clamp(4px, var(--avatar-size) - 24px, 6px)', }, } -export const SkeletonAvatar: React.FC = ({size = DEFAULT_AVATAR_SIZE, square, ...rest}) => { - const avatarSx = isResponsiveValue(size) +export const SkeletonAvatar: React.FC = ({ + size = DEFAULT_AVATAR_SIZE, + square, + className, + style, + ...rest +}) => { + const responsive = isResponsiveValue(size) + const cssSizeVars = {} as Record + const enabled = useFeatureFlag('primer_react_css_modules_team') + const avatarSx = responsive ? { ...getBreakpointDeclarations( size, @@ -41,12 +54,25 @@ export const SkeletonAvatar: React.FC = ({size = DEFAULT_AV ...avatarSkeletonStyles, } + if (enabled) { + if (responsive) { + for (const [key, value] of Object.entries(size)) { + cssSizeVars[`--avatarSize-${key}`] = `${value}px` + } + } else { + cssSizeVars['--avatarSize-regular'] = `${size}px` + } + } + return ( ) } diff --git a/packages/react/src/experimental/Skeleton/SkeletonBox.module.css b/packages/react/src/experimental/Skeleton/SkeletonBox.module.css new file mode 100644 index 00000000000..08edcbd9750 --- /dev/null +++ b/packages/react/src/experimental/Skeleton/SkeletonBox.module.css @@ -0,0 +1,30 @@ +@keyframes shimmer { + from { + mask-position: 200%; + } + + to { + mask-position: 0%; + } +} + +.SkeletonBox { + display: block; + height: 1rem; + background-color: var(--bgColor-muted); + border-radius: var(--borderRadius-small); + animation: shimmer; + + @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; + } +} diff --git a/packages/react/src/experimental/Skeleton/SkeletonBox.tsx b/packages/react/src/experimental/Skeleton/SkeletonBox.tsx index a5b2a0af465..7df654aa354 100644 --- a/packages/react/src/experimental/Skeleton/SkeletonBox.tsx +++ b/packages/react/src/experimental/Skeleton/SkeletonBox.tsx @@ -1,40 +1,80 @@ -import type React from 'react' +import React from 'react' import styled, {keyframes} from 'styled-components' -import sx, {type SxProp} from '../../sx' +import sx, {merge, type SxProp} from '../../sx' import {get} from '../../constants' +import {type CSSProperties, type HTMLProps} from 'react' +import {toggleStyledComponent} from '../../internal/utils/toggleStyledComponent' +import {clsx} from 'clsx' +import classes from './SkeletonBox.module.css' +import {useFeatureFlag} from '../../FeatureFlags' type SkeletonBoxProps = { /** Height of the skeleton "box". Accepts any valid CSS `height` value. */ - height?: React.CSSProperties['height'] + height?: CSSProperties['height'] /** Width of the skeleton "box". Accepts any valid CSS `width` value. */ - width?: React.CSSProperties['width'] -} & SxProp + width?: CSSProperties['width'] + /** The className of the skeleton box */ + className?: string +} & SxProp & + HTMLProps const shimmer = keyframes` from { mask-position: 200%; } to { mask-position: 0%; } ` +const CSS_MODULES_FEATURE_FLAG = 'primer_react_css_modules_team' -export const SkeletonBox = styled.div` - animation: ${shimmer}; - display: block; - background-color: var(--bgColor-muted, ${get('colors.canvas.subtle')}); - border-radius: 3px; - height: ${props => props.height || '1rem'}; - 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%; +const StyledSkeletonBox = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'div', + styled.div` animation: ${shimmer}; - animation-duration: 1s; - animation-iteration-count: infinite; - } + display: block; + background-color: var(--bgColor-muted, ${get('colors.canvas.subtle')}); + border-radius: 3px; + height: ${props => props.height || '1rem'}; + width: ${props => props.width}; - @media (forced-colors: active) { - outline: 1px solid transparent; - outline-offset: -1px; - } + @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; + } - ${sx}; -` + @media (forced-colors: active) { + outline: 1px solid transparent; + outline-offset: -1px; + } + + ${sx}; + `, +) + +export const SkeletonBox = React.forwardRef(function SkeletonBox( + {height, width, className, style, ...props}, + ref, +) { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + return ( + + ) +}) diff --git a/packages/react/src/experimental/Skeleton/SkeletonText.module.css b/packages/react/src/experimental/Skeleton/SkeletonText.module.css new file mode 100644 index 00000000000..7615d4eb17b --- /dev/null +++ b/packages/react/src/experimental/Skeleton/SkeletonText.module.css @@ -0,0 +1,65 @@ +.SkeletonText { + --font-size: var(--text-body-size-medium); + --line-height: var(--text-body-lineHeight-medium); + --leading: calc(var(--font-size) * var(--line-height) - var(--font-size)); + + @supports (margin-block: mod(1px, 1px)) { + --leading: mod(var(--font-size) * var(--line-height), var(--font-size)); + } + + height: var(--font-size); + border-radius: var(--borderRadius-small); + /* stylelint-disable-next-line primer/spacing */ + margin-block: calc(var(--leading) / 2); + + &:where([data-in-multiline]) { + /* stylelint-disable-next-line primer/spacing */ + margin-block-end: calc(var(--leading) * 2); + + &:last-child { + min-width: 50px; + max-width: 65%; + margin-bottom: 0; + } + } + + &:where([data-text-skeleton-size='display']), + &:where([data-text-skeleton-size='titleLarge']) { + border-radius: var(--borderRadius-medium); + } + + &:where([data-text-skeleton-size='display']) { + --font-size: var(--text-display-size); + --line-height: var(--text-display-lineHeight); + } + + &:where([data-text-skeleton-size='titleLarge']) { + --font-size: var(--text-title-size-large); + --line-height: var(--text-title-lineHeight-large); + } + + &:where([data-text-skeleton-size='titleMedium']) { + --font-size: var(--text-title-size-medium); + --line-height: var(--text-title-lineHeight-medium); + } + + &:where([data-text-skeleton-size='titleSmall']) { + --font-size: var(--text-title-size-small); + --line-height: var(--text-title-lineHeight-small); + } + + &:where([data-text-skeleton-size='subtitle']) { + --font-size: var(--text-subtitle-size); + --line-height: var(--text-subtitle-lineHeight); + } + + &:where([data-text-skeleton-size='bodyLarge']) { + --font-size: var(--text-body-size-large); + --line-height: var(--text-body-lineHeight-large); + } + + &:where([data-text-skeleton-size='bodySmall']) { + --font-size: var(--text-body-size-small); + --line-height: var(--text-body-lineHeight-small); + } +} diff --git a/packages/react/src/experimental/Skeleton/SkeletonText.tsx b/packages/react/src/experimental/Skeleton/SkeletonText.tsx index 5c33c482d70..f26e0f98b27 100644 --- a/packages/react/src/experimental/Skeleton/SkeletonText.tsx +++ b/packages/react/src/experimental/Skeleton/SkeletonText.tsx @@ -1,6 +1,10 @@ -import React from 'react' +import React, {type CSSProperties, type HTMLProps} from 'react' import Box from '../../Box' import {SkeletonBox} from './SkeletonBox' +import classes from './SkeletonText.module.css' +import {useFeatureFlag} from '../../FeatureFlags' +import {clsx} from 'clsx' +import {merge} from '../../sx' type SkeletonTextProps = { /** Size of the text that the skeleton is replacing. */ @@ -11,7 +15,7 @@ type SkeletonTextProps = { maxWidth?: React.CSSProperties['maxWidth'] /** Class name for custom styling */ className?: string -} +} & Omit, 'size'> const skeletonTextStyles = { '&[data-component="SkeletonText"]': { @@ -68,39 +72,60 @@ const skeletonTextStyles = { }, } -export const SkeletonText: React.FC = ({lines = 1, maxWidth, size = 'bodyMedium', ...rest}) => { - return lines < 2 ? ( - - ) : ( - - {Array.from({length: lines}, (_, index) => ( - - ))} - - ) +export const SkeletonText: React.FC = ({ + lines = 1, + maxWidth, + size = 'bodyMedium', + className, + style, + ...rest +}) => { + const enabled = useFeatureFlag('primer_react_css_modules_team') + + if (lines < 2) { + return ( + + ) + } else { + return ( + + {Array.from({length: lines}, (_, index) => ( + + ))} + + ) + } } diff --git a/packages/react/src/experimental/Skeleton/__tests__/SkeletonBox.test.tsx b/packages/react/src/experimental/Skeleton/__tests__/SkeletonBox.test.tsx new file mode 100644 index 00000000000..a22e2ef2be5 --- /dev/null +++ b/packages/react/src/experimental/Skeleton/__tests__/SkeletonBox.test.tsx @@ -0,0 +1,25 @@ +import {render} from '@testing-library/react' +import React from 'react' +import {FeatureFlags} from '../../../FeatureFlags' +import {SkeletonBox} from '../SkeletonBox' + +describe('SkeletonBox', () => { + it('should support `className` on the outermost element', () => { + const Element = () => + const FeatureFlagElement = () => { + return ( + + + + ) + } + expect(render().container.firstChild).toHaveClass('test-class-name') + expect(render().container.firstChild).toHaveClass('test-class-name') + }) +}) diff --git a/packages/react/src/experimental/Skeleton/__tests__/SkeletonText.test.tsx b/packages/react/src/experimental/Skeleton/__tests__/SkeletonText.test.tsx new file mode 100644 index 00000000000..ab854af9bb4 --- /dev/null +++ b/packages/react/src/experimental/Skeleton/__tests__/SkeletonText.test.tsx @@ -0,0 +1,25 @@ +import {render} from '@testing-library/react' +import React from 'react' +import {FeatureFlags} from '../../../FeatureFlags' +import {SkeletonText} from '../SkeletonText' + +describe('SkeletonText', () => { + it('should support `className` on the outermost element', () => { + const Element = () => + const FeatureFlagElement = () => { + return ( + + + + ) + } + expect(render().container.firstChild).toHaveClass('test-class-name') + expect(render().container.firstChild).toHaveClass('test-class-name') + }) +})