diff --git a/README.md b/README.md index 97766502df..d3cd0f0a5d 100644 --- a/README.md +++ b/README.md @@ -572,6 +572,8 @@ function onCellContextMenu(args: CellMouseArgs, 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` @@ -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 `` 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)>` Control whether cells can be edited with `renderEditCell`. diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index ad622e23d1..d904d4ca4b 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -803,6 +803,10 @@ export function DataGrid(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) { diff --git a/src/EditCell.tsx b/src/EditCell.tsx index e9b04cbe56..a13923c9d8 100644 --- a/src/EditCell.tsx +++ b/src/EditCell.tsx @@ -126,6 +126,18 @@ export default function EditCell({ } function handleKeyDown(event: React.KeyboardEvent) { + // 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( diff --git a/src/globals.d.ts b/src/globals.d.ts index a11d5f873d..22b033481f 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -2,6 +2,12 @@ declare module 'react' { interface CSSProperties { [key: `--${string}`]: string | number | undefined; } + + // TODO: remove once in React types + interface ButtonHTMLAttributes extends React.HTMLAttributes { + command?: string; + commandfor?: string; + } } // required to make types work diff --git a/test/browser/column/renderEditCell.test.tsx b/test/browser/column/renderEditCell.test.tsx index 7326feed1c..5141718251 100644 --- a/test/browser/column/renderEditCell.test.tsx +++ b/test/browser/column/renderEditCell.test.tsx @@ -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(); @@ -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(); + const columns: readonly Column>[] = [ + { + key: 'col1', + name: 'Column1', + renderEditCell() { + return ; + } + } + ]; + + await page.render( + <> +

text

+ + + ); + + 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(); @@ -329,8 +356,74 @@ 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( + + + + ); + 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( + + ); + 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, 'editorOptions' | 'editable'>, @@ -338,6 +431,7 @@ interface EditorTestProps onSave?: (rows: readonly Row[]) => void; gridRows?: readonly Row[]; createEditorPortal?: boolean; + modalEditor?: boolean; } const initialRows: readonly Row[] = [ @@ -357,7 +451,8 @@ function EditorTest({ onCellKeyDown, onSave, gridRows = initialRows, - createEditorPortal + createEditorPortal, + modalEditor }: EditorTestProps) { const [rows, setRows] = useState(gridRows); @@ -382,8 +477,8 @@ function EditorTest({ key: 'col2', name: 'Col2', editable, - renderEditCell({ row, onRowChange }) { - const editor = ( + renderEditCell({ row, onRowChange, onClose }) { + let editor = ( ); + if (modalEditor) { + editor = ( + onClose()}> + {editor} + + + ); + } + return createEditorPortal ? createPortal(editor, document.body) : editor; }, editorOptions } ]; - }, [editable, editorOptions, createEditorPortal]); + }, [editable, editorOptions, createEditorPortal, modalEditor]); return ( <> diff --git a/tsconfig.website.json b/tsconfig.website.json index becf2b7502..66071dc2db 100644 --- a/tsconfig.website.json +++ b/tsconfig.website.json @@ -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" }] } diff --git a/website/routes/CommonFeatures.tsx b/website/routes/CommonFeatures.tsx index e031064dcb..6e7248c6ca 100644 --- a/website/routes/CommonFeatures.tsx +++ b/website/routes/CommonFeatures.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState } from 'react'; -import { createPortal, flushSync } from 'react-dom'; +import { flushSync } from 'react-dom'; import { faker } from '@faker-js/faker'; import { createFileRoute } from '@tanstack/react-router'; import { css } from 'ecij'; @@ -11,11 +11,17 @@ import { SelectColumn, type Column, type DataGridHandle, - type Direction, type SortColumn } from '../../src'; import { textEditorClassname } from '../../src/editors/renderTextEditor'; -import { compare, exportToCsv, exportToPdf } from '../utils'; +import { + compare, + currencyFormatter, + dateFormatter, + exportToCsv, + exportToPdf, + showModalRef +} from '../utils'; import { useDirection } from '../directionContext'; export const Route = createFileRoute('/CommonFeatures')({ @@ -29,31 +35,6 @@ const toolbarClassname = css` margin-block-end: 8px; `; -const dialogContainerClassname = css` - position: absolute; - inset: 0; - display: flex; - place-items: center; - background: rgb(0 0 0 / 10%); - - > dialog { - width: 300px; - > input { - width: 100%; - } - - > menu { - text-align: end; - } - } -`; - -const dateFormatter = new Intl.DateTimeFormat(navigator.language); -const currencyFormatter = new Intl.NumberFormat(navigator.language, { - style: 'currency', - currency: 'eur' -}); - interface SummaryRow { id: string; totalCount: number; @@ -78,177 +59,194 @@ interface Row { available: boolean; } -function getColumns( - countries: readonly string[], - direction: Direction -): readonly Column[] { - return [ - SelectColumn, - { - key: 'id', - name: 'ID', - frozen: true, - resizable: false, - renderSummaryCell() { - return Total; - } - }, - { - key: 'title', - name: 'Task', - frozen: 'start', - renderEditCell: renderTextEditor, - renderSummaryCell({ row }) { - return `${row.totalCount} records`; - } - }, - { - key: 'client', - name: 'Client', - width: 'max-content', - draggable: true, - renderEditCell: renderTextEditor - }, - { - key: 'area', - name: 'Area', - renderEditCell: renderTextEditor +const columns: readonly Column[] = [ + SelectColumn, + { + key: 'id', + name: 'ID', + frozen: true, + resizable: false, + renderSummaryCell() { + return Total; + } + }, + { + key: 'title', + name: 'Task', + frozen: 'start', + renderEditCell: renderTextEditor, + renderSummaryCell({ row }) { + return `${row.totalCount} records`; + } + }, + { + key: 'client', + name: 'Client', + width: 'max-content', + draggable: true, + renderEditCell: renderTextEditor + }, + { + key: 'area', + name: 'Area', + renderEditCell: renderTextEditor + }, + { + key: 'country', + name: 'Country', + renderEditCell: (p) => ( + + ) + }, + { + key: 'contact', + name: 'Contact', + renderEditCell: renderTextEditor + }, + { + key: 'assignee', + name: 'Assignee', + renderEditCell: renderTextEditor + }, + { + key: 'progress', + name: 'Completion', + renderCell(props) { + const value = props.row.progress; + return ( + <> + {Math.round(value)}% + + ); }, - { - key: 'country', - name: 'Country', - renderEditCell: (p) => ( - - ) - }, - { - key: 'contact', - name: 'Contact', - renderEditCell: renderTextEditor - }, - { - key: 'assignee', - name: 'Assignee', - renderEditCell: renderTextEditor - }, - { - key: 'progress', - name: 'Completion', - renderCell(props) { - const value = props.row.progress; - return ( - <> - {Math.round(value)}% - - ); - }, - renderEditCell({ row, onRowChange, onClose }) { - return createPortal( -
{ - if (event.key === 'Escape') { - onClose(); - } - }} + onRowChange({ ...row, progress: e.target.valueAsNumber })} + /> + - - onRowChange({ ...row, progress: e.target.valueAsNumber })} - /> - - - - - -
, - document.body - ); - }, - editorOptions: { - displayCellContent: true - } - }, - { - key: 'startTimestamp', - name: 'Start date', - renderCell(props) { - return dateFormatter.format(props.row.startTimestamp); - } - }, - { - key: 'endTimestamp', - name: 'Deadline', - renderCell(props) { - return dateFormatter.format(props.row.endTimestamp); - } - }, - { - key: 'budget', - name: 'Budget', - renderCell(props) { - return currencyFormatter.format(props.row.budget); - } +
  • + +
  • +
  • + +
  • + +
    + ); }, - { - key: 'transaction', - name: 'Transaction type' - }, - { - key: 'account', - name: 'Account' - }, - { - key: 'version', - name: 'Version', - renderEditCell: renderTextEditor + editorOptions: { + displayCellContent: true + } + }, + { + key: 'startTimestamp', + name: 'Start date', + renderCell(props) { + return dateFormatter.format(props.row.startTimestamp); + } + }, + { + key: 'endTimestamp', + name: 'Deadline', + renderCell(props) { + return dateFormatter.format(props.row.endTimestamp); + } + }, + { + key: 'budget', + name: 'Budget', + renderCell(props) { + return currencyFormatter.format(props.row.budget); + } + }, + { + key: 'transaction', + name: 'Transaction type' + }, + { + key: 'account', + name: 'Account' + }, + { + key: 'version', + name: 'Version', + renderEditCell: renderTextEditor + }, + { + key: 'available', + name: 'Available', + frozen: 'end', + renderCell({ row, onRowChange, tabIndex }) { + return ( + { + onRowChange({ ...row, available: !row.available }); + }} + tabIndex={tabIndex} + /> + ); }, - { - key: 'available', - name: 'Available', - frozen: 'end', - renderCell({ row, onRowChange, tabIndex }) { - return ( - { - onRowChange({ ...row, available: !row.available }); - }} - tabIndex={tabIndex} - /> - ); - }, - renderSummaryCell({ row: { yesCount, totalCount } }) { - return `${Math.floor((100 * yesCount) / totalCount)}% ✔️`; - } + renderSummaryCell({ row: { yesCount, totalCount } }) { + return `${Math.floor((100 * yesCount) / totalCount)}% ✔️`; } - ]; -} + } +]; function rowKeyGetter(row: Row) { return row.id; } -let countries: string[] = []; +let countries: string[]; function createRows(): readonly Row[] { const now = Date.now(); @@ -335,7 +333,6 @@ function CommonFeatures() { const [selectedRows, setSelectedRows] = useState((): ReadonlySet => new Set()); const [isExporting, setIsExporting] = useState(false); const gridRef = useRef(null); - const columns = useMemo(() => getColumns(countries, direction), [direction]); const summaryRows = useMemo((): readonly SummaryRow[] => { return [ diff --git a/website/utils.tsx b/website/utils.tsx index f8b7f8a0f8..77aa4be74e 100644 --- a/website/utils.tsx +++ b/website/utils.tsx @@ -1,4 +1,15 @@ -export const { compare } = new Intl.Collator('en-US', { numeric: true }); +const { language } = navigator; + +export const { compare } = new Intl.Collator(language, { numeric: true }); +export const dateFormatter = new Intl.DateTimeFormat(language); +export const currencyFormatter = new Intl.NumberFormat(language, { + style: 'currency', + currency: 'eur' +}); + +export function showModalRef(dialog: HTMLDialogElement | null) { + dialog?.showModal(); +} export function exportToCsv(gridEl: HTMLDivElement, fileName: string) { // TODO: remove both toArray calls https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/join