diff --git a/packages/ui-table/src/Table/README.md b/packages/ui-table/src/Table/README.md index b2d2065fe2..94b857dd0a 100644 --- a/packages/ui-table/src/Table/README.md +++ b/packages/ui-table/src/Table/README.md @@ -582,10 +582,7 @@ By default, the options in the `Select` for sorting in stacked layout are genera )} - +
{this.renderHeaderRow(direction)} @@ -601,13 +598,6 @@ By default, the options in the `Select` for sorting in stacked layout are genera ))}
- document.getElementById('flash-messages')} - liveRegionPoliteness="polite" - screenReaderOnly - > - {`Sorted by ${sortBy} in ${direction} order`} - )} @@ -806,10 +796,7 @@ By default, the options in the `Select` for sorting in stacked layout are genera )} - +
{renderHeaderRow(direction)} @@ -825,13 +812,6 @@ By default, the options in the `Select` for sorting in stacked layout are genera ))}
- document.getElementById('flash-messages')} - liveRegionPoliteness="polite" - screenReaderOnly - > - {`Sorted by ${sortBy} in ${direction} order`} - )} @@ -966,10 +946,7 @@ that selection does not re-paginate or re-sort the table, and pagination does no {`${selected.size} of ${rowIds.length} selected`} - +
Sort by @@ -1175,15 +1152,6 @@ that selection does not re-paginate or re-sort the table, and pagination does no ascending={ascending} perPage={perPage} /> - document.getElementById('flash-messages')} - liveRegionPoliteness="polite" - screenReaderOnly - > - {`Sorted by ${sortBy} in ${ - ascending ? 'ascending' : 'descending' - } order`} - ) } @@ -1305,10 +1273,7 @@ that selection does not re-paginate or re-sort the table, and pagination does no {`${selected.size} of ${rowIds.length} selected`} -
+
Sort by @@ -1485,15 +1450,6 @@ that selection does not re-paginate or re-sort the table, and pagination does no ascending={ascending} perPage={perPage} /> - document.getElementById('flash-messages')} - liveRegionPoliteness="polite" - screenReaderOnly - > - {`Sorted by ${sortBy} in ${ - ascending ? 'ascending' : 'descending' - } order`} - ) } diff --git a/packages/ui-table/src/Table/index.tsx b/packages/ui-table/src/Table/index.tsx index 96e642fd0a..6e2f131e17 100644 --- a/packages/ui-table/src/Table/index.tsx +++ b/packages/ui-table/src/Table/index.tsx @@ -71,23 +71,55 @@ class Table extends Component { 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 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
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() { @@ -101,19 +133,69 @@ class Table extends Component { }) } + 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 ( + {/* ARIA live region for dynamic sort announcements. + MUST be outside due to Safari/VoiceOver bug. + Empty on page load, populated only when sorting changes. */} +
{ + this._liveRegionRef = el + }} + aria-live="polite" + aria-atomic="true" + role="status" + css={styles?.liveRegion} + /> + { 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 && (
)} - {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 + )} ) diff --git a/packages/ui-table/src/Table/props.ts b/packages/ui-table/src/Table/props.ts index 9590157818..2c8582d717 100644 --- a/packages/ui-table/src/Table/props.ts +++ b/packages/ui-table/src/Table/props.ts @@ -80,7 +80,7 @@ type TableProps = TableOwnProps & WithStyleProps & OtherHTMLAttributes -type TableStyle = ComponentStyle<'table'> +type TableStyle = ComponentStyle<'table' | 'liveRegion'> const propTypes: PropValidators = { caption: PropTypes.node.isRequired, diff --git a/packages/ui-table/src/Table/styles.ts b/packages/ui-table/src/Table/styles.ts index 29c8778b1a..8c101badea 100644 --- a/packages/ui-table/src/Table/styles.ts +++ b/packages/ui-table/src/Table/styles.ts @@ -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' } } }
- {caption} + {captionText}