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
52 changes: 4 additions & 48 deletions packages/ui-table/src/Table/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,10 +582,7 @@ By default, the options in the `Select` for sorting in stacked layout are genera
</View>
)}

<Table
caption={`${caption}: sorted by ${sortBy} in ${direction} order`}
{...props}
>
<Table {...props}>
<Table.Head renderSortLabel="Sort by">
{this.renderHeaderRow(direction)}
</Table.Head>
Expand All @@ -601,13 +598,6 @@ By default, the options in the `Select` for sorting in stacked layout are genera
))}
</Table.Body>
</Table>
<Alert
liveRegion={() => document.getElementById('flash-messages')}
liveRegionPoliteness="polite"
screenReaderOnly
>
{`Sorted by ${sortBy} in ${direction} order`}
</Alert>
</div>
)}
</Responsive>
Expand Down Expand Up @@ -806,10 +796,7 @@ By default, the options in the `Select` for sorting in stacked layout are genera
</View>
)}

<Table
caption={`${caption}: sorted by ${sortBy} in ${direction} order`}
{...props}
>
<Table {...props}>
<Table.Head renderSortLabel="Sort by">
{renderHeaderRow(direction)}
</Table.Head>
Expand All @@ -825,13 +812,6 @@ By default, the options in the `Select` for sorting in stacked layout are genera
))}
</Table.Body>
</Table>
<Alert
liveRegion={() => document.getElementById('flash-messages')}
liveRegionPoliteness="polite"
screenReaderOnly
>
{`Sorted by ${sortBy} in ${direction} order`}
</Alert>
</div>
)}
</Responsive>
Expand Down Expand Up @@ -966,10 +946,7 @@ that selection does not re-paginate or re-sort the table, and pagination does no
<View as="div" padding="small" background="primary-inverse">
{`${selected.size} of ${rowIds.length} selected`}
</View>
<Table
caption={`${caption}: sorted by ${sortBy} in ${direction} order`}
{...props}
>
<Table {...props}>
<Table.Head
renderSortLabel={
<ScreenReaderContent>Sort by</ScreenReaderContent>
Expand Down Expand Up @@ -1175,15 +1152,6 @@ that selection does not re-paginate or re-sort the table, and pagination does no
ascending={ascending}
perPage={perPage}
/>
<Alert
liveRegion={() => document.getElementById('flash-messages')}
liveRegionPoliteness="polite"
screenReaderOnly
>
{`Sorted by ${sortBy} in ${
ascending ? 'ascending' : 'descending'
} order`}
</Alert>
</div>
)
}
Expand Down Expand Up @@ -1305,10 +1273,7 @@ that selection does not re-paginate or re-sort the table, and pagination does no
<View as="div" padding="small" background="primary-inverse">
{`${selected.size} of ${rowIds.length} selected`}
</View>
<Table
caption={`${caption}: sorted by ${sortBy} in ${direction} order`}
{...props}
>
<Table {...props}>
<Table.Head
renderSortLabel={
<ScreenReaderContent>Sort by</ScreenReaderContent>
Expand Down Expand Up @@ -1485,15 +1450,6 @@ that selection does not re-paginate or re-sort the table, and pagination does no
ascending={ascending}
perPage={perPage}
/>
<Alert
liveRegion={() => document.getElementById('flash-messages')}
liveRegionPoliteness="polite"
screenReaderOnly
>
{`Sorted by ${sortBy} in ${
ascending ? 'ascending' : 'descending'
} order`}
</Alert>
</div>
)
}
Expand Down
120 changes: 101 additions & 19 deletions packages/ui-table/src/Table/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,23 +71,55 @@ class Table extends Component<TableProps> {
static Cell = Cell

ref: Element | null = null
// Reference to hidden aria-live region for announcing caption changes to screen readers
_liveRegionRef: HTMLDivElement | null = null
// Timeout for delayed announcement (workaround for Safari/VoiceOver caption update bug)
_announcementTimeout?: ReturnType<typeof setTimeout>

handleRef = (el: Element | null) => {
const { elementRef } = this.props

this.ref = el

if (typeof elementRef === 'function') {
elementRef(el)
}
this.props.elementRef?.(el)
}

componentDidMount() {
this.props.makeStyles?.()
}

componentDidUpdate() {
componentDidUpdate(prevProps: TableProps) {
this.props.makeStyles?.()

// Announce caption changes for screen readers (especially VoiceOver)
// Safari/VoiceOver has a known bug where dynamic <caption> updates aren't announced,
// so we use an aria-live region as a workaround
const prevSortInfo = this.getSortedHeaderInfo(prevProps)
const currentSortInfo = this.getSortedHeaderInfo(this.props)

// Only announce if sorting actually changed
const sortingChanged =
prevSortInfo?.header !== currentSortInfo?.header ||
prevSortInfo?.direction !== currentSortInfo?.direction

if (sortingChanged && currentSortInfo && this._liveRegionRef) {
// Clear any pending announcement
clearTimeout(this._announcementTimeout)
// Clear the live region first (part of the clear-then-set pattern)
this._liveRegionRef.textContent = ''

// Wait 100ms before setting new content to ensure screen readers detect the change
this._announcementTimeout = setTimeout(() => {
if (this._liveRegionRef) {
const currentCaption = this.getCaptionText(this.props)
// Append non-breaking space (\u00A0) to force Safari/VoiceOver to treat
// repeated captions as different announcements
this._liveRegionRef.textContent = currentCaption + '\u00A0'
}
}, 100)
}
}

componentWillUnmount() {
// Clean up pending announcement timeout
clearTimeout(this._announcementTimeout)
}

getHeaders() {
Expand All @@ -101,19 +133,69 @@ class Table extends Component<TableProps> {
})
}

getSortedHeaderInfo(props: TableProps) {
const [headChild] = Children.toArray(props.children)
const [firstRow] = Children.toArray(
isValidElement(headChild) ? headChild.props.children : []
)
const colHeaders = Children.toArray(
isValidElement(firstRow) ? firstRow.props.children : []
)

// Find the column with an active sort direction
for (const colHeader of colHeaders) {
if (
isValidElement(colHeader) &&
colHeader.props.sortDirection &&
colHeader.props.sortDirection !== 'none'
) {
// Extract header text (may be nested in child components)
const headerText =
typeof colHeader.props.children === 'string'
? colHeader.props.children
: colHeader.props.children?.props?.children ?? ''
return { header: headerText, direction: colHeader.props.sortDirection }
}
}
return null
}

getCaptionText(props: TableProps) {
const sortInfo = this.getSortedHeaderInfo(props)
const caption = props.caption as string

if (!sortInfo) return caption

const sortText = ` Sorted by ${sortInfo.header} (${sortInfo.direction})`
return caption ? caption + sortText : sortText.trim()
}

render() {
const { margin, layout, caption, children, hover, styles } = this.props
const isStacked = layout === 'stacked'
const headers = isStacked ? this.getHeaders() : undefined
const captionText = this.getCaptionText(this.props)

return (
<TableContext.Provider
value={{
isStacked: isStacked,
isStacked,
hover: hover!,
headers: headers
headers: isStacked ? this.getHeaders() : undefined
}}
>
{/* ARIA live region for dynamic sort announcements.
MUST be outside <table> due to Safari/VoiceOver bug.
Empty on page load, populated only when sorting changes. */}
<div
ref={(el) => {
this._liveRegionRef = el
}}
aria-live="polite"
aria-atomic="true"
role="status"
css={styles?.liveRegion}
/>

<View
{...View.omitViewProps(
omitProps(this.props, Table.allowedProps),
Expand All @@ -124,19 +206,19 @@ class Table extends Component<TableProps> {
elementRef={this.handleRef}
css={styles?.table}
role={isStacked ? 'table' : undefined}
aria-label={isStacked ? (caption as string) : undefined}
aria-label={captionText}
>
{!isStacked && (
{/* Caption for visual display and semantic HTML */}
{!isStacked && caption && (
<caption>
<ScreenReaderContent>{caption}</ScreenReaderContent>
<ScreenReaderContent>{captionText}</ScreenReaderContent>
</caption>
)}
{Children.map(children, (child) => {
if (isValidElement(child)) {
return safeCloneElement(child, { key: child.props.name })
}
return child
})}
{Children.map(children, (child) =>
isValidElement(child)
? safeCloneElement(child, { key: child.props.name })
: child
)}
</View>
</TableContext.Provider>
)
Expand Down
2 changes: 1 addition & 1 deletion packages/ui-table/src/Table/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ type TableProps = TableOwnProps &
WithStyleProps<TableTheme, TableStyle> &
OtherHTMLAttributes<TableOwnProps>

type TableStyle = ComponentStyle<'table'>
type TableStyle = ComponentStyle<'table' | 'liveRegion'>

const propTypes: PropValidators<PropKeys> = {
caption: PropTypes.node.isRequired,
Expand Down
8 changes: 8 additions & 0 deletions packages/ui-table/src/Table/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ const generateStyle = (
borderSpacing: 0,
...(layout === 'fixed' && { tableLayout: 'fixed' }),
caption: { textAlign: 'start' }
},
liveRegion: {
label: 'table__liveRegion',
position: 'absolute',
left: '-10000px',
width: '1px',
height: '1px',
overflow: 'hidden'
}
}
}
Expand Down