-
From b38f348a8bc9a76444c711c224d5adf630c7ba25 Mon Sep 17 00:00:00 2001
From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com>
Date: Fri, 29 May 2026 17:29:32 -0700
Subject: [PATCH 2/6] Prevent role="list" from being overridden by consumers
Move role="list" after the props spread so it cannot be overridden, and
omit role from TimelineProps to prevent passing it at the type level.
Matches the pattern used for Timeline.Break's role="presentation".
---
packages/react/src/Timeline/Timeline.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/react/src/Timeline/Timeline.tsx b/packages/react/src/Timeline/Timeline.tsx
index 7bbbdfcc486..10d129a72c2 100644
--- a/packages/react/src/Timeline/Timeline.tsx
+++ b/packages/react/src/Timeline/Timeline.tsx
@@ -4,7 +4,7 @@ import classes from './Timeline.module.css'
type StyledTimelineProps = {clipSidebar?: boolean | 'start' | 'end' | 'both'; className?: string}
-export type TimelineProps = StyledTimelineProps & React.ComponentPropsWithoutRef<'ol'>
+export type TimelineProps = StyledTimelineProps & Omit
, 'role'>
function resolveClipSidebar(clipSidebar: TimelineProps['clipSidebar']): string | undefined {
if (clipSidebar === true || clipSidebar === 'both') return 'both'
@@ -16,10 +16,10 @@ const Timeline = React.forwardRef(({clipSidebar
const resolvedClipSidebar = resolveClipSidebar(clipSidebar)
return (
Date: Fri, 29 May 2026 17:44:24 -0700
Subject: [PATCH 3/6] Suppress jsx-a11y/no-redundant-roles for role=list
The rule flags role="list" on as redundant, but it is required to
restore list semantics in Safari/VoiceOver when list-style: none is
applied.
---
packages/react/src/Timeline/Timeline.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/packages/react/src/Timeline/Timeline.tsx b/packages/react/src/Timeline/Timeline.tsx
index 10d129a72c2..290781d59b3 100644
--- a/packages/react/src/Timeline/Timeline.tsx
+++ b/packages/react/src/Timeline/Timeline.tsx
@@ -15,10 +15,11 @@ function resolveClipSidebar(clipSidebar: TimelineProps['clipSidebar']): string |
const Timeline = React.forwardRef(({clipSidebar, className, ...props}, forwardRef) => {
const resolvedClipSidebar = resolveClipSidebar(clipSidebar)
return (
+ // Explicit role restores list semantics in Safari/VoiceOver, which strips
+ // them when list-style: none is applied (WebKit intentional behaviour).
+ // eslint-disable-next-line jsx-a11y/no-redundant-roles
Date: Mon, 1 Jun 2026 10:32:39 -0700
Subject: [PATCH 4/6] chore: retrigger CI for canary release
From 5bd58bf2cf6cc98833a99ff59cc0a72ce5de460a Mon Sep 17 00:00:00 2001
From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com>
Date: Tue, 2 Jun 2026 13:19:40 -0700
Subject: [PATCH 5/6] chore: retrigger CI for canary release
From 5c4473902a527168cfc2193da7b4be07fb684fe1 Mon Sep 17 00:00:00 2001
From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com>
Date: Wed, 3 Jun 2026 15:09:16 -0700
Subject: [PATCH 6/6] Gate Timeline list semantics behind
primer_react_timeline_list_semantics flag
The unconditional ol/li change broke github-ui integration tests (DOM
nesting, ref types, role=list axe issues). Gate the new semantics behind
the primer_react_timeline_list_semantics feature flag so the default
behaviour remains /
until consumers opt in.
When the flag is enabled:
- Timeline renders as
- Timeline.Item renders as -
- Timeline.Break renders as
-
Ref types widened to HTMLDivElement | HTMLOListElement (Timeline) and
HTMLDivElement | HTMLLIElement (Item, Break) so consumers using either
state compile.
Tests cover both flag states. A new WithListSemantics story demonstrates
the opt-in behaviour for visual review.
Changeset bumped from minor to patch since the new behaviour is opt-in.
Refs github/primer#6679
---
.changeset/timeline-list-semantics.md | 16 ++--
.../src/FeatureFlags/DefaultFeatureFlags.ts | 1 +
.../Timeline/Timeline.features.stories.tsx | 27 ++++++
packages/react/src/Timeline/Timeline.tsx | 94 ++++++++++++++-----
.../src/Timeline/__tests__/Timeline.test.tsx | 51 ++++++++--
.../__snapshots__/Timeline.test.tsx.snap | 2 +-
6 files changed, 153 insertions(+), 38 deletions(-)
diff --git a/.changeset/timeline-list-semantics.md b/.changeset/timeline-list-semantics.md
index 7f7f1ad1e8f..c1f63f19e7a 100644
--- a/.changeset/timeline-list-semantics.md
+++ b/.changeset/timeline-list-semantics.md
@@ -1,13 +1,17 @@
---
-'@primer/react': minor
+'@primer/react': patch
---
-Timeline: Render as `
` with `- ` items for list semantics
+Timeline: Add `primer_react_timeline_list_semantics` feature flag to opt into list semantics
-Timeline now renders as an ordered list (`
`) instead of a ``, and Timeline.Item renders as `
- ` instead of `
`. This gives screen reader users list navigation — they can hear the total number of events and their position in the sequence ("item 3 of 12").
+When the `primer_react_timeline_list_semantics` feature flag is enabled, `Timeline` renders as `
` and `Timeline.Item` / `Timeline.Break` render as `- ` so screen reader users get list navigation (total item count, position in sequence). The default behavior is unchanged — `Timeline` and its subcomponents still render as `
` until the flag is opted into.
-Timeline.Break renders as `
- ` so it does not contribute to the list item count.
+Enable the flag with the `FeatureFlags` provider:
-An explicit `role="list"` is applied to the `
` to restore list semantics in Safari/VoiceOver, which strips them when `list-style: none` is applied.
+```tsx
+import {FeatureFlags} from '@primer/react/experimental'
-**Migration:** If you pass a `ref` to `Timeline`, the ref type changes from `HTMLDivElement` to `HTMLOListElement`. If you pass a `ref` to `Timeline.Item` or `Timeline.Break`, the ref type changes from `HTMLDivElement` to `HTMLLIElement`. All other props continue to work unchanged.
+
+ …
+
+```
diff --git a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts
index efe60fb47ad..eebbd691c10 100644
--- a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts
+++ b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts
@@ -6,4 +6,5 @@ export const DefaultFeatureFlags = FeatureFlagScope.create({
primer_react_select_panel_order_selected_at_top: false,
primer_react_styled_react_use_primer_theme_providers: false,
primer_react_action_list_group_heading_trailing_action: false,
+ primer_react_timeline_list_semantics: false,
})
diff --git a/packages/react/src/Timeline/Timeline.features.stories.tsx b/packages/react/src/Timeline/Timeline.features.stories.tsx
index 58a7d81e641..806f7b42e5c 100644
--- a/packages/react/src/Timeline/Timeline.features.stories.tsx
+++ b/packages/react/src/Timeline/Timeline.features.stories.tsx
@@ -1,5 +1,6 @@
import type {Meta} from '@storybook/react-vite'
import type {ComponentProps} from '../utils/types'
+import {FeatureFlags} from '../FeatureFlags'
import Timeline from './Timeline'
import {
CheckIcon,
@@ -375,3 +376,29 @@ export const WithAvatar = () => (
)
+
+export const WithListSemantics = () => (
+
+
+
+
+
+
+ Opted in: Timeline renders as an ordered list with list items.
+
+
+
+
+
+ Each Timeline.Item renders as a li.
+
+
+
+
+
+
+ Timeline.Break renders as a presentational li.
+
+
+
+)
diff --git a/packages/react/src/Timeline/Timeline.tsx b/packages/react/src/Timeline/Timeline.tsx
index 290781d59b3..c2a89a85db0 100644
--- a/packages/react/src/Timeline/Timeline.tsx
+++ b/packages/react/src/Timeline/Timeline.tsx
@@ -1,5 +1,6 @@
import {clsx} from 'clsx'
import React from 'react'
+import {useFeatureFlag} from '../FeatureFlags'
import classes from './Timeline.module.css'
type StyledTimelineProps = {clipSidebar?: boolean | 'start' | 'end' | 'both'; className?: string}
@@ -12,21 +13,36 @@ function resolveClipSidebar(clipSidebar: TimelineProps['clipSidebar']): string |
return undefined
}
-const Timeline = React.forwardRef(({clipSidebar, className, ...props}, forwardRef) => {
- const resolvedClipSidebar = resolveClipSidebar(clipSidebar)
- return (
- // Explicit role restores list semantics in Safari/VoiceOver, which strips
- // them when list-style: none is applied (WebKit intentional behaviour).
- // eslint-disable-next-line jsx-a11y/no-redundant-roles
-
- )
-})
+const Timeline = React.forwardRef(
+ ({clipSidebar, className, ...props}, forwardRef) => {
+ const useListSemantics = useFeatureFlag('primer_react_timeline_list_semantics')
+ const resolvedClipSidebar = resolveClipSidebar(clipSidebar)
+
+ if (useListSemantics) {
+ return (
+ // Explicit role restores list semantics in Safari/VoiceOver, which strips
+ // them when list-style: none is applied (WebKit intentional behaviour).
+ // eslint-disable-next-line jsx-a11y/no-redundant-roles
+ }
+ data-clip-sidebar={resolvedClipSidebar}
+ />
+ )
+ }
+
+ return (
+ )}
+ className={clsx(className, classes.Timeline)}
+ ref={forwardRef as React.ForwardedRef
}
+ data-clip-sidebar={resolvedClipSidebar}
+ />
+ )
+ },
+)
Timeline.displayName = 'Timeline'
@@ -39,13 +55,26 @@ export type TimelineItemsProps = StyledTimelineItemProps & React.ComponentPropsW
export type TimelineItemProps = StyledTimelineItemProps & React.ComponentPropsWithoutRef<'li'>
-const TimelineItem = React.forwardRef(
+const TimelineItem = React.forwardRef(
({condensed, className, ...props}, forwardRef) => {
+ const useListSemantics = useFeatureFlag('primer_react_timeline_list_semantics')
+
+ if (useListSemantics) {
+ return (
+ - }
+ data-condensed={condensed ? '' : undefined}
+ />
+ )
+ }
+
return (
-
- )}
className={clsx(className, 'Timeline-Item', classes.TimelineItem)}
- ref={forwardRef}
+ ref={forwardRef as React.ForwardedRef}
data-condensed={condensed ? '' : undefined}
/>
)
@@ -101,9 +130,30 @@ export type TimelineBreakProps = {
className?: string
} & Omit, 'role'>
-const TimelineBreak = React.forwardRef(({className, ...props}, forwardRef) => {
- return
-})
+const TimelineBreak = React.forwardRef(
+ ({className, ...props}, forwardRef) => {
+ const useListSemantics = useFeatureFlag('primer_react_timeline_list_semantics')
+
+ if (useListSemantics) {
+ return (
+
- }
+ role="presentation"
+ />
+ )
+ }
+
+ return (
+
)}
+ className={clsx(className, classes.TimelineBreak)}
+ ref={forwardRef as React.ForwardedRef
}
+ />
+ )
+ },
+)
TimelineBreak.displayName = 'TimelineBreak'
diff --git a/packages/react/src/Timeline/__tests__/Timeline.test.tsx b/packages/react/src/Timeline/__tests__/Timeline.test.tsx
index b7d096d506d..66a1e9b7061 100644
--- a/packages/react/src/Timeline/__tests__/Timeline.test.tsx
+++ b/packages/react/src/Timeline/__tests__/Timeline.test.tsx
@@ -1,20 +1,26 @@
import {render} from '@testing-library/react'
+import type {ReactElement} from 'react'
import {describe, expect, it} from 'vitest'
import Timeline from '..'
+import {FeatureFlags} from '../../FeatureFlags'
import {implementsClassName} from '../../utils/testing'
import classes from '../Timeline.module.css'
+function renderWithListSemantics(ui: ReactElement) {
+ return render({ui})
+}
+
describe('Timeline', () => {
implementsClassName(Timeline, classes.Timeline)
- it('renders as an ordered list', () => {
+ it('renders as a div by default (flag off)', () => {
const {container} = render()
- expect(container.firstChild?.nodeName).toBe('OL')
+ expect(container.firstChild?.nodeName).toBe('DIV')
})
- it('has role="list" to restore semantics in Safari/VoiceOver', () => {
+ it('does not set role="list" by default (flag off)', () => {
const {container} = render()
- expect(container.firstChild).toHaveAttribute('role', 'list')
+ expect(container.firstChild).not.toHaveAttribute('role')
})
it('renders with clipSidebar prop (boolean)', () => {
@@ -48,12 +54,39 @@ describe('Timeline', () => {
})
})
+describe('Timeline with primer_react_timeline_list_semantics flag', () => {
+ it('renders as an ordered list', () => {
+ const {container} = renderWithListSemantics()
+ expect(container.firstChild?.nodeName).toBe('OL')
+ })
+
+ it('has role="list" to restore semantics in Safari/VoiceOver', () => {
+ const {container} = renderWithListSemantics()
+ expect(container.firstChild).toHaveAttribute('role', 'list')
+ })
+
+ it('renders items as list items', () => {
+ const {container} = renderWithListSemantics(
+
+
+ ,
+ )
+ expect(container.querySelector('ol > li')).not.toBeNull()
+ })
+
+ it('renders break as a presentational list item', () => {
+ const {container} = renderWithListSemantics()
+ expect(container.firstChild?.nodeName).toBe('LI')
+ expect(container.firstChild).toHaveAttribute('role', 'presentation')
+ })
+})
+
describe('Timeline.Item', () => {
implementsClassName(Timeline.Item, classes.TimelineItem)
- it('renders as a list item', () => {
+ it('renders as a div by default (flag off)', () => {
const {container} = render()
- expect(container.firstChild?.nodeName).toBe('LI')
+ expect(container.firstChild?.nodeName).toBe('DIV')
})
it('renders with condensed prop', () => {
@@ -88,10 +121,10 @@ describe('Timeline.Body', () => {
describe('Timeline.Break', () => {
implementsClassName(Timeline.Break, classes.TimelineBreak)
- it('renders as a presentational list item', () => {
+ it('renders as a div by default (flag off)', () => {
const {container} = render()
- expect(container.firstChild?.nodeName).toBe('LI')
- expect(container.firstChild).toHaveAttribute('role', 'presentation')
+ expect(container.firstChild?.nodeName).toBe('DIV')
+ expect(container.firstChild).not.toHaveAttribute('role')
})
})
diff --git a/packages/react/src/Timeline/__tests__/__snapshots__/Timeline.test.tsx.snap b/packages/react/src/Timeline/__tests__/__snapshots__/Timeline.test.tsx.snap
index d8faca516fa..542aaab3ecf 100644
--- a/packages/react/src/Timeline/__tests__/__snapshots__/Timeline.test.tsx.snap
+++ b/packages/react/src/Timeline/__tests__/__snapshots__/Timeline.test.tsx.snap
@@ -2,7 +2,7 @@
exports[`Timeline.Item > renders with condensed prop 1`] = `
-