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-intrinsic-width.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Timeline: Prevent content-sized timelines from collapsing while preserving responsive action layouts.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions e2e/components/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,58 @@ const stories = [
title: 'Badge Variants',
id: 'components-timeline-features--badge-variants',
},
{
title: 'With Actions',
id: 'components-timeline-features--with-actions',
},
] as const

test.describe('Timeline', () => {
test('retains its intrinsic width inside a shrink-to-fit container', async ({page}) => {
await visit(page, {
id: 'components-timeline--default',
})

const body = page.getByText('This is a message').first()
const timeline = body.locator('../..')

await timeline.evaluate(element => {
const outer = document.createElement('div')
const inner = document.createElement('div')
outer.style.display = 'flex'
outer.style.justifyContent = 'center'
element.parentElement?.insertBefore(outer, element)
outer.appendChild(inner)
inner.appendChild(element)
})

await expect.poll(async () => (await timeline.boundingBox())?.width).toBeGreaterThan(0)
await expect.poll(async () => (await body.boundingBox())?.width).toBeGreaterThan(0)
})

test('wraps Actions below Body only when narrower than 480px', async ({page}) => {
await visit(page, {
id: 'components-timeline-features--with-actions',
})

const actions = page.getByRole('button', {name: 'View details'}).first().locator('..')
const item = actions.locator('..')
const timeline = item.locator('..')
const body = item.locator('[class*="TimelineBody"]')

await timeline.evaluate(element => {
element.style.width = '479px'
})
await expect(timeline).toHaveAttribute('data-timeline-narrow', '')
await expect.poll(async () => (await actions.boundingBox())?.y).toBeGreaterThan((await body.boundingBox())?.y ?? 0)
Comment thread
liuliu-dev marked this conversation as resolved.

await timeline.evaluate(element => {
element.style.width = '480px'
})
await expect(timeline).not.toHaveAttribute('data-timeline-narrow')
await expect.poll(async () => (await actions.boundingBox())?.y).toBeLessThan((await body.boundingBox())?.y ?? 0)
})

for (const story of stories) {
test.describe(story.title, () => {
for (const theme of themes) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,9 @@
margin-top: var(--base-size-4);
}

@container (width < 480px) {
.CrossReferenceRow {
flex-direction: column;
gap: var(--base-size-4);
}
:where([data-timeline-narrow]) .CrossReferenceRow {
flex-direction: column;
gap: var(--base-size-4);
}

.CrossReferenceTitle {
Expand Down Expand Up @@ -116,10 +114,8 @@
/* Hides Timeline.Actions children (Verified / Unverified signature labels
on commit rows) when Timeline is narrow, so only the compact icon + SHA
cluster remains after the Actions row wraps beneath Body. */
@container (width < 480px) {
.HideAtNarrow {
display: none;
}
:where([data-timeline-narrow]) .HideAtNarrow {
display: none;
}

.Timestamp {
Expand Down
13 changes: 4 additions & 9 deletions packages/react/src/Timeline/Timeline.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@
list-style: none;
padding: 0;
margin: 0;
/* Container for the narrow-viewport rules at the end of this file. Uses
an inline-size container so behavior tracks the timeline's own width,
not the viewport (works inside narrow side panels and split views). */
container-type: inline-size;

&:where([data-clip-sidebar='start']),
&:where([data-clip-sidebar='both']) {
Expand Down Expand Up @@ -199,10 +195,10 @@
flex-shrink: 0;
}

/* Below 480px inline size, wrap Timeline.Actions onto its own row under
Timeline.Body via a 2-column grid. The badge keeps the left column so
the vertical rule stays consistent. */
@container (width < 480px) {
/* Below 480px, wrap Timeline.Actions onto its own row under Timeline.Body
via a 2-column grid. The badge keeps the left column so the vertical rule
stays consistent. */
.Timeline:where([data-timeline-narrow]) {
.TimelineItem {
display: grid;
grid-template-columns: auto 1fr;
Expand All @@ -221,7 +217,6 @@

.TimelineItemActions {
grid-area: actions;
/* Reset flex-row auto-push; the wrapped row sits under Body. */
margin-top: var(--base-size-8);
margin-left: 0;
justify-content: flex-start;
Expand Down
15 changes: 13 additions & 2 deletions packages/react/src/Timeline/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {clsx} from 'clsx'
import React from 'react'
import {useFeatureFlag} from '../FeatureFlags'
import {useMergedRefs} from '../hooks'
import {useResizeObserver} from '../hooks/useResizeObserver'
import classes from './Timeline.module.css'
import type {TimelineBadgeVariants} from './constants'

Expand All @@ -18,6 +20,13 @@ const Timeline = React.forwardRef<HTMLDivElement | HTMLOListElement, TimelinePro
({clipSidebar, className, ...props}, forwardRef) => {
const useListSemantics = useFeatureFlag('primer_react_timeline_list_semantics')
const resolvedClipSidebar = resolveClipSidebar(clipSidebar)
const [isNarrow, setIsNarrow] = React.useState(false)
const timelineRef = React.useRef<HTMLDivElement | HTMLOListElement>(null)
const mergedRef = useMergedRefs(forwardRef, timelineRef)

useResizeObserver(entries => {
setIsNarrow(entries[0]?.contentRect.width < 480)
}, timelineRef)
Comment thread
liuliu-dev marked this conversation as resolved.

if (useListSemantics) {
return (
Expand All @@ -28,8 +37,9 @@ const Timeline = React.forwardRef<HTMLDivElement | HTMLOListElement, TimelinePro
{...props}
role="list"
className={clsx(className, classes.Timeline)}
ref={forwardRef as React.ForwardedRef<HTMLOListElement>}
ref={mergedRef as React.Ref<HTMLOListElement>}
data-clip-sidebar={resolvedClipSidebar}
data-timeline-narrow={isNarrow ? '' : undefined}
/>
)
}
Expand All @@ -38,8 +48,9 @@ const Timeline = React.forwardRef<HTMLDivElement | HTMLOListElement, TimelinePro
<div
{...(props as React.ComponentPropsWithoutRef<'div'>)}
className={clsx(className, classes.Timeline)}
ref={forwardRef as React.ForwardedRef<HTMLDivElement>}
ref={mergedRef as React.Ref<HTMLDivElement>}
data-clip-sidebar={resolvedClipSidebar}
data-timeline-narrow={isNarrow ? '' : undefined}
/>
)
},
Expand Down
18 changes: 17 additions & 1 deletion packages/react/src/Timeline/__tests__/Timeline.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {render} from '@testing-library/react'
import type {ReactElement} from 'react'
import {createRef, type ReactElement} from 'react'
import {describe, expect, it} from 'vitest'
import Timeline from '..'
import {FeatureFlags} from '../../FeatureFlags'
Expand All @@ -23,6 +23,14 @@ describe('Timeline', () => {
expect(container.firstChild).not.toHaveAttribute('role')
})

it('forwards its ref to the root div', () => {
const ref = createRef<HTMLDivElement | HTMLOListElement>()

render(<Timeline ref={ref} />)

expect(ref.current).toBeInstanceOf(HTMLDivElement)
})

it('renders with clipSidebar prop (boolean)', () => {
const {container} = render(<Timeline clipSidebar />)
expect(container.firstChild).toHaveAttribute('data-clip-sidebar', 'both')
Expand Down Expand Up @@ -65,6 +73,14 @@ describe('Timeline with primer_react_timeline_list_semantics flag', () => {
expect(container.firstChild).toHaveAttribute('role', 'list')
})

it('forwards its ref to the root ordered list', () => {
const ref = createRef<HTMLDivElement | HTMLOListElement>()

renderWithListSemantics(<Timeline ref={ref} />)

expect(ref.current).toBeInstanceOf(HTMLOListElement)
})

it('renders items as list items', () => {
const {container} = renderWithListSemantics(
<Timeline>
Expand Down
Loading