From c971e23c0bff309a7341f8bcb92b38effee2ebfa Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Fri, 29 May 2026 16:00:48 -0700 Subject: [PATCH 1/3] Use list semantics for Timeline (ol/li) Timeline renders as
    , Timeline.Item as
  1. , and Timeline.Break as
  2. . Gives screen reader users list navigation with item count and position. Explicit role="list" restores semantics in Safari/VoiceOver which strips them when list-style: none is applied (WebKit intentional behaviour). Refs github/primer#6679 --- .changeset/timeline-list-semantics.md | 13 +++++++++++ .../react/src/Timeline/Timeline.module.css | 3 +++ packages/react/src/Timeline/Timeline.tsx | 23 +++++++++++-------- .../src/Timeline/__tests__/Timeline.test.tsx | 22 ++++++++++++++++++ .../__snapshots__/Timeline.test.tsx.snap | 2 +- 5 files changed, 52 insertions(+), 11 deletions(-) create mode 100644 .changeset/timeline-list-semantics.md 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 `
    1. ` items for list semantics + +Timeline now renders as an ordered list (`
        `) instead of a `
        `, and Timeline.Item renders as `
      1. ` 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 `
      2. ` 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..c8096d112a1 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 & React.ComponentPropsWithoutRef<'ol'> function resolveClipSidebar(clipSidebar: TimelineProps['clipSidebar']): string | undefined { if (clipSidebar === true || clipSidebar === 'both') return 'both' @@ -12,10 +12,13 @@ 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
        1. }) 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`] = `
          -
          From 7d323815d9bc68e6f4162d69701d754f0e42e450 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/3] 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 c8096d112a1..ec3c0cec6cb 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/3] 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 ec3c0cec6cb..a6dd97dd062 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