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..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
      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`] = `
        -