Skip to content

Timeline: Use list semantics for screen reader navigation#7910

Open
janmaarten-a11y wants to merge 3 commits into
mainfrom
timeline/list-semantics
Open

Timeline: Use list semantics for screen reader navigation#7910
janmaarten-a11y wants to merge 3 commits into
mainfrom
timeline/list-semantics

Conversation

@janmaarten-a11y
Copy link
Copy Markdown
Contributor

@janmaarten-a11y janmaarten-a11y commented May 29, 2026

Refs github/primer#6679
Closes github/primer#2206
Closes github/primer#3354
Part of the Timeline redesign (github/primer#6654)

Timeline and Timeline.Item currently render as bare <div>s. Screen reader users have no way to know how many events are in the timeline or where they are in the sequence — they have to step through every event one by one.

This was originally flagged by @patrickhlauke in the TetraLogical component audit (April 2023) and tracked for PRC in github/primer#3354.

This changes the rendered elements so assistive technology can announce list size and position (e.g. "item 3 of 12"):

  • Timeline<ol role="list"> (was <div>)
  • Timeline.Item<li> (was <div>)
  • Timeline.Break<li role="presentation"> (was <div>) — excluded from list item count since it's a visual separator, not a timeline event

The explicit role="list" on the <ol> restores list semantics in Safari/VoiceOver, which strips them when list-style: none is applied.

Changelog

Changed

  • Timeline renders as <ol role="list"> instead of <div>. Ref type changes from HTMLDivElement to HTMLOListElement.
  • Timeline.Item renders as <li> instead of <div>. Ref type changes from HTMLDivElement to HTMLLIElement.
  • Timeline.Break renders as <li role="presentation"> instead of <div>. Ref type changes from HTMLDivElement to HTMLLIElement. The role prop is omitted from the type to prevent overriding.

Rollout strategy

  • Patch release
  • Minor release
  • Major release; if selected, include a written rollout or migration plan
  • None

Note on semver: The ref types narrowed (HTMLDivElementHTMLOListElement/HTMLLIElement), which is technically a breaking type change. Marked as minor because: (1) Timeline refs are rarely used in practice, (2) the change is an accessibility improvement, not a feature removal, and (3) all HTML attributes continue to work identically. Open to major if reviewers prefer.

Guidance for paginated timelines

Note

Adding this as reference for future work updating the Timeline component guidance on primer.style

GitHub timelines often contain pagination controls ("Load hidden items") and "show more" buttons interspersed between events. These are not timeline events and should not inflate the list item count. Guidance for consumers:

Wrapping non-event children

Any child of Timeline that is not a timeline event should render as <li role="presentation"> (the same pattern Timeline.Break uses). This keeps it inside the <ol> (valid HTML) while excluding it from the list item count.

Before loading hidden items (81 visible items):

<Timeline aria-label="Pull request timeline">
  {/* items 1–39 of 81 */}
  <li role="presentation">
    <button>Load 42 hidden items</button>
  </li>
  {/* items 40–81 of 81 */}
</Timeline>

After loading hidden items (123 total items):

<Timeline aria-label="Pull request timeline">
  {/* items 1–39 of 123 */}
  {/* newly loaded: items 40–81 of 123 */}
  {/* items 82–123 of 123 */}
</Timeline>

Labelling the timeline

Pass a descriptive aria-label to Timeline (e.g. "Pull request timeline"). Do not include counts in the label — the native <ol> list count updates automatically as items are loaded, so a static count in the label would become stale after "load more" is activated. Screen readers announce the label plus the live item count on entry: "list, Pull request timeline, 81 items".

Focus management after loading more items

When the "load more" button is activated and new items are inserted, the button itself is removed from the DOM. Move focus to the first newly loaded <li> so the user continues from where the gap was. Without explicit focus management, focus falls to <body> and the user loses their place.

Discoverability of load-more controls

Users navigating by list items (e.g. the "i" shortcut in VoiceOver) will skip <li role="presentation"> elements, jumping from item 39 directly to item 40. The load-more button is still reachable via linear browsing (arrow keys) or Tab. This is acceptable because the loaded items are still coherent, but worth noting during AT testing.

Item numbering after loading

After loading hidden items, the native list numbering shifts: what was "item 40 of 81" becomes "item 82 of 123". This is correct — items are now in their true chronological position — but may be disorienting on first encounter. No action needed; this is inherent to how ordered lists work with dynamic content.

Testing & Reviewing

  1. Open any Timeline story in Storybook
  2. Inspect the DOM — confirm <ol role="list"> wraps <li> items
  3. If Timeline.Break is present, confirm it renders as <li role="presentation">
  4. Test with VoiceOver + Safari: navigate to the timeline and confirm it announces as a list with item count
  5. Test with VoiceOver + Chrome or NVDA + Firefox/Chrome: same check

Merge checklist

  • Added/updated tests
  • Added/updated documentation
  • Added/updated previews (Storybook) — no visual change; purely semantic
  • Changes are SSR compatiblestandard HTML elements, no client-only APIs
  • Tested in Chrome
  • Tested in Firefox
  • Tested in Safari
  • Tested in Edge
  • (GitHub staff only) Integration tests pass at github/github-ui

Timeline renders as <ol role="list">, Timeline.Item as <li>, and
Timeline.Break as <li role="presentation">. 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-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 29, 2026

🦋 Changeset detected

Latest commit: a4a8dc2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

⚠️ Action required

👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Check the integration testing docs for step-by-step instructions. Or, apply the integration-tests: skipped manually label to skip these checks.

To publish a canary release for integration testing, apply the Canary Release label to this PR.

@github-actions github-actions Bot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label May 29, 2026
@janmaarten-a11y janmaarten-a11y self-assigned this May 29, 2026
@primer
Copy link
Copy Markdown
Contributor

primer Bot commented May 29, 2026

🤖 Lint issues have been automatically fixed and committed to this PR.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves @primer/react’s Timeline accessibility by restoring true list semantics so assistive tech can announce list size and position (e.g. “item 3 of 12”), supporting the broader Timeline redesign work.

Changes:

  • Timeline now renders an <ol role="list"> and updates its ref/type surface accordingly.
  • Timeline.Item now renders an <li>; Timeline.Break now renders an <li role="presentation"> to avoid inflating the announced item count.
  • Adds CSS resets for list styling and updates/extends unit tests + snapshots for the new markup.
Show a summary per file
File Description
packages/react/src/Timeline/Timeline.tsx Switches Timeline to <ol role="list"> and items/breaks to <li> semantics; updates ref and prop types.
packages/react/src/Timeline/Timeline.module.css Resets list styling (list-style/padding/margin) to preserve existing visuals with <ol>.
packages/react/src/Timeline/tests/Timeline.test.tsx Adds assertions for new rendered elements and required ARIA role semantics.
packages/react/src/Timeline/tests/snapshots/Timeline.test.tsx.snap Updates snapshot output for <li> rendering.
.changeset/timeline-list-semantics.md Documents the semantic change and ref type migrations as a minor release.

Copilot's findings

  • Files reviewed: 5/5 changed files
  • Comments generated: 1

Comment thread packages/react/src/Timeline/Timeline.tsx
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".
@primer
Copy link
Copy Markdown
Contributor

primer Bot commented May 30, 2026

🤖 Lint issues have been automatically fixed and committed to this PR.

The rule flags role="list" on <ol> as redundant, but it is required to
restore list semantics in Safari/VoiceOver when list-style: none is
applied.
@janmaarten-a11y janmaarten-a11y force-pushed the timeline/list-semantics branch from e28cff0 to a4a8dc2 Compare May 30, 2026 00:45
@github-actions github-actions Bot requested a deployment to storybook-preview-7910 May 30, 2026 00:49 Abandoned
@janmaarten-a11y janmaarten-a11y marked this pull request as ready for review May 30, 2026 01:09
@janmaarten-a11y janmaarten-a11y requested a review from a team as a code owner May 30, 2026 01:09
@janmaarten-a11y janmaarten-a11y requested a review from jonrohan May 30, 2026 01:09
@janmaarten-a11y janmaarten-a11y added Canary Release Apply this label when you want CI to create a canary release of the current PR component: Timeline component: TimelineItem labels May 30, 2026
@primer-integration
Copy link
Copy Markdown

⚠️ Hi from github/github-ui! The integration workflow could not find a canary version for the latest commit on this PR.

A successful canary CI run (i.e., a valid canary version published via the release.yml workflow) must exist for the latest commit before integration checks will succeed.

Next steps:

  1. Make sure the Canary Release label is applied to the PR — the release.yml workflow requires this label to publish a canary version.
  2. Wait for the release.yml canary CI run to complete successfully for the latest commit on this PR.
  3. Once a valid canary version exists, re-trigger the integration workflow by visiting the primer-react-pr-test workflow page, clicking Run workflow, and pasting this PR's URL.

For more details, see this workflow run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Canary Release Apply this label when you want CI to create a canary release of the current PR component: Timeline component: TimelineItem integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants