diff --git a/docs/data/data-grid/row-grouping/RowGroupingAriaV8.js b/docs/data/data-grid/row-grouping/RowGroupingAriaV8.js new file mode 100644 index 0000000000000..6689443db0822 --- /dev/null +++ b/docs/data/data-grid/row-grouping/RowGroupingAriaV8.js @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { + DataGridPremium, + useGridApiRef, + useKeepGroupedColumnsHidden, +} from '@mui/x-data-grid-premium'; +import { useMovieData } from '@mui/x-data-grid-generator'; + +export default function RowGroupingAriaV8() { + const data = useMovieData(); + const apiRef = useGridApiRef(); + + const initialState = useKeepGroupedColumnsHidden({ + apiRef, + initialState: { + rowGrouping: { + model: ['company'], + }, + }, + }); + + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/row-grouping/RowGroupingAriaV8.tsx b/docs/data/data-grid/row-grouping/RowGroupingAriaV8.tsx new file mode 100644 index 0000000000000..6689443db0822 --- /dev/null +++ b/docs/data/data-grid/row-grouping/RowGroupingAriaV8.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { + DataGridPremium, + useGridApiRef, + useKeepGroupedColumnsHidden, +} from '@mui/x-data-grid-premium'; +import { useMovieData } from '@mui/x-data-grid-generator'; + +export default function RowGroupingAriaV8() { + const data = useMovieData(); + const apiRef = useGridApiRef(); + + const initialState = useKeepGroupedColumnsHidden({ + apiRef, + initialState: { + rowGrouping: { + model: ['company'], + }, + }, + }); + + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/row-grouping/RowGroupingAriaV8.tsx.preview b/docs/data/data-grid/row-grouping/RowGroupingAriaV8.tsx.preview new file mode 100644 index 0000000000000..303e3b3ef2367 --- /dev/null +++ b/docs/data/data-grid/row-grouping/RowGroupingAriaV8.tsx.preview @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/row-grouping/RowGroupingFullExample.js b/docs/data/data-grid/row-grouping/RowGroupingFullExample.js index 8c84089146301..d1a08e3ff0624 100644 --- a/docs/data/data-grid/row-grouping/RowGroupingFullExample.js +++ b/docs/data/data-grid/row-grouping/RowGroupingFullExample.js @@ -39,6 +39,7 @@ export default function RowGroupingFullExample() { groupingColDef={{ leafField: 'traderEmail', }} + experimentalFeatures={{ ariaV8: true }} /> ); diff --git a/docs/data/data-grid/row-grouping/RowGroupingFullExample.tsx b/docs/data/data-grid/row-grouping/RowGroupingFullExample.tsx index 8c84089146301..d1a08e3ff0624 100644 --- a/docs/data/data-grid/row-grouping/RowGroupingFullExample.tsx +++ b/docs/data/data-grid/row-grouping/RowGroupingFullExample.tsx @@ -39,6 +39,7 @@ export default function RowGroupingFullExample() { groupingColDef={{ leafField: 'traderEmail', }} + experimentalFeatures={{ ariaV8: true }} /> ); diff --git a/docs/data/data-grid/row-grouping/RowGroupingFullExample.tsx.preview b/docs/data/data-grid/row-grouping/RowGroupingFullExample.tsx.preview index b20dbc70dc3ad..35a7dd3ccbcd0 100644 --- a/docs/data/data-grid/row-grouping/RowGroupingFullExample.tsx.preview +++ b/docs/data/data-grid/row-grouping/RowGroupingFullExample.tsx.preview @@ -7,4 +7,5 @@ groupingColDef={{ leafField: 'traderEmail', }} + experimentalFeatures={{ ariaV8: true }} /> \ No newline at end of file diff --git a/docs/data/data-grid/row-grouping/row-grouping.md b/docs/data/data-grid/row-grouping/row-grouping.md index 08ef4281d6cfd..8b387aa8d42b2 100644 --- a/docs/data/data-grid/row-grouping/row-grouping.md +++ b/docs/data/data-grid/row-grouping/row-grouping.md @@ -344,6 +344,22 @@ Don't hesitate to leave a comment on the same issue to influence what gets built With this panel, your users will be able to control which columns are used for grouping just by dragging them inside the panel. +## Accessibility changes in v8 + +The Data Grid v8 with row grouping feature will improve the accessibility and will be more aligned with the WAI-ARIA authoring practices. + +You can start using the new accessibility features by enabling `ariaV8` experimental feature flag: + +```tsx + +``` + +:::warning +The value of `ariaV8` should be constant and not change during the lifetime of the Data Grid. +::: + +{{"demo": "RowGroupingAriaV8.js", "bg": "inline", "defaultCodeOpen": false}} + ## Full example {{"demo": "RowGroupingFullExample.js", "bg": "inline", "defaultCodeOpen": false}} diff --git a/docs/pages/x/api/data-grid/data-grid-premium.json b/docs/pages/x/api/data-grid/data-grid-premium.json index 1c79c35a04e6e..45c00921edc94 100644 --- a/docs/pages/x/api/data-grid/data-grid-premium.json +++ b/docs/pages/x/api/data-grid/data-grid-premium.json @@ -75,7 +75,10 @@ }, "estimatedRowCount": { "type": { "name": "number" } }, "experimentalFeatures": { - "type": { "name": "shape", "description": "{ warnIfFocusStateIsNotSynced?: bool }" } + "type": { + "name": "shape", + "description": "{ ariaV8?: bool, warnIfFocusStateIsNotSynced?: bool }" + } }, "filterDebounceMs": { "type": { "name": "number" }, "default": "150" }, "filterMode": { diff --git a/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json b/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json index a8b79b49fb93e..68ba1ef303b5f 100644 --- a/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json +++ b/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json @@ -223,7 +223,7 @@ "description": "Determines if a row can be selected.", "typeDescriptions": { "params": "With all properties from GridRowParams.", - "boolean": "A boolean indicating if the cell is selectable." + "boolean": "A boolean indicating if the row is selectable." } }, "keepColumnPositionIfDraggedOutside": { diff --git a/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json b/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json index 914817291c87f..b52063f75d28a 100644 --- a/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json +++ b/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json @@ -204,7 +204,7 @@ "description": "Determines if a row can be selected.", "typeDescriptions": { "params": "With all properties from GridRowParams.", - "boolean": "A boolean indicating if the cell is selectable." + "boolean": "A boolean indicating if the row is selectable." } }, "keepColumnPositionIfDraggedOutside": { diff --git a/docs/translations/api-docs/data-grid/data-grid/data-grid.json b/docs/translations/api-docs/data-grid/data-grid/data-grid.json index 08fa6f50e6c46..545e3c09ac3a1 100644 --- a/docs/translations/api-docs/data-grid/data-grid/data-grid.json +++ b/docs/translations/api-docs/data-grid/data-grid/data-grid.json @@ -150,7 +150,7 @@ "description": "Determines if a row can be selected.", "typeDescriptions": { "params": "With all properties from GridRowParams.", - "boolean": "A boolean indicating if the cell is selectable." + "boolean": "A boolean indicating if the row is selectable." } }, "keepNonExistentRowsSelected": { diff --git a/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx b/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx index 13c08a00b7e19..fb0abd4f63b0c 100644 --- a/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx +++ b/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx @@ -23,9 +23,17 @@ import { } from '../models/dataGridPremiumProps'; import { useDataGridPremiumProps } from './useDataGridPremiumProps'; import { getReleaseInfo } from '../utils/releaseInfo'; +import { useGridAriaAttributes } from '../hooks/utils/useGridAriaAttributes'; +import { useGridRowAriaAttributes } from '../hooks/features/rows/useGridRowAriaAttributes'; export type { GridPremiumSlotsComponent as GridSlots } from '../models'; +const configuration = { + hooks: { + useGridAriaAttributes, + useGridRowAriaAttributes, + }, +}; const releaseInfo = getReleaseInfo(); let dataGridPremiumPropValidators: PropValidator[]; @@ -40,14 +48,13 @@ const DataGridPremiumRaw = React.forwardRef(function DataGridPremium + => { const { apiRef, rowTree, isRowMatchingFilters, filterModel } = params; const filteredRowsLookup: Record = {}; + const filteredChildrenCountLookup: Record = {}; const filteredDescendantCountLookup: Record = {}; const filterCache = {}; @@ -110,6 +111,7 @@ export const filterRowTreeFromGroupingColumns = ( isPassingFiltering = true; } + let filteredChildrenCount = 0; let filteredDescendantCount = 0; if (node.type === 'group') { node.children.forEach((childId) => { @@ -120,6 +122,9 @@ export const filterRowTreeFromGroupingColumns = ( [...ancestorsResults, filterResults], ); filteredDescendantCount += childSubTreeSize; + if (childSubTreeSize > 0) { + filteredChildrenCount += 1; + } }); } @@ -145,6 +150,7 @@ export const filterRowTreeFromGroupingColumns = ( return 0; } + filteredChildrenCountLookup[node.id] = filteredChildrenCount; filteredDescendantCountLookup[node.id] = filteredDescendantCount; if (node.type !== 'group') { @@ -164,6 +170,7 @@ export const filterRowTreeFromGroupingColumns = ( return { filteredRowsLookup, + filteredChildrenCountLookup, filteredDescendantCountLookup, }; }; diff --git a/packages/x-data-grid-premium/src/hooks/features/rows/index.tsx b/packages/x-data-grid-premium/src/hooks/features/rows/index.tsx new file mode 100644 index 0000000000000..bf3c51abb3373 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/rows/index.tsx @@ -0,0 +1 @@ +export * from './useGridRowAriaAttributes'; diff --git a/packages/x-data-grid-premium/src/hooks/features/rows/useGridRowAriaAttributes.tsx b/packages/x-data-grid-premium/src/hooks/features/rows/useGridRowAriaAttributes.tsx new file mode 100644 index 0000000000000..f844554f633ba --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/rows/useGridRowAriaAttributes.tsx @@ -0,0 +1,12 @@ +import { + useGridRowAriaAttributes as useGridRowAriaAttributesPro, + useGridSelector, +} from '@mui/x-data-grid-pro/internals'; +import { useGridPrivateApiContext } from '../../utils/useGridPrivateApiContext'; +import { gridRowGroupingSanitizedModelSelector } from '../rowGrouping/gridRowGroupingSelector'; + +export const useGridRowAriaAttributes = () => { + const apiRef = useGridPrivateApiContext(); + const gridRowGroupingModel = useGridSelector(apiRef, gridRowGroupingSanitizedModelSelector); + return useGridRowAriaAttributesPro(gridRowGroupingModel.length > 0); +}; diff --git a/packages/x-data-grid-premium/src/hooks/utils/useGridAriaAttributes.tsx b/packages/x-data-grid-premium/src/hooks/utils/useGridAriaAttributes.tsx new file mode 100644 index 0000000000000..8c5023f2af49a --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/utils/useGridAriaAttributes.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { + useGridAriaAttributes as useGridAriaAttributesPro, + useGridSelector, +} from '@mui/x-data-grid-pro/internals'; +import { gridRowGroupingSanitizedModelSelector } from '../features/rowGrouping/gridRowGroupingSelector'; +import { useGridPrivateApiContext } from './useGridPrivateApiContext'; +import { useGridRootProps } from './useGridRootProps'; + +export const useGridAriaAttributes = (): React.HTMLAttributes => { + const rootProps = useGridRootProps(); + const ariaAttributesPro = useGridAriaAttributesPro(); + const apiRef = useGridPrivateApiContext(); + const gridRowGroupingModel = useGridSelector(apiRef, gridRowGroupingSanitizedModelSelector); + + const ariaAttributesPremium = + rootProps.experimentalFeatures?.ariaV8 && gridRowGroupingModel.length > 0 + ? { role: 'treegrid' } + : {}; + + return { + ...ariaAttributesPro, + ...ariaAttributesPremium, + }; +}; diff --git a/packages/x-data-grid-premium/src/models/dataGridPremiumProps.ts b/packages/x-data-grid-premium/src/models/dataGridPremiumProps.ts index b2f6e79752094..e924af2815adb 100644 --- a/packages/x-data-grid-premium/src/models/dataGridPremiumProps.ts +++ b/packages/x-data-grid-premium/src/models/dataGridPremiumProps.ts @@ -24,7 +24,14 @@ import { GridInitialStatePremium } from './gridStatePremium'; import { GridApiPremium } from './gridApiPremium'; import { GridCellSelectionModel } from '../hooks/features/cellSelection'; -export interface GridExperimentalPremiumFeatures extends GridExperimentalProFeatures {} +export interface GridExperimentalPremiumFeatures extends GridExperimentalProFeatures { + /** + * Enables accessibility improvements that will be enabled by default in V8. + * If you rely on the v7 ARIA attributes (e.g. for CSS selectors), this might be a breaking change. + * @default false + */ + ariaV8: boolean; +} export interface DataGridPremiumPropsWithComplexDefaultValueBeforeProcessing extends Pick { diff --git a/packages/x-data-grid-premium/src/tests/rowGrouping.DataGridPremium.test.tsx b/packages/x-data-grid-premium/src/tests/rowGrouping.DataGridPremium.test.tsx index 824d610884a26..994100b91b893 100644 --- a/packages/x-data-grid-premium/src/tests/rowGrouping.DataGridPremium.test.tsx +++ b/packages/x-data-grid-premium/src/tests/rowGrouping.DataGridPremium.test.tsx @@ -14,6 +14,7 @@ import { getColumnValues, getCell, getSelectByName, + getRow, } from 'test/utils/helperFn'; import { expect } from 'chai'; import { @@ -2381,6 +2382,45 @@ describe(' - Row grouping', () => { // Corresponds to rows id 0, 1, 2 because of Cat A, ann id 4 because of Cat 1 expect(getColumnValues(1)).to.deep.equal(['', '0', '1', '2', '', '4']); }); + + it('should keep the correct count of the children and descendants in the filter state', () => { + const extendedColumns = [ + ...baselineProps.columns, + { + field: 'value1', + }, + ]; + + const extendedRows = rows.map((row, index) => ({ ...row, value1: `Value${index}` })); + const additionalRows = [ + { id: 5, category1: 'Cat A', category2: 'Cat 2', value1: 'Value5' }, + { id: 6, category1: 'Cat A', category2: 'Cat 2', value1: 'Value6' }, + { id: 7, category1: 'Cat B', category2: 'Cat 1', value1: 'Value7' }, + ]; + + render( + , + ); + + const { filteredChildrenCountLookup, filteredDescendantCountLookup } = + apiRef.current.state.filter; + + expect(filteredChildrenCountLookup['auto-generated-row-category1/Cat A']).to.equal(2); + expect(filteredDescendantCountLookup['auto-generated-row-category1/Cat A']).to.equal(5); + + expect( + filteredChildrenCountLookup['auto-generated-row-category1/Cat A-category2/Cat 2'], + ).to.equal(4); + expect( + filteredDescendantCountLookup['auto-generated-row-category1/Cat A-category2/Cat 2'], + ).to.equal(4); + }); }); describe('prop: rowGroupingColumnMode = "multiple"', () => { @@ -2723,6 +2763,27 @@ describe(' - Row grouping', () => { }); }); + describe('accessibility', () => { + it('should add necessary treegrid aria attributes to the rows', () => { + render( + , + ); + + expect(getRow(0).getAttribute('aria-level')).to.equal('1'); // Cat A + expect(getRow(1).getAttribute('aria-level')).to.equal('2'); // Cat 1 + expect(getRow(1).getAttribute('aria-posinset')).to.equal('1'); + expect(getRow(1).getAttribute('aria-setsize')).to.equal('2'); // Cat A has Cat 1 & Cat 2 + expect(getRow(2).getAttribute('aria-level')).to.equal('3'); // Cat 1 row + expect(getRow(3).getAttribute('aria-posinset')).to.equal('2'); // Cat 2 + expect(getRow(4).getAttribute('aria-posinset')).to.equal('1'); // Cat 2 row + expect(getRow(4).getAttribute('aria-setsize')).to.equal('2'); // Cat 2 has 2 rows + }); + }); + // See https://github.com/mui/mui-x/issues/8626 it('should properly update the rows when they change', async () => { render( diff --git a/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx b/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx index 1cacac533b70c..b168aad4b0b4a 100644 --- a/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx +++ b/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx @@ -16,9 +16,17 @@ import { DataGridProProps } from '../models/dataGridProProps'; import { useDataGridProProps } from './useDataGridProProps'; import { getReleaseInfo } from '../utils/releaseInfo'; import { propValidatorsDataGridPro } from '../internals/propValidation'; +import { useGridAriaAttributes } from '../hooks/utils/useGridAriaAttributes'; +import { useGridRowAriaAttributes } from '../hooks/features/rows/useGridRowAriaAttributes'; export type { GridProSlotsComponent as GridSlots } from '../models'; +const configuration = { + hooks: { + useGridAriaAttributes, + useGridRowAriaAttributes, + }, +}; const releaseInfo = getReleaseInfo(); const DataGridProRaw = React.forwardRef(function DataGridPro( @@ -33,7 +41,7 @@ const DataGridProRaw = React.forwardRef(function DataGridPro + { + const apiRef = useGridPrivateApiContext(); + const props = useGridRootProps(); + const getRowAriaAttributesCommunity = useGridRowAriaAttributesCommunity(); + + const filteredTopLevelRowCount = useGridSelector(apiRef, gridFilteredTopLevelRowCountSelector); + const filteredChildrenCountLookup = useGridSelector( + apiRef, + gridFilteredChildrenCountLookupSelector, + ); + const sortedVisibleRowPositionsLookup = useGridSelector( + apiRef, + gridExpandedSortedRowTreeLevelPositionLookupSelector, + ); + + return React.useCallback( + (rowNode: GridTreeNode, index: number) => { + const ariaAttributes = getRowAriaAttributesCommunity(rowNode, index); + + if (rowNode === null || !(props.treeData || addTreeDataAttributes)) { + return ariaAttributes; + } + + // pinned and footer rows are not part of the rowgroup and should not get the set specific aria attributes + if (rowNode.type === 'footer' || rowNode.type === 'pinnedRow') { + return ariaAttributes; + } + + ariaAttributes['aria-level'] = rowNode.depth + 1; + + const filteredChildrenCount = filteredChildrenCountLookup[rowNode.id] ?? 0; + // aria-expanded should only be added to the rows that contain children + if (rowNode.type === 'group' && filteredChildrenCount > 0) { + ariaAttributes['aria-expanded'] = Boolean(rowNode.childrenExpanded); + } + + // if the parent is null, set size and position cannot be determined + if (rowNode.parent !== null) { + ariaAttributes['aria-setsize'] = + rowNode.parent === GRID_ROOT_GROUP_ID + ? filteredTopLevelRowCount + : filteredChildrenCountLookup[rowNode.parent]; + ariaAttributes['aria-posinset'] = sortedVisibleRowPositionsLookup[rowNode.id]; + } + + return ariaAttributes; + }, + [ + props.treeData, + addTreeDataAttributes, + filteredTopLevelRowCount, + filteredChildrenCountLookup, + sortedVisibleRowPositionsLookup, + getRowAriaAttributesCommunity, + ], + ); +}; diff --git a/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors.tsx b/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors.tsx index 6c182e6c04d12..c09bc45b8dedf 100644 --- a/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors.tsx +++ b/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors.tsx @@ -151,7 +151,7 @@ export const useGridDataSourceTreeDataPreProcessors = ( path: [...parentPath, getGroupKey(params.dataRowIdToModelLookup[rowId])].map( (key): RowTreeBuilderGroupingCriterion => ({ key, field: null }), ), - hasServerChildren: !!count && count !== 0, + serverChildrenCount: count, }; }; diff --git a/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/utils.ts b/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/utils.ts index e1314f6aef1d1..677f7210c9274 100644 --- a/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/utils.ts +++ b/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/utils.ts @@ -3,16 +3,19 @@ import { getTreeNodeDescendants } from '@mui/x-data-grid/internals'; export function skipFiltering(rowTree: GridRowTreeConfig) { const filteredRowsLookup: Record = {}; + const filteredChildrenCountLookup: Record = {}; const filteredDescendantCountLookup: Record = {}; const nodes = Object.values(rowTree); for (let i = 0; i < nodes.length; i += 1) { const node: any = nodes[i]; filteredRowsLookup[node.id] = true; + filteredChildrenCountLookup[node.id] = node.serverChildrenCount ?? 0; } return { filteredRowsLookup, + filteredChildrenCountLookup, filteredDescendantCountLookup, }; } diff --git a/packages/x-data-grid-pro/src/hooks/features/treeData/gridTreeDataUtils.ts b/packages/x-data-grid-pro/src/hooks/features/treeData/gridTreeDataUtils.ts index 9926ef35c3df5..ee8224e89b082 100644 --- a/packages/x-data-grid-pro/src/hooks/features/treeData/gridTreeDataUtils.ts +++ b/packages/x-data-grid-pro/src/hooks/features/treeData/gridTreeDataUtils.ts @@ -32,6 +32,7 @@ export const filterRowTreeFromTreeData = ( ): Omit => { const { apiRef, rowTree, disableChildrenFiltering, isRowMatchingFilters } = params; const filteredRowsLookup: Record = {}; + const filteredChildrenCountLookup: Record = {}; const filteredDescendantCountLookup: Record = {}; const filterCache = {}; @@ -64,6 +65,7 @@ export const filterRowTreeFromTreeData = ( ); } + let filteredChildrenCount = 0; let filteredDescendantCount = 0; if (node.type === 'group') { node.children.forEach((childId) => { @@ -75,6 +77,9 @@ export const filterRowTreeFromTreeData = ( ); filteredDescendantCount += childSubTreeSize; + if (childSubTreeSize > 0) { + filteredChildrenCount += 1; + } }); } @@ -100,6 +105,7 @@ export const filterRowTreeFromTreeData = ( return 0; } + filteredChildrenCountLookup[node.id] = filteredChildrenCount; filteredDescendantCountLookup[node.id] = filteredDescendantCount; if (node.type === 'footer') { @@ -119,6 +125,7 @@ export const filterRowTreeFromTreeData = ( return { filteredRowsLookup, + filteredChildrenCountLookup, filteredDescendantCountLookup, }; }; diff --git a/packages/x-data-grid-pro/src/hooks/utils/useGridAriaAttributes.tsx b/packages/x-data-grid-pro/src/hooks/utils/useGridAriaAttributes.tsx new file mode 100644 index 0000000000000..1f5507728d8bd --- /dev/null +++ b/packages/x-data-grid-pro/src/hooks/utils/useGridAriaAttributes.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { useGridAriaAttributes as useGridAriaAttributesCommunity } from '@mui/x-data-grid/internals'; +import { useGridRootProps } from './useGridRootProps'; + +export const useGridAriaAttributes = (): React.HTMLAttributes => { + const ariaAttributesCommunity = useGridAriaAttributesCommunity(); + const rootProps = useGridRootProps(); + + const ariaAttributesPro = rootProps.treeData ? { role: 'treegrid' } : {}; + + return { + ...ariaAttributesCommunity, + ...ariaAttributesPro, + }; +}; diff --git a/packages/x-data-grid-pro/src/internals/index.ts b/packages/x-data-grid-pro/src/internals/index.ts index 28b184e2bd42c..c0de9e27064b8 100644 --- a/packages/x-data-grid-pro/src/internals/index.ts +++ b/packages/x-data-grid-pro/src/internals/index.ts @@ -4,8 +4,14 @@ export * from '@mui/x-data-grid/internals'; export { GridColumnHeaders } from '../components/GridColumnHeaders'; export { DATA_GRID_PRO_DEFAULT_SLOTS_COMPONENTS } from '../constants/dataGridProDefaultSlotsComponents'; -// eslint-disable-next-line import/export +/* eslint-disable import/export -- + * x-data-grid-pro internals that are overriding the x-data-grid internals + */ export { useGridColumnHeaders } from '../hooks/features/columnHeaders/useGridColumnHeaders'; +export { useGridAriaAttributes } from '../hooks/utils/useGridAriaAttributes'; +export { useGridRowAriaAttributes } from '../hooks/features/rows/useGridRowAriaAttributes'; +// eslint-enable import/export + export { useGridColumnPinning, columnPinningStateInitializer, @@ -22,6 +28,7 @@ export { } from '../hooks/features/detailPanel/useGridDetailPanel'; export { useGridDetailPanelPreProcessors } from '../hooks/features/detailPanel/useGridDetailPanelPreProcessors'; export { useGridInfiniteLoader } from '../hooks/features/infiniteLoader/useGridInfiniteLoader'; + export { useGridRowReorder } from '../hooks/features/rowReorder/useGridRowReorder'; export { useGridRowReorderPreProcessors } from '../hooks/features/rowReorder/useGridRowReorderPreProcessors'; export { useGridTreeData } from '../hooks/features/treeData/useGridTreeData'; diff --git a/packages/x-data-grid-pro/src/tests/treeData.DataGridPro.test.tsx b/packages/x-data-grid-pro/src/tests/treeData.DataGridPro.test.tsx index 9f80a0d0858e7..12260bda0f58d 100644 --- a/packages/x-data-grid-pro/src/tests/treeData.DataGridPro.test.tsx +++ b/packages/x-data-grid-pro/src/tests/treeData.DataGridPro.test.tsx @@ -4,6 +4,7 @@ import { getColumnHeaderCell, getColumnHeadersTextContent, getColumnValues, + getRow, } from 'test/utils/helperFn'; import * as React from 'react'; import { expect } from 'chai'; @@ -591,6 +592,47 @@ describe(' - Tree data', () => { expect(getColumnValues(0)).to.deep.equal(['B (1)', 'D', 'D (1)', 'A']); }); + + it('should keep the correct count of the children and descendants in the filter state', () => { + render( + , + ); + + const { filteredChildrenCountLookup, filteredDescendantCountLookup } = + apiRef.current.state.filter; + + expect(filteredChildrenCountLookup.A).to.equal(3); + expect(filteredDescendantCountLookup.A).to.equal(5); + + expect(filteredChildrenCountLookup.B).to.equal(1); + expect(filteredDescendantCountLookup.B).to.equal(1); + + expect(filteredChildrenCountLookup.C).to.equal(undefined); + expect(filteredDescendantCountLookup.C).to.equal(undefined); + + act(() => { + apiRef.current.updateRows([{ name: 'A.D' }]); + }); + + expect(apiRef.current.state.filter.filteredChildrenCountLookup.A).to.equal(4); + expect(apiRef.current.state.filter.filteredDescendantCountLookup.A).to.equal(6); + }); }); describe('sorting', () => { @@ -725,6 +767,67 @@ describe(' - Tree data', () => { }); }); + describe('accessibility', () => { + it('should add necessary treegrid aria attributes to the rows', () => { + render(); + + expect(getRow(0).getAttribute('aria-level')).to.equal('1'); // A + expect(getRow(1).getAttribute('aria-level')).to.equal('2'); // A.A + expect(getRow(1).getAttribute('aria-posinset')).to.equal('1'); + expect(getRow(1).getAttribute('aria-setsize')).to.equal('2'); + expect(getRow(2).getAttribute('aria-level')).to.equal('2'); // A.B + expect(getRow(4).getAttribute('aria-posinset')).to.equal('1'); // B.A + }); + + it('should adjust treegrid aria attributes after filtering', () => { + render( + , + ); + + expect(getRow(0).getAttribute('aria-level')).to.equal('1'); // A + expect(getRow(1).getAttribute('aria-level')).to.equal('2'); // A.B + expect(getRow(1).getAttribute('aria-posinset')).to.equal('1'); + expect(getRow(1).getAttribute('aria-setsize')).to.equal('1'); // A.A is filtered out, set size is now 1 + expect(getRow(2).getAttribute('aria-level')).to.equal('1'); // B + expect(getRow(3).getAttribute('aria-posinset')).to.equal('1'); // B.A + expect(getRow(3).getAttribute('aria-setsize')).to.equal('2'); // B.A & B.B + }); + + it('should not add the set specific aria attributes to pinned rows', () => { + render( + , + ); + + expect(getRow(0).getAttribute('aria-rowindex')).to.equal('2'); // header row is 1 + expect(getRow(0).getAttribute('aria-level')).to.equal(null); + expect(getRow(0).getAttribute('aria-posinset')).to.equal(null); + expect(getRow(0).getAttribute('aria-setsize')).to.equal(null); + expect(getRow(1).getAttribute('aria-rowindex')).to.equal('3'); + expect(getRow(1).getAttribute('aria-level')).to.equal('1'); // A + expect(getRow(1).getAttribute('aria-posinset')).to.equal('1'); + expect(getRow(1).getAttribute('aria-setsize')).to.equal('3'); // A, B, C + }); + }); + describe('regressions', () => { // See https://github.com/mui/mui-x/issues/9402 it('should not fail with checkboxSelection', () => { diff --git a/packages/x-data-grid-pro/src/utils/tree/createRowTree.ts b/packages/x-data-grid-pro/src/utils/tree/createRowTree.ts index 6f79cc1c727c0..081f518c13c61 100644 --- a/packages/x-data-grid-pro/src/utils/tree/createRowTree.ts +++ b/packages/x-data-grid-pro/src/utils/tree/createRowTree.ts @@ -33,7 +33,7 @@ export const createRowTree = (params: CreateRowTreeParams): GridRowTreeCreationV previousTree: params.previousTree, id: node.id, path: node.path, - hasServerChildren: node.hasServerChildren, + serverChildrenCount: node.serverChildrenCount, onDuplicatePath: params.onDuplicatePath, treeDepths, isGroupExpandedByDefault: params.isGroupExpandedByDefault, diff --git a/packages/x-data-grid-pro/src/utils/tree/insertDataRowInTree.ts b/packages/x-data-grid-pro/src/utils/tree/insertDataRowInTree.ts index 86cd27e020261..d006eb177b34e 100644 --- a/packages/x-data-grid-pro/src/utils/tree/insertDataRowInTree.ts +++ b/packages/x-data-grid-pro/src/utils/tree/insertDataRowInTree.ts @@ -59,7 +59,7 @@ interface InsertDataRowInTreeParams { onDuplicatePath?: GridTreePathDuplicateHandler; isGroupExpandedByDefault?: DataGridProProps['isGroupExpandedByDefault']; defaultGroupingExpansionDepth: number; - hasServerChildren?: boolean; + serverChildrenCount?: number; groupsToFetch?: Set; } @@ -79,7 +79,7 @@ export const insertDataRowInTree = ({ onDuplicatePath, isGroupExpandedByDefault, defaultGroupingExpansionDepth, - hasServerChildren, + serverChildrenCount, groupsToFetch, }: InsertDataRowInTreeParams) => { let parentNodeId = GRID_ROOT_GROUP_ID; @@ -99,7 +99,7 @@ export const insertDataRowInTree = ({ // We create a leaf node for the data row. if (existingNodeIdWithPartialPath == null) { let node: GridLeafNode | GridDataSourceGroupNode; - if (hasServerChildren) { + if (serverChildrenCount !== undefined && serverChildrenCount !== 0) { node = { type: 'group', id, @@ -112,7 +112,7 @@ export const insertDataRowInTree = ({ children: [], childrenFromPath: {}, childrenExpanded: false, - hasServerChildren: true, + serverChildrenCount, }; const shouldFetchChildren = checkGroupChildrenExpansion( node, diff --git a/packages/x-data-grid-pro/src/utils/tree/models.ts b/packages/x-data-grid-pro/src/utils/tree/models.ts index 871d3fc86c97f..b9daf689647e8 100644 --- a/packages/x-data-grid-pro/src/utils/tree/models.ts +++ b/packages/x-data-grid-pro/src/utils/tree/models.ts @@ -8,7 +8,7 @@ export interface RowTreeBuilderGroupingCriterion { export interface RowTreeBuilderNode { id: GridRowId; path: RowTreeBuilderGroupingCriterion[]; - hasServerChildren?: boolean; + serverChildrenCount?: number; } /** diff --git a/packages/x-data-grid-pro/src/utils/tree/updateRowTree.ts b/packages/x-data-grid-pro/src/utils/tree/updateRowTree.ts index 0c7ddc5144033..e56ca16d74f68 100644 --- a/packages/x-data-grid-pro/src/utils/tree/updateRowTree.ts +++ b/packages/x-data-grid-pro/src/utils/tree/updateRowTree.ts @@ -36,7 +36,7 @@ export const updateRowTree = (params: UpdateRowTreeParams): GridRowTreeCreationV : new Set([]); for (let i = 0; i < params.nodes.inserted.length; i += 1) { - const { id, path, hasServerChildren } = params.nodes.inserted[i]; + const { id, path, serverChildrenCount } = params.nodes.inserted[i]; insertDataRowInTree({ previousTree: params.previousTree, @@ -45,7 +45,7 @@ export const updateRowTree = (params: UpdateRowTreeParams): GridRowTreeCreationV updatedGroupsManager, id, path, - hasServerChildren, + serverChildrenCount, onDuplicatePath: params.onDuplicatePath, isGroupExpandedByDefault: params.isGroupExpandedByDefault, defaultGroupingExpansionDepth: params.defaultGroupingExpansionDepth, @@ -65,7 +65,7 @@ export const updateRowTree = (params: UpdateRowTreeParams): GridRowTreeCreationV } for (let i = 0; i < params.nodes.modified.length; i += 1) { - const { id, path, hasServerChildren } = params.nodes.modified[i]; + const { id, path, serverChildrenCount } = params.nodes.modified[i]; const pathInPreviousTree = getNodePathInTree({ tree, id }); const isInSameGroup = isDeepEqual(pathInPreviousTree, path); @@ -84,7 +84,7 @@ export const updateRowTree = (params: UpdateRowTreeParams): GridRowTreeCreationV updatedGroupsManager, id, path, - hasServerChildren, + serverChildrenCount, onDuplicatePath: params.onDuplicatePath, isGroupExpandedByDefault: params.isGroupExpandedByDefault, defaultGroupingExpansionDepth: params.defaultGroupingExpansionDepth, diff --git a/packages/x-data-grid/src/DataGrid/DataGrid.tsx b/packages/x-data-grid/src/DataGrid/DataGrid.tsx index ab054cbab9179..72fcb94533209 100644 --- a/packages/x-data-grid/src/DataGrid/DataGrid.tsx +++ b/packages/x-data-grid/src/DataGrid/DataGrid.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { GridBody, GridFooterPlaceholder, GridHeader, GridRoot } from '../components'; +import { useGridAriaAttributes } from '../hooks/utils/useGridAriaAttributes'; +import { useGridRowAriaAttributes } from '../hooks/features/rows/useGridRowAriaAttributes'; import { DataGridProcessedProps, DataGridProps } from '../models/props/DataGridProps'; import { GridContextProvider } from '../context/GridContextProvider'; import { useDataGridComponent } from './useDataGridComponent'; @@ -15,6 +17,12 @@ import { export type { GridSlotsComponent as GridSlots } from '../models'; +const configuration = { + hooks: { + useGridAriaAttributes, + useGridRowAriaAttributes, + }, +}; let propValidators: PropValidator[]; if (process.env.NODE_ENV !== 'production') { @@ -45,7 +53,7 @@ const DataGridRaw = React.forwardRef(function DataGrid + (undefined); + +if (process.env.NODE_ENV !== 'production') { + GridConfigurationContext.displayName = 'GridConfigurationContext'; +} diff --git a/packages/x-data-grid/src/components/GridRow.tsx b/packages/x-data-grid/src/components/GridRow.tsx index ee26a2b20c1f6..ec36976c2dc2b 100644 --- a/packages/x-data-grid/src/components/GridRow.tsx +++ b/packages/x-data-grid/src/components/GridRow.tsx @@ -24,11 +24,11 @@ import { GRID_DETAIL_PANEL_TOGGLE_FIELD } from '../constants/gridDetailPanelTogg import type { GridDimensions } from '../hooks/features/dimensions'; import { gridSortModelSelector } from '../hooks/features/sorting/gridSortingSelector'; import { gridRowMaximumTreeDepthSelector } from '../hooks/features/rows/gridRowsSelector'; -import { gridColumnGroupsHeaderMaxDepthSelector } from '../hooks/features/columnGrouping/gridColumnGroupsSelector'; import { gridEditRowsStateSelector } from '../hooks/features/editing/gridEditingSelectors'; import { PinnedPosition, gridPinnedColumnPositionLookup } from './cell/GridCell'; import { GridScrollbarFillerCell as ScrollbarFiller } from './GridScrollbarFillerCell'; import { getPinnedCellOffset } from '../internals/utils/getPinnedCellOffset'; +import { useGridConfiguration } from '../hooks/utils/useGridConfiguration'; export interface GridRowProps extends React.HTMLAttributes { row: GridRowModel; @@ -112,12 +112,12 @@ const GridRow = React.forwardRef(function GridRow( ...other } = props; const apiRef = useGridApiContext(); + const configuration = useGridConfiguration(); const ref = React.useRef(null); const rootProps = useGridRootProps(); const currentPage = useGridVisibleRows(apiRef, rootProps); const sortModel = useGridSelector(apiRef, gridSortModelSelector); const treeDepth = useGridSelector(apiRef, gridRowMaximumTreeDepthSelector); - const headerGroupingMaxDepth = useGridSelector(apiRef, gridColumnGroupsHeaderMaxDepthSelector); const columnPositions = useGridSelector(apiRef, gridColumnPositionsSelector); const editRowsState = useGridSelector(apiRef, gridEditRowsStateSelector); const handleRef = useForkRef(ref, refProp); @@ -137,8 +137,6 @@ const GridRow = React.forwardRef(function GridRow( focusedColumnIndex < visibleColumns.length - pinnedColumns.right.length && focusedColumnIndex >= renderContext.lastColumnIndex; - const ariaRowIndex = index + headerGroupingMaxDepth + 2; // 1 for the header row and 1 as it's 1-based - const classes = composeGridClasses(rootProps.classes, { root: [ 'row', @@ -151,6 +149,7 @@ const GridRow = React.forwardRef(function GridRow( rowHeight === 'auto' && 'row--dynamicHeight', ], }); + const getRowAriaAttributes = configuration.hooks.useGridRowAriaAttributes(); React.useLayoutEffect(() => { if (currentPage.range) { @@ -307,6 +306,7 @@ const GridRow = React.forwardRef(function GridRow( }, [isNotVisible, rowHeight, styleProp, minHeight, sizes, rootProps.rowSpacingType]); const rowClassNames = apiRef.current.unstable_applyPipeProcessors('rowClassName', [], rowId); + const ariaAttributes = rowNode ? getRowAriaAttributes(rowNode, index) : undefined; if (typeof rootProps.getRowClassName === 'function') { const indexRelativeToCurrentPage = index - (currentPage.range?.firstRowIndex || 0); @@ -479,9 +479,8 @@ const GridRow = React.forwardRef(function GridRow( data-rowindex={index} role="row" className={clsx(...rowClassNames, classes.root, className)} - aria-rowindex={ariaRowIndex} - aria-selected={selected} style={style} + {...ariaAttributes} {...eventHandlers} {...other} > diff --git a/packages/x-data-grid/src/components/virtualization/GridMainContainer.tsx b/packages/x-data-grid/src/components/virtualization/GridMainContainer.tsx index 5a0d3668d12f2..8fcbd0a4109ee 100644 --- a/packages/x-data-grid/src/components/virtualization/GridMainContainer.tsx +++ b/packages/x-data-grid/src/components/virtualization/GridMainContainer.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { styled } from '@mui/system'; import { DataGridProcessedProps } from '../../models/props/DataGridProps'; import { useGridRootProps } from '../../hooks/utils/useGridRootProps'; -import { useGridAriaAttributes } from '../../hooks/utils/useGridAriaAttributes'; +import { useGridConfiguration } from '../../hooks/utils/useGridConfiguration'; const GridPanelAnchor = styled('div')({ position: 'absolute', @@ -28,8 +28,9 @@ export const GridMainContainer = React.forwardRef< className: string; }> >((props, ref) => { - const ariaAttributes = useGridAriaAttributes(); const rootProps = useGridRootProps(); + const configuration = useGridConfiguration(); + const ariaAttributes = configuration.hooks.useGridAriaAttributes(); return ( ; + configuration: GridConfiguration; props: {}; children: React.ReactNode; }; -export function GridContextProvider({ privateApiRef, props, children }: GridContextProviderProps) { +export function GridContextProvider({ + privateApiRef, + configuration, + props, + children, +}: GridContextProviderProps) { const apiRef = React.useRef(privateApiRef.current.getPublicApi()); return ( - - - {children} - - + + + + {children} + + + ); } diff --git a/packages/x-data-grid/src/hooks/features/filter/gridFilterSelector.ts b/packages/x-data-grid/src/hooks/features/filter/gridFilterSelector.ts index 9d376a2321f23..f9733a04f577c 100644 --- a/packages/x-data-grid/src/hooks/features/filter/gridFilterSelector.ts +++ b/packages/x-data-grid/src/hooks/features/filter/gridFilterSelector.ts @@ -1,4 +1,5 @@ import { createSelector, createSelectorMemoized } from '../../../utils/createSelector'; +import { GridRowId } from '../../../models/gridRows'; import { GridFilterItem } from '../../../models/gridFilterItem'; import { GridStateCommunity } from '../../../models/gridStateCommunity'; import { gridSortedRowEntriesSelector } from '../sorting/gridSortingSelector'; @@ -43,6 +44,15 @@ export const gridFilteredRowsLookupSelector = createSelector( (filterState) => filterState.filteredRowsLookup, ); +/** + * @category Filtering + * @ignore - do not document. + */ +export const gridFilteredChildrenCountLookupSelector = createSelector( + gridFilterStateSelector, + (filterState) => filterState.filteredChildrenCountLookup, +); + /** * @category Filtering * @ignore - do not document. @@ -96,6 +106,41 @@ export const gridFilteredSortedRowIdsSelector = createSelectorMemoized( (filteredSortedRowEntries) => filteredSortedRowEntries.map((row) => row.id), ); +/** + * Get the ids to position in the current tree level lookup of the rows accessible after the filtering process. + * Does not contain the collapsed children. + * @category Filtering + * @ignore - do not document. + */ +export const gridExpandedSortedRowTreeLevelPositionLookupSelector = createSelectorMemoized( + gridExpandedSortedRowIdsSelector, + gridRowTreeSelector, + (visibleSortedRowIds, rowTree) => { + const depthPositionCounter: Record = {}; + let lastDepth = 0; + + return visibleSortedRowIds.reduce((acc: Record, rowId) => { + const rowNode = rowTree[rowId]; + + if (!depthPositionCounter[rowNode.depth]) { + depthPositionCounter[rowNode.depth] = 0; + } + + // going deeper in the tree should reset the counter + // since it might have been used in some other branch at the same level, up in the tree + // going back up should keep the counter and continue where it left off + if (rowNode.depth > lastDepth) { + depthPositionCounter[rowNode.depth] = 0; + } + + lastDepth = rowNode.depth; + depthPositionCounter[rowNode.depth] += 1; + acc[rowId] = depthPositionCounter[rowNode.depth]; + return acc; + }, {}); + }, +); + /** * Get the id and the model of the top level rows accessible after the filtering process. * @category Filtering diff --git a/packages/x-data-grid/src/hooks/features/filter/gridFilterState.ts b/packages/x-data-grid/src/hooks/features/filter/gridFilterState.ts index 0c216b8f55197..c48354461cf09 100644 --- a/packages/x-data-grid/src/hooks/features/filter/gridFilterState.ts +++ b/packages/x-data-grid/src/hooks/features/filter/gridFilterState.ts @@ -21,6 +21,12 @@ export interface GridFilterState { * This is the equivalent of the `visibleRowsLookup` if all the groups were expanded. */ filteredRowsLookup: Record; + /** + * Amount of children that are passing the filters or have children that are passing the filter (does not count grand children). + * If a row is not registered in this lookup, it is supposed to have no descendant passing the filters. + * If `GridDataSource` is being used to load the data, the value is `-1` if there are some children but the count is unknown. + */ + filteredChildrenCountLookup: Record; /** * Amount of descendants that are passing the filters. * For the Tree Data, it includes all the intermediate depth levels (= amount of children + amount of grand children + ...). diff --git a/packages/x-data-grid/src/hooks/features/filter/index.ts b/packages/x-data-grid/src/hooks/features/filter/index.ts index e28153eb734b1..5aa46fea24b24 100644 --- a/packages/x-data-grid/src/hooks/features/filter/index.ts +++ b/packages/x-data-grid/src/hooks/features/filter/index.ts @@ -1,3 +1,21 @@ export type { GridFilterState, GridFilterInitialState } from './gridFilterState'; export { getDefaultGridFilterModel } from './gridFilterState'; -export * from './gridFilterSelector'; +export { + gridFilterModelSelector, + gridQuickFilterValuesSelector, + gridVisibleRowsLookupSelector, + gridFilteredRowsLookupSelector, + gridFilteredDescendantCountLookupSelector, + gridExpandedSortedRowEntriesSelector, + gridExpandedSortedRowIdsSelector, + gridFilteredSortedRowEntriesSelector, + gridFilteredSortedRowIdsSelector, + gridFilteredSortedTopLevelRowEntriesSelector, + gridExpandedRowCountSelector, + gridFilteredTopLevelRowCountSelector, + gridFilteredRowCountSelector, + gridFilteredDescendantRowCountSelector, + gridFilterActiveItemsSelector, + gridFilterActiveItemsLookupSelector, +} from './gridFilterSelector'; +export type { GridFilterActiveItemsLookup } from './gridFilterSelector'; diff --git a/packages/x-data-grid/src/hooks/features/filter/useGridFilter.tsx b/packages/x-data-grid/src/hooks/features/filter/useGridFilter.tsx index 031320af23c33..1ad1ae3d7d947 100644 --- a/packages/x-data-grid/src/hooks/features/filter/useGridFilter.tsx +++ b/packages/x-data-grid/src/hooks/features/filter/useGridFilter.tsx @@ -46,6 +46,7 @@ export const filterStateInitializer: GridStateInitializer< filter: { filterModel: sanitizeFilterModel(filterModel, props.disableMultipleColumnsFiltering, apiRef), filteredRowsLookup: {}, + filteredChildrenCountLookup: {}, filteredDescendantCountLookup: {}, }, visibleRowsLookup: {}, @@ -424,6 +425,7 @@ export const useGridFilter = ( if (props.filterMode !== 'client' || !params.isRowMatchingFilters) { return { filteredRowsLookup: {}, + filteredChildrenCountLookup: {}, filteredDescendantCountLookup: {}, }; } @@ -464,6 +466,7 @@ export const useGridFilter = ( return { filteredRowsLookup, + filteredChildrenCountLookup: {}, filteredDescendantCountLookup: {}, }; }, diff --git a/packages/x-data-grid/src/hooks/features/rowSelection/useGridRowSelection.ts b/packages/x-data-grid/src/hooks/features/rowSelection/useGridRowSelection.ts index fe7495699b332..e42e696554f9e 100644 --- a/packages/x-data-grid/src/hooks/features/rowSelection/useGridRowSelection.ts +++ b/packages/x-data-grid/src/hooks/features/rowSelection/useGridRowSelection.ts @@ -186,6 +186,10 @@ export const useGridRowSelection = ( const isRowSelectable = React.useCallback( (id) => { + if (props.rowSelection === false) { + return false; + } + if (propIsRowSelectable && !propIsRowSelectable(apiRef.current.getRowParams(id))) { return false; } @@ -197,7 +201,7 @@ export const useGridRowSelection = ( return true; }, - [apiRef, propIsRowSelectable], + [apiRef, props.rowSelection, propIsRowSelectable], ); const getSelectedRows = React.useCallback( diff --git a/packages/x-data-grid/src/hooks/features/rows/useGridRowAriaAttributes.tsx b/packages/x-data-grid/src/hooks/features/rows/useGridRowAriaAttributes.tsx new file mode 100644 index 0000000000000..112d58e4dfa3c --- /dev/null +++ b/packages/x-data-grid/src/hooks/features/rows/useGridRowAriaAttributes.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { GridTreeNode } from '../../../models/gridRows'; +import { GetRowAriaAttributesFn } from '../../../models/configuration/gridRowConfiguration'; +import { selectedIdsLookupSelector } from '../rowSelection'; +import { useGridSelector } from '../../utils/useGridSelector'; +import { gridColumnGroupsHeaderMaxDepthSelector } from '../columnGrouping/gridColumnGroupsSelector'; +import { useGridPrivateApiContext } from '../../utils/useGridPrivateApiContext'; + +export const useGridRowAriaAttributes = (): GetRowAriaAttributesFn => { + const apiRef = useGridPrivateApiContext(); + const selectedIdsLookup = useGridSelector(apiRef, selectedIdsLookupSelector); + const headerGroupingMaxDepth = useGridSelector(apiRef, gridColumnGroupsHeaderMaxDepthSelector); + + return React.useCallback( + (rowNode: GridTreeNode, index: number) => { + const ariaAttributes = {} as Record; + + const ariaRowIndex = index + headerGroupingMaxDepth + 2; // 1 for the header row and 1 as it's 1-based + ariaAttributes['aria-rowindex'] = ariaRowIndex; + + if (apiRef.current.isRowSelectable(rowNode.id)) { + ariaAttributes['aria-selected'] = selectedIdsLookup[rowNode.id] !== undefined; + } + + return ariaAttributes; + }, + [apiRef, selectedIdsLookup, headerGroupingMaxDepth], + ); +}; diff --git a/packages/x-data-grid/src/hooks/utils/useGridAriaAttributes.tsx b/packages/x-data-grid/src/hooks/utils/useGridAriaAttributes.tsx index fc4efefbcc8cb..abfee1ab87465 100644 --- a/packages/x-data-grid/src/hooks/utils/useGridAriaAttributes.tsx +++ b/packages/x-data-grid/src/hooks/utils/useGridAriaAttributes.tsx @@ -1,31 +1,25 @@ +import * as React from 'react'; import { gridVisibleColumnDefinitionsSelector } from '../features/columns/gridColumnsSelector'; import { useGridSelector } from './useGridSelector'; import { useGridRootProps } from './useGridRootProps'; import { gridColumnGroupsHeaderMaxDepthSelector } from '../features/columnGrouping/gridColumnGroupsSelector'; -import { - gridPinnedRowsCountSelector, - gridRowCountSelector, -} from '../features/rows/gridRowsSelector'; +import { gridPinnedRowsCountSelector } from '../features/rows/gridRowsSelector'; import { useGridPrivateApiContext } from './useGridPrivateApiContext'; import { isMultipleRowSelectionEnabled } from '../features/rowSelection/utils'; +import { gridExpandedRowCountSelector } from '../features/filter/gridFilterSelector'; -export const useGridAriaAttributes = () => { +export const useGridAriaAttributes = (): React.HTMLAttributes => { const apiRef = useGridPrivateApiContext(); const rootProps = useGridRootProps(); const visibleColumns = useGridSelector(apiRef, gridVisibleColumnDefinitionsSelector); - const totalRowCount = useGridSelector(apiRef, gridRowCountSelector); + const accessibleRowCount = useGridSelector(apiRef, gridExpandedRowCountSelector); const headerGroupingMaxDepth = useGridSelector(apiRef, gridColumnGroupsHeaderMaxDepthSelector); const pinnedRowsCount = useGridSelector(apiRef, gridPinnedRowsCountSelector); - let role = 'grid'; - if ((rootProps as any).treeData) { - role = 'treegrid'; - } - return { - role, + role: 'grid', 'aria-colcount': visibleColumns.length, - 'aria-rowcount': headerGroupingMaxDepth + 1 + pinnedRowsCount + totalRowCount, + 'aria-rowcount': headerGroupingMaxDepth + 1 + pinnedRowsCount + accessibleRowCount, 'aria-multiselectable': isMultipleRowSelectionEnabled(rootProps), }; }; diff --git a/packages/x-data-grid/src/hooks/utils/useGridConfiguration.ts b/packages/x-data-grid/src/hooks/utils/useGridConfiguration.ts new file mode 100644 index 0000000000000..f65b863f77dba --- /dev/null +++ b/packages/x-data-grid/src/hooks/utils/useGridConfiguration.ts @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { GridConfigurationContext } from '../../components/GridConfigurationContext'; +import { GridConfiguration } from '../../models/configuration/gridConfiguration'; + +export const useGridConfiguration = () => { + const configuration = React.useContext(GridConfigurationContext); + + if (configuration === undefined) { + throw new Error( + [ + 'MUI X: Could not find the data grid configuration context.', + 'It looks like you rendered your component outside of a DataGrid, DataGridPro or DataGridPremium parent component.', + 'This can also happen if you are bundling multiple versions of the data grid.', + ].join('\n'), + ); + } + + return configuration as GridConfiguration; +}; diff --git a/packages/x-data-grid/src/internals/index.ts b/packages/x-data-grid/src/internals/index.ts index 90f1ca592e487..a9ec03d957be2 100644 --- a/packages/x-data-grid/src/internals/index.ts +++ b/packages/x-data-grid/src/internals/index.ts @@ -56,6 +56,10 @@ export { useGridCsvExport } from '../hooks/features/export/useGridCsvExport'; export { useGridPrintExport } from '../hooks/features/export/useGridPrintExport'; export { useGridFilter, filterStateInitializer } from '../hooks/features/filter/useGridFilter'; export { passFilterLogic } from '../hooks/features/filter/gridFilterUtils'; +export { + gridFilteredChildrenCountLookupSelector, + gridExpandedSortedRowTreeLevelPositionLookupSelector, +} from '../hooks/features/filter/gridFilterSelector'; export { isSingleSelectColDef } from '../components/panel/filterPanel/filterPanelUtils'; export type { GridAggregatedFilterItemApplier, @@ -74,6 +78,8 @@ export { export { useGridEditing, editingStateInitializer } from '../hooks/features/editing/useGridEditing'; export { gridEditRowsStateSelector } from '../hooks/features/editing/gridEditingSelectors'; export { useGridRows, rowsStateInitializer } from '../hooks/features/rows/useGridRows'; +export { useGridAriaAttributes } from '../hooks/utils/useGridAriaAttributes'; +export { useGridRowAriaAttributes } from '../hooks/features/rows/useGridRowAriaAttributes'; export { useGridRowsPreProcessors } from '../hooks/features/rows/useGridRowsPreProcessors'; export type { GridRowTreeCreationParams, diff --git a/packages/x-data-grid/src/models/configuration/gridConfiguration.ts b/packages/x-data-grid/src/models/configuration/gridConfiguration.ts new file mode 100644 index 0000000000000..d4a7d7b2c4f05 --- /dev/null +++ b/packages/x-data-grid/src/models/configuration/gridConfiguration.ts @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { GridRowAriaAttributesInternalHook } from './gridRowConfiguration'; + +export interface GridAriaAttributesInternalHook { + useGridAriaAttributes: () => React.HTMLAttributes; +} + +export interface GridInternalHook + extends GridAriaAttributesInternalHook, + GridRowAriaAttributesInternalHook {} + +export interface GridConfiguration { + hooks: GridInternalHook; +} diff --git a/packages/x-data-grid/src/models/configuration/gridRowConfiguration.ts b/packages/x-data-grid/src/models/configuration/gridRowConfiguration.ts new file mode 100644 index 0000000000000..c206f3b0dbab2 --- /dev/null +++ b/packages/x-data-grid/src/models/configuration/gridRowConfiguration.ts @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { GridTreeNode } from '../gridRows'; + +/** + * Get the ARIA attributes for a row + * @param {GridTreeNode} rowNode The row node + * @param {number} index The position index of the row + * @returns {React.HTMLAttributes} The ARIA attributes + */ +export type GetRowAriaAttributesFn = ( + rowNode: GridTreeNode, + index: number, +) => React.HTMLAttributes; + +export interface GridRowAriaAttributesInternalHook { + useGridRowAriaAttributes: () => GetRowAriaAttributesFn; +} diff --git a/packages/x-data-grid/src/models/gridDataSource.ts b/packages/x-data-grid/src/models/gridDataSource.ts index 58038fe0e2905..f2a26241b659f 100644 --- a/packages/x-data-grid/src/models/gridDataSource.ts +++ b/packages/x-data-grid/src/models/gridDataSource.ts @@ -72,7 +72,8 @@ export interface GridDataSource { /** * Used to determine the number of children a row has on server. * @param {GridRowModel} row The row to check the number of children - * @returns {number} The number of children the row has + * @returns {number} The number of children the row has. + * If the children count is not available for some reason, but there are some children, `getChildrenCount` should return `-1`. */ getChildrenCount?: (row: GridRowModel) => number; } diff --git a/packages/x-data-grid/src/models/gridRows.ts b/packages/x-data-grid/src/models/gridRows.ts index 703433dc873cc..f7282e9c59a12 100644 --- a/packages/x-data-grid/src/models/gridRows.ts +++ b/packages/x-data-grid/src/models/gridRows.ts @@ -116,9 +116,9 @@ export interface GridDataGroupNode extends GridBasicGroupNode { export interface GridDataSourceGroupNode extends GridDataGroupNode { /** - * If true, this node has children on server. + * Number of children this node has on the server. Equals to `-1` if there are some children but the count is unknown. */ - hasServerChildren: boolean; + serverChildrenCount: number; /** * The cached path to be passed on as `groupKey` to the server. */ diff --git a/packages/x-data-grid/src/models/props/DataGridProps.ts b/packages/x-data-grid/src/models/props/DataGridProps.ts index 47a56c9a742a6..97b44620eca30 100644 --- a/packages/x-data-grid/src/models/props/DataGridProps.ts +++ b/packages/x-data-grid/src/models/props/DataGridProps.ts @@ -469,7 +469,7 @@ export interface DataGridPropsWithoutDefaultValue) => boolean; /** diff --git a/packages/x-data-grid/src/tests/rowSelection.DataGrid.test.tsx b/packages/x-data-grid/src/tests/rowSelection.DataGrid.test.tsx index 4cfe95e32b9ca..f01954e76a1fe 100644 --- a/packages/x-data-grid/src/tests/rowSelection.DataGrid.test.tsx +++ b/packages/x-data-grid/src/tests/rowSelection.DataGrid.test.tsx @@ -837,6 +837,27 @@ describe(' - Row selection', () => { }); }); + describe('accessibility', () => { + it('should add aria-selected attributes to the selectable rows', () => { + render(); + + // Select the first row + userEvent.mousePress(getCell(0, 0)); + expect(getRow(0).getAttribute('aria-selected')).to.equal('true'); + expect(getRow(1).getAttribute('aria-selected')).to.equal('false'); + }); + + it('should not add aria-selected attributes if the row selection is disabled', () => { + render(); + expect(getRow(0).getAttribute('aria-selected')).to.equal(null); + + // Try to select the first row + userEvent.mousePress(getCell(0, 0)); + // nothing should change + expect(getRow(0).getAttribute('aria-selected')).to.equal(null); + }); + }); + describe('performance', () => { it('should not rerender unrelated nodes', () => { // Couldn't use because we need to track multiple components diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 086dbe63f1f28..061c818d1f704 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -481,7 +481,7 @@ importers: version: 9.7.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/query-core': specifier: ^5.51.15 - version: 5.51.15 + version: 5.51.21 ast-types: specifier: ^0.14.2 version: 0.14.2 @@ -3861,8 +3861,8 @@ packages: '@swc/types@0.1.9': resolution: {integrity: sha512-qKnCno++jzcJ4lM4NTfYifm1EFSCeIfKiAHAfkENZAV5Kl9PjJIyd2yeeVv6c/2CckuLyv2NmRC5pv6pm2WQBg==} - '@tanstack/query-core@5.51.15': - resolution: {integrity: sha512-xyobHDJ0yhPE3+UkSQ2/4X1fLSg7ICJI5J1JyU9yf7F3deQfEwSImCDrB1WSRrauJkMtXW7YIEcC0oA6ZZWt5A==} + '@tanstack/query-core@5.51.21': + resolution: {integrity: sha512-POQxm42IUp6n89kKWF4IZi18v3fxQWFRolvBA6phNVmA8psdfB1MvDnGacCJdS+EOX12w/CyHM62z//rHmYmvw==} '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} @@ -12839,7 +12839,7 @@ snapshots: dependencies: '@swc/counter': 0.1.3 - '@tanstack/query-core@5.51.15': {} + '@tanstack/query-core@5.51.21': {} '@testing-library/dom@10.4.0': dependencies: