diff --git a/.changeset/timeline-intrinsic-width.md b/.changeset/timeline-intrinsic-width.md new file mode 100644 index 00000000000..9049c07d53f --- /dev/null +++ b/.changeset/timeline-intrinsic-width.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Timeline: Prevent content-sized timelines from collapsing while preserving responsive action layouts. diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-colorblind-linux.png new file mode 100644 index 00000000000..b3d934b9563 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-dimmed-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-dimmed-linux.png new file mode 100644 index 00000000000..fabbc3c6b45 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-high-contrast-linux.png new file mode 100644 index 00000000000..75ebbad7bb9 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-linux.png new file mode 100644 index 00000000000..15d21e7de0c Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-tritanopia-linux.png new file mode 100644 index 00000000000..adc3f85ce28 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-light-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-light-colorblind-linux.png new file mode 100644 index 00000000000..382b632bc65 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-light-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-light-high-contrast-linux.png new file mode 100644 index 00000000000..db9664b7d6e Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-light-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-light-linux.png new file mode 100644 index 00000000000..74822222de1 Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-light-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-light-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-light-tritanopia-linux.png new file mode 100644 index 00000000000..8629334deee Binary files /dev/null and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Actions-light-tritanopia-linux.png differ diff --git a/e2e/components/Timeline.test.ts b/e2e/components/Timeline.test.ts index 53ed90c5677..13cc4b17ea0 100644 --- a/e2e/components/Timeline.test.ts +++ b/e2e/components/Timeline.test.ts @@ -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) + + 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) { diff --git a/packages/react/src/Timeline/Timeline.features.stories.module.css b/packages/react/src/Timeline/Timeline.features.stories.module.css index d1337a60e6b..38509be0e99 100644 --- a/packages/react/src/Timeline/Timeline.features.stories.module.css +++ b/packages/react/src/Timeline/Timeline.features.stories.module.css @@ -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 { @@ -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 { diff --git a/packages/react/src/Timeline/Timeline.module.css b/packages/react/src/Timeline/Timeline.module.css index 340516926f5..fe30e2c349a 100644 --- a/packages/react/src/Timeline/Timeline.module.css +++ b/packages/react/src/Timeline/Timeline.module.css @@ -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']) { @@ -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; @@ -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; diff --git a/packages/react/src/Timeline/Timeline.tsx b/packages/react/src/Timeline/Timeline.tsx index a3d257ed4e5..3e2155c885b 100644 --- a/packages/react/src/Timeline/Timeline.tsx +++ b/packages/react/src/Timeline/Timeline.tsx @@ -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' @@ -18,6 +20,13 @@ const Timeline = React.forwardRef { const useListSemantics = useFeatureFlag('primer_react_timeline_list_semantics') const resolvedClipSidebar = resolveClipSidebar(clipSidebar) + const [isNarrow, setIsNarrow] = React.useState(false) + const timelineRef = React.useRef(null) + const mergedRef = useMergedRefs(forwardRef, timelineRef) + + useResizeObserver(entries => { + setIsNarrow(entries[0]?.contentRect.width < 480) + }, timelineRef) if (useListSemantics) { return ( @@ -28,8 +37,9 @@ const Timeline = React.forwardRef} + ref={mergedRef as React.Ref} data-clip-sidebar={resolvedClipSidebar} + data-timeline-narrow={isNarrow ? '' : undefined} /> ) } @@ -38,8 +48,9 @@ const Timeline = React.forwardRef)} className={clsx(className, classes.Timeline)} - ref={forwardRef as React.ForwardedRef} + ref={mergedRef as React.Ref} data-clip-sidebar={resolvedClipSidebar} + data-timeline-narrow={isNarrow ? '' : undefined} /> ) }, diff --git a/packages/react/src/Timeline/__tests__/Timeline.test.tsx b/packages/react/src/Timeline/__tests__/Timeline.test.tsx index 66a1e9b7061..dcd94955f0d 100644 --- a/packages/react/src/Timeline/__tests__/Timeline.test.tsx +++ b/packages/react/src/Timeline/__tests__/Timeline.test.tsx @@ -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' @@ -23,6 +23,14 @@ describe('Timeline', () => { expect(container.firstChild).not.toHaveAttribute('role') }) + it('forwards its ref to the root div', () => { + const ref = createRef() + + render() + + expect(ref.current).toBeInstanceOf(HTMLDivElement) + }) + it('renders with clipSidebar prop (boolean)', () => { const {container} = render() expect(container.firstChild).toHaveAttribute('data-clip-sidebar', 'both') @@ -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() + + renderWithListSemantics() + + expect(ref.current).toBeInstanceOf(HTMLOListElement) + }) + it('renders items as list items', () => { const {container} = renderWithListSemantics(