diff --git a/.changeset/timeline-list-semantics.md b/.changeset/timeline-list-semantics.md
new file mode 100644
index 00000000000..7f7f1ad1e8f
--- /dev/null
+++ b/.changeset/timeline-list-semantics.md
@@ -0,0 +1,13 @@
+---
+'@primer/react': minor
+---
+
+Timeline: Render as `
` with `- ` items for 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").
+
+Timeline.Break renders as `
- ` so it does not contribute to the list item count.
+
+An explicit `role="list"` is applied to the `
` to restore list semantics in Safari/VoiceOver, which strips them when `list-style: none` is applied.
+
+**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/Timeline/Timeline.module.css b/packages/react/src/Timeline/Timeline.module.css
index 2636131a40a..bbedaee66a7 100644
--- a/packages/react/src/Timeline/Timeline.module.css
+++ b/packages/react/src/Timeline/Timeline.module.css
@@ -1,6 +1,9 @@
.Timeline {
display: flex;
flex-direction: column;
+ list-style: none;
+ padding: 0;
+ margin: 0;
&:where([data-clip-sidebar='start']),
&:where([data-clip-sidebar='both']) {
diff --git a/packages/react/src/Timeline/Timeline.tsx b/packages/react/src/Timeline/Timeline.tsx
index b2d68ff1b76..a6dd97dd062 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<'div'>
+export type TimelineProps = StyledTimelineProps & Omit, 'role'>
function resolveClipSidebar(clipSidebar: TimelineProps['clipSidebar']): string | undefined {
if (clipSidebar === true || clipSidebar === 'both') return 'both'
@@ -12,11 +12,15 @@ function resolveClipSidebar(clipSidebar: TimelineProps['clipSidebar']): string |
return undefined
}
-const Timeline = React.forwardRef(({clipSidebar, className, ...props}, forwardRef) => {
+const Timeline = React.forwardRef(({clipSidebar, className, ...props}, forwardRef) => {
const resolvedClipSidebar = resolveClipSidebar(clipSidebar)
return (
-
+export type TimelineItemsProps = StyledTimelineItemProps & React.ComponentPropsWithoutRef<'li'>
-export type TimelineItemProps = StyledTimelineItemProps & React.ComponentPropsWithoutRef<'div'>
+export type TimelineItemProps = StyledTimelineItemProps & React.ComponentPropsWithoutRef<'li'>
-const TimelineItem = React.forwardRef
(
+const TimelineItem = React.forwardRef(
({condensed, className, ...props}, forwardRef) => {
return (
-
+} & Omit
, 'role'>
-const TimelineBreak = React.forwardRef(({className, ...props}, forwardRef) => {
- return
+const TimelineBreak = React.forwardRef(({className, ...props}, forwardRef) => {
+ return
})
TimelineBreak.displayName = 'TimelineBreak'
diff --git a/packages/react/src/Timeline/__tests__/Timeline.test.tsx b/packages/react/src/Timeline/__tests__/Timeline.test.tsx
index c7f37362159..b7d096d506d 100644
--- a/packages/react/src/Timeline/__tests__/Timeline.test.tsx
+++ b/packages/react/src/Timeline/__tests__/Timeline.test.tsx
@@ -7,6 +7,16 @@ import classes from '../Timeline.module.css'
describe('Timeline', () => {
implementsClassName(Timeline, classes.Timeline)
+ it('renders as an ordered list', () => {
+ const {container} = render()
+ expect(container.firstChild?.nodeName).toBe('OL')
+ })
+
+ it('has role="list" to restore semantics in Safari/VoiceOver', () => {
+ const {container} = render()
+ expect(container.firstChild).toHaveAttribute('role', 'list')
+ })
+
it('renders with clipSidebar prop (boolean)', () => {
const {container} = render()
expect(container.firstChild).toHaveAttribute('data-clip-sidebar', 'both')
@@ -40,6 +50,12 @@ describe('Timeline', () => {
describe('Timeline.Item', () => {
implementsClassName(Timeline.Item, classes.TimelineItem)
+
+ it('renders as a list item', () => {
+ const {container} = render()
+ expect(container.firstChild?.nodeName).toBe('LI')
+ })
+
it('renders with condensed prop', () => {
const {container} = render()
expect(container).toMatchSnapshot()
@@ -71,6 +87,12 @@ describe('Timeline.Body', () => {
describe('Timeline.Break', () => {
implementsClassName(Timeline.Break, classes.TimelineBreak)
+
+ it('renders as a presentational list item', () => {
+ const {container} = render()
+ expect(container.firstChild?.nodeName).toBe('LI')
+ expect(container.firstChild).toHaveAttribute('role', 'presentation')
+ })
})
describe('Timeline.Actions', () => {
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 542aaab3ecf..d8faca516fa 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`] = `