@instructure/ui-table 11.7.2-snapshot-50 → 11.7.2-snapshot-52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -70,23 +70,51 @@ class Table extends Component<TableProps> {
70
70
  static Cell = Cell
71
71
 
72
72
  ref: Element | null = null
73
+ // Reference to hidden aria-live region for announcing caption changes to screen readers
74
+ _liveRegionRef: HTMLDivElement | null = null
75
+ // Timeout for delayed announcement (workaround for Safari/VoiceOver caption update bug)
76
+ _announcementTimeout?: ReturnType<typeof setTimeout>
73
77
 
74
78
  handleRef = (el: Element | null) => {
75
- const { elementRef } = this.props
76
-
77
79
  this.ref = el
78
-
79
- if (typeof elementRef === 'function') {
80
- elementRef(el)
81
- }
80
+ this.props.elementRef?.(el)
82
81
  }
83
82
 
84
83
  componentDidMount() {
85
84
  this.props.makeStyles?.()
86
85
  }
87
86
 
88
- componentDidUpdate() {
87
+ componentDidUpdate(prevProps: TableProps) {
89
88
  this.props.makeStyles?.()
89
+ // Announce caption changes for screen readers (especially VoiceOver)
90
+ // Safari/VoiceOver has a known bug where dynamic <caption> updates aren't announced,
91
+ // so we use an aria-live region as a workaround
92
+ const prevSortInfo = this.getSortedHeaderInfo(prevProps)
93
+ const currentSortInfo = this.getSortedHeaderInfo(this.props)
94
+ // Only announce if sorting actually changed
95
+ const sortingChanged =
96
+ prevSortInfo?.header !== currentSortInfo?.header ||
97
+ prevSortInfo?.direction !== currentSortInfo?.direction
98
+ if (sortingChanged && currentSortInfo && this._liveRegionRef) {
99
+ // Clear any pending announcement
100
+ clearTimeout(this._announcementTimeout)
101
+ // Clear the live region first (part of the clear-then-set pattern)
102
+ this._liveRegionRef.textContent = ''
103
+ // Wait 100ms before setting new content to ensure screen readers detect the change
104
+ this._announcementTimeout = setTimeout(() => {
105
+ if (this._liveRegionRef) {
106
+ const currentCaption = this.getCaptionText(this.props)
107
+ // Append non-breaking space (\u00A0) to force Safari/VoiceOver to treat
108
+ // repeated captions as different announcements
109
+ this._liveRegionRef.textContent = currentCaption + '\u00A0'
110
+ }
111
+ }, 100)
112
+ }
113
+ }
114
+
115
+ componentWillUnmount() {
116
+ // Clean up pending announcement timeout
117
+ clearTimeout(this._announcementTimeout)
90
118
  }
91
119
 
92
120
  getHeaders() {
@@ -105,11 +133,45 @@ class Table extends Component<TableProps> {
105
133
  )
106
134
  }
107
135
 
136
+ getSortedHeaderInfo(props: TableProps) {
137
+ const [headChild] = Children.toArray(props.children)
138
+ const [firstRow] = Children.toArray(
139
+ isValidElement(headChild) ? headChild.props.children : []
140
+ )
141
+ const colHeaders = Children.toArray(
142
+ isValidElement(firstRow) ? firstRow.props.children : []
143
+ )
144
+ // Find the column with an active sort direction
145
+ for (const colHeader of colHeaders) {
146
+ if (
147
+ isValidElement(colHeader) &&
148
+ colHeader.props.sortDirection &&
149
+ colHeader.props.sortDirection !== 'none'
150
+ ) {
151
+ // Extract header text (may be nested in child components)
152
+ const headerText =
153
+ typeof colHeader.props.children === 'string'
154
+ ? colHeader.props.children
155
+ : colHeader.props.children?.props?.children ?? ''
156
+ return { header: headerText, direction: colHeader.props.sortDirection }
157
+ }
158
+ }
159
+ return null
160
+ }
161
+
162
+ getCaptionText(props: TableProps) {
163
+ const sortInfo = this.getSortedHeaderInfo(props)
164
+ const caption = props.caption as string
165
+ if (!sortInfo) return caption
166
+ const sortText = ` Sorted by ${sortInfo.header} (${sortInfo.direction})`
167
+ return caption ? caption + sortText : sortText.trim()
168
+ }
169
+
108
170
  render() {
109
171
  const { margin, layout, caption, children, hover, styles, minWidth } =
110
172
  this.props
111
173
  const isStacked = layout === 'stacked'
112
- const headers = isStacked ? this.getHeaders() : undefined
174
+ const captionText = this.getCaptionText(this.props)
113
175
 
114
176
  if (!caption) {
115
177
  error(false, `[Table] required prop caption is not set.`)
@@ -118,11 +180,23 @@ class Table extends Component<TableProps> {
118
180
  return (
119
181
  <TableContext.Provider
120
182
  value={{
121
- isStacked: isStacked,
183
+ isStacked,
122
184
  hover: hover!,
123
- headers: headers
185
+ headers: isStacked ? this.getHeaders() : undefined
124
186
  }}
125
187
  >
188
+ {/* ARIA live region for dynamic sort announcements.
189
+ MUST be outside <table> due to Safari/VoiceOver bug.
190
+ Empty on page load, populated only when sorting changes. */}
191
+ <div
192
+ ref={(el) => {
193
+ this._liveRegionRef = el
194
+ }}
195
+ aria-live="polite"
196
+ aria-atomic="true"
197
+ role="status"
198
+ css={styles?.liveRegion}
199
+ />
126
200
  <View
127
201
  // All HTML props, except the ones accepted by `View` and `Table`
128
202
  {...View.omitViewProps(
@@ -135,21 +209,21 @@ class Table extends Component<TableProps> {
135
209
  elementRef={this.handleRef}
136
210
  css={styles?.table}
137
211
  role={isStacked ? 'table' : undefined}
138
- aria-label={isStacked ? (caption as string) : undefined}
212
+ aria-label={captionText}
139
213
  >
140
- {!isStacked && (
214
+ {/* Caption for visual display and semantic HTML */}
215
+ {!isStacked && caption && (
141
216
  <caption>
142
- <ScreenReaderContent>{caption}</ScreenReaderContent>
217
+ <ScreenReaderContent>{captionText}</ScreenReaderContent>
143
218
  </caption>
144
219
  )}
145
- {Children.map(children, (child) => {
146
- if (isValidElement(child)) {
147
- return safeCloneElement(child, {
148
- key: (child as ReactElement<any>).props.name
149
- })
150
- }
151
- return child
152
- })}
220
+ {Children.map(children, (child) =>
221
+ isValidElement(child)
222
+ ? safeCloneElement(child, {
223
+ key: (child as ReactElement<any>).props.name
224
+ })
225
+ : child
226
+ )}
153
227
  </View>
154
228
  </TableContext.Provider>
155
229
  )
@@ -79,7 +79,7 @@ type TableProps = TableOwnProps &
79
79
  WithStyleProps<null, TableStyle> &
80
80
  OtherHTMLAttributes<TableOwnProps>
81
81
 
82
- type TableStyle = ComponentStyle<'table'>
82
+ type TableStyle = ComponentStyle<'table' | 'liveRegion'>
83
83
  const allowedProps: AllowedPropKeys = [
84
84
  'caption',
85
85
  'children',
@@ -56,6 +56,14 @@ const generateStyle = (
56
56
  borderSpacing: 0,
57
57
  ...(layout === 'fixed' && { tableLayout: 'fixed' }),
58
58
  caption: { textAlign: 'start' }
59
+ },
60
+ liveRegion: {
61
+ label: 'table__liveRegion',
62
+ position: 'absolute',
63
+ left: '-10000px',
64
+ width: '1px',
65
+ height: '1px',
66
+ overflow: 'hidden'
59
67
  }
60
68
  }
61
69
  }