diff --git a/.changeset/rotten-teachers-brush.md b/.changeset/rotten-teachers-brush.md new file mode 100644 index 00000000000..5b0aaa70fff --- /dev/null +++ b/.changeset/rotten-teachers-brush.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +Update `Timeline` component to use CSS modules behind the feature flag primer_react_css_modules_team diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-colorblind-linux.png new file mode 100644 index 00000000000..cd814fe66e7 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-dimmed-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-dimmed-linux.png new file mode 100644 index 00000000000..56aa9ef8b10 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-high-contrast-linux.png new file mode 100644 index 00000000000..03f1475c831 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-linux.png new file mode 100644 index 00000000000..3de87f7854a Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-tritanopia-linux.png new file mode 100644 index 00000000000..3de87f7854a Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-light-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-light-colorblind-linux.png new file mode 100644 index 00000000000..af26dc01247 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-light-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-light-high-contrast-linux.png new file mode 100644 index 00000000000..4c2242f7484 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-light-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-light-linux.png new file mode 100644 index 00000000000..64926bde6a6 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-light-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-light-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-light-tritanopia-linux.png new file mode 100644 index 00000000000..64926bde6a6 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-SX-Props-light-tritanopia-linux.png differ diff --git a/e2e/components/Timeline.test.ts b/e2e/components/Timeline.test.ts index ceba14c85ec..480467c8547 100644 --- a/e2e/components/Timeline.test.ts +++ b/e2e/components/Timeline.test.ts @@ -2,143 +2,65 @@ import {test, expect} from '@playwright/test' import {visit} from '../test-helpers/storybook' import {themes} from '../test-helpers/themes' -test.describe('Timeline', () => { - test.describe('Default', () => { - for (const theme of themes) { - test.describe(theme, () => { - test('default @vrt', async ({page}) => { - await visit(page, { - id: 'components-timeline--default', - globals: { - colorScheme: theme, - }, - }) - - // Default state - expect(await page.screenshot()).toMatchSnapshot(`Timeline.Default.${theme}.png`) - }) - - test('axe @aat', async ({page}) => { - await visit(page, { - id: 'components-timeline--default', - globals: { - colorScheme: theme, - }, - }) - await expect(page).toHaveNoViolations({ - rules: { - 'color-contrast': { - enabled: theme !== 'dark_dimmed', - }, - }, - }) - }) - }) - } - }) +const stories = [ + { + title: 'Default', + id: 'components-timeline--default', + }, + { + title: 'Clip Sidebar', + id: 'components-timeline-features--clip-sidebar', + }, + { + title: 'Condensed Items', + id: 'components-timeline-features--condensed-items', + }, + { + title: 'Timeline Break', + id: 'components-timeline-features--timeline-break', + }, + { + title: 'SX Props', + id: 'components-timeline-dev--sx-props', + }, +] as const - test.describe('Clip Sidebar', () => { - for (const theme of themes) { - test.describe(theme, () => { - test('default @vrt', async ({page}) => { - await visit(page, { - id: 'components-timeline-features--clip-sidebar', - globals: { - colorScheme: theme, - }, - }) - - // Default state - expect(await page.screenshot()).toMatchSnapshot(`Timeline.Clip Sidebar.${theme}.png`) - }) - - test('axe @aat', async ({page}) => { - await visit(page, { - id: 'components-timeline-features--clip-sidebar', - globals: { - colorScheme: theme, - }, - }) - await expect(page).toHaveNoViolations({ - rules: { - 'color-contrast': { - enabled: theme !== 'dark_dimmed', +test.describe('Timeline', () => { + for (const story of stories) { + test.describe(story.title, () => { + for (const theme of themes) { + test.describe(theme, () => { + test('@vrt', async ({page}) => { + await visit(page, { + id: story.id, + globals: { + colorScheme: theme, }, - }, - }) - }) - }) - } - }) + }) - test.describe('Condensed Items', () => { - for (const theme of themes) { - test.describe(theme, () => { - test('default @vrt', async ({page}) => { - await visit(page, { - id: 'components-timeline-features--condensed-items', - globals: { - colorScheme: theme, - }, + // Default state + expect(await page.screenshot()).toMatchSnapshot(`Timeline.${story.title}.${theme}.png`) }) - // Default state - expect(await page.screenshot()).toMatchSnapshot(`Timeline.Condensed Items.${theme}.png`) - }) - - test('axe @aat', async ({page}) => { - await visit(page, { - id: 'components-timeline-features--condensed-items', - globals: { - colorScheme: theme, - }, - }) - await expect(page).toHaveNoViolations({ - rules: { - 'color-contrast': { - enabled: theme !== 'dark_dimmed', + test('axe @aat', async ({page}) => { + await visit(page, { + id: story.id, + globals: { + colorScheme: theme, }, - }, - }) - }) - }) - } - }) - - test.describe('Timeline Break', () => { - for (const theme of themes) { - test.describe(theme, () => { - test('default @vrt', async ({page}) => { - await visit(page, { - id: 'components-timeline-features--timeline-break', - globals: { - colorScheme: theme, - }, - }) - - // Default state - expect(await page.screenshot()).toMatchSnapshot(`Timeline.Timeline Break.${theme}.png`) - }) - - test('axe @aat', async ({page}) => { - await visit(page, { - id: 'components-timeline-features--timeline-break', - globals: { - colorScheme: theme, - }, - }) - await expect(page).toHaveNoViolations({ - rules: { - 'color-contrast': { - enabled: theme !== 'dark_dimmed', + }) + await expect(page).toHaveNoViolations({ + rules: { + 'color-contrast': { + enabled: theme !== 'dark_dimmed', + }, }, - }, + }) }) }) - }) - } - }) - + } + }) + } test.describe('With Inline Links', () => { for (const theme of themes) { test.describe(theme, () => { diff --git a/packages/react/src/Timeline/Timeline.dev.stories.tsx b/packages/react/src/Timeline/Timeline.dev.stories.tsx new file mode 100644 index 00000000000..dfb7b7ddbed --- /dev/null +++ b/packages/react/src/Timeline/Timeline.dev.stories.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import type {Meta} from '@storybook/react' +import type {ComponentProps} from '../utils/types' +import Timeline from './Timeline' +import Octicon from '../Octicon' +import {GitCommitIcon} from '@primer/octicons-react' + +export default { + title: 'Components/Timeline/Dev', + component: Timeline, + subcomponents: { + 'Timeline.Item': Timeline.Item, + 'Timeline.Badge': Timeline.Badge, + 'Timeline.Body': Timeline.Body, + 'Timeline.Break': Timeline.Break, + }, +} as Meta> + +export const SxProps = () => ( + + + + + + + This is a message + + + + + + + + This is a message + + + + + + + + This is a message + + +) diff --git a/packages/react/src/Timeline/Timeline.module.css b/packages/react/src/Timeline/Timeline.module.css new file mode 100644 index 00000000000..b3485290038 --- /dev/null +++ b/packages/react/src/Timeline/Timeline.module.css @@ -0,0 +1,99 @@ +.Timeline { + display: flex; + flex-direction: column; + + &:where([data-clip-sidebar]) { + .TimelineItem:first-child { + padding-top: 0; + } + + .TimelineItem:last-child { + padding-bottom: 0; + } + } +} + +.TimelineItem { + position: relative; + display: flex; + padding: var(--base-size-16) 0; + margin-left: var(--base-size-16); + + &::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + display: block; + width: 2px; + content: ''; + /* stylelint-disable-next-line primer/colors */ + background-color: var(--borderColor-muted); + } + + &:where([data-condensed]) { + padding-top: var(--base-size-4); + padding-bottom: 0; + + &:last-child { + padding-bottom: var(--base-size-16); + } + + .TimelineBadge { + height: 16px; + margin-top: var(--base-size-8); + margin-bottom: var(--base-size-8); + color: var(--fgColor-muted); + background-color: var(--bgColor-default); + border: 0; + } + } +} + +.TimelineBadgeWrapper { + position: relative; + z-index: 1; +} + +.TimelineBadge { + display: flex; + width: 32px; + height: 32px; + margin-right: var(--base-size-8); + /* stylelint-disable-next-line primer/spacing */ + margin-left: -15px; + flex-shrink: 0; + overflow: hidden; + color: var(--fgColor-muted); + + /* TODOl not quite sure if this is the correct migration for this line */ + background-color: var(--timelineBadge-bgColor); + /* stylelint-disable-next-line primer/colors */ + border-color: var(--bgColor-default); + border-style: solid; + border-width: var(--borderWidth-thick); + border-radius: 50%; + align-items: center; + justify-content: center; +} + +.TimelineBody { + min-width: 0; + max-width: 100%; + margin-top: var(--base-size-4); + font-size: var(--text-body-size-medium); + color: var(--fgColor-muted); + flex: auto; +} + +.TimelineBreak { + position: relative; + z-index: 1; + height: var(--base-size-24); + margin: 0; + margin-bottom: calc(-1 * var(--base-size-16)); + margin-left: 0; + background-color: var(--bgColor-default); + border: 0; + border-top: var(--borderWidth-thicker) solid var(--borderColor-default); +} diff --git a/packages/react/src/Timeline/Timeline.tsx b/packages/react/src/Timeline/Timeline.tsx index 32c7c33aa7a..6252afd07de 100644 --- a/packages/react/src/Timeline/Timeline.tsx +++ b/packages/react/src/Timeline/Timeline.tsx @@ -1,76 +1,158 @@ import {clsx} from 'clsx' -import React from 'react' +import React, {type HTMLProps} from 'react' import styled, {css} from 'styled-components' import Box from '../Box' import {get} from '../constants' import type {SxProp} from '../sx' import sx from '../sx' -import type {ComponentProps} from '../utils/types' - -const Timeline = styled.div<{clipSidebar?: boolean} & SxProp>` - display: flex; - flex-direction: column; - ${props => - props.clipSidebar && - css` - .Timeline-Item:first-child { - padding-top: 0; - } - - .Timeline-Item:last-child { +import {toggleStyledComponent} from '../internal/utils/toggleStyledComponent' +import {useFeatureFlag} from '../FeatureFlags' +import classes from './Timeline.module.css' +import {defaultSxProp} from '../utils/defaultSxProp' + +const CSS_MODULES_FEATURE_FLAG = 'primer_react_css_modules_team' + +type StyledTimelineProps = {clipSidebar?: boolean; className?: string} & SxProp + +const ToggleTimeline = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'div', + styled.div` + display: flex; + flex-direction: column; + ${props => + props.clipSidebar && + css` + .Timeline-Item:first-child { + padding-top: 0; + } + + .Timeline-Item:last-child { + padding-bottom: 0; + } + `} + + ${sx}; + `, +) + +export type TimelineProps = StyledTimelineProps & HTMLProps + +const Timeline = React.forwardRef(function Timeline( + {clipSidebar, className, ...props}, + forwardRef, +) { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + if (enabled) { + return ( + + ) + } + + return +}) + +Timeline.displayName = 'Timeline' + +type StyledTimelineItemProps = {condensed?: boolean; className?: string} & SxProp + +const ToggleTimelineItem = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'div', + styled.div.attrs(props => ({ + className: clsx('Timeline-Item', props.className), + }))` + display: flex; + position: relative; + padding: ${get('space.3')} 0; + margin-left: ${get('space.3')}; + + &::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + display: block; + width: 2px; + content: ''; + background-color: ${get('colors.border.muted')}; + } + + ${props => + props.condensed && + css` + padding-top: ${get('space.1')}; padding-bottom: 0; - } - `} - - ${sx}; -` - -type StyledTimelineItemProps = {condensed?: boolean} & SxProp - -const TimelineItem = styled.div.attrs(props => ({ - className: clsx('Timeline-Item', props.className), -}))` - display: flex; - position: relative; - padding: ${get('space.3')} 0; - margin-left: ${get('space.3')}; - - &::before { - position: absolute; - top: 0; - bottom: 0; - left: 0; - display: block; - width: 2px; - content: ''; - background-color: ${get('colors.border.muted')}; + &:last-child { + padding-bottom: ${get('space.3')}; + } + + .TimelineItem-Badge { + height: 16px; + margin-top: ${get('space.2')}; + margin-bottom: ${get('space.2')}; + color: ${get('colors.fg.muted')}; + background-color: ${get('colors.canvas.default')}; + border: 0; + } + `} + + ${sx}; + `, +) + +/** + * @deprecated Use the `TimelineItemProps` type instead + */ +export type TimelineItemsProps = StyledTimelineItemProps & HTMLProps + +export type TimelineItemProps = StyledTimelineItemProps & HTMLProps + +const TimelineItem = React.forwardRef(function TimelineItem( + {condensed, className, ...props}, + forwardRef, +) { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + if (enabled) { + return ( + + ) } - ${props => - props.condensed && - css` - padding-top: ${get('space.1')}; - padding-bottom: 0; - &:last-child { - padding-bottom: ${get('space.3')}; - } - - .TimelineItem-Badge { - height: 16px; - margin-top: ${get('space.2')}; - margin-bottom: ${get('space.2')}; - color: ${get('colors.fg.muted')}; - background-color: ${get('colors.canvas.default')}; - border: 0; - } - `} - - ${sx}; -` - -export type TimelineBadgeProps = {children?: React.ReactNode} & SxProp - -const TimelineBadge = (props: TimelineBadgeProps) => { + return +}) + +TimelineItem.displayName = 'TimelineItem' + +export type TimelineBadgeProps = {children?: React.ReactNode; className?: string} & SxProp & + React.ComponentPropsWithoutRef<'div'> + +const TimelineBadge = ({sx, className, ...props}: TimelineBadgeProps) => { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + if (enabled) { + if (sx !== defaultSxProp) { + return ( +
+ +
+ ) + } + return ( +
+
+
+ ) + } return ( { ml="-15px" alignItems="center" justifyContent="center" - sx={props.sx} + sx={sx} > {props.children} @@ -98,41 +180,79 @@ const TimelineBadge = (props: TimelineBadgeProps) => { ) } -const TimelineBody = styled.div` - min-width: 0; - max-width: 100%; - margin-top: ${get('space.1')}; - color: ${get('colors.fg.muted')}; - flex: auto; - font-size: ${get('fontSizes.1')}; - ${sx}; -` - -const TimelineBreak = styled.div` - position: relative; - z-index: 1; - height: 24px; - margin: 0; - margin-bottom: -${get('space.3')}; - margin-left: 0; - background-color: ${get('colors.canvas.default')}; - border: 0; - border-top: ${get('space.1')} solid ${get('colors.border.default')}; - ${sx}; -` - -TimelineItem.displayName = 'Timeline.Item' - TimelineBadge.displayName = 'Timeline.Badge' -TimelineBody.displayName = 'Timeline.Body' +const ToggleTimelineBody = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'div', + styled.div` + min-width: 0; + max-width: 100%; + margin-top: ${get('space.1')}; + color: ${get('colors.fg.muted')}; + flex: auto; + font-size: ${get('fontSizes.1')}; + ${sx}; + `, +) + +export type TimelineBodyProps = { + /** Class name for custom styling */ + className?: string +} & SxProp & + HTMLProps + +const TimelineBody = React.forwardRef(function TimelineBody( + {className, ...props}, + forwardRef, +) { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + if (enabled) { + return + } + + return +}) + +TimelineBody.displayName = 'TimelineBody' + +const ToggleTimelineBreak = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'div', + styled.div` + position: relative; + z-index: 1; + height: 24px; + margin: 0; + margin-bottom: -${get('space.3')}; + margin-left: 0; + background-color: ${get('colors.canvas.default')}; + border: 0; + border-top: ${get('space.1')} solid ${get('colors.border.default')}; + ${sx}; + `, +) + +export type TimelineBreakProps = { + /** Class name for custom styling */ + className?: string +} & SxProp & + HTMLProps + +const TimelineBreak = React.forwardRef(function TimelineBreak( + {className, ...props}, + forwardRef, +) { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + if (enabled) { + return + } + + return +}) -TimelineBreak.displayName = 'Timeline.Break' +TimelineBreak.displayName = 'TimelineBreak' -export type TimelineProps = ComponentProps -export type TimelineItemsProps = ComponentProps -export type TimelineBodyProps = ComponentProps -export type TimelineBreakProps = ComponentProps export default Object.assign(Timeline, { Item: TimelineItem, Badge: TimelineBadge, diff --git a/packages/react/src/Timeline/__tests__/Timeline.test.tsx b/packages/react/src/Timeline/__tests__/Timeline.test.tsx index c02faab4135..d661e8cc04c 100644 --- a/packages/react/src/Timeline/__tests__/Timeline.test.tsx +++ b/packages/react/src/Timeline/__tests__/Timeline.test.tsx @@ -4,6 +4,7 @@ import {render, rendersClass, behavesAsComponent, checkExports} from '../../util import React from 'react' import Timeline from '..' +import {FeatureFlags} from '../../FeatureFlags' describe('Timeline', () => { behavesAsComponent({Component: Timeline}) @@ -21,6 +22,25 @@ describe('Timeline', () => { it('renders with clipSidebar prop', () => { expect(render()).toMatchSnapshot() }) + + it('should support `className` on the outermost element', () => { + const Element = () => + const FeatureFlagElement = () => { + return ( + + + + ) + } + expect(HTMLRender().container.firstChild).toHaveClass('test-class-name') + expect(HTMLRender().container.firstChild).toHaveClass('test-class-name') + }) }) describe('Timeline.Item', () => { @@ -39,6 +59,25 @@ describe('Timeline.Item', () => { it('adds the Timeline-Item class', () => { expect(rendersClass(, 'Timeline-Item')).toEqual(true) }) + + it('should support `className` on the outermost element', () => { + const Element = () => + const FeatureFlagElement = () => { + return ( + + + + ) + } + expect(HTMLRender().container.firstChild).toHaveClass('test-class-name') + expect(HTMLRender().container.firstChild).toHaveClass('test-class-name') + }) }) describe('Timeline.Badge', () => { @@ -49,4 +88,80 @@ describe('Timeline.Badge', () => { const results = await axe.run(container) expect(results).toHaveNoViolations() }) + + it('should support `className` on the outermost element', () => { + const Element = () => + const FeatureFlagElement = () => { + return ( + + + + ) + } + expect(HTMLRender().container.firstChild?.firstChild).toHaveClass('test-class-name') + }) +}) + +describe('Timeline.Body', () => { + behavesAsComponent({Component: Timeline.Badge, options: {skipAs: true}}) + + it('should have no axe violations', async () => { + const {container} = HTMLRender() + const results = await axe.run(container) + expect(results).toHaveNoViolations() + }) + + it('should support `className` on the outermost element', () => { + const Element = () => + const FeatureFlagElement = () => { + return ( + + + + ) + } + expect(HTMLRender().container.firstChild).toHaveClass('test-class-name') + expect(HTMLRender().container.firstChild).toHaveClass('test-class-name') + }) +}) + +describe('Timeline.Break', () => { + behavesAsComponent({Component: Timeline.Badge, options: {skipAs: true}}) + + it('should have no axe violations', async () => { + const {container} = HTMLRender() + const results = await axe.run(container) + expect(results).toHaveNoViolations() + }) + + it('should support `className` on the outermost element', () => { + const Element = () => + const FeatureFlagElement = () => { + return ( + + + + ) + } + expect(HTMLRender().container.firstChild).toHaveClass('test-class-name') + expect(HTMLRender().container.firstChild).toHaveClass('test-class-name') + }) }) diff --git a/packages/react/src/Timeline/index.ts b/packages/react/src/Timeline/index.ts index cde44809333..55b0dce49f6 100644 --- a/packages/react/src/Timeline/index.ts +++ b/packages/react/src/Timeline/index.ts @@ -2,6 +2,7 @@ export {default} from './Timeline' export type { TimelineProps, TimelineItemsProps, + TimelineItemProps, TimelineBadgeProps, TimelineBodyProps, TimelineBreakProps,