diff --git a/cypress/component/Table.cy.tsx b/cypress/component/Table.cy.tsx
index 72807ae638..8c89de401d 100644
--- a/cypress/component/Table.cy.tsx
+++ b/cypress/component/Table.cy.tsx
@@ -35,7 +35,7 @@ describe('
', () => {
setHoverStateTo?: boolean
hover?: boolean
}) => (
-
+ 'Test table'} hover={hover}>
ColHeader
@@ -54,7 +54,7 @@ describe('', () => {
it('can render table head as a combobox when in stacked layout', async () => {
const sortFoo = cy.stub()
cy.mount(
-
+ 'Sortable table'} layout="stacked">
diff --git a/packages/ui-buttons/src/CondensedButton/v1/README.md b/packages/ui-buttons/src/CondensedButton/v1/README.md
index 9fa79832ad..7d07f3ee1d 100644
--- a/packages/ui-buttons/src/CondensedButton/v1/README.md
+++ b/packages/ui-buttons/src/CondensedButton/v1/README.md
@@ -19,7 +19,7 @@ In the following example, CondensedButton is used so that the button content can
---
type: example
---
-
+ 'Tallest Roller Coasters'}>
diff --git a/packages/ui-buttons/src/CondensedButton/v2/README.md b/packages/ui-buttons/src/CondensedButton/v2/README.md
index 9fa79832ad..7d07f3ee1d 100644
--- a/packages/ui-buttons/src/CondensedButton/v2/README.md
+++ b/packages/ui-buttons/src/CondensedButton/v2/README.md
@@ -19,7 +19,7 @@ In the following example, CondensedButton is used so that the button content can
---
type: example
---
-
+ 'Tallest Roller Coasters'}>
diff --git a/packages/ui-pages/src/Pages/v1/README.md b/packages/ui-pages/src/Pages/v1/README.md
index 18dfcbb35e..873ee77c8f 100644
--- a/packages/ui-pages/src/Pages/v1/README.md
+++ b/packages/ui-pages/src/Pages/v1/README.md
@@ -348,7 +348,7 @@ const Example = () => {
-
+ "User details"}>
Age
@@ -357,7 +357,7 @@ const Example = () => {
{user.email && (
-
+ "User details"}>
Email
@@ -367,7 +367,7 @@ const Example = () => {
)}
{!isNaN(user.spouse) && (
-
+ "User details"}>
Spouse
@@ -379,7 +379,7 @@ const Example = () => {
)}
{Array.isArray(user.parents) && (
-
+ "User details"}>
Parents
diff --git a/packages/ui-table/README.md b/packages/ui-table/README.md
index e2232ac12f..479154d0a9 100644
--- a/packages/ui-table/README.md
+++ b/packages/ui-table/README.md
@@ -29,7 +29,7 @@ import { Table } from '@instructure/ui-table'
const MyTable = () => {
return (
-
+ 'Top rated movies'}>
Rank
diff --git a/packages/ui-table/src/Table/__tests__/Table.test.tsx b/packages/ui-table/src/Table/__tests__/Table.test.tsx
index b853812400..b8f66d4710 100644
--- a/packages/ui-table/src/Table/__tests__/Table.test.tsx
+++ b/packages/ui-table/src/Table/__tests__/Table.test.tsx
@@ -48,7 +48,7 @@ describe('', async () => {
const renderTable = (props?: TableProps) =>
render(
-
+ 'Test table'} {...props}>
ColHeader
@@ -82,7 +82,7 @@ describe('', async () => {
it('applies a fixed column layout', async () => {
await renderTable({
layout: 'fixed',
- caption: 'Test table'
+ caption: () => 'Test table'
})
const table = screen.getByRole('table')
@@ -92,7 +92,7 @@ describe('', async () => {
it('passes hover to table row', async () => {
renderTable({
hover: true,
- caption: 'Test table'
+ caption: () => 'Test table'
})
const tableRows = screen.getAllByRole('row')
@@ -123,7 +123,7 @@ describe('', async () => {
it('can render table in stacked layout', async () => {
renderTable({
layout: 'stacked',
- caption: 'Test table'
+ caption: () => 'Test table'
})
const stackedTable = screen.getByRole('table')
@@ -135,7 +135,7 @@ describe('', async () => {
it('can handle non-existent head in stacked layout', async () => {
render(
-
+ 'Test table'} layout="stacked">
)
@@ -146,7 +146,7 @@ describe('', async () => {
it('can handle empty head in stacked layout', async () => {
render(
-
+ 'Test table'} layout="stacked">
)
@@ -157,7 +157,7 @@ describe('', async () => {
it('can handle invalid header in stacked layout', async () => {
render(
-
+ 'Test table'} layout="stacked">
Foo
@@ -189,7 +189,7 @@ describe('', async () => {
it('does not crash for invalid children in stacked layout', async () => {
render(
-
+ 'Test table'} layout="stacked">
test1
test
{/* @ts-ignore error is normal here */}
@@ -233,7 +233,7 @@ describe('', async () => {
layout: TableProps['layout'] = 'auto'
) =>
render(
-
+ 'Sortable table'} layout={layout}>
@@ -348,6 +348,39 @@ describe('', async () => {
expect(header).toHaveAttribute('aria-sort', 'descending')
})
+
+ it('calls the caption function with the sorted header and direction', async () => {
+ const caption = vi.fn((header: string, direction: string) =>
+ header ? `Movies, sorted by ${header} ${direction}` : 'Movies'
+ )
+ const { container } = render(
+
+
+
+
+ Foo
+
+ Bar
+
+
+
+
+
+
+ )
+
+ expect(caption).toHaveBeenCalledWith('Foo', 'ascending')
+ expect(container.querySelector('caption')).toHaveTextContent(
+ 'Movies, sorted by Foo ascending'
+ )
+ })
+
+ it('calls the caption function with an empty header and "none" when nothing is sorted', async () => {
+ const caption = vi.fn(() => 'Movies')
+ renderTable({ caption } as Partial as TableProps)
+
+ expect(caption).toHaveBeenCalledWith('', 'none')
+ })
})
describe('when using custom components', () => {
@@ -369,7 +402,7 @@ describe('', async () => {
}
}
const table = render(
-
+ 'Test custom table'}>
ColHeader
@@ -410,7 +443,7 @@ describe('', async () => {
}
const table = render(
-
+ 'Test custom table'}>
ColHeader
diff --git a/packages/ui-table/src/Table/v1/index.tsx b/packages/ui-table/src/Table/v1/index.tsx
index a4c1ca36e0..c63acedf9b 100644
--- a/packages/ui-table/src/Table/v1/index.tsx
+++ b/packages/ui-table/src/Table/v1/index.tsx
@@ -92,11 +92,18 @@ class Table extends Component {
// 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
+ // Only announce if sorting actually changed. A plain ReactNode caption
+ // ignores the sort state (see `getCaptionText`), so we only announce sort
+ // changes when `caption` is a function that can describe them.
const sortingChanged =
prevSortInfo?.header !== currentSortInfo?.header ||
prevSortInfo?.direction !== currentSortInfo?.direction
- if (sortingChanged && currentSortInfo && this._liveRegionRef) {
+ if (
+ sortingChanged &&
+ currentSortInfo &&
+ typeof this.props.caption === 'function' &&
+ this._liveRegionRef
+ ) {
// Clear any pending announcement
clearTimeout(this._announcementTimeout)
// Clear the live region first (part of the clear-then-set pattern)
@@ -161,11 +168,13 @@ class Table extends Component {
}
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()
+ const { caption } = props
+ if (typeof caption === 'function') {
+ const sortInfo = this.getSortedHeaderInfo(props)
+ return caption(sortInfo?.header ?? '', sortInfo?.direction ?? 'none')
+ }
+
+ return caption as string
}
render() {
diff --git a/packages/ui-table/src/Table/v1/props.ts b/packages/ui-table/src/Table/v1/props.ts
index c0c62cb7b6..c24b4dda19 100644
--- a/packages/ui-table/src/Table/v1/props.ts
+++ b/packages/ui-table/src/Table/v1/props.ts
@@ -31,12 +31,21 @@ import type { OtherHTMLAttributes, TableTheme } from '@instructure/shared-types'
type RowChild = React.ReactElement<{ children: React.ReactElement }>
+type TableCaption = (
+ sortByHeader: string,
+ sortDirection: 'none' | 'ascending' | 'descending'
+) => string
+
type TableOwnProps = {
/**
* Provide a screen reader friendly description. Anything passed to this
* prop will be wrapped by `` when it is rendered.
+ *
+ * A plain `ReactNode` (e.g. a string) is rendered as-is and the sort state
+ * is ignored. Pass a function (see {@link TableCaption}) to build a
+ * localized caption that also reflects the current sort state.
*/
- caption: React.ReactNode
+ caption: React.ReactNode | TableCaption
/**
* Valid values are `0`, `none`, `auto`, `xxx-small`, `xx-small`, `x-small`,
* `small`, `medium`, `large`, `x-large`, `xx-large`. Apply these values via
@@ -92,6 +101,7 @@ const allowedProps: AllowedPropKeys = [
export type {
TableProps,
TableStyle,
+ TableCaption,
// children
RowChild
}
diff --git a/packages/ui-table/src/Table/v2/README.md b/packages/ui-table/src/Table/v2/README.md
index c92bd6359e..9a2f991f65 100644
--- a/packages/ui-table/src/Table/v2/README.md
+++ b/packages/ui-table/src/Table/v2/README.md
@@ -54,7 +54,7 @@ const Example = () => {
return (
{renderOptions()}
-
+ 'Top rated movies'} layout={layout} hover={hover}>
Rank
@@ -114,7 +114,7 @@ const Example = ({ headers, rows }) => {
>
{({ layout }) => (
-
+ 'Top rated movies'} layout={layout}>
{(headers || []).map(({ id, text, width, textAlign }) => (
@@ -298,7 +298,7 @@ const SortableTable = ({ caption, headers, rows }) => {
summary="Set text-align for columns"
background="default"
>
-
+ 'Set text-align for columns'}>
{renderHeaderRow()}
@@ -375,7 +375,11 @@ const SortableTable = ({ caption, headers, rows }) => {
render(
+ sortBy
+ ? `Top rated movies, sorted by ${sortBy} (${sortDirection})`
+ : 'Top rated movies'
+ }
headers={[
{
id: 'rank',
@@ -682,7 +686,11 @@ const renderRating = (rating) => (
render(
+ sortBy
+ ? `Top rated movies, sorted by ${sortBy} (${sortDirection})`
+ : 'Top rated movies'
+ }
headers={[
{
id: 'Rank',
@@ -809,7 +817,7 @@ const Example = () => {
return (
{renderOptions()}
-
+ 'Top rated movies'} layout={layout} hover={hover}>
Rank
@@ -918,7 +926,7 @@ const Example = () => {
return (
{renderOptions()}
-
+ 'Top rated movies'} layout={layout} hover={hover}>
Rank
@@ -1062,7 +1070,7 @@ const Example = () => {
return (
{renderOptions()}
-
+ 'Top rated movies'} layout={layout} hover={hover}>
Rank
diff --git a/packages/ui-table/src/Table/v2/index.tsx b/packages/ui-table/src/Table/v2/index.tsx
index 859b97dc6b..7e601a7513 100644
--- a/packages/ui-table/src/Table/v2/index.tsx
+++ b/packages/ui-table/src/Table/v2/index.tsx
@@ -161,19 +161,15 @@ class Table extends Component {
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()
+ return props.caption(sortInfo?.header ?? '', sortInfo?.direction ?? 'none')
}
render() {
- const { margin, layout, caption, children, hover, styles, minWidth } =
- this.props
+ const { margin, layout, children, hover, styles, minWidth } = this.props
const isStacked = layout === 'stacked'
const captionText = this.getCaptionText(this.props)
- if (!caption) {
+ if (!captionText) {
error(false, `[Table] required prop caption is not set.`)
}
@@ -212,7 +208,7 @@ class Table extends Component {
aria-label={captionText}
>
{/* Caption for visual display and semantic HTML */}
- {!isStacked && caption && (
+ {!isStacked && captionText && (
{captionText}
diff --git a/packages/ui-table/src/Table/v2/props.ts b/packages/ui-table/src/Table/v2/props.ts
index f2afc07c07..b6f9c48773 100644
--- a/packages/ui-table/src/Table/v2/props.ts
+++ b/packages/ui-table/src/Table/v2/props.ts
@@ -32,12 +32,20 @@ import type { NewComponentTypes } from '@instructure/ui-themes'
type RowChild = React.ReactElement<{ children: React.ReactElement }>
+type TableCaption = (
+ sortByHeader: string,
+ sortDirection: 'none' | 'ascending' | 'descending'
+) => string
+
type TableOwnProps = {
/**
- * Provide a screen reader friendly description. Anything passed to this
- * prop will be wrapped by `` when it is rendered.
+ * Provide a screen reader friendly description. The returned string is
+ * wrapped by `` when it is rendered.
+ *
+ * This is a function so consumers can build a localized caption that also
+ * reflects the current sort state. See {@link TableCaption}.
*/
- caption: React.ReactNode
+ caption: TableCaption
/**
* Valid values are `0`, `none`, `auto`, `xxx-small`, `xx-small`, `x-small`,
* `small`, `medium`, `large`, `x-large`, `xx-large`. Apply these values via
@@ -93,6 +101,7 @@ const allowedProps: AllowedPropKeys = [
export type {
TableProps,
TableStyle,
+ TableCaption,
// children
RowChild
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e8429b3949..5c158f8abb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3703,7 +3703,7 @@ importers:
version: link:../command-utils
'@instructure/instructure-design-tokens':
specifier: github:instructure/instructure-design-tokens
- version: https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/9916a91be6b27a8c2d15136d2865e6f69a2d1c08
+ version: https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/6085eab6b704bbcaf27a1bf6626ae8d092fce4df
'@instructure/pkg-utils':
specifier: workspace:*
version: link:../pkg-utils
@@ -6711,8 +6711,8 @@ packages:
'@types/node':
optional: true
- '@instructure/instructure-design-tokens@https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/9916a91be6b27a8c2d15136d2865e6f69a2d1c08':
- resolution: {tarball: https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/9916a91be6b27a8c2d15136d2865e6f69a2d1c08}
+ '@instructure/instructure-design-tokens@https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/6085eab6b704bbcaf27a1bf6626ae8d092fce4df':
+ resolution: {tarball: https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/6085eab6b704bbcaf27a1bf6626ae8d092fce4df}
version: 1.0.0
'@isaacs/cliui@8.0.2':
@@ -9205,8 +9205,8 @@ packages:
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
engines: {node: '>=10.13.0'}
- enhanced-resolve@5.22.0:
- resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==}
+ enhanced-resolve@5.22.1:
+ resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==}
engines: {node: '>=10.13.0'}
enquirer@2.3.6:
@@ -12922,8 +12922,8 @@ packages:
uglify-js:
optional: true
- terser-webpack-plugin@5.6.0:
- resolution: {integrity: sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==}
+ terser-webpack-plugin@5.6.1:
+ resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==}
engines: {node: '>= 10.13.0'}
peerDependencies:
'@minify-html/node': '*'
@@ -15459,7 +15459,7 @@ snapshots:
optionalDependencies:
'@types/node': 22.19.15
- '@instructure/instructure-design-tokens@https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/9916a91be6b27a8c2d15136d2865e6f69a2d1c08':
+ '@instructure/instructure-design-tokens@https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/6085eab6b704bbcaf27a1bf6626ae8d092fce4df':
dependencies:
glob: 13.0.6
@@ -18328,7 +18328,7 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
- enhanced-resolve@5.22.0:
+ enhanced-resolve@5.22.1:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.3
@@ -22912,7 +22912,7 @@ snapshots:
optionalDependencies:
esbuild: 0.28.0
- terser-webpack-plugin@5.6.0(esbuild@0.28.0)(webpack@5.107.2(esbuild@0.28.0)):
+ terser-webpack-plugin@5.6.1(esbuild@0.28.0)(webpack@5.107.2(esbuild@0.28.0)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
@@ -22922,7 +22922,7 @@ snapshots:
optionalDependencies:
esbuild: 0.28.0
- terser-webpack-plugin@5.6.0(esbuild@0.28.0)(webpack@5.107.2):
+ terser-webpack-plugin@5.6.1(esbuild@0.28.0)(webpack@5.107.2):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
@@ -23553,7 +23553,7 @@ snapshots:
acorn-import-phases: 1.0.4(acorn@8.16.0)
browserslist: 4.28.2
chrome-trace-event: 1.0.4
- enhanced-resolve: 5.22.0
+ enhanced-resolve: 5.22.1
es-module-lexer: 2.1.0
eslint-scope: 5.1.1
events: 3.3.0
@@ -23564,7 +23564,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.3
- terser-webpack-plugin: 5.6.0(esbuild@0.28.0)(webpack@5.107.2(esbuild@0.28.0))
+ terser-webpack-plugin: 5.6.1(esbuild@0.28.0)(webpack@5.107.2(esbuild@0.28.0))
watchpack: 2.5.1
webpack-sources: 3.5.0
transitivePeerDependencies:
@@ -23592,7 +23592,7 @@ snapshots:
acorn-import-phases: 1.0.4(acorn@8.16.0)
browserslist: 4.28.2
chrome-trace-event: 1.0.4
- enhanced-resolve: 5.22.0
+ enhanced-resolve: 5.22.1
es-module-lexer: 2.1.0
eslint-scope: 5.1.1
events: 3.3.0
@@ -23603,7 +23603,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.3
- terser-webpack-plugin: 5.6.0(esbuild@0.28.0)(webpack@5.107.2)
+ terser-webpack-plugin: 5.6.1(esbuild@0.28.0)(webpack@5.107.2)
watchpack: 2.5.1
webpack-sources: 3.5.0
optionalDependencies:
diff --git a/regression-test/src/app/table/page.tsx b/regression-test/src/app/table/page.tsx
index 74df9c5e83..0fafa979e3 100644
--- a/regression-test/src/app/table/page.tsx
+++ b/regression-test/src/app/table/page.tsx
@@ -32,7 +32,7 @@ export default function TablePage() {
return (
-
+ 'Top rated movies'} layout="auto">
Rank
@@ -65,7 +65,11 @@ export default function TablePage() {
-
+ 'Top rated movies (fixed layout)'}
+ layout="fixed"
+ hover
+ >