diff --git a/pages/table/inline-editor.permutations.page.tsx b/pages/table/inline-editor.permutations.page.tsx index deab5003f6..5067e2abad 100644 --- a/pages/table/inline-editor.permutations.page.tsx +++ b/pages/table/inline-editor.permutations.page.tsx @@ -67,6 +67,7 @@ export default function InlineEditorPermutations() { wrapLines={false} columnId="id" stickyState={stickyState} + tableRole="grid" {...permutation} /> diff --git a/src/table/__tests__/a11y.test.tsx b/src/table/__tests__/a11y.test.tsx index d432985337..533f7d3fda 100644 --- a/src/table/__tests__/a11y.test.tsx +++ b/src/table/__tests__/a11y.test.tsx @@ -10,6 +10,7 @@ import liveRegionStyles from '../../../lib/components/internal/components/live-r interface Item { id: number; name: string; + value: string; } const defaultColumns: TableProps.ColumnDefinition[] = [ @@ -18,9 +19,9 @@ const defaultColumns: TableProps.ColumnDefinition[] = [ ]; const defaultItems: Item[] = [ - { id: 1, name: 'Apples' }, - { id: 2, name: 'Oranges' }, - { id: 3, name: 'Bananas' }, + { id: 1, name: 'Apples', value: 'apples' }, + { id: 2, name: 'Oranges', value: 'oranges' }, + { id: 3, name: 'Bananas', value: 'bananas' }, ]; function renderTableWrapper(props?: Partial) { @@ -34,6 +35,26 @@ afterAll(() => { jest.restoreAllMocks(); }); +describe('roles', () => { + test('table has role="table" when no columns are editable', () => { + const wrapper = renderTableWrapper({ columnDefinitions: defaultColumns }); + expect(wrapper.find('[role="table"]')).not.toBeNull(); + expect(wrapper.find('[role="grid"]')).toBeNull(); + }); + + test('table has role="grid" when at least one defined column is editable', () => { + const wrapper = renderTableWrapper({ + columnDefinitions: [ + ...defaultColumns, + { header: 'value', cell: item => item.value, editConfig: { editingCell: () => null } }, + ], + visibleColumns: ['id', 'name'], + }); + expect(wrapper.find('[role="table"]')).toBeNull(); + expect(wrapper.find('[role="grid"]')).not.toBeNull(); + }); +}); + describe('labels', () => { test('not to have aria-label if omitted', () => { const wrapper = renderTableWrapper(); diff --git a/src/table/__tests__/body-cell.test.tsx b/src/table/__tests__/body-cell.test.tsx index 650a4b2bcd..70e2fc0000 100644 --- a/src/table/__tests__/body-cell.test.tsx +++ b/src/table/__tests__/body-cell.test.tsx @@ -8,6 +8,8 @@ import { renderHook } from '../../__tests__/render-hook'; import { useStickyColumns } from '../../../lib/components/table/sticky-columns'; import styles from '../../../lib/components/table/body-cell/styles.selectors.js'; +const tableRole = 'table'; + const testItem = { test: 'testData', }; @@ -59,6 +61,7 @@ const TestComponent = ({ isEditing = false, successfulEdit = false }) => { stickyState={result.current} successfulEdit={successfulEdit} columnId="id" + tableRole={tableRole} /> @@ -91,6 +94,7 @@ const TestComponent2 = ({ column }: any) => { wrapLines={false} stickyState={result.current} columnId="id" + tableRole={tableRole} /> diff --git a/src/table/__tests__/header-cell.test.tsx b/src/table/__tests__/header-cell.test.tsx index 89884b8184..9ab9caa0da 100644 --- a/src/table/__tests__/header-cell.test.tsx +++ b/src/table/__tests__/header-cell.test.tsx @@ -11,6 +11,8 @@ import resizerStyles from '../../../lib/components/table/resizer/styles.css.js'; import { useStickyColumns } from '../../../lib/components/table/sticky-columns'; import TestI18nProvider from '../../../lib/components/i18n/testing'; +const tableRole = 'table'; + const testItem = { test: 'test', }; @@ -58,6 +60,7 @@ it('renders a fake focus outline on the sort control', () => { stickyState={result.current} columnId="id" cellRef={() => {}} + tableRole={tableRole} /> ); @@ -82,6 +85,7 @@ it('renders a fake focus outline on the resize control', () => { stickyState={result.current} columnId="id" cellRef={() => {}} + tableRole={tableRole} /> ); @@ -108,6 +112,7 @@ describe('i18n', () => { columnId="id" isEditable={true} cellRef={() => {}} + tableRole={tableRole} /> diff --git a/src/table/body-cell/td-element.tsx b/src/table/body-cell/td-element.tsx index 12e4446b28..d5769be237 100644 --- a/src/table/body-cell/td-element.tsx +++ b/src/table/body-cell/td-element.tsx @@ -4,7 +4,8 @@ import clsx from 'clsx'; import React from 'react'; import styles from './styles.css.js'; import { getStickyClassNames } from '../utils'; -import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns/use-sticky-columns.js'; +import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; +import { TableRole, getTableCellRoleProps } from '../table-role/table-role-helper.js'; export interface TableTdElementProps { className?: string; @@ -31,6 +32,7 @@ export interface TableTdElementProps { columnId: PropertyKey; stickyState: StickyColumnsModel; isVisualRefresh?: boolean; + tableRole: TableRole; } export const TableTdElement = React.forwardRef( @@ -57,17 +59,13 @@ export const TableTdElement = React.forwardRef { - let Element: 'th' | 'td' = 'td'; - if (isRowHeader) { - Element = 'th'; - nativeAttributes = { - ...nativeAttributes, - scope: 'row', - }; - } + const Element = isRowHeader ? 'th' : 'td'; + + nativeAttributes = { ...nativeAttributes, ...getTableCellRoleProps({ tableRole, isRowHeader }) }; const stickyStyles = useStickyCellStyles({ stickyColumns: stickyState, diff --git a/src/table/header-cell/index.tsx b/src/table/header-cell/index.tsx index 9ba182646c..c9fb6911a9 100644 --- a/src/table/header-cell/index.tsx +++ b/src/table/header-cell/index.tsx @@ -5,7 +5,7 @@ import React from 'react'; import InternalIcon from '../../icon/internal'; import { KeyCode } from '../../internal/keycode'; import { TableProps } from '../interfaces'; -import { getAriaSort, getSortingIconName, getSortingStatus, isSorted } from './utils'; +import { getSortingIconName, getSortingStatus, isSorted } from './utils'; import styles from './styles.css.js'; import { Resizer } from '../resizer'; import { useUniqueId } from '../../internal/hooks/use-unique-id'; @@ -14,6 +14,7 @@ import { getStickyClassNames } from '../utils'; import { useInternalI18n } from '../../i18n/context'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; import { useMergeRefs } from '../../internal/hooks/use-merge-refs'; +import { TableRole, getTableColHeaderRoleProps } from '../table-role'; interface TableHeaderCellProps { className?: string; @@ -38,6 +39,7 @@ interface TableHeaderCellProps { cellRef: React.RefCallback; focusedComponent?: InteractiveComponent | null; onFocusedComponentChange?: (element: InteractiveComponent | null) => void; + tableRole: TableRole; } export function TableHeaderCell({ @@ -108,10 +110,9 @@ export function TableHeaderCell({ }, stickyStyles.className )} - aria-sort={sortingStatus && getAriaSort(sortingStatus)} style={{ ...style, ...stickyStyles.style }} - scope="col" ref={mergedRef} + {...getTableColHeaderRoleProps({ sortingStatus })} >
stateToIcon[sortingState]; -export const getAriaSort = (sortingState: SortingStatus) => stateToAriaSort[sortingState]; export const isSorted = (column: TableProps.ColumnDefinition, sortingColumn: TableProps.SortingColumn) => column === sortingColumn || (column.sortingField !== undefined && column.sortingField === sortingColumn.sortingField) || diff --git a/src/table/internal.tsx b/src/table/internal.tsx index ef04ea3d87..9249be1218 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -36,6 +36,7 @@ import { StickyScrollbar } from './sticky-scrollbar'; import { checkColumnWidths } from './column-widths-utils'; import { useMobile } from '../internal/hooks/use-mobile'; import { useContainerQuery } from '@cloudscape-design/component-toolkit'; +import { getTableRoleProps, getTableRowRoleProps } from './table-role'; const SELECTION_COLUMN_WIDTH = 54; const selectionColumnId = Symbol('selection-column-id'); @@ -176,6 +177,9 @@ const InternalTable = React.forwardRef( stickyColumnsLast: stickyColumns?.last || 0, }); + const hasEditableCells = !!columnDefinitions.find(col => col.editConfig); + const tableRole = hasEditableCells ? 'grid' : 'table'; + const theadProps: TheadProps = { containerWidth, selectionType, @@ -202,6 +206,7 @@ const InternalTable = React.forwardRef( stripedRows, stickyState, selectionColumnId, + tableRole, }; const wrapperRef = useMergeRefs(wrapperMeasureRef, wrapperRefObject, stickyState.refs.wrapper); @@ -269,6 +274,7 @@ const InternalTable = React.forwardRef( onScroll={handleScroll} tableHasHeader={hasHeader} contentDensity={contentDensity} + tableRole={tableRole} /> )} @@ -315,11 +321,7 @@ const InternalTable = React.forwardRef( resizableColumns && styles['table-layout-fixed'], contentDensity === 'compact' && getVisualContextClassname('compact-table') )} - // Browsers have weird mechanism to guess whether it's a data table or a layout table. - // If we state explicitly, they get it always correctly even with low number of rows. - role="table" - aria-label={ariaLabels?.tableLabel} - aria-rowcount={totalItemsCount ? totalItemsCount + 1 : -1} + {...getTableRoleProps({ tableRole, totalItemsCount, ariaLabel: ariaLabels?.tableLabel })} > {selectionType !== undefined && ( ); })} diff --git a/src/table/sticky-header.tsx b/src/table/sticky-header.tsx index 3ca15889dc..34d47e6272 100644 --- a/src/table/sticky-header.tsx +++ b/src/table/sticky-header.tsx @@ -8,6 +8,7 @@ import Thead, { InteractiveComponent, TheadProps } from './thead'; import { useStickyHeader } from './use-sticky-header'; import styles from './styles.css.js'; import { getVisualContextClassname } from '../internal/components/visual-context'; +import { TableRole, getTableRoleProps } from './table-role'; export interface StickyHeaderRef { scrollToTop(): void; @@ -25,6 +26,7 @@ interface StickyHeaderProps { onScroll?: React.UIEventHandler; contentDensity?: 'comfortable' | 'compact'; tableHasHeader?: boolean; + tableRole: TableRole; } export default forwardRef(StickyHeader); @@ -40,6 +42,7 @@ function StickyHeader( tableRef, tableHasHeader, contentDensity, + tableRole, }: StickyHeaderProps, ref: React.Ref ) { @@ -81,8 +84,8 @@ function StickyHeader( styles['table-layout-fixed'], contentDensity === 'compact' && getVisualContextClassname('compact-table') )} - role="table" ref={secondaryTableRef} + {...getTableRoleProps({ tableRole })} > stateToAriaSort[sortingState]; + +// Depending on its content the table can have different semantic representation which includes the +// ARIA role of the table component ("table", "grid", "treegrid") but also roles and other semantic attributes +// of the child elements. The TableRole helper encapsulates table's semantic structure. + +export type TableRole = 'table' | 'grid'; + +export function getTableRoleProps(options: { + tableRole: TableRole; + ariaLabel?: string; + totalItemsCount?: number; +}): React.TableHTMLAttributes { + const nativeProps: React.TableHTMLAttributes = {}; + + // Browsers have weird mechanism to guess whether it's a data table or a layout table. + // If we state explicitly, they get it always correctly even with low number of rows. + nativeProps.role = options.tableRole; + + nativeProps['aria-label'] = options.ariaLabel; + + // Incrementing the total count by one to account for the header row. + nativeProps['aria-rowcount'] = options.totalItemsCount ? options.totalItemsCount + 1 : -1; + + return nativeProps; +} + +export function getTableRowRoleProps(options: { + tableRole: TableRole; + rowIndex: number; + firstIndex?: number; +}): React.HTMLAttributes { + const nativeProps: React.HTMLAttributes = {}; + + if (options.tableRole === 'grid') { + nativeProps.role = 'row'; + } + + if (options.firstIndex !== undefined) { + nativeProps['aria-rowindex'] = options.firstIndex + options.rowIndex + 1; + } + + return nativeProps; +} + +export function getTableColHeaderRoleProps(options: { + sortingStatus?: SortingStatus; +}): React.ThHTMLAttributes { + const nativeProps: React.ThHTMLAttributes = {}; + + nativeProps.scope = 'col'; + + if (options.sortingStatus) { + nativeProps['aria-sort'] = getAriaSort(options.sortingStatus); + } + + return nativeProps; +} + +export function getTableCellRoleProps(options: { + tableRole: TableRole; + isRowHeader?: boolean; +}): React.TdHTMLAttributes { + const nativeProps: React.TdHTMLAttributes = {}; + + if (options.tableRole === 'grid') { + nativeProps.role = 'gridcell'; + } + + if (options.isRowHeader) { + nativeProps.scope = 'row'; + } + + return nativeProps; +} diff --git a/src/table/thead.tsx b/src/table/thead.tsx index 7396be282d..5efe88fd57 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -15,6 +15,7 @@ import cellStyles from './header-cell/styles.css.js'; import headerCellStyles from './header-cell/styles.css.js'; import ScreenreaderOnly from '../internal/components/screenreader-only'; import { StickyColumnsModel, useStickyCellStyles } from './sticky-columns'; +import { getTableColHeaderRoleProps, TableRole } from './table-role'; export type InteractiveComponent = | { type: 'selection' } @@ -44,6 +45,7 @@ export interface TheadProps { selectionColumnId: PropertyKey; focusedComponent?: InteractiveComponent | null; onFocusedComponentChange?: (element: InteractiveComponent | null) => void; + tableRole: TableRole; } const Thead = React.forwardRef( @@ -71,6 +73,7 @@ const Thead = React.forwardRef( selectionColumnId, focusedComponent, onFocusedComponentChange, + tableRole, }: TheadProps, outerRef: React.Ref ) => { @@ -112,6 +115,7 @@ const Thead = React.forwardRef( style={stickyStyles.style} ref={stickyStyles.ref} scope="col" + {...getTableColHeaderRoleProps({})} > {selectionType === 'multi' ? ( setCell(columnId, node)} + tableRole={tableRole} /> ); })}