diff --git a/change/@fluentui-react-table-7398941b-ae22-4a18-878d-1ac8e853c6ee.json b/change/@fluentui-react-table-7398941b-ae22-4a18-878d-1ac8e853c6ee.json new file mode 100644 index 0000000000000..8a693f617402c --- /dev/null +++ b/change/@fluentui-react-table-7398941b-ae22-4a18-878d-1ac8e853c6ee.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: Implement `focusMode` prop for DataGrid, apply role=\"grid\" correctly", + "packageName": "@fluentui/react-table", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-table/etc/react-table.api.md b/packages/react-components/react-table/etc/react-table.api.md index 49439cd2ca878..d5f227874125d 100644 --- a/packages/react-components/react-table/etc/react-table.api.md +++ b/packages/react-components/react-table/etc/react-table.api.md @@ -90,7 +90,9 @@ export type DataGridCellState = TableCellState; export const dataGridClassNames: SlotClassNames; // @public (undocumented) -export type DataGridContextValue = HeadlessTableState; +export type DataGridContextValue = HeadlessTableState & { + focusMode: FocusMode; +}; // @public (undocumented) export type DataGridContextValues = TableContextValues & { @@ -128,7 +130,7 @@ export type DataGridHeaderSlots = TableHeaderSlots; export type DataGridHeaderState = TableHeaderState; // @public -export type DataGridProps = TableProps & Pick; +export type DataGridProps = TableProps & Pick & Pick, 'focusMode'>; // @public export const DataGridRow: ForwardRefComponent; @@ -168,7 +170,10 @@ export type DataGridSlots = TableSlots; // @public export type DataGridState = TableState & { tableState: HeadlessTableState; -}; +} & Pick; + +// @public (undocumented) +export type FocusMode = 'none' | 'cell'; // @public (undocumented) export interface HeadlessTableState extends Pick, 'items' | 'getRowId'> { diff --git a/packages/react-components/react-table/src/components/DataGrid/DataGrid.test.tsx b/packages/react-components/react-table/src/components/DataGrid/DataGrid.test.tsx index 0a25798b6f4ce..c8f74edb91365 100644 --- a/packages/react-components/react-table/src/components/DataGrid/DataGrid.test.tsx +++ b/packages/react-components/react-table/src/components/DataGrid/DataGrid.test.tsx @@ -51,4 +51,69 @@ describe('DataGrid', () => { ); expect(result.container).toMatchSnapshot(); }); + + it('should render tabster attributes when `focusMode` has value `cell`', () => { + const result = render( + + + + {({ renderHeaderCell, columnId }) => {renderHeaderCell()}} + + + + {({ item, rowId }: RowState) => ( + + {({ renderCell, columnId }) => {renderCell(item)}} + + )} + + , + ); + + expect(result.getByRole('grid').getAttribute('data-tabster')).toMatchInlineSnapshot( + `"{\\"mover\\":{\\"cyclic\\":false,\\"direction\\":3}}"`, + ); + }); + + it('should not render tabster attributes when `focusMode` has value `none`', () => { + const result = render( + + + + {({ renderHeaderCell, columnId }) => {renderHeaderCell()}} + + + + {({ item, rowId }: RowState) => ( + + {({ renderCell, columnId }) => {renderCell(item)}} + + )} + + , + ); + + expect(result.getByRole('grid').hasAttribute('data-tabster')).toBe(false); + }); + + it('should not render tabster attributes when `focusMode` prop is not set', () => { + const result = render( + + + + {({ renderHeaderCell, columnId }) => {renderHeaderCell()}} + + + + {({ item, rowId }: RowState) => ( + + {({ renderCell, columnId }) => {renderCell(item)}} + + )} + + , + ); + + expect(result.getByRole('grid').hasAttribute('data-tabster')).toBe(false); + }); }); diff --git a/packages/react-components/react-table/src/components/DataGrid/DataGrid.types.ts b/packages/react-components/react-table/src/components/DataGrid/DataGrid.types.ts index b9049215b02a1..ffe61382a45df 100644 --- a/packages/react-components/react-table/src/components/DataGrid/DataGrid.types.ts +++ b/packages/react-components/react-table/src/components/DataGrid/DataGrid.types.ts @@ -3,6 +3,8 @@ import { TableState as HeadlessTableState } from '../../hooks'; export type DataGridSlots = TableSlots; +export type FocusMode = 'none' | 'cell'; + export type DataGridContextValues = TableContextValues & { dataGrid: DataGridContextValue; }; @@ -10,14 +12,25 @@ export type DataGridContextValues = TableContextValues & { // Use any here since we can't know the user types // The user is responsible for narrowing the type downstream // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type DataGridContextValue = HeadlessTableState; +export type DataGridContextValue = HeadlessTableState & { + /** + * How focus navigation will work in the datagrid + * @default none + */ + focusMode: FocusMode; +}; /** * DataGrid Props */ -export type DataGridProps = TableProps & Pick; +export type DataGridProps = TableProps & + Pick & + Pick, 'focusMode'>; /** * State used in rendering DataGrid */ -export type DataGridState = TableState & { tableState: HeadlessTableState }; +export type DataGridState = TableState & { tableState: HeadlessTableState } & Pick< + DataGridContextValue, + 'focusMode' + >; diff --git a/packages/react-components/react-table/src/components/DataGrid/__snapshots__/DataGrid.test.tsx.snap b/packages/react-components/react-table/src/components/DataGrid/__snapshots__/DataGrid.test.tsx.snap index c3428b371fe84..218377aa2d002 100644 --- a/packages/react-components/react-table/src/components/DataGrid/__snapshots__/DataGrid.test.tsx.snap +++ b/packages/react-components/react-table/src/components/DataGrid/__snapshots__/DataGrid.test.tsx.snap @@ -4,7 +4,7 @@ exports[`DataGrid renders a default state 1`] = `
first
second
third
@@ -44,19 +44,19 @@ exports[`DataGrid renders a default state 1`] = ` >
first
second
third
@@ -67,19 +67,19 @@ exports[`DataGrid renders a default state 1`] = ` >
first
second
third
@@ -90,19 +90,19 @@ exports[`DataGrid renders a default state 1`] = ` >
first
second
third
diff --git a/packages/react-components/react-table/src/components/DataGrid/useDataGrid.ts b/packages/react-components/react-table/src/components/DataGrid/useDataGrid.ts index eb2766d7ffdb8..8b993e5fd84bd 100644 --- a/packages/react-components/react-table/src/components/DataGrid/useDataGrid.ts +++ b/packages/react-components/react-table/src/components/DataGrid/useDataGrid.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useArrowNavigationGroup } from '@fluentui/react-tabster'; import type { DataGridProps, DataGridState } from './DataGrid.types'; import { useTable_unstable } from '../Table/useTable'; import { useTable } from '../../hooks/useTable'; @@ -13,12 +14,18 @@ import { useTable } from '../../hooks/useTable'; * @param ref - reference to root HTMLElement of DataGrid */ export const useDataGrid_unstable = (props: DataGridProps, ref: React.Ref): DataGridState => { - const { items, columns } = props; + const { items, columns, focusMode = 'none' } = props; + const navigable = focusMode !== 'none'; + const keyboardNavAttr = useArrowNavigationGroup({ axis: 'grid' }); const tableState = useTable({ items, columns }, []); - const baseTableState = useTable_unstable({ ...props, as: 'div' }, ref); + const baseTableState = useTable_unstable( + { role: 'grid', as: 'div', ...(navigable && keyboardNavAttr), ...props }, + ref, + ); return { ...baseTableState, + focusMode, tableState, }; }; diff --git a/packages/react-components/react-table/src/components/DataGrid/useDataGridContextValues.ts b/packages/react-components/react-table/src/components/DataGrid/useDataGridContextValues.ts index f451ffcd8eb7f..f137d0af4712f 100644 --- a/packages/react-components/react-table/src/components/DataGrid/useDataGridContextValues.ts +++ b/packages/react-components/react-table/src/components/DataGrid/useDataGridContextValues.ts @@ -7,6 +7,7 @@ export function useDataGridContextValues_unstable(state: DataGridState): DataGri ...tableContextValues, dataGrid: { ...state.tableState, + focusMode: state.focusMode, }, }; } diff --git a/packages/react-components/react-table/src/components/DataGridCell/DataGridCell.test.tsx b/packages/react-components/react-table/src/components/DataGridCell/DataGridCell.test.tsx index fcb1b4e7b8a94..24d3aab8de936 100644 --- a/packages/react-components/react-table/src/components/DataGridCell/DataGridCell.test.tsx +++ b/packages/react-components/react-table/src/components/DataGridCell/DataGridCell.test.tsx @@ -3,6 +3,8 @@ import { render } from '@testing-library/react'; import { DataGridCell } from './DataGridCell'; import { isConformant } from '../../testing/isConformant'; import { DataGridCellProps } from './DataGridCell.types'; +import { DataGridContextProvider } from '../../contexts/dataGridContext'; +import { mockDataGridContext } from '../../testing/mockDataGridContext'; describe('DataGridCell', () => { isConformant({ @@ -10,10 +12,31 @@ describe('DataGridCell', () => { displayName: 'DataGridCell', }); - // TODO add more tests here, and create visual regression tests in /apps/vr-tests - it('renders a default state', () => { const result = render(Default DataGridCell); expect(result.container).toMatchSnapshot(); }); + + it('should set tabindex="0" when focusMode is cell', () => { + const context = mockDataGridContext({ focusMode: 'cell' }); + const result = render( + + Default DataGridCell + , + ); + + expect(result.getByRole('gridcell').tabIndex).toBe(0); + }); + + it('should not set tabindex when focusMode is none', () => { + const context = mockDataGridContext({ focusMode: 'none' }); + const result = render( + + Default DataGridCell + , + ); + + expect(result.getByRole('gridcell').tabIndex).toBe(-1); + expect(result.getByRole('gridcell').hasAttribute('tabindex')).toBe(false); + }); }); diff --git a/packages/react-components/react-table/src/components/DataGridCell/__snapshots__/DataGridCell.test.tsx.snap b/packages/react-components/react-table/src/components/DataGridCell/__snapshots__/DataGridCell.test.tsx.snap index 7ed4393b206f1..0fdcd01391a6e 100644 --- a/packages/react-components/react-table/src/components/DataGridCell/__snapshots__/DataGridCell.test.tsx.snap +++ b/packages/react-components/react-table/src/components/DataGridCell/__snapshots__/DataGridCell.test.tsx.snap @@ -4,7 +4,7 @@ exports[`DataGridCell renders a default state 1`] = `
Default DataGridCell
diff --git a/packages/react-components/react-table/src/components/DataGridCell/useDataGridCell.ts b/packages/react-components/react-table/src/components/DataGridCell/useDataGridCell.ts index f4f9670026258..ea3e6a43fdd68 100644 --- a/packages/react-components/react-table/src/components/DataGridCell/useDataGridCell.ts +++ b/packages/react-components/react-table/src/components/DataGridCell/useDataGridCell.ts @@ -1,6 +1,7 @@ import * as React from 'react'; import type { DataGridCellProps, DataGridCellState } from './DataGridCell.types'; import { useTableCell_unstable } from '../TableCell/useTableCell'; +import { useDataGridContext_unstable } from '../../contexts/dataGridContext'; /** * Create the state required to render DataGridCell. @@ -12,5 +13,6 @@ import { useTableCell_unstable } from '../TableCell/useTableCell'; * @param ref - reference to root HTMLElement of DataGridCell */ export const useDataGridCell_unstable = (props: DataGridCellProps, ref: React.Ref): DataGridCellState => { - return useTableCell_unstable({ ...props, as: 'div' }, ref); + const tabbable = useDataGridContext_unstable(ctx => ctx.focusMode !== 'none'); + return useTableCell_unstable({ as: 'div', role: 'gridcell', tabIndex: tabbable ? 0 : undefined, ...props }, ref); }; diff --git a/packages/react-components/react-table/src/components/DataGridSelectionCell/__snapshots__/DataGridSelectionCell.test.tsx.snap b/packages/react-components/react-table/src/components/DataGridSelectionCell/__snapshots__/DataGridSelectionCell.test.tsx.snap index ea3274911a89b..8021a16c39f33 100644 --- a/packages/react-components/react-table/src/components/DataGridSelectionCell/__snapshots__/DataGridSelectionCell.test.tsx.snap +++ b/packages/react-components/react-table/src/components/DataGridSelectionCell/__snapshots__/DataGridSelectionCell.test.tsx.snap @@ -4,7 +4,7 @@ exports[`DataGridSelectionCell renders a default state 1`] = `
, ): DataGridSelectionCellState => { - return useTableSelectionCell_unstable({ ...props, as: 'div' }, ref); + return useTableSelectionCell_unstable({ as: 'div', role: 'gridcell', ...props }, ref); }; diff --git a/packages/react-components/react-table/src/contexts/dataGridContext.ts b/packages/react-components/react-table/src/contexts/dataGridContext.ts index 41212883ad189..eb6f0f4cfcee0 100644 --- a/packages/react-components/react-table/src/contexts/dataGridContext.ts +++ b/packages/react-components/react-table/src/contexts/dataGridContext.ts @@ -7,6 +7,7 @@ const dataGridContext = createContext(undefine const dataGridContextDefaultValue: DataGridContextValue = { ...defaultTableState, + focusMode: 'none', }; export const DataGridContextProvider = dataGridContext.Provider; diff --git a/packages/react-components/react-table/src/index.ts b/packages/react-components/react-table/src/index.ts index 20d437b0d7013..f2aaa2844a794 100644 --- a/packages/react-components/react-table/src/index.ts +++ b/packages/react-components/react-table/src/index.ts @@ -140,6 +140,7 @@ export type { DataGridState, DataGridContextValues, DataGridContextValue, + FocusMode, } from './DataGrid'; export { diff --git a/packages/react-components/react-table/src/testing/mockDataGridContext.ts b/packages/react-components/react-table/src/testing/mockDataGridContext.ts new file mode 100644 index 0000000000000..e4f1d70265ce5 --- /dev/null +++ b/packages/react-components/react-table/src/testing/mockDataGridContext.ts @@ -0,0 +1,34 @@ +import { DataGridContextValue } from '../components/DataGrid/DataGrid.types'; +import { ColumnDefinition, createColumn, defaultTableSelectionState, defaultTableSortState } from '../hooks'; + +interface Item { + first: string; + second: string; + third: string; +} + +const testColumns: ColumnDefinition[] = [ + createColumn({ columnId: 'first', renderHeaderCell: () => 'first', renderCell: item => item.first }), + createColumn({ columnId: 'second', renderHeaderCell: () => 'second', renderCell: item => item.second }), + createColumn({ columnId: 'third', renderHeaderCell: () => 'third', renderCell: item => item.third }), +]; +const testItems: Item[] = [ + { first: 'first', second: 'second', third: 'third' }, + { first: 'first', second: 'second', third: 'third' }, + { first: 'first', second: 'second', third: 'third' }, +]; + +export function mockDataGridContext(options: Partial = {}) { + const mockContext: DataGridContextValue = { + columns: testColumns, + items: testItems, + focusMode: 'none', + getRowId: () => '', + getRows: () => [], + selection: defaultTableSelectionState, + sort: defaultTableSortState, + ...options, + }; + + return mockContext; +} diff --git a/packages/react-components/react-table/stories/DataGrid/Default.stories.tsx b/packages/react-components/react-table/stories/DataGrid/Default.stories.tsx index 510b8171d067b..93ad86f7db334 100644 --- a/packages/react-components/react-table/stories/DataGrid/Default.stories.tsx +++ b/packages/react-components/react-table/stories/DataGrid/Default.stories.tsx @@ -88,63 +88,68 @@ const items: Item[] = [ }, ]; -const columns: ColumnDefinition[] = [ - createColumn({ - columnId: 'file', - compare: (a, b) => { - return a.file.label.localeCompare(b.file.label); - }, - renderHeaderCell: () => { - return 'File'; - }, - renderCell: item => { - return {item.file.label}; - }, - }), - createColumn({ - columnId: 'author', - compare: (a, b) => { - return a.author.label.localeCompare(b.author.label); - }, - renderHeaderCell: () => { - return 'Author'; - }, - renderCell: item => { - return ( - }>{item.author.label} - ); - }, - }), - createColumn({ - columnId: 'lastUpdated', - compare: (a, b) => { - return a.lastUpdated.timestamp - b.lastUpdated.timestamp; - }, - renderHeaderCell: () => { - return 'Last updated'; - }, +export const Default = () => { + const columns: ColumnDefinition[] = React.useMemo( + () => [ + createColumn({ + columnId: 'file', + compare: (a, b) => { + return a.file.label.localeCompare(b.file.label); + }, + renderHeaderCell: () => { + return 'File'; + }, + renderCell: item => { + return {item.file.label}; + }, + }), + createColumn({ + columnId: 'author', + compare: (a, b) => { + return a.author.label.localeCompare(b.author.label); + }, + renderHeaderCell: () => { + return 'Author'; + }, + renderCell: item => { + return ( + }> + {item.author.label} + + ); + }, + }), + createColumn({ + columnId: 'lastUpdated', + compare: (a, b) => { + return a.lastUpdated.timestamp - b.lastUpdated.timestamp; + }, + renderHeaderCell: () => { + return 'Last updated'; + }, - renderCell: item => { - return item.lastUpdated.label; - }, - }), - createColumn({ - columnId: 'lastUpdate', - compare: (a, b) => { - return a.lastUpdate.label.localeCompare(b.lastUpdate.label); - }, - renderHeaderCell: () => { - return 'Last update'; - }, - renderCell: item => { - return {item.lastUpdate.label}; - }, - }), -]; + renderCell: item => { + return item.lastUpdated.label; + }, + }), + createColumn({ + columnId: 'lastUpdate', + compare: (a, b) => { + return a.lastUpdate.label.localeCompare(b.lastUpdate.label); + }, + renderHeaderCell: () => { + return 'Last update'; + }, + renderCell: item => { + return {item.lastUpdate.label}; + }, + }), + ], + [], + ); -export const Default = () => { return ( - + {({ renderHeaderCell, columnId }: ColumnDefinition) => (