Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/timeline-list-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@primer/react': minor
---

Timeline: Render as `<ol>` with `<li>` items for list semantics

Timeline now renders as an ordered list (`<ol>`) instead of a `<div>`, and Timeline.Item renders as `<li>` instead of `<div>`. 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 `<li role="presentation">` so it does not contribute to the list item count.

An explicit `role="list"` is applied to the `<ol>` 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.
3 changes: 3 additions & 0 deletions packages/react/src/Timeline/Timeline.module.css
Original file line number Diff line number Diff line change
@@ -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']) {
Expand Down
24 changes: 14 additions & 10 deletions packages/react/src/Timeline/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@ 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<React.ComponentPropsWithoutRef<'ol'>, 'role'>

function resolveClipSidebar(clipSidebar: TimelineProps['clipSidebar']): string | undefined {
if (clipSidebar === true || clipSidebar === 'both') return 'both'
if (clipSidebar === 'start' || clipSidebar === 'end') return clipSidebar
return undefined
}

const Timeline = React.forwardRef<HTMLDivElement, TimelineProps>(({clipSidebar, className, ...props}, forwardRef) => {
const Timeline = React.forwardRef<HTMLOListElement, TimelineProps>(({clipSidebar, className, ...props}, forwardRef) => {
const resolvedClipSidebar = resolveClipSidebar(clipSidebar)
return (
<div
// 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
<ol
{...props}
role="list"
className={clsx(className, classes.Timeline)}
Comment thread
janmaarten-a11y marked this conversation as resolved.
ref={forwardRef}
data-clip-sidebar={resolvedClipSidebar}
Expand All @@ -31,14 +35,14 @@ type StyledTimelineItemProps = {condensed?: boolean; className?: string}
/**
* @deprecated Use the `TimelineItemProps` type instead
*/
export type TimelineItemsProps = StyledTimelineItemProps & React.ComponentPropsWithoutRef<'div'>
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<HTMLDivElement, TimelineItemProps>(
const TimelineItem = React.forwardRef<HTMLLIElement, TimelineItemProps>(
({condensed, className, ...props}, forwardRef) => {
return (
<div
<li
{...props}
className={clsx(className, 'Timeline-Item', classes.TimelineItem)}
ref={forwardRef}
Expand Down Expand Up @@ -92,10 +96,10 @@ TimelineBody.displayName = 'TimelineBody'
export type TimelineBreakProps = {
/** Class name for custom styling */
className?: string
} & React.ComponentPropsWithoutRef<'div'>
} & Omit<React.ComponentPropsWithoutRef<'li'>, 'role'>

const TimelineBreak = React.forwardRef<HTMLDivElement, TimelineBreakProps>(({className, ...props}, forwardRef) => {
return <div {...props} className={clsx(className, classes.TimelineBreak)} ref={forwardRef} />
const TimelineBreak = React.forwardRef<HTMLLIElement, TimelineBreakProps>(({className, ...props}, forwardRef) => {
return <li {...props} className={clsx(className, classes.TimelineBreak)} ref={forwardRef} role="presentation" />
})

TimelineBreak.displayName = 'TimelineBreak'
Expand Down
22 changes: 22 additions & 0 deletions packages/react/src/Timeline/__tests__/Timeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Timeline />)
expect(container.firstChild?.nodeName).toBe('OL')
})

it('has role="list" to restore semantics in Safari/VoiceOver', () => {
const {container} = render(<Timeline />)
expect(container.firstChild).toHaveAttribute('role', 'list')
})

it('renders with clipSidebar prop (boolean)', () => {
const {container} = render(<Timeline clipSidebar />)
expect(container.firstChild).toHaveAttribute('data-clip-sidebar', 'both')
Expand Down Expand Up @@ -40,6 +50,12 @@ describe('Timeline', () => {

describe('Timeline.Item', () => {
implementsClassName(Timeline.Item, classes.TimelineItem)

it('renders as a list item', () => {
const {container} = render(<Timeline.Item />)
expect(container.firstChild?.nodeName).toBe('LI')
})

it('renders with condensed prop', () => {
const {container} = render(<Timeline.Item condensed />)
expect(container).toMatchSnapshot()
Expand Down Expand Up @@ -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(<Timeline.Break />)
expect(container.firstChild?.nodeName).toBe('LI')
expect(container.firstChild).toHaveAttribute('role', 'presentation')
})
})

describe('Timeline.Actions', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

exports[`Timeline.Item > renders with condensed prop 1`] = `
<div>
<div
<li
class="Timeline-Item prc-Timeline-TimelineItem-yUFAS"
data-condensed=""
/>
Expand Down
Loading