Skip to content

Commit aac2b17

Browse files
committed
fix(ui-table): fix caption for table to be responsive to sorting
INSTUI-5047
1 parent f0a221d commit aac2b17

13 files changed

Lines changed: 8705 additions & 3113 deletions

File tree

cypress/component/Table.cy.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe('<Table/>', () => {
3535
setHoverStateTo?: boolean
3636
hover?: boolean
3737
}) => (
38-
<Table caption="Test table" hover={hover}>
38+
<Table caption={() => 'Test table'} hover={hover}>
3939
<Table.Head>
4040
<Table.Row data-testid="target-row" setHoverStateTo={setHoverStateTo}>
4141
<Table.ColHeader id="foo">ColHeader</Table.ColHeader>
@@ -54,7 +54,7 @@ describe('<Table/>', () => {
5454
it('can render table head as a combobox when in stacked layout', () => {
5555
const sortFoo = cy.stub()
5656
cy.mount(
57-
<Table caption="Sortable table" layout="stacked">
57+
<Table caption={() => 'Sortable table'} layout="stacked">
5858
<Table.Head>
5959
<Table.Row>
6060
<Table.ColHeader id="id" onRequestSort={sortFoo}>

packages/ui-buttons/src/CondensedButton/v1/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ In the following example, CondensedButton is used so that the button content can
1919
---
2020
type: example
2121
---
22-
<Table caption='Tallest Roller Coasters'>
22+
<Table caption={() => 'Tallest Roller Coasters'}>
2323
<Table.Head>
2424
<Table.Row>
2525
<Table.ColHeader id="Roller Coaster">

packages/ui-buttons/src/CondensedButton/v2/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ In the following example, CondensedButton is used so that the button content can
1919
---
2020
type: example
2121
---
22-
<Table caption='Tallest Roller Coasters'>
22+
<Table caption={() => 'Tallest Roller Coasters'}>
2323
<Table.Head>
2424
<Table.Row>
2525
<Table.ColHeader id="Roller Coaster">

packages/ui-pages/src/Pages/v1/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ const Example = () => {
348348
<Byline description={user.name}>
349349
<Avatar name={user.name} />
350350
</Byline>
351-
<Table caption="User details">
351+
<Table caption={() => "User details"}>
352352
<Table.Body>
353353
<Table.Row>
354354
<Table.RowHeader>Age</Table.RowHeader>
@@ -357,7 +357,7 @@ const Example = () => {
357357
</Table.Body>
358358
</Table>
359359
{user.email && (
360-
<Table caption="User details">
360+
<Table caption={() => "User details"}>
361361
<Table.Body>
362362
<Table.Row>
363363
<Table.RowHeader>Email</Table.RowHeader>
@@ -367,7 +367,7 @@ const Example = () => {
367367
</Table>
368368
)}
369369
{!isNaN(user.spouse) && (
370-
<Table caption="User details">
370+
<Table caption={() => "User details"}>
371371
<Table.Body>
372372
<Table.Row>
373373
<Table.RowHeader>Spouse</Table.RowHeader>
@@ -379,7 +379,7 @@ const Example = () => {
379379
</Table>
380380
)}
381381
{Array.isArray(user.parents) && (
382-
<Table caption="User details">
382+
<Table caption={() => "User details"}>
383383
<Table.Body>
384384
<Table.Row>
385385
<Table.RowHeader>Parents</Table.RowHeader>

packages/ui-table/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { Table } from '@instructure/ui-table'
2929

3030
const MyTable = () => {
3131
return (
32-
<Table caption='Top rated movies'>
32+
<Table caption={() => 'Top rated movies'}>
3333
<Table.Head>
3434
<Table.Row>
3535
<Table.ColHeader>Rank</Table.ColHeader>

packages/ui-table/src/Table/__tests__/Table.test.tsx

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ describe('<Table />', async () => {
4848

4949
const renderTable = (props?: TableProps) =>
5050
render(
51-
<Table caption="Test table" {...props}>
51+
<Table caption={() => 'Test table'} {...props}>
5252
<Table.Head>
5353
<Table.Row>
5454
<Table.ColHeader id="foo">ColHeader</Table.ColHeader>
@@ -82,7 +82,7 @@ describe('<Table />', async () => {
8282
it('applies a fixed column layout', async () => {
8383
await renderTable({
8484
layout: 'fixed',
85-
caption: 'Test table'
85+
caption: () => 'Test table'
8686
})
8787
const table = screen.getByRole('table')
8888

@@ -92,7 +92,7 @@ describe('<Table />', async () => {
9292
it('passes hover to table row', async () => {
9393
renderTable({
9494
hover: true,
95-
caption: 'Test table'
95+
caption: () => 'Test table'
9696
})
9797
const tableRows = screen.getAllByRole('row')
9898

@@ -123,7 +123,7 @@ describe('<Table />', async () => {
123123
it('can render table in stacked layout', async () => {
124124
renderTable({
125125
layout: 'stacked',
126-
caption: 'Test table'
126+
caption: () => 'Test table'
127127
})
128128
const stackedTable = screen.getByRole('table')
129129

@@ -135,7 +135,7 @@ describe('<Table />', async () => {
135135

136136
it('can handle non-existent head in stacked layout', async () => {
137137
render(
138-
<Table caption="Test table" layout="stacked">
138+
<Table caption={() => 'Test table'} layout="stacked">
139139
<Table.Body></Table.Body>
140140
</Table>
141141
)
@@ -146,7 +146,7 @@ describe('<Table />', async () => {
146146

147147
it('can handle empty head in stacked layout', async () => {
148148
render(
149-
<Table caption="Test table" layout="stacked">
149+
<Table caption={() => 'Test table'} layout="stacked">
150150
<Table.Head></Table.Head>
151151
</Table>
152152
)
@@ -157,7 +157,7 @@ describe('<Table />', async () => {
157157

158158
it('can handle invalid header in stacked layout', async () => {
159159
render(
160-
<Table caption="Test table" layout="stacked">
160+
<Table caption={() => 'Test table'} layout="stacked">
161161
<Table.Head>
162162
<Table.Row>
163163
<Table.Cell>Foo</Table.Cell>
@@ -189,7 +189,7 @@ describe('<Table />', async () => {
189189

190190
it('does not crash for invalid children in stacked layout', async () => {
191191
render(
192-
<Table caption="Test table" layout="stacked">
192+
<Table caption={() => 'Test table'} layout="stacked">
193193
test1
194194
<span>test</span>
195195
{/* @ts-ignore error is normal here */}
@@ -233,7 +233,7 @@ describe('<Table />', async () => {
233233
layout: TableProps['layout'] = 'auto'
234234
) =>
235235
render(
236-
<Table caption="Sortable table" layout={layout}>
236+
<Table caption={() => 'Sortable table'} layout={layout}>
237237
<Table.Head>
238238
<Table.Row>
239239
<Table.ColHeader id="foo" {...props} {...handlers}>
@@ -348,6 +348,39 @@ describe('<Table />', async () => {
348348

349349
expect(header).toHaveAttribute('aria-sort', 'descending')
350350
})
351+
352+
it('calls the caption function with the sorted header and direction', async () => {
353+
const caption = vi.fn((header: string, direction: string) =>
354+
header ? `Movies, sorted by ${header} ${direction}` : 'Movies'
355+
)
356+
const { container } = render(
357+
<Table caption={caption}>
358+
<Table.Head>
359+
<Table.Row>
360+
<Table.ColHeader id="foo" sortDirection="ascending">
361+
Foo
362+
</Table.ColHeader>
363+
<Table.ColHeader id="bar">Bar</Table.ColHeader>
364+
</Table.Row>
365+
</Table.Head>
366+
<Table.Body>
367+
<Table.Row></Table.Row>
368+
</Table.Body>
369+
</Table>
370+
)
371+
372+
expect(caption).toHaveBeenCalledWith('Foo', 'ascending')
373+
expect(container.querySelector('caption')).toHaveTextContent(
374+
'Movies, sorted by Foo ascending'
375+
)
376+
})
377+
378+
it('calls the caption function with an empty header and "none" when nothing is sorted', async () => {
379+
const caption = vi.fn(() => 'Movies')
380+
renderTable({ caption } as Partial<TableProps> as TableProps)
381+
382+
expect(caption).toHaveBeenCalledWith('', 'none')
383+
})
351384
})
352385

353386
describe('when using custom components', () => {
@@ -369,7 +402,7 @@ describe('<Table />', async () => {
369402
}
370403
}
371404
const table = render(
372-
<Table caption="Test custom table">
405+
<Table caption={() => 'Test custom table'}>
373406
<Table.Head>
374407
<Table.Row>
375408
<Table.ColHeader id="foo">ColHeader</Table.ColHeader>
@@ -410,7 +443,7 @@ describe('<Table />', async () => {
410443
}
411444

412445
const table = render(
413-
<Table caption="Test custom table">
446+
<Table caption={() => 'Test custom table'}>
414447
<Table.Head>
415448
<CustomTableRow>
416449
<CustomTableCell id="foo">ColHeader</CustomTableCell>

packages/ui-table/src/Table/v1/index.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,18 @@ class Table extends Component<TableProps> {
9393
// so we use an aria-live region as a workaround
9494
const prevSortInfo = this.getSortedHeaderInfo(prevProps)
9595
const currentSortInfo = this.getSortedHeaderInfo(this.props)
96-
// Only announce if sorting actually changed
96+
// Only announce if sorting actually changed. A plain ReactNode caption
97+
// ignores the sort state (see `getCaptionText`), so we only announce sort
98+
// changes when `caption` is a function that can describe them.
9799
const sortingChanged =
98100
prevSortInfo?.header !== currentSortInfo?.header ||
99101
prevSortInfo?.direction !== currentSortInfo?.direction
100-
if (sortingChanged && currentSortInfo && this._liveRegionRef) {
102+
if (
103+
sortingChanged &&
104+
currentSortInfo &&
105+
// typeof this.props.caption === 'function' &&
106+
this._liveRegionRef
107+
) {
101108
// Clear any pending announcement
102109
clearTimeout(this._announcementTimeout)
103110
// Clear the live region first (part of the clear-then-set pattern)
@@ -154,18 +161,21 @@ class Table extends Component<TableProps> {
154161
const headerText =
155162
typeof colHeader.props.children === 'string'
156163
? colHeader.props.children
157-
: colHeader.props.children?.props?.children ?? ''
164+
: (colHeader.props.children?.props?.children ?? '')
158165
return { header: headerText, direction: colHeader.props.sortDirection }
159166
}
160167
}
161168
return null
162169
}
163170

164171
getCaptionText(props: TableProps) {
172+
const { caption } = props
165173
const sortInfo = this.getSortedHeaderInfo(props)
166-
const caption = props.caption as string
167-
if (!sortInfo) return caption
168-
const sortText = ` Sorted by ${sortInfo.header} (${sortInfo.direction})`
174+
if (typeof caption === 'function') {
175+
return caption(sortInfo?.header ?? '', sortInfo?.direction ?? 'none')
176+
}
177+
const sortText = ` Sorted by ${sortInfo?.header} (${sortInfo?.direction})`
178+
169179
return caption ? caption + sortText : sortText.trim()
170180
}
171181

packages/ui-table/src/Table/v1/props.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,21 @@ import type { OtherHTMLAttributes, TableTheme } from '@instructure/shared-types'
3131

3232
type RowChild = React.ReactElement<{ children: React.ReactElement }>
3333

34+
type TableCaption = (
35+
sortByHeader: string,
36+
sortDirection: 'none' | 'ascending' | 'descending'
37+
) => string
38+
3439
type TableOwnProps = {
3540
/**
3641
* Provide a screen reader friendly description. Anything passed to this
3742
* prop will be wrapped by `<ScreenReaderContent>` when it is rendered.
43+
*
44+
* A plain `ReactNode` (e.g. a string) is rendered as-is and the sort state
45+
* is ignored. Pass a function (see {@link TableCaption}) to build a
46+
* localized caption that also reflects the current sort state.
3847
*/
39-
caption: React.ReactNode
48+
caption: React.ReactNode | TableCaption
4049
/**
4150
* Valid values are `0`, `none`, `auto`, `xxx-small`, `xx-small`, `x-small`,
4251
* `small`, `medium`, `large`, `x-large`, `xx-large`. Apply these values via
@@ -92,6 +101,7 @@ const allowedProps: AllowedPropKeys = [
92101
export type {
93102
TableProps,
94103
TableStyle,
104+
TableCaption,
95105
// children
96106
RowChild
97107
}

packages/ui-table/src/Table/v2/README.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const Example = () => {
5454
return (
5555
<div>
5656
{renderOptions()}
57-
<Table caption="Top rated movies" layout={layout} hover={hover}>
57+
<Table caption={() => 'Top rated movies'} layout={layout} hover={hover}>
5858
<Table.Head>
5959
<Table.Row>
6060
<Table.ColHeader id="Rank">Rank</Table.ColHeader>
@@ -114,7 +114,7 @@ const Example = ({ headers, rows }) => {
114114
>
115115
{({ layout }) => (
116116
<div>
117-
<Table caption="Top rated movies" layout={layout}>
117+
<Table caption={() => 'Top rated movies'} layout={layout}>
118118
<Table.Head>
119119
<Table.Row>
120120
{(headers || []).map(({ id, text, width, textAlign }) => (
@@ -298,7 +298,7 @@ const SortableTable = ({ caption, headers, rows }) => {
298298
summary="Set text-align for columns"
299299
background="default"
300300
>
301-
<Table caption="Set text-align for columns">
301+
<Table caption={() => 'Set text-align for columns'}>
302302
<Table.Head>{renderHeaderRow()}</Table.Head>
303303
<Table.Body>
304304
<Table.Row>
@@ -375,7 +375,11 @@ const SortableTable = ({ caption, headers, rows }) => {
375375

376376
render(
377377
<SortableTable
378-
caption="Top rated movies"
378+
caption={(sortBy, sortDirection) =>
379+
sortBy
380+
? `Top rated movies, sorted by ${sortBy} (${sortDirection})`
381+
: 'Top rated movies'
382+
}
379383
headers={[
380384
{
381385
id: 'rank',
@@ -682,7 +686,11 @@ const renderRating = (rating) => (
682686

683687
render(
684688
<SortableTable
685-
caption="Top rated movies"
689+
caption={(sortBy, sortDirection) =>
690+
sortBy
691+
? `Top rated movies, sorted by ${sortBy} (${sortDirection})`
692+
: 'Top rated movies'
693+
}
686694
headers={[
687695
{
688696
id: 'Rank',
@@ -809,7 +817,7 @@ const Example = () => {
809817
return (
810818
<div>
811819
{renderOptions()}
812-
<Table caption="Top rated movies" layout={layout} hover={hover}>
820+
<Table caption={() => 'Top rated movies'} layout={layout} hover={hover}>
813821
<Table.Head>
814822
<Table.Row>
815823
<Table.ColHeader id="Rank">Rank</Table.ColHeader>
@@ -918,7 +926,7 @@ const Example = () => {
918926
return (
919927
<div>
920928
{renderOptions()}
921-
<Table caption="Top rated movies" layout={layout} hover={hover}>
929+
<Table caption={() => 'Top rated movies'} layout={layout} hover={hover}>
922930
<Table.Head>
923931
<CustomTableRow>
924932
<CustomTableCell scope="col">Rank</CustomTableCell>
@@ -1062,7 +1070,7 @@ const Example = () => {
10621070
return (
10631071
<div>
10641072
{renderOptions()}
1065-
<Table caption="Top rated movies" layout={layout} hover={hover}>
1073+
<Table caption={() => 'Top rated movies'} layout={layout} hover={hover}>
10661074
<Table.Head>
10671075
<CustomTableRow>
10681076
<CustomTableCell scope="col">Rank</CustomTableCell>

0 commit comments

Comments
 (0)