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 && ( 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 (
-
{captionText}
+
'Top rated movies'} layout="auto"> Rank @@ -65,7 +65,11 @@ export default function TablePage() {
-
+
'Top rated movies (fixed layout)'} + layout="fixed" + hover + >