Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/timeline-badge-variant-clip-sidebar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Timeline: add `variant` prop to `Timeline.Badge` for built-in color schemes, and extend `clipSidebar` to accept `'start'` or `'end'` for one-sided trimming
12 changes: 12 additions & 0 deletions e2e/components/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ const stories = [
title: 'Clip Sidebar',
id: 'components-timeline-features--clip-sidebar',
},
{
title: 'Clip Sidebar Start',
id: 'components-timeline-features--clip-sidebar-start',
},
{
title: 'Clip Sidebar End',
id: 'components-timeline-features--clip-sidebar-end',
},
{
title: 'Condensed Items',
id: 'components-timeline-features--condensed-items',
Expand All @@ -19,6 +27,10 @@ const stories = [
title: 'Timeline Break',
id: 'components-timeline-features--timeline-break',
},
{
title: 'Badge Variants',
id: 'components-timeline-features--badge-variants',
},
] as const

test.describe('Timeline', () => {
Expand Down
21 changes: 18 additions & 3 deletions packages/react/src/Timeline/Timeline.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,21 @@
{
"id": "components-timeline-features--clip-sidebar"
},
{
"id": "components-timeline-features--clip-sidebar-start"
},
{
"id": "components-timeline-features--clip-sidebar-end"
},
{
"id": "components-timeline-features--condensed-items"
},
{
"id": "components-timeline-features--timeline-break"
},
{
"id": "components-timeline-features--badge-variants"
},
{
"id": "components-timeline-features--with-inline-links"
}
Expand All @@ -24,8 +33,8 @@
"props": [
{
"name": "clipSidebar",
"type": "boolean",
"description": "Hides the sidebar above the first Timeline.Item and below the last Timeline.Item."
"type": "boolean | 'start' | 'end'",
"description": "Clips the sidebar line. When true, clips both ends. Use 'start' or 'end' to clip only one end."
}
],
"subcomponents": [
Expand All @@ -41,7 +50,13 @@
},
{
"name": "Timeline.Badge",
"props": []
"props": [
{
"name": "variant",
"type": "'accent' | 'success' | 'attention' | 'severe' | 'danger' | 'done' | 'open' | 'closed' | 'sponsors'",
"description": "The color variant of the badge."
}
]
},
{
"name": "Timeline.Body",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
.BadgeWithDoneBackground {
background-color: var(--bgColor-done-emphasis);
}

.LinkWithBoldStyle {
font-weight: var(--base-text-weight-semibold);
color: var(--fgColor-default);
Expand All @@ -11,7 +7,3 @@
.LinkWithBoldStyle:hover {
color: var(--fgColor-accent);
}

.GitMergeIcon {
color: var(--fgColor-onEmphasis);
}
110 changes: 107 additions & 3 deletions packages/react/src/Timeline/Timeline.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@ import type {Meta} from '@storybook/react-vite'
import type {ComponentProps} from '../utils/types'
import Timeline from './Timeline'
import Octicon from '../Octicon'
import {GitBranchIcon, GitCommitIcon, GitMergeIcon} from '@primer/octicons-react'
import {
FlameIcon,
GitBranchIcon,
GitCommitIcon,
GitMergeIcon,
GitPullRequestIcon,
HeartIcon,
IssueClosedIcon,
IssueOpenedIcon,
SkipIcon,
XIcon,
} from '@primer/octicons-react'
import Link from '../Link'
import classes from './Timeline.features.stories.module.css'

Expand Down Expand Up @@ -34,6 +45,40 @@ export const ClipSidebar = () => (
</Timeline>
)

export const ClipSidebarStart = () => (
<Timeline clipSidebar="start">
<Timeline.Item>
<Timeline.Badge>
<Octicon icon={GitCommitIcon} aria-label="Commit" />
</Timeline.Badge>
<Timeline.Body>This is a message</Timeline.Body>
</Timeline.Item>
<Timeline.Item>
<Timeline.Badge>
<Octicon icon={GitCommitIcon} aria-label="Commit" />
</Timeline.Badge>
<Timeline.Body>This is a message</Timeline.Body>
</Timeline.Item>
</Timeline>
)

export const ClipSidebarEnd = () => (
<Timeline clipSidebar="end">
<Timeline.Item>
<Timeline.Badge>
<Octicon icon={GitCommitIcon} aria-label="Commit" />
</Timeline.Badge>
<Timeline.Body>This is a message</Timeline.Body>
</Timeline.Item>
<Timeline.Item>
<Timeline.Badge>
<Octicon icon={GitCommitIcon} aria-label="Commit" />
</Timeline.Badge>
<Timeline.Body>This is a message</Timeline.Body>
</Timeline.Item>
</Timeline>
)

export const CondensedItems = () => (
<Timeline>
<Timeline.Item condensed>
Expand Down Expand Up @@ -67,8 +112,8 @@ export const CondensedItems = () => (
export const TimelineBreak = () => (
<Timeline>
<Timeline.Item>
<Timeline.Badge className={classes.BadgeWithDoneBackground}>
<Octicon icon={GitMergeIcon} className={classes.GitMergeIcon} aria-label="Merged" />
<Timeline.Badge variant="done">
<Octicon icon={GitMergeIcon} aria-label="Merged" />
</Timeline.Badge>
<Timeline.Body>This is a message</Timeline.Body>
</Timeline.Item>
Expand All @@ -82,6 +127,65 @@ export const TimelineBreak = () => (
</Timeline>
)

export const BadgeVariants = () => (
<Timeline>
<Timeline.Item>
<Timeline.Badge variant="accent">
<Octicon icon={GitPullRequestIcon} aria-label="Pull request" />
</Timeline.Badge>
<Timeline.Body>Accent</Timeline.Body>
</Timeline.Item>
<Timeline.Item>
<Timeline.Badge variant="success">
<Octicon icon={IssueClosedIcon} aria-label="Closed" />
</Timeline.Badge>
<Timeline.Body>Success</Timeline.Body>
</Timeline.Item>
<Timeline.Item>
<Timeline.Badge variant="attention">
<Octicon icon={FlameIcon} aria-label="Attention" />
</Timeline.Badge>
<Timeline.Body>Attention</Timeline.Body>
</Timeline.Item>
<Timeline.Item>
<Timeline.Badge variant="severe">
<Octicon icon={SkipIcon} aria-label="Severe" />
</Timeline.Badge>
<Timeline.Body>Severe</Timeline.Body>
</Timeline.Item>
<Timeline.Item>
<Timeline.Badge variant="danger">
<Octicon icon={XIcon} aria-label="Danger" />
</Timeline.Badge>
<Timeline.Body>Danger</Timeline.Body>
</Timeline.Item>
<Timeline.Item>
<Timeline.Badge variant="done">
<Octicon icon={GitMergeIcon} aria-label="Merged" />
</Timeline.Badge>
<Timeline.Body>Done</Timeline.Body>
</Timeline.Item>
<Timeline.Item>
<Timeline.Badge variant="open">
<Octicon icon={IssueOpenedIcon} aria-label="Open" />
</Timeline.Badge>
<Timeline.Body>Open</Timeline.Body>
</Timeline.Item>
<Timeline.Item>
<Timeline.Badge variant="closed">
<Octicon icon={IssueClosedIcon} aria-label="Closed" />
</Timeline.Badge>
<Timeline.Body>Closed</Timeline.Body>
</Timeline.Item>
<Timeline.Item>
<Timeline.Badge variant="sponsors">
<Octicon icon={HeartIcon} aria-label="Sponsors" />
</Timeline.Badge>
<Timeline.Body>Sponsors</Timeline.Body>
</Timeline.Item>
</Timeline>
)

export const WithInlineLinks = () => (
<Timeline>
<Timeline.Item>
Expand Down
45 changes: 44 additions & 1 deletion packages/react/src/Timeline/Timeline.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@
display: flex;
flex-direction: column;

&:where([data-clip-sidebar]) {
&:where([data-clip-sidebar=''], [data-clip-sidebar='start']) {
.TimelineItem:first-child {
padding-top: 0;

&:where([data-condensed])::before {
top: var(--base-size-12);
}
}
}

&:where([data-clip-sidebar=''], [data-clip-sidebar='end']) {
.TimelineItem:last-child {
padding-bottom: 0;

Expand Down Expand Up @@ -83,6 +85,47 @@
border-radius: 50%;
align-items: center;
justify-content: center;

&:where([data-variant]) {
color: var(--fgColor-onEmphasis);
border-color: transparent;
}

&:where([data-variant='accent']) {
background-color: var(--bgColor-accent-emphasis);
}

&:where([data-variant='success']) {
background-color: var(--bgColor-success-emphasis);
}

&:where([data-variant='attention']) {
background-color: var(--bgColor-attention-emphasis);
}

&:where([data-variant='severe']) {
background-color: var(--bgColor-severe-emphasis);
}

&:where([data-variant='danger']) {
background-color: var(--bgColor-danger-emphasis);
}

&:where([data-variant='done']) {
background-color: var(--bgColor-done-emphasis);
}

&:where([data-variant='open']) {
background-color: var(--bgColor-open-emphasis);
}

&:where([data-variant='closed']) {
background-color: var(--bgColor-closed-emphasis);
}

&:where([data-variant='sponsors']) {
background-color: var(--bgColor-sponsors-emphasis);
}
}

.TimelineBody {
Expand Down
27 changes: 23 additions & 4 deletions packages/react/src/Timeline/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@ import {clsx} from 'clsx'
import React from 'react'
import classes from './Timeline.module.css'

type StyledTimelineProps = {clipSidebar?: boolean; className?: string}
type StyledTimelineProps = {clipSidebar?: boolean | 'start' | 'end'; className?: string}

export type TimelineProps = StyledTimelineProps & React.ComponentPropsWithoutRef<'div'>

function clipSidebarValue(clipSidebar: TimelineProps['clipSidebar']): string | undefined {
if (clipSidebar === true) return ''
if (clipSidebar === 'start' || clipSidebar === 'end') return clipSidebar
return undefined
}

const Timeline = React.forwardRef<HTMLDivElement, TimelineProps>(({clipSidebar, className, ...props}, forwardRef) => {
return (
<div
{...props}
className={clsx(className, classes.Timeline)}
ref={forwardRef}
data-clip-sidebar={clipSidebar ? '' : undefined}
data-clip-sidebar={clipSidebarValue(clipSidebar)}
/>
)
})
Expand Down Expand Up @@ -43,15 +49,28 @@ const TimelineItem = React.forwardRef<HTMLDivElement, TimelineItemProps>(

TimelineItem.displayName = 'TimelineItem'

export type TimelineBadgeVariant =
| 'accent'
| 'success'
| 'attention'
| 'severe'
| 'danger'
| 'done'
| 'open'
| 'closed'
| 'sponsors'

export type TimelineBadgeProps = {
children?: React.ReactNode
className?: string
/** The color variant of the badge */
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TimelineBadgeVariant includes a 'default' value, but Timeline.Badge treats 'default' the same as omitting variant (no data-variant is rendered). Consider clarifying this in the variant JSDoc (e.g., “defaults to 'default' / omit for default styling”) or, if 'default' shouldn’t be public API, remove it from the union and update docs/tests accordingly.

Suggested change
/** The color variant of the badge */
/**
* The color variant of the badge.
*
* Defaults to the "default" styling. Omit this prop or set it to "default"
* for the default appearance; other values render a variant-specific style.
*/

Copilot uses AI. Check for mistakes.
variant?: TimelineBadgeVariant
} & React.ComponentPropsWithoutRef<'div'>

const TimelineBadge = ({className, ...props}: TimelineBadgeProps) => {
const TimelineBadge = ({className, variant, ...props}: TimelineBadgeProps) => {
return (
<div className={classes.TimelineBadgeWrapper}>
<div {...props} className={clsx(className, classes.TimelineBadge)} />
<div {...props} className={clsx(className, classes.TimelineBadge)} data-variant={variant} />
</div>
)
}
Expand Down
20 changes: 20 additions & 0 deletions packages/react/src/Timeline/__tests__/Timeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ describe('Timeline', () => {
const {container} = render(<Timeline clipSidebar />)
expect(container).toMatchSnapshot()
})

it('renders with clipSidebar="start"', () => {
const {container} = render(<Timeline clipSidebar="start" />)
expect(container.firstChild).toHaveAttribute('data-clip-sidebar', 'start')
})

it('renders with clipSidebar="end"', () => {
const {container} = render(<Timeline clipSidebar="end" />)
expect(container.firstChild).toHaveAttribute('data-clip-sidebar', 'end')
})
})

describe('Timeline.Item', () => {
Expand All @@ -28,6 +38,16 @@ describe('Timeline.Item', () => {

describe('Timeline.Badge', () => {
implementsClassName(Timeline.Badge, classes.TimelineBadge)

it('renders with variant prop', () => {
const {container} = render(<Timeline.Badge variant="done" />)
expect(container.querySelector(`.${classes.TimelineBadge}`)).toHaveAttribute('data-variant', 'done')
})

it('does not render data-variant when variant is omitted', () => {
const {container} = render(<Timeline.Badge />)
expect(container.querySelector(`.${classes.TimelineBadge}`)).not.toHaveAttribute('data-variant')
})
})

describe('Timeline.Body', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/Timeline/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type {
TimelineProps,
TimelineItemsProps,
TimelineItemProps,
TimelineBadgeVariant,
TimelineBadgeProps,
TimelineBodyProps,
TimelineBreakProps,
Expand Down
Loading
Loading