From e75a01d6642fdb266257e1770632e6ae184cf76a 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/6] 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 dc173c0ba35..7bbbdfcc486 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 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
                1. - Timeline.Break renders as
                2. 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 `
                  1. ` 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 `
                    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"). +When the `primer_react_timeline_list_semantics` feature flag is enabled, `Timeline` renders as `
                        ` and `Timeline.Item` / `Timeline.Break` render as `
                      1. ` 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 `
                      2. ` 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 ( +
                        1. } + data-condensed={condensed ? '' : undefined} + /> + ) + } + return ( -
                        2. )} 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
                        3. -}) +const TimelineBreak = React.forwardRef( + ({className, ...props}, forwardRef) => { + const useListSemantics = useFeatureFlag('primer_react_timeline_list_semantics') + + if (useListSemantics) { + return ( +
                        4. } + 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`] = `
                          -