diff --git a/docs/data/data-grid/demo/PopularFeaturesDemo.js b/docs/data/data-grid/demo/PopularFeaturesDemo.js index 4c9494d625db..a51d18b25690 100644 --- a/docs/data/data-grid/demo/PopularFeaturesDemo.js +++ b/docs/data/data-grid/demo/PopularFeaturesDemo.js @@ -382,7 +382,7 @@ export default function PopularFeaturesDemo() { [`& .${gridClasses.detailPanel}`]: { background: 'transparent', }, - [`& .${gridClasses.cell}:focus, & .${gridClasses.cell}:focus-within`]: { + [`& .${gridClasses['cell--outlined']}`]: { outline: 'none', }, [`& .${gridClasses.columnHeader}:focus, & .${gridClasses.columnHeader}:focus-within`]: diff --git a/docs/data/data-grid/demo/PopularFeaturesDemo.tsx b/docs/data/data-grid/demo/PopularFeaturesDemo.tsx index c8d867196b30..97c07193342e 100644 --- a/docs/data/data-grid/demo/PopularFeaturesDemo.tsx +++ b/docs/data/data-grid/demo/PopularFeaturesDemo.tsx @@ -393,7 +393,7 @@ export default function PopularFeaturesDemo() { [`& .${gridClasses.detailPanel}`]: { background: 'transparent', }, - [`& .${gridClasses.cell}:focus, & .${gridClasses.cell}:focus-within`]: { + [`& .${gridClasses['cell--outlined']}`]: { outline: 'none', }, [`& .${gridClasses.columnHeader}:focus, & .${gridClasses.columnHeader}:focus-within`]: diff --git a/docs/data/data-grid/events/events.json b/docs/data/data-grid/events/events.json index 4a0b4aeab96b..3586c0b1a339 100644 --- a/docs/data/data-grid/events/events.json +++ b/docs/data/data-grid/events/events.json @@ -6,6 +6,13 @@ "params": "GridAggregationModel", "event": "MuiEvent<{}>" }, + { + "projects": ["x-data-grid", "x-data-grid-pro", "x-data-grid-premium"], + "name": "cellBlur", + "description": "Fired when a blur event happens in a cell.", + "params": "GridCellParams", + "event": "MuiEvent>" + }, { "projects": ["x-data-grid", "x-data-grid-pro", "x-data-grid-premium"], "name": "cellClick", @@ -38,6 +45,13 @@ "event": "MuiEvent", "componentProp": "onCellEditStop" }, + { + "projects": ["x-data-grid", "x-data-grid-pro", "x-data-grid-premium"], + "name": "cellFocus", + "description": "Fired when a focus event happens in a cell.", + "params": "GridCellParams", + "event": "MuiEvent>" + }, { "projects": ["x-data-grid", "x-data-grid-pro", "x-data-grid-premium"], "name": "cellFocusIn", diff --git a/docs/data/migration/migration-data-grid-v5/migration-data-grid-v5.md b/docs/data/migration/migration-data-grid-v5/migration-data-grid-v5.md index a802615f1981..c7fda842d9cf 100644 --- a/docs/data/migration/migration-data-grid-v5/migration-data-grid-v5.md +++ b/docs/data/migration/migration-data-grid-v5/migration-data-grid-v5.md @@ -284,6 +284,16 @@ Most of this breaking change is handled by `preset-safe` codemod but some furthe ### CSS classes +- To update the outline style of a focused cell, use the `.MuiDataGrid-cell--outlined` class instead of the `:focus-within` selector. + ```diff + -'.MuiDataGrid-cell:focus-within': { + +'.MuiDataGrid-cell--outlined': { + ``` + The new class name is also available in `gridClasses`: + ```diff + -`.${gridClasses.cell}:focus-within`: { + +`.${gridClasses['cell--outlined']}`: { + ``` - Some CSS classes were removed or renamed | MUI X v5 classes | MUI X v6 classes | Note | 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 01501a218c45..d3fca53cc1d2 100644 --- a/docs/pages/x/api/data-grid/data-grid-premium.json +++ b/docs/pages/x/api/data-grid/data-grid-premium.json @@ -71,7 +71,7 @@ "experimentalFeatures": { "type": { "name": "shape", - "description": "{ columnGrouping?: bool, lazyLoading?: bool, rowPinning?: bool, warnIfFocusStateIsNotSynced?: bool }" + "description": "{ columnGrouping?: bool, lazyLoading?: bool, rowPinning?: bool }" } }, "filterMode": { @@ -370,6 +370,7 @@ "cell--textLeft", "cell--textRight", "cell--withRenderer", + "cell--outlined", "cell--rangeTop", "cell--rangeBottom", "cell--rangeLeft", diff --git a/docs/pages/x/api/data-grid/data-grid-pro.json b/docs/pages/x/api/data-grid/data-grid-pro.json index 1edaeff7e5e6..7f649b8cd3c2 100644 --- a/docs/pages/x/api/data-grid/data-grid-pro.json +++ b/docs/pages/x/api/data-grid/data-grid-pro.json @@ -60,7 +60,7 @@ "experimentalFeatures": { "type": { "name": "shape", - "description": "{ columnGrouping?: bool, lazyLoading?: bool, rowPinning?: bool, warnIfFocusStateIsNotSynced?: bool }" + "description": "{ columnGrouping?: bool, lazyLoading?: bool, rowPinning?: bool }" } }, "filterMode": { @@ -339,6 +339,7 @@ "cell--textLeft", "cell--textRight", "cell--withRenderer", + "cell--outlined", "cell--rangeTop", "cell--rangeBottom", "cell--rangeLeft", diff --git a/docs/pages/x/api/data-grid/data-grid.json b/docs/pages/x/api/data-grid/data-grid.json index db2101822d41..3fc703422d69 100644 --- a/docs/pages/x/api/data-grid/data-grid.json +++ b/docs/pages/x/api/data-grid/data-grid.json @@ -39,10 +39,7 @@ }, "error": { "type": { "name": "any" } }, "experimentalFeatures": { - "type": { - "name": "shape", - "description": "{ columnGrouping?: bool, warnIfFocusStateIsNotSynced?: bool }" - } + "type": { "name": "shape", "description": "{ columnGrouping?: bool }" } }, "filterMode": { "type": { "name": "enum", "description": "'client'
| 'server'" }, @@ -276,6 +273,7 @@ "cell--textLeft", "cell--textRight", "cell--withRenderer", + "cell--outlined", "cell--rangeTop", "cell--rangeBottom", "cell--rangeLeft", diff --git a/docs/translations/api-docs/data-grid/data-grid-premium.json b/docs/translations/api-docs/data-grid/data-grid-premium.json index 42a2ccaebd6e..930d727f8771 100644 --- a/docs/translations/api-docs/data-grid/data-grid-premium.json +++ b/docs/translations/api-docs/data-grid/data-grid-premium.json @@ -217,6 +217,11 @@ "nodeName": "the cell element", "conditions": "the cell has a custom renderer" }, + "cell--outlined": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "the cell is outlined" + }, "cell--rangeTop": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the cell element", diff --git a/docs/translations/api-docs/data-grid/data-grid-pro.json b/docs/translations/api-docs/data-grid/data-grid-pro.json index 7b0529fa3b27..4c86bb18ed13 100644 --- a/docs/translations/api-docs/data-grid/data-grid-pro.json +++ b/docs/translations/api-docs/data-grid/data-grid-pro.json @@ -204,6 +204,11 @@ "nodeName": "the cell element", "conditions": "the cell has a custom renderer" }, + "cell--outlined": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "the cell is outlined" + }, "cell--rangeTop": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the cell element", diff --git a/docs/translations/api-docs/data-grid/data-grid.json b/docs/translations/api-docs/data-grid/data-grid.json index c7bbaadd74fd..efde63bb281a 100644 --- a/docs/translations/api-docs/data-grid/data-grid.json +++ b/docs/translations/api-docs/data-grid/data-grid.json @@ -172,6 +172,11 @@ "nodeName": "the cell element", "conditions": "the cell has a custom renderer" }, + "cell--outlined": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the cell element", + "conditions": "the cell is outlined" + }, "cell--rangeTop": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the cell element", diff --git a/packages/grid/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx b/packages/grid/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx index 8d3fcb0106ad..4df87599d6b2 100644 --- a/packages/grid/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx +++ b/packages/grid/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx @@ -281,7 +281,6 @@ DataGridPremiumRaw.propTypes = { columnGrouping: PropTypes.bool, lazyLoading: PropTypes.bool, rowPinning: PropTypes.bool, - warnIfFocusStateIsNotSynced: PropTypes.bool, }), /** * Filtering can be processed on the server or client-side. diff --git a/packages/grid/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx b/packages/grid/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx index 6d99056a23a9..1b954df54034 100644 --- a/packages/grid/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx +++ b/packages/grid/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx @@ -252,7 +252,6 @@ DataGridProRaw.propTypes = { columnGrouping: PropTypes.bool, lazyLoading: PropTypes.bool, rowPinning: PropTypes.bool, - warnIfFocusStateIsNotSynced: PropTypes.bool, }), /** * Filtering can be processed on the server or client-side. diff --git a/packages/grid/x-data-grid/src/DataGrid/DataGrid.tsx b/packages/grid/x-data-grid/src/DataGrid/DataGrid.tsx index 78b881b94a28..1da3894ce5ac 100644 --- a/packages/grid/x-data-grid/src/DataGrid/DataGrid.tsx +++ b/packages/grid/x-data-grid/src/DataGrid/DataGrid.tsx @@ -191,7 +191,6 @@ DataGridRaw.propTypes = { */ experimentalFeatures: PropTypes.shape({ columnGrouping: PropTypes.bool, - warnIfFocusStateIsNotSynced: PropTypes.bool, }), /** * Filtering can be processed on the server or client-side. diff --git a/packages/grid/x-data-grid/src/components/GridRow.tsx b/packages/grid/x-data-grid/src/components/GridRow.tsx index 77f2341c3d2b..4f9e26681cb2 100644 --- a/packages/grid/x-data-grid/src/components/GridRow.tsx +++ b/packages/grid/x-data-grid/src/components/GridRow.tsx @@ -13,7 +13,6 @@ import { GridEditingState, GridCellModes, } from '../models/gridEditRowModel'; -import { useGridApiContext } from '../hooks/utils/useGridApiContext'; import { getDataGridUtilityClass, gridClasses } from '../constants/gridClasses'; import { useGridRootProps } from '../hooks/utils/useGridRootProps'; import { DataGridProcessedProps } from '../models/props/DataGridProps'; @@ -33,6 +32,7 @@ import { gridRowMaximumTreeDepthSelector } from '../hooks/features/rows/gridRows import { gridColumnGroupsHeaderMaxDepthSelector } from '../hooks/features/columnGrouping/gridColumnGroupsSelector'; import { randomNumberBetween } from '../utils/utils'; import { GridCellProps } from './cell/GridCell'; +import { useGridPrivateApiContext } from '../hooks/utils/useGridPrivateApiContext'; export interface GridRowProps { rowId: GridRowId; @@ -122,7 +122,7 @@ const GridRow = React.forwardRef< onMouseLeave, ...other } = props; - const apiRef = useGridApiContext(); + const apiRef = useGridPrivateApiContext(); const ref = React.useRef(null); const rootProps = useGridRootProps(); const currentPage = useGridVisibleRows(apiRef, rootProps); @@ -362,6 +362,7 @@ const GridRow = React.forwardRef< cellMode={cellParams.cellMode} colIndex={cellProps.indexRelativeToAllColumns} isEditable={cellParams.isEditable} + isOutlined={apiRef.current.isCellOutlined(rowId, column.field)} isSelected={isSelected} hasFocus={hasFocus} tabIndex={tabIndex} diff --git a/packages/grid/x-data-grid/src/components/cell/GridCell.tsx b/packages/grid/x-data-grid/src/components/cell/GridCell.tsx index e212a7b7e6dc..6dcaeed097ba 100644 --- a/packages/grid/x-data-grid/src/components/cell/GridCell.tsx +++ b/packages/grid/x-data-grid/src/components/cell/GridCell.tsx @@ -18,7 +18,6 @@ import { import { GridAlignment } from '../../models/colDef/gridColDef'; import { useGridApiContext } from '../../hooks/utils/useGridApiContext'; import { useGridRootProps } from '../../hooks/utils/useGridRootProps'; -import { gridFocusCellSelector } from '../../hooks/features/focus/gridFocusStateSelector'; import { DataGridProcessedProps } from '../../models/props/DataGridProps'; import { FocusElement } from '../../models/params/gridCellParams'; @@ -32,6 +31,7 @@ export interface GridCellProps { hasFocus?: boolean; height: number | 'auto'; isEditable?: boolean; + isOutlined?: boolean; isSelected?: boolean; showRightBorder?: boolean; value?: V; @@ -65,17 +65,21 @@ function doesSupportPreventScroll(): boolean { return cachedSupportsPreventScroll; } -type OwnerState = Pick & { +type OwnerState = Pick< + GridCellProps, + 'align' | 'showRightBorder' | 'isEditable' | 'isOutlined' | 'isSelected' +> & { classes?: DataGridProcessedProps['classes']; }; const useUtilityClasses = (ownerState: OwnerState) => { - const { align, showRightBorder, isEditable, isSelected, classes } = ownerState; + const { align, isOutlined, showRightBorder, isEditable, isSelected, classes } = ownerState; const slots = { root: [ 'cell', `cell--text${capitalize(align)}`, + isOutlined && `cell--outlined`, isEditable && 'cell--editable', isSelected && 'selected', showRightBorder && 'cell--withRightBorder', @@ -87,8 +91,6 @@ const useUtilityClasses = (ownerState: OwnerState) => { return composeClasses(slots, getDataGridUtilityClass, classes); }; -let warnedOnce = false; - function GridCell(props: GridCellProps) { const { align, @@ -101,6 +103,7 @@ function GridCell(props: GridCellProps) { hasFocus, height, isEditable, + isOutlined, isSelected, rowId, tabIndex, @@ -118,6 +121,8 @@ function GridCell(props: GridCellProps) { onMouseUp, onMouseOver, onKeyDown, + onFocus, + onBlur, onKeyUp, onDragEnter, onDragOver, @@ -130,7 +135,14 @@ function GridCell(props: GridCellProps) { const apiRef = useGridApiContext(); const rootProps = useGridRootProps(); - const ownerState = { align, showRightBorder, isEditable, classes: rootProps.classes, isSelected }; + const ownerState = { + align, + showRightBorder, + isEditable, + classes: rootProps.classes, + isSelected, + isOutlined, + }; const classes = useUtilityClasses(ownerState); const publishMouseUp = React.useCallback( @@ -203,36 +215,6 @@ function GridCell(props: GridCellProps) { } }, [hasFocus, cellMode, apiRef]); - let handleFocus: any = other.onFocus; - - if ( - process.env.NODE_ENV === 'test' && - rootProps.experimentalFeatures?.warnIfFocusStateIsNotSynced - ) { - handleFocus = (event: React.FocusEvent) => { - const focusedCell = gridFocusCellSelector(apiRef); - if (focusedCell?.id === rowId && focusedCell.field === field) { - if (typeof other.onFocus === 'function') { - other.onFocus(event); - } - return; - } - - if (!warnedOnce) { - console.warn( - [ - `MUI: The cell with id=${rowId} and field=${field} received focus.`, - `According to the state, the focus should be at id=${focusedCell?.id}, field=${focusedCell?.field}.`, - "Not syncing the state may cause unwanted behaviors since the `cellFocusIn` event won't be fired.", - 'Call `fireEvent.mouseUp` before the `fireEvent.click` to sync the focus with the state.', - ].join('\n'), - ); - - warnedOnce = true; - } - }; - } - const column = apiRef.current.getColumn(field); const managesOwnFocus = column.type === 'actions'; @@ -272,10 +254,11 @@ function GridCell(props: GridCellProps) { onMouseDown={publishMouseDown('cellMouseDown')} onMouseUp={publishMouseUp('cellMouseUp')} onKeyDown={publish('cellKeyDown', onKeyDown)} + onBlur={publish('cellBlur', onBlur)} + onFocus={publish('cellFocus', onFocus)} onKeyUp={publish('cellKeyUp', onKeyUp)} {...draggableEventHandlers} {...other} - onFocus={handleFocus} > {renderChildren()} @@ -299,6 +282,7 @@ GridCell.propTypes = { hasFocus: PropTypes.bool, height: PropTypes.oneOfType([PropTypes.oneOf(['auto']), PropTypes.number]).isRequired, isEditable: PropTypes.bool, + isOutlined: PropTypes.bool, isSelected: PropTypes.bool, onClick: PropTypes.func, onDoubleClick: PropTypes.func, diff --git a/packages/grid/x-data-grid/src/components/containers/GridRootStyles.ts b/packages/grid/x-data-grid/src/components/containers/GridRootStyles.ts index 2fc18aee01ac..78fca7a551df 100644 --- a/packages/grid/x-data-grid/src/components/containers/GridRootStyles.ts +++ b/packages/grid/x-data-grid/src/components/containers/GridRootStyles.ts @@ -124,7 +124,7 @@ export const GridRootStyles = styled('div', { padding: '0 10px', boxSizing: 'border-box', }, - [`& .${gridClasses.columnHeader}:focus-within, & .${gridClasses.cell}:focus-within`]: { + [`& .${gridClasses.columnHeader}:focus-within, & .${gridClasses['cell--outlined']}`]: { outline: `solid ${ theme.vars ? `rgba(${theme.vars.palette.primary.mainChannel} / 0.5)` @@ -133,7 +133,7 @@ export const GridRootStyles = styled('div', { outlineWidth: 1, outlineOffset: -1, }, - [`& .${gridClasses.columnHeader}:focus, & .${gridClasses.cell}:focus`]: { + [`& .${gridClasses.columnHeader}:focus, & .${gridClasses['cell--outlined']}`]: { outline: `solid ${theme.palette.primary.main} 1px`, }, [`& .${gridClasses.columnHeaderCheckbox}, & .${gridClasses.cellCheckbox}`]: { @@ -353,10 +353,6 @@ export const GridRootStyles = styled('div', { display: 'flex', boxShadow: theme.shadows[2], backgroundColor: (theme.vars || theme).palette.background.paper, - '&:focus-within': { - outline: `solid ${(theme.vars || theme).palette.primary.main} 1px`, - outlineOffset: '-1px', - }, }, [`& .${gridClasses['row--editing']}`]: { boxShadow: theme.shadows[2], diff --git a/packages/grid/x-data-grid/src/constants/gridClasses.ts b/packages/grid/x-data-grid/src/constants/gridClasses.ts index d6394c964e96..290542af19a2 100644 --- a/packages/grid/x-data-grid/src/constants/gridClasses.ts +++ b/packages/grid/x-data-grid/src/constants/gridClasses.ts @@ -60,6 +60,10 @@ export interface GridClasses { * Styles applied to the cell element if the cell has a custom renderer. */ 'cell--withRenderer': string; + /** + * Styles applied to the cell element if the cell is outlined. + */ + 'cell--outlined': string; /** * Styles applied to the cell element if it is at the top edge of a cell selection range. */ @@ -548,6 +552,7 @@ export const gridClasses = generateUtilityClasses('MuiDataGrid', [ 'cell--textLeft', 'cell--textRight', 'cell--withRenderer', + 'cell--outlined', 'cell--rangeTop', 'cell--rangeBottom', 'cell--rangeLeft', diff --git a/packages/grid/x-data-grid/src/hooks/features/focus/gridFocusState.ts b/packages/grid/x-data-grid/src/hooks/features/focus/gridFocusState.ts index cbbe1ef4714b..6215dac62b30 100644 --- a/packages/grid/x-data-grid/src/hooks/features/focus/gridFocusState.ts +++ b/packages/grid/x-data-grid/src/hooks/features/focus/gridFocusState.ts @@ -1,6 +1,6 @@ import { GridRowId } from '../../../models/gridRows'; -export type GridCellIdentifier = { id: GridRowId; field: string }; +export type GridCellIdentifier = { id: GridRowId; field: string }; // TODO: Reuse GridCellCoordinates export type GridColumnIdentifier = { field: string }; export type GridColumnGroupIdentifier = { field: string; depth: number }; @@ -10,6 +10,10 @@ export interface GridFocusState { columnGroupHeader: GridColumnGroupIdentifier | null; } +export interface GridOutlineState { + cell: GridCellIdentifier | null; +} + export interface GridTabIndexState { cell: GridCellIdentifier | null; columnHeader: GridColumnIdentifier | null; diff --git a/packages/grid/x-data-grid/src/hooks/features/focus/gridFocusStateSelector.ts b/packages/grid/x-data-grid/src/hooks/features/focus/gridFocusStateSelector.ts index d48d170da81d..02058fb10403 100644 --- a/packages/grid/x-data-grid/src/hooks/features/focus/gridFocusStateSelector.ts +++ b/packages/grid/x-data-grid/src/hooks/features/focus/gridFocusStateSelector.ts @@ -1,6 +1,6 @@ import { createSelector } from '../../../utils/createSelector'; import { GridStateCommunity } from '../../../models/gridStateCommunity'; -import { GridFocusState, GridTabIndexState } from './gridFocusState'; +import { GridFocusState, GridOutlineState, GridTabIndexState } from './gridFocusState'; export const gridFocusStateSelector = (state: GridStateCommunity) => state.focus; @@ -37,3 +37,16 @@ export const unstable_gridTabIndexColumnGroupHeaderSelector = createSelector( gridTabIndexStateSelector, (state: GridTabIndexState) => state.columnGroupHeader, ); + +/** + * @ignore - do not document. + */ +export const gridOutlineStateSelector = (state: GridStateCommunity) => state.outline; + +/** + * @ignore - do not document. + */ +export const gridCellOutlineCellSelector = createSelector( + gridOutlineStateSelector, + (state: GridOutlineState) => state.cell, +); diff --git a/packages/grid/x-data-grid/src/hooks/features/focus/useGridFocus.ts b/packages/grid/x-data-grid/src/hooks/features/focus/useGridFocus.ts index 132d9da218da..1e2b10825cdf 100644 --- a/packages/grid/x-data-grid/src/hooks/features/focus/useGridFocus.ts +++ b/packages/grid/x-data-grid/src/hooks/features/focus/useGridFocus.ts @@ -1,5 +1,8 @@ import * as React from 'react'; -import { unstable_ownerDocument as ownerDocument } from '@mui/utils'; +import { + unstable_ownerDocument as ownerDocument, + unstable_useEventCallback as useEventCallback, +} from '@mui/utils'; import { GridEventListener, GridEventLookup } from '../../../models/events'; import { GridPrivateApiCommunity } from '../../../models/api/gridApiCommunity'; import { GridFocusApi, GridFocusPrivateApi } from '../../../models/api/gridFocusApi'; @@ -10,6 +13,7 @@ import { useGridApiEventHandler } from '../../utils/useGridApiEventHandler'; import { DataGridProcessedProps } from '../../../models/props/DataGridProps'; import { isNavigationKey } from '../../../utils/keyboardUtils'; import { + gridCellOutlineCellSelector, gridFocusCellSelector, unstable_gridFocusColumnGroupHeaderSelector, } from './gridFocusStateSelector'; @@ -23,6 +27,7 @@ export const focusStateInitializer: GridStateInitializer = (state) => ({ ...state, focus: { cell: null, columnHeader: null, columnGroupHeader: null }, tabIndex: { cell: null, columnHeader: null, columnGroupHeader: null }, + outline: { cell: null }, }); /** @@ -35,7 +40,7 @@ export const useGridFocus = ( props: Pick, ): void => { const logger = useGridLogger(apiRef, 'useGridFocus'); - + const lastKeydownEvent = React.useRef(null); const lastClickedCell = React.useRef(null); const publishCellFocusOut = React.useCallback( @@ -67,6 +72,7 @@ export const useGridFocus = ( ...state, tabIndex: { cell: { id, field }, columnHeader: null, columnGroupHeader: null }, focus: { cell: { id, field }, columnHeader: null, columnGroupHeader: null }, + outline: { cell: { id, field } }, }; }); apiRef.current.forceUpdate(); @@ -99,6 +105,7 @@ export const useGridFocus = ( ...state, tabIndex: { columnHeader: { field }, cell: null, columnGroupHeader: null }, focus: { columnHeader: { field }, cell: null, columnGroupHeader: null }, + outline: { cell: null }, // The column header outline is handled via CSS }; }); @@ -137,6 +144,14 @@ export const useGridFocus = ( GridFocusPrivateApi['getColumnGroupHeaderFocus'] >(() => unstable_gridFocusColumnGroupHeaderSelector(apiRef), [apiRef]); + const isCellOutlined = React.useCallback( + (id, field) => { + const outlinedCell = gridCellOutlineCellSelector(apiRef); + return outlinedCell?.id === id && outlinedCell?.field === field; + }, + [apiRef], + ); + const moveFocusToRelativeCell = React.useCallback( (id, field, direction) => { let columnIndexToFocus = apiRef.current.getColumnIndex(field); @@ -214,6 +229,25 @@ export const useGridFocus = ( [apiRef], ); + const handleCellBlur = useEventCallback(() => { + if (lastKeydownEvent.current?.key !== 'Tab') { + return; + } + apiRef.current.setState((state) => ({ + ...state, + // The tabIndex is kept to allow the focus to return to this cell if Shift+Tab is pressed + focus: { cell: null, columnHeader: null, columnGroupHeader: null }, + outline: { cell: null }, + })); + apiRef.current.forceUpdate(); + }); + + const handleCellFocus = useEventCallback((params: GridCellParams) => { + if (lastKeydownEvent.current?.key === 'Tab') { + apiRef.current.setCellFocus(params.id, params.field); + } + }); + const handleColumnHeaderFocus = React.useCallback>( ({ field }, event) => { if (event.target !== event.currentTarget) { @@ -224,7 +258,7 @@ export const useGridFocus = ( [apiRef], ); - const focussedColumnGroup = unstable_gridFocusColumnGroupHeaderSelector(apiRef); + const focusedColumnGroup = unstable_gridFocusColumnGroupHeaderSelector(apiRef); const handleColumnGroupHeaderFocus = React.useCallback< GridEventListener<'columnGroupHeaderFocus'> @@ -234,19 +268,19 @@ export const useGridFocus = ( return; } if ( - focussedColumnGroup !== null && - focussedColumnGroup.depth === depth && - fields.includes(focussedColumnGroup.field) + focusedColumnGroup !== null && + focusedColumnGroup.depth === depth && + fields.includes(focusedColumnGroup.field) ) { // This group cell has already been focused return; } apiRef.current.setColumnGroupHeaderFocus(fields[0], depth, event); }, - [apiRef, focussedColumnGroup], + [apiRef, focusedColumnGroup], ); - const handleBlur = React.useCallback>(() => { + const handleColumnHeaderBlur = React.useCallback>(() => { logger.debug(`Clearing focus`); apiRef.current.setState((state) => ({ ...state, @@ -287,6 +321,7 @@ export const useGridFocus = ( apiRef.current.setState((state) => ({ ...state, focus: { cell: null, columnHeader: null, columnGroupHeader: null }, + outline: { cell: null }, })); apiRef.current.forceUpdate(); @@ -298,6 +333,14 @@ export const useGridFocus = ( [apiRef, publishCellFocusOut], ); + const handleDocumentKeyDown = useEventCallback<[KeyboardEvent], void>((event) => { + lastKeydownEvent.current = event; + }); + + const handleDocumentKeyUp = useEventCallback(() => { + lastKeydownEvent.current = null; + }); + const handleCellModeChange = React.useCallback>( (params) => { if (params.cellMode === 'view') { @@ -319,6 +362,7 @@ export const useGridFocus = ( apiRef.current.setState((state) => ({ ...state, focus: { cell: null, columnHeader: null, columnGroupHeader: null }, + outline: { cell: null }, })); } }, [apiRef]); @@ -332,6 +376,7 @@ export const useGridFocus = ( moveFocusToRelativeCell, setColumnGroupHeaderFocus, getColumnGroupHeaderFocus, + isCellOutlined, }; useGridApiMethod(apiRef, focusApi, 'public'); @@ -340,13 +385,19 @@ export const useGridFocus = ( React.useEffect(() => { const doc = ownerDocument(apiRef.current.rootElementRef!.current); doc.addEventListener('click', handleDocumentClick); + doc.addEventListener('keydown', handleDocumentKeyDown); + doc.addEventListener('keyup', handleDocumentKeyUp); return () => { doc.removeEventListener('click', handleDocumentClick); + doc.removeEventListener('keydown', handleDocumentKeyDown); + doc.removeEventListener('keyup', handleDocumentKeyUp); }; - }, [apiRef, handleDocumentClick]); + }, [apiRef, handleDocumentClick, handleDocumentKeyDown, handleDocumentKeyUp]); - useGridApiEventHandler(apiRef, 'columnHeaderBlur', handleBlur); + useGridApiEventHandler(apiRef, 'cellFocus', handleCellFocus); + useGridApiEventHandler(apiRef, 'cellBlur', handleCellBlur); + useGridApiEventHandler(apiRef, 'columnHeaderBlur', handleColumnHeaderBlur); useGridApiEventHandler(apiRef, 'cellDoubleClick', handleCellDoubleClick); useGridApiEventHandler(apiRef, 'cellMouseDown', handleCellMouseDown); useGridApiEventHandler(apiRef, 'cellKeyDown', handleCellKeyDown); diff --git a/packages/grid/x-data-grid/src/models/api/gridFocusApi.ts b/packages/grid/x-data-grid/src/models/api/gridFocusApi.ts index e2b98e1e008b..f3dfe3173081 100644 --- a/packages/grid/x-data-grid/src/models/api/gridFocusApi.ts +++ b/packages/grid/x-data-grid/src/models/api/gridFocusApi.ts @@ -43,4 +43,11 @@ export interface GridFocusPrivateApi { field: string, direction: 'below' | 'right' | 'left', ) => void; + /** + * Checks if a given cell has outline. + * @param {GridRowId} id The row id. + * @param {string} field The column field. + * @returns {boolean} Whether the cell has outline or not. + */ + isCellOutlined: (id: GridRowId, field: string) => boolean; } diff --git a/packages/grid/x-data-grid/src/models/events/gridEventLookup.ts b/packages/grid/x-data-grid/src/models/events/gridEventLookup.ts index 888f7eab1f38..d395c02f694c 100644 --- a/packages/grid/x-data-grid/src/models/events/gridEventLookup.ts +++ b/packages/grid/x-data-grid/src/models/events/gridEventLookup.ts @@ -259,6 +259,20 @@ export interface GridCellEventLookup { params: GridCellParams; event: React.KeyboardEvent; }; + /** + * Fired when a `focus` event happens in a cell. + */ + cellFocus: { + params: GridCellParams; + event: React.FocusEvent; + }; + /** + * Fired when a `blur` event happens in a cell. + */ + cellBlur: { + params: GridCellParams; + event: React.FocusEvent; + }; /** * Fired when the dragged cell enters a valid drop target. It's mapped to the `dragend` DOM event. * @ignore - do not document. diff --git a/packages/grid/x-data-grid/src/models/gridStateCommunity.ts b/packages/grid/x-data-grid/src/models/gridStateCommunity.ts index 08f55af3d682..baed572670b4 100644 --- a/packages/grid/x-data-grid/src/models/gridStateCommunity.ts +++ b/packages/grid/x-data-grid/src/models/gridStateCommunity.ts @@ -15,6 +15,7 @@ import type { GridSortingInitialState, GridSortingState, GridTabIndexState, + GridOutlineState, } from '../hooks'; import type { GridRowsMetaState } from '../hooks/features/rows/gridRowsMetaState'; import type { GridEditingState } from './gridEditRowModel'; @@ -33,6 +34,7 @@ export interface GridStateCommunity { columnMenu: GridColumnMenuState; sorting: GridSortingState; focus: GridFocusState; + outline: GridOutlineState; tabIndex: GridTabIndexState; rowSelection: GridRowSelectionModel; filter: GridFilterState; diff --git a/packages/grid/x-data-grid/src/models/props/DataGridProps.ts b/packages/grid/x-data-grid/src/models/props/DataGridProps.ts index c7ff97d27f95..1d50de575cca 100644 --- a/packages/grid/x-data-grid/src/models/props/DataGridProps.ts +++ b/packages/grid/x-data-grid/src/models/props/DataGridProps.ts @@ -36,11 +36,6 @@ export interface GridExperimentalFeatures { * Enables the column grouping. */ columnGrouping: boolean; - /** - * Emits a warning if the cell receives focus without also syncing the focus state. - * Only works if NODE_ENV=test. - */ - warnIfFocusStateIsNotSynced: boolean; } /** diff --git a/packages/grid/x-data-grid/src/tests/cells.DataGrid.test.tsx b/packages/grid/x-data-grid/src/tests/cells.DataGrid.test.tsx index fcd468171f4d..4586a7e355cf 100644 --- a/packages/grid/x-data-grid/src/tests/cells.DataGrid.test.tsx +++ b/packages/grid/x-data-grid/src/tests/cells.DataGrid.test.tsx @@ -167,24 +167,6 @@ describe(' - Cells', () => { expect(valueFormatter.lastCall.args[0].value).to.equal(true); }); - it('should throw when focusing cell without updating the state', () => { - render( -
- -
, - ); - - userEvent.mousePress(getCell(0, 0)); - - expect(() => { - getCell(1, 0).focus(); - }).toWarnDev(['MUI: The cell with id=1 and field=brand received focus.']); - }); - // See https://github.com/mui/mui-x/issues/6378 it('should not cause scroll jump when focused cell mounts in the render zone', async function test() { if (isJSDOM) { diff --git a/packages/grid/x-data-grid/src/tests/keyboard.DataGrid.test.tsx b/packages/grid/x-data-grid/src/tests/keyboard.DataGrid.test.tsx index 22c94f1cc36e..33b9f78b47f9 100644 --- a/packages/grid/x-data-grid/src/tests/keyboard.DataGrid.test.tsx +++ b/packages/grid/x-data-grid/src/tests/keyboard.DataGrid.test.tsx @@ -11,7 +11,7 @@ import { getColumnValues, getRow, } from 'test/utils/helperFn'; -import { DataGrid, DataGridProps, GridColDef } from '@mui/x-data-grid'; +import { DataGrid, DataGridProps, GridColDef, gridClasses } from '@mui/x-data-grid'; import { useBasicDemoData, getBasicGridData } from '@mui/x-data-grid-generator'; const isJSDOM = /jsdom/.test(window.navigator.userAgent); @@ -55,7 +55,7 @@ describe(' - Keyboard', () => { columnHeaderHeight={HEADER_HEIGHT} hideFooter filterModel={{ items: [{ field: 'id', operator: '>', value: 10 }] }} - experimentalFeatures={{ warnIfFocusStateIsNotSynced: true, columnGrouping: true }} + experimentalFeatures={{ columnGrouping: true }} {...props} /> @@ -300,6 +300,57 @@ describe(' - Keyboard', () => { }); }); + describe('cell outline', () => { + it('should add the outlined class to the cell when it is focused', () => { + render(); + const cell = getCell(8, 1); + expect(cell).not.to.have.class(gridClasses['cell--outlined']); + userEvent.mousePress(cell); + expect(cell).to.have.class(gridClasses['cell--outlined']); + }); + + it('should remove the outlined class from the cell when Tab is pressed', () => { + render(); + const cell = getCell(8, 1); + userEvent.mousePress(cell); + expect(cell).to.have.class(gridClasses['cell--outlined']); + fireEvent.keyDown(cell, { key: 'Tab' }); + act(() => cell.blur()); + fireEvent.keyUp(document.activeElement, { key: 'Tab' }); + expect(cell).not.to.have.class(gridClasses['cell--outlined']); + }); + + it('should keep the cell focusable after Tab is pressed', () => { + render(); + const cell = getCell(8, 1); + userEvent.mousePress(cell); + expect(cell).to.have.attr('tabindex', '0'); + fireEvent.keyDown(cell, { key: 'Tab' }); + act(() => cell.blur()); + fireEvent.keyUp(document.activeElement, { key: 'Tab' }); + expect(cell).to.have.attr('tabindex', '0'); + }); + + it('should add the outlined class to the cell when it is focused via Tab', () => { + render(); + const cell = getCell(8, 1); + userEvent.mousePress(cell); + expect(cell).to.have.class(gridClasses['cell--outlined']); + + // Simulates cell losing focus to another element with Tab + fireEvent.keyDown(cell, { key: 'Tab' }); + act(() => cell.blur()); + fireEvent.keyUp(document.activeElement, { key: 'Tab' }); + expect(cell).not.to.have.class(gridClasses['cell--outlined']); + + // Simulates cell gaining focus again with Shift+Tab + fireEvent.keyDown(document.activeElement, { key: 'Tab', shiftKey: true }); + act(() => cell.focus()); + fireEvent.keyUp(cell, { key: 'Tab', shiftKey: true }); + expect(cell).to.have.class(gridClasses['cell--outlined']); + }); + }); + describe('column header navigation', () => { it('should scroll horizontally when navigating between column headers with arrows', function test() { if (isJSDOM) { @@ -484,7 +535,7 @@ describe(' - Keyboard', () => { hideFooter disableVirtualization columnGroupingModel={columnGroupingModel} - experimentalFeatures={{ warnIfFocusStateIsNotSynced: true, columnGrouping: true }} + experimentalFeatures={{ columnGrouping: true }} {...props} /> diff --git a/packages/grid/x-data-grid/src/tests/rowSelection.DataGrid.test.tsx b/packages/grid/x-data-grid/src/tests/rowSelection.DataGrid.test.tsx index 9421bca12d52..5826a4e21119 100644 --- a/packages/grid/x-data-grid/src/tests/rowSelection.DataGrid.test.tsx +++ b/packages/grid/x-data-grid/src/tests/rowSelection.DataGrid.test.tsx @@ -43,15 +43,7 @@ describe(' - Row Selection', () => { function TestDataGridSelection(props: Partial) { return (
- +
); } diff --git a/scripts/x-data-grid-premium.exports.json b/scripts/x-data-grid-premium.exports.json index 314eb2316e9f..e339d6b0a660 100644 --- a/scripts/x-data-grid-premium.exports.json +++ b/scripts/x-data-grid-premium.exports.json @@ -112,6 +112,7 @@ { "name": "GridCellMode", "kind": "TypeAlias" }, { "name": "GridCellModes", "kind": "Enum" }, { "name": "GridCellModesModel", "kind": "TypeAlias" }, + { "name": "gridCellOutlineCellSelector", "kind": "Variable" }, { "name": "GridCellParams", "kind": "Interface" }, { "name": "GridCellProps", "kind": "Interface" }, { "name": "GridCellSelectionApi", "kind": "Interface" }, @@ -354,6 +355,8 @@ { "name": "GridNativeColTypes", "kind": "TypeAlias" }, { "name": "GridNoRowsOverlay", "kind": "Variable" }, { "name": "gridNumberComparator", "kind": "Variable" }, + { "name": "GridOutlineState", "kind": "Interface" }, + { "name": "gridOutlineStateSelector", "kind": "Variable" }, { "name": "GridOverlay", "kind": "Variable" }, { "name": "GridOverlayProps", "kind": "TypeAlias" }, { "name": "GridOverlays", "kind": "Function" }, diff --git a/scripts/x-data-grid-pro.exports.json b/scripts/x-data-grid-pro.exports.json index 229ebbf6bc8c..bb2b8e0cc17f 100644 --- a/scripts/x-data-grid-pro.exports.json +++ b/scripts/x-data-grid-pro.exports.json @@ -90,6 +90,7 @@ { "name": "GridCellMode", "kind": "TypeAlias" }, { "name": "GridCellModes", "kind": "Enum" }, { "name": "GridCellModesModel", "kind": "TypeAlias" }, + { "name": "gridCellOutlineCellSelector", "kind": "Variable" }, { "name": "GridCellParams", "kind": "Interface" }, { "name": "GridCellProps", "kind": "Interface" }, { "name": "GridCheckCircleIcon", "kind": "Variable" }, @@ -318,6 +319,8 @@ { "name": "GridNativeColTypes", "kind": "TypeAlias" }, { "name": "GridNoRowsOverlay", "kind": "Variable" }, { "name": "gridNumberComparator", "kind": "Variable" }, + { "name": "GridOutlineState", "kind": "Interface" }, + { "name": "gridOutlineStateSelector", "kind": "Variable" }, { "name": "GridOverlay", "kind": "Variable" }, { "name": "GridOverlayProps", "kind": "TypeAlias" }, { "name": "GridOverlays", "kind": "Function" }, diff --git a/scripts/x-data-grid.exports.json b/scripts/x-data-grid.exports.json index 97b197bf3e0f..89ce8c000fd0 100644 --- a/scripts/x-data-grid.exports.json +++ b/scripts/x-data-grid.exports.json @@ -83,6 +83,7 @@ { "name": "GridCellMode", "kind": "TypeAlias" }, { "name": "GridCellModes", "kind": "Enum" }, { "name": "GridCellModesModel", "kind": "TypeAlias" }, + { "name": "gridCellOutlineCellSelector", "kind": "Variable" }, { "name": "GridCellParams", "kind": "Interface" }, { "name": "GridCellProps", "kind": "Interface" }, { "name": "GridCheckCircleIcon", "kind": "Variable" }, @@ -291,6 +292,8 @@ { "name": "GridNativeColTypes", "kind": "TypeAlias" }, { "name": "GridNoRowsOverlay", "kind": "Variable" }, { "name": "gridNumberComparator", "kind": "Variable" }, + { "name": "GridOutlineState", "kind": "Interface" }, + { "name": "gridOutlineStateSelector", "kind": "Variable" }, { "name": "GridOverlay", "kind": "Variable" }, { "name": "GridOverlayProps", "kind": "TypeAlias" }, { "name": "GridOverlays", "kind": "Function" },