Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,8 @@ function onCellContextMenu(args: CellMouseArgs<R, SR>, event: CellMouseEvent) {

A function called when keydown event is triggered on a cell. This event can be used to customize cell navigation and editing behavior.

It is not called for keydown events triggered in a [`:modal`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:modal) editor, see [`renderEditCell`](#rendereditcell-maybeprops-rendereditcellpropstrow-tsummaryrow--reactnode).

**Examples**

- Prevent editing on `Enter`
Expand Down Expand Up @@ -1352,6 +1354,8 @@ Render function to render the content of group cells when using `TreeDataGrid`.

Render function to render the content of edit cells. When set, the column is automatically set to be editable

Editors can render a [`:modal`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:modal) element, like a `<dialog>` opened with `showModal()`, either in the cell or in a portal. Keydown events triggered in the modal are left to the editor: `onCellKeyDown` is not called, and the grid does not close the editor on `Escape`/`Enter` or navigate on `Tab`, so the editor must call `onClose` itself, for example in the dialog's `onClose` handler.

##### `editable?: Maybe<boolean | ((row: TRow) => boolean)>`

Control whether cells can be edited with `renderEditCell`.
Expand Down
4 changes: 4 additions & 0 deletions src/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,10 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
const samePosition = isSamePosition(activePosition, position);

if (options?.enableEditor && isCellEditable(position)) {
// avoid selecting text in the editor, for example
// after double-clicking and displayCellContent is enabled
gridRef.current?.ownerDocument.getSelection()?.removeAllRanges();

const row = rows[position.rowIdx];
setActivePosition({ ...position, mode: 'EDIT', row, originalRow: row });
} else if (samePosition) {
Expand Down
12 changes: 12 additions & 0 deletions src/EditCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ export default function EditCell<R, SR>({
}

function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
// Let :modal editors handle their own keyboard events,
// skipping both `onCellKeyDown` and the default behavior.
// The :modal may be rendered in the cell or in a portal,
// but the grid itself may also be rendered in a :modal.
// Ideally we would check if the cell is inert,
// but there's no good way to do it.
// https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:modal
const modal = (event.target as Element).closest(':modal');
if (modal !== null && !modal.contains(event.currentTarget)) {
return;
}

if (onKeyDown) {
const cellEvent = createCellEvent(event);
onKeyDown(
Expand Down
6 changes: 6 additions & 0 deletions src/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ declare module 'react' {
interface CSSProperties {
[key: `--${string}`]: string | number | undefined;
}

// TODO: remove once in React types
interface ButtonHTMLAttributes<T> extends React.HTMLAttributes<T> {
command?: string;
commandfor?: string;
}
}

// required to make types work
Expand Down
118 changes: 112 additions & 6 deletions test/browser/column/renderEditCell.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useMemo, useState } from 'react';
import { createRef, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { page, server, userEvent } from 'vitest/browser';

import { DataGrid } from '../../../src';
import type { Column, DataGridProps } from '../../../src';
import type { Column, DataGridHandle, DataGridProps } from '../../../src';
import { getCellsAtRowIndex, getRowWithCell, safeTab, scrollGrid, testCount } from '../utils';

const grid = page.getGrid();
Expand Down Expand Up @@ -107,6 +107,33 @@ describe('Editor', () => {
await expect.element(grid).toHaveProperty('scrollTop', 0);
});

it('should clear the document selection when opening the editor', async () => {
const ref = createRef<DataGridHandle>();
const columns: readonly Column<NonNullable<unknown>>[] = [
{
key: 'col1',
name: 'Column1',
renderEditCell() {
return <input type="checkbox" aria-label="col1-input" autoFocus />;
}
}
];

await page.render(
<>
<p>text</p>
<DataGrid ref={ref} columns={columns} rows={[{}]} />
</>
);

const selection = document.getSelection()!;
selection.selectAllChildren(document.body);
expect(selection.type).toBe('Range');
ref.current!.setActivePosition({ idx: 0, rowIdx: 0 }, { enableEditor: true });
await expect.element(page.getByRole('checkbox', { name: 'col1-input' })).toHaveFocus();
expect(selection.type).not.toBe('Range');
});

describe('editable', () => {
it('should be editable if an editor is specified and editable is undefined/null', async () => {
await page.render(<EditorTest />);
Expand Down Expand Up @@ -329,15 +356,82 @@ describe('Editor', () => {
await expect.element(col2Input).not.toBeInTheDocument();
});
});

describe('modal editors', () => {
it('should not handle keydown events triggered in a modal editor rendered in the cell', async () => {
await testModalEditor(false);
});

it('should not handle keydown events triggered in a modal editor rendered in a portal', async () => {
await testModalEditor(true);
});

it('should handle keydown events triggered in an editor when the grid is rendered in a modal', async () => {
const onCellKeyDown = vi.fn();
await page.render(
<dialog ref={showModalRef}>
<EditorTest onCellKeyDown={onCellKeyDown} />
</dialog>
);
await userEvent.dblClick(getCellsAtRowIndex(0).nth(1));
await expect.element(col2Editor).toHaveFocus();
await userEvent.keyboard('bc{enter}');
await expect.element(col2Editor).not.toBeInTheDocument();
await expect.element(getCellsAtRowIndex(0).nth(1)).toHaveTextContent('a1bc');
expect(onCellKeyDown).toHaveBeenCalledTimes(3);
});
});
});

async function testModalEditor(createEditorPortal: boolean) {
const onCellKeyDown = vi.fn();
await page.render(
<EditorTest
modalEditor
createEditorPortal={createEditorPortal}
editorOptions={{ displayCellContent: true }}
onCellKeyDown={onCellKeyDown}
/>
);
const cell = getCellsAtRowIndex(0).nth(1);
const commitButton = page.getByRole('button', { name: 'commit' });

await userEvent.dblClick(cell);
await expect.element(col2Editor).toHaveFocus();
// the grid does not commit on Enter nor navigate on Tab
await userEvent.keyboard('{end}bc{enter}');
await expect.element(col2Editor).toHaveFocus();
await expect.element(col2Editor).toHaveValue('a1bc');
await userEvent.tab();
await expect.element(commitButton).toHaveFocus();

// the dialog closes on Escape, which closes the editor and discards changes
await userEvent.keyboard('{escape}');
await expect.element(col2Editor).not.toBeInTheDocument();
await expect.element(cell).toHaveTextContent('a1');
await expect.element(cell).toHaveFocus();

await userEvent.dblClick(cell);
await userEvent.keyboard('{end}d');
await userEvent.click(commitButton);
await expect.element(col2Editor).not.toBeInTheDocument();
await expect.element(cell).toHaveTextContent('a1d');

expect(onCellKeyDown).not.toHaveBeenCalled();
}

function showModalRef(dialog: HTMLDialogElement | null) {
dialog?.showModal();
}

interface EditorTestProps
extends
Pick<Column<Row>, 'editorOptions' | 'editable'>,
Pick<DataGridProps<Row>, 'onCellKeyDown'> {
onSave?: (rows: readonly Row[]) => void;
gridRows?: readonly Row[];
createEditorPortal?: boolean;
modalEditor?: boolean;
}

const initialRows: readonly Row[] = [
Expand All @@ -357,7 +451,8 @@ function EditorTest({
onCellKeyDown,
onSave,
gridRows = initialRows,
createEditorPortal
createEditorPortal,
modalEditor
}: EditorTestProps) {
const [rows, setRows] = useState(gridRows);

Expand All @@ -382,8 +477,8 @@ function EditorTest({
key: 'col2',
name: 'Col2',
editable,
renderEditCell({ row, onRowChange }) {
const editor = (
renderEditCell({ row, onRowChange, onClose }) {
let editor = (
<input
autoFocus
aria-label="col2-editor"
Expand All @@ -392,12 +487,23 @@ function EditorTest({
/>
);

if (modalEditor) {
editor = (
<dialog ref={showModalRef} onClose={() => onClose()}>
{editor}
<button type="button" onClick={() => onClose(true)}>
commit
</button>
</dialog>
);
}

return createEditorPortal ? createPortal(editor, document.body) : editor;
},
editorOptions
}
];
}, [editable, editorOptions, createEditorPortal]);
}, [editable, editorOptions, createEditorPortal, modalEditor]);

return (
<>
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.website.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"lib": ["ESNext", "DOM"],
"skipLibCheck": true
},
"include": ["src/css.d.ts", "website/**/*"],
"include": ["src/css.d.ts", "src/globals.d.ts", "website/**/*"],
"references": [{ "path": "tsconfig.src.json" }]
}
Loading