diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af3ea4..fc9f461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,19 @@ ## [Unreleased] +## [3.11.0] — 2026-10-26 + ### Added +- **Context menu** (`contextMenu`). Right-click, Shift+F10 / the Menu key on a focused cell, or Ctrl+click on Apple devices opens a menu of cell actions: Copy, Copy with headers, and Undo / Redo with `undoRedo` (disabled when there is nothing to undo or redo). Inside the current cell range, Copy writes the range (the same text as Ctrl/Cmd+C) and the range and focus stay; elsewhere it copies the clicked cell and focus moves there. `getContextMenuItems(params, defaultItems)` returns the entries (items and `'divider'`; leading, trailing and consecutive dividers are dropped); a list with no items shows no menu and leaves the browser's own menu. Headers, the header filter row, the footer, detail panels, editors, checkbox / expand / reorder cells and nested grids keep the browser menu. The menu closes on a choice, Escape or Tab (focus returns to the cell), an outside press, grid scroll, window resize and when its row leaves the rendered rows. New public types `GridContextMenuParams`, `GridContextMenuItem`, `GridContextMenuEntry`; `apiRef.openContextMenu(id, field)` and `closeContextMenu()`; `localeText` keys `contextMenuCopy`, `contextMenuCopyWithHeaders`, `contextMenuUndo`, `contextMenuRedo`, `contextMenuLabel`. Not covered: the header menu, touch long-press, Paste and Export built-ins. Guide: `docs/features/context-menu.md`; demo page **Context Menu**. + - **Dev warning for re-created `localeText` functions.** A `localeText` whose function entries (`paginationOf`, `rowSelectLabel`, …) are written inline is a new object on every render, and every rendered row reads the locale, so each parent render re-rendered all of them. The grid now warns once in development, naming the entries, after three renders in a row re-create the same function. Behaviour is unchanged: the functions are not frozen, so one that reads new state (a language switch) still updates the rows. +### Changed + +- `GridApi` gained `openContextMenu` / `closeContextMenu`; this affects only code that implements `GridApi` itself. `openContextMenu` opens the menu on a later animation frame, after the cell is scrolled into view, and opens nothing if the cell is not rendered within a few frames or its row moved; tests should wait for `role="menu"`. +- `ColumnMenu` and the context menu share one keyboard handler (`useMenuKeyboard`). Internal; the column menu's behaviour is unchanged. + ## [3.10.1] — 2026-10-13 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 46c8137..3c3b280 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This file is auto-loaded by Claude Code and other AI coding assistants. It provi - **Build:** `npm run build:lib` produces the published `dist/opengridx.es.js` / `dist/opengridx.umd.js`. `npm run build` type-checks the lib and builds the **demo site**. - **Published:** yes — on npm as `@opencorestack/opengridx` with external consumers. Treat public types and behaviour changes as semver-relevant. - **Lint:** `npm run lint` (ESLint). Must pass before every commit. -- **Current version:** 3.10.1 +- **Current version:** 3.11.0 --- @@ -157,6 +157,14 @@ The GitHub wiki is generated from `docs/` + `wiki/` by `scripts/build-wiki.mjs` --- +## v3.11.0 — significant changes + +- **Context menu** (`contextMenu`, `getContextMenuItems`; guide `docs/features/context-menu.md`). Hook step 37a `useGridContextMenu`: a native `contextmenu` listener on the grid root; the row comes from `data-rowindex`, which already indexes [top-pinned, page rows, bottom-pinned], so there is no pinned offset. `openAt` reaches keyboard navigation (Shift+F10 / Menu key) through a ref created before `useGridKeyboardNavigation` +- `useMenuKeyboard` (`lib/components/ui`) is shared by `ColumnMenu` and `GridContextMenu`; its Escape handler is document-level +- A right press (or Apple Ctrl+left) inside the cell range is `preventDefault`-ed on mousedown in the capture phase, so focus does not collapse the range. Menu state is cleared during render when its row leaves the rendered rows (no effect) +- Copy goes through `buildCellCopyText` / `getCopyText({ includeHeaders })` (header line `headerName ?? field`); synthetic rows are skipped +- Browser tests right-click with `userEvent.click(el, { button: 'right' })` (`pointer({ keys: '[MouseRight]' })` does not exist in Vitest's Playwright provider) + ## v3.10.0 — significant changes - **CI guards** (each fails the build): `check:versions` (`llms.txt` + its demo copy, CLAUDE.md "Current version" and a CHANGELOG entry must match `package.json`; bump them together), `check:compiler` (React Compiler: only the allowlisted `EXPECTED` results in `scripts/check-react-compiler.mjs`), `check:package` (no test artifacts, DataGrid-only size budget), ESLint `no-explicit-any` (now `error`, also in the demo) and `no-restricted-syntax` against `as unknown as` in `lib/` outside the boundary files listed in `eslint.config.js` diff --git a/demo/App.tsx b/demo/App.tsx index 3472dcb..da716bf 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -54,6 +54,7 @@ const PdfExportDemo = lazy(() => import('./examples/PdfExportDemo/PdfExportDemo' const MultiSortDemo = lazy(() => import('./examples/MultiSortDemo/MultiSortDemo')); const HeaderWrapDemo = lazy(() => import('./examples/HeaderWrapDemo/HeaderWrapDemo')); const HeaderFiltersDemo = lazy(() => import('./examples/HeaderFiltersDemo/HeaderFiltersDemo')); +const ContextMenuDemo = lazy(() => import('./examples/ContextMenuDemo/ContextMenuDemo')); const RtlDemo = lazy(() => import('./examples/RtlDemo/RtlDemo')); // Sidebar order: categories and pages most developers look for first come first. Paths never change @@ -103,6 +104,7 @@ const examplesConfig = [ // Selection & spreadsheet { path: '/cell-selection', name: 'Cell Range Selection', component: CellSelectionDemo, category: 'Selection & spreadsheet' }, { path: '/fill-handle', name: 'Fill Handle', component: FillHandleDemo, category: 'Selection & spreadsheet' }, + { path: '/context-menu', name: 'Context Menu', component: ContextMenuDemo, category: 'Selection & spreadsheet' }, // Layout { path: '/col-grouping', name: 'Column Grouping', component: ColumnGroupingExample, category: 'Layout' }, diff --git a/demo/examples/ContextMenuDemo/ContextMenuDemo.tsx b/demo/examples/ContextMenuDemo/ContextMenuDemo.tsx new file mode 100644 index 0000000..29710d1 --- /dev/null +++ b/demo/examples/ContextMenuDemo/ContextMenuDemo.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { + DataGrid, + exportToCsv, + useGridApiRef, + type GridColDef, + type GridContextMenuEntry, + type GridContextMenuParams, +} from '@opencorestack/opengridx'; +import { DocsLayout } from '../../components/DocsLayout'; +import { CodeBlock } from '../../components/CodeBlock'; +import '../PasteDemos.css'; + +import sourceCode from './ContextMenuDemo.tsx?raw'; + +interface Employee { + id: number; + name: string; + department: string; + salary: number; + city: string; +} + +const initialRows: Employee[] = [ + { id: 1, name: 'Ada Lovelace', department: 'Engineering', salary: 128000, city: 'London' }, + { id: 2, name: 'Grace Hopper', department: 'Engineering', salary: 134000, city: 'New York' }, + { id: 3, name: 'Alan Turing', department: 'Research', salary: 121000, city: 'Manchester' }, + { id: 4, name: 'Margaret Hamilton', department: 'Engineering', salary: 139000, city: 'Boston' }, + { id: 5, name: 'Katherine Johnson', department: 'Research', salary: 117000, city: 'Hampton' }, + { id: 6, name: 'Edsger Dijkstra', department: 'Research', salary: 125000, city: 'Austin' }, + { id: 7, name: 'Barbara Liskov', department: 'Design', salary: 119000, city: 'Cambridge' }, + { id: 8, name: 'Donald Knuth', department: 'Design', salary: 131000, city: 'Stanford' }, + { id: 9, name: 'Radia Perlman', department: 'Support', salary: 98000, city: 'Seattle' }, + { id: 10, name: 'Tim Berners-Lee', department: 'Support', salary: 102000, city: 'London' }, +]; + +const columns: GridColDef[] = [ + { field: 'name', headerName: 'Name', width: 180, editable: true }, + { field: 'department', headerName: 'Department', width: 140, editable: true }, + { field: 'salary', headerName: 'Salary', type: 'number', width: 120, editable: true }, + { field: 'city', headerName: 'City', width: 140, editable: true }, +]; + +const snippet = ` [ + ...defaultItems, + 'divider', + { id: 'export', label: 'Export CSV', + onClick: () => exportToCsv(params.api.getAllFilteredRows(), columns) }, + { id: 'delete', label: 'Delete row', danger: true, + onClick: () => removeRow(params.id) }, + ]} +/> + +// Return [] to show no menu (the browser menu then shows instead).`; + +export default function ContextMenuDemo() { + const apiRef = useGridApiRef(); + const [rows, setRows] = useState(initialRows); + const [log, setLog] = useState([]); + + const note = (text: string) => setLog(prev => [text, ...prev].slice(0, 6)); + + const getContextMenuItems = ( + params: GridContextMenuParams, + defaultItems: readonly GridContextMenuEntry[], + ): GridContextMenuEntry[] => [ + ...defaultItems, + 'divider', + { + id: 'export', + label: 'Export CSV', + onClick: () => { + exportToCsv(params.api.getAllFilteredRows(), columns, { fileName: 'employees.csv' }); + note('Exported employees.csv'); + }, + }, + { + id: 'delete', + label: `Delete ${params.row.name}`, + danger: true, + onClick: () => { + setRows(prev => prev.filter(r => r.id !== params.id)); + note(`Deleted row ${params.id} (${params.row.name})`); + }, + }, + ]; + + return ( + + With contextMenu on, right-clicking a body cell (or pressing Shift + F10) + opens a menu of cell actions: Copy, Copy with headers, and Undo / Redo when undoRedo is + on. getContextMenuItems adds your own items, here Export CSV and a danger Delete row. + + } + sourceCode={sourceCode} + > +
+
+ Try it +
    +
  • Drag over a few cells, then right-click inside the range: Copy copies the whole range and the range stays.
  • +
  • Right-click a cell outside the range: the range collapses to that cell.
  • +
  • Edit a cell, then right-click and choose Undo.
  • +
  • Focus a cell with the arrow keys and press Shift + F10; Esc closes the menu and returns to the cell.
  • +
  • Right-click a column header: the browser's own menu shows.
  • +
+
+ + + rows={rows} + columns={columns} + apiRef={apiRef} + cellSelection + undoRedo + contextMenu + getContextMenuItems={getContextMenuItems} + processRowUpdate={(row) => { + setRows(prev => prev.map(r => (r.id === row.id ? row : r))); + return row; + }} + height={380} + /> + +
+ Actions (latest first) + {log.length === 0 &&

Choose Export CSV or Delete from the menu.

} +
    + {log.map((entry, i) =>
  1. {entry}
  2. )} +
+
+ + +
+
+ ); +} diff --git a/demo/public/llms.txt b/demo/public/llms.txt index eb15d2d..4997c05 100644 --- a/demo/public/llms.txt +++ b/demo/public/llms.txt @@ -1,6 +1,6 @@ # OpenGridX — AI Agent Context # Package: @opencorestack/opengridx -# Version: 3.10.1 +# Version: 3.11.0 # Docs: https://opencorestack.github.io/OpenGridX/ # This file is machine-readable. AI developers: start here. @@ -736,3 +736,4 @@ Arrows move between cells (skipping hidden columns); Home/End, Ctrl+Home/End, Pa 28. **3.10**: the AI assistant's change chips are `localeText` keys too (`aiAssistantChipSort(field, direction)`, `aiAssistantChipSummary(fn, field)`, …). Select-all at 1M rows takes ~130 ms. The library forbids `as unknown as` outside documented boundaries and `any` everywhere (ESLint), and CI checks that `llms.txt`, CLAUDE.md and the CHANGELOG name the `package.json` version. 29. **Large client-side datasets**: sorting or filtering 1M rows blocks for ~0.4–0.5 s (one native sort). There is no time-sliced mode (measured and rejected: 1.5–2× slower overall). For interactive sorting and filtering at that size use a `dataSource`. 30. **3.10.1**: at 1M rows select-all ~60–90 ms, deselect-all < 50 ms, applying a grouping ~190 ms, the quick filter's first search ~1.6–2 s (later keystrokes ~210 ms). Grouping creates leaf tree nodes and row metadata only when read, still one `params.rowMeta` object per row. A Next.js 16 App Router fixture in the package smoke suite re-checks the Next.js guide on every CI run. +31. **Context menu (3.11+)**: `contextMenu` shows Copy, Copy with headers and (with `undoRedo`) Undo / Redo on right-click, Shift+F10 / the Menu key or macOS Ctrl+click over a body cell. `getContextMenuItems(params, defaultItems)` returns items and `'divider'`; an empty list shows no menu and the browser menu shows. Right-click inside a cell range copies the range and keeps it; elsewhere it copies the clicked cell. `apiRef.openContextMenu(id, field)` / `closeContextMenu()`; `localeText` keys `contextMenu*`. Types `GridContextMenuParams`, `GridContextMenuItem`, `GridContextMenuEntry`. Headers, footer, editors and detail panels keep the browser menu; no touch long-press. Guide: `docs/features/context-menu.md`. diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index a0a08e9..81d6ee6 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -113,6 +113,31 @@ interface GridHistoryChangeParams { canUndo: boolean; canRedo: boolean; size: nu interface GridUndoRedoOptions { limit?: number } ``` +#### Context Menu (v3.11) + +| Prop | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `contextMenu` | `boolean` | `false` | Shows a menu on right-click, Shift+F10 / the Menu key or macOS Ctrl+click over a body cell: Copy, Copy with headers, and Undo / Redo with `undoRedo`. Headers, the header filter row, the footer, detail panels, editors, checkbox / expand / reorder cells and nested grids keep the browser menu. See [Context Menu](features/context-menu.md). | +| `getContextMenuItems` | `(params: GridContextMenuParams, defaultItems: readonly GridContextMenuEntry[]) => readonly GridContextMenuEntry[]` | — | Builds the menu for the cell it opens on from the built-in items. An empty list shows no menu (the browser menu shows). A throw falls back to the built-in items with one dev warning. Does nothing without `contextMenu` (dev warning). | + +```ts +interface GridContextMenuParams { + id: GridRowId; field: string; row: R; colDef: GridColDef; + value: unknown; // read through valueGetter + rowMeta: GridRowMeta | undefined; + cellRange: GridCellSelectionModel | null; + api: GridApi; +} +interface GridContextMenuItem { + id: string; // built-ins: 'copy', 'copyWithHeaders', 'undo', 'redo' + label: string; onClick?: () => void; disabled?: boolean; + shortcut?: string; icon?: React.ReactNode; danger?: boolean; +} +type GridContextMenuEntry = GridContextMenuItem | 'divider'; +``` + +Public CSS: `ogx-context-menu`, `ogx-context-menu__item` (`--danger`, `--disabled`), `ogx-context-menu__shortcut`. Locale keys: `contextMenuCopy`, `contextMenuCopyWithHeaders`, `contextMenuUndo`, `contextMenuRedo`, `contextMenuLabel`. + #### Fill Handle (v3.5) | Prop | Type | Default | Description | @@ -419,6 +444,8 @@ Access these methods via the `apiRef` prop. | `setPivotModel(model)` | `void` | Replace the pivot model. Fires `onPivotModelChange` (v3.5). | | `getGridAiState()` | `GridAiState` | The current `filterModel`, `sortModel`, `rowGroupingModel`, `aggregationModel`, `pivotModel` and `columnVisibilityModel`, as the AI toolkit reads them. Like the other getters it sees a setter's value in the same tick (v3.5). | | `openAiAssistant()` / `closeAiAssistant()` | `void` | Open or close the `aiAssistant` panel; `openAiAssistant` does nothing without `aiAssistant` (v3.5). | +| `openContextMenu(id, field)` | `void` | Scroll a cell into view and open the context menu on it on a later animation frame. Does nothing without `contextMenu`, and the menu does not open if the cell is not rendered within a few frames or its row moved in the meantime; tests should wait for `role="menu"` (v3.11). | +| `closeContextMenu()` | `void` | Close the context menu (v3.11). | | `copySelectedRows()` | `Promise` | Copy every selected row that passes the filter (other pages, collapsed groups and pinned rows included) as TSV, with the visible columns in screen order. Resolves without writing when no selected row is found; rejects when the clipboard write fails. | --- @@ -998,6 +1025,7 @@ interface GridLocaleText { paginationLastPageLabel; paginationLastPageTooltip; // Cell selection stats bar cellSelectionStatsCount; cellSelectionStatsSum; cellSelectionStatsAverage; + contextMenuCopy; contextMenuCopyWithHeaders; contextMenuUndo; contextMenuRedo; contextMenuLabel; // v3.11 } ``` diff --git a/docs/architecture/datagrid-orchestration.md b/docs/architecture/datagrid-orchestration.md index 330bb4f..dee84bd 100644 --- a/docs/architecture/datagrid-orchestration.md +++ b/docs/architecture/datagrid-orchestration.md @@ -96,6 +96,7 @@ in an effect, so **keep the call order when you move or add a hook**. In particu | 36c | `useGridUndoRedo` (in `useGridRangeEditing`) | `hooks/core/useGridUndoRedo.ts` | History (v3.4, `undoRedo`): records the channel's changes, replays them as batch edits with an `expect` value per cell (stale cells skipped), queues undo/redo, selects and scrolls to the affected cells, Ctrl/Cmd+Z / Shift+Z / Ctrl+Y on the grid root, `onHistoryChange`, and `apiRef.undo` / `redo` / `canUndo` / `canRedo` / `clearHistory` | | 36d | `useGridFillHandle` (in `useGridRangeEditing`) | `hooks/core/useGridFillHandle.ts` | Fill handle (v3.5, `cellSelection` without `disableFillHandle`): where the handle is drawn (`rowRenderProps.fillHandle`, the range's bottom-right cell or its span origin), a `pointerdown` listener on the viewport for `.ogx__cell-fill-handle` that runs `cellSelection.startTrackedDrag` (the range drag's hit testing, auto-scroll and slow-machine fallback, on pointer events so touch works), the fill itself (`utils/editing/fill.ts`: series detection, lines, `GridColDef.fillValue`) as one batch edit with source `'fill'`, the new selection, `onFill`, and Ctrl/Cmd+D / Ctrl/Cmd+R on the grid root | | 37 | `useGridColumnMenuHandlers` | `hooks/core/useGridHeaderHandlers.ts` | Column menu Hide / Pin (`pinColumnTo` in `utils/pinning`) | +| 37a | `useGridContextMenu` | `hooks/features/useGridContextMenu.ts` | Cell context menu (v3.11, `contextMenu`): a native `contextmenu` listener on the grid root (attached only with `contextMenu`) resolves the body cell from `data-field` and the row's `data-rowindex` (an index into `allRenderableRows`, read through a latest-ref; covered span cells resolve to the origin), keeps a range that contains the cell or moves focus and collapses the range to it, builds Copy / Copy with headers (/ Undo / Redo with `undoRedo`), passes them through `getContextMenuItems` (contained) and opens `GridContextMenu` (rendered by `GridRoot`). An empty list keeps the browser menu. Installs `apiRef.openContextMenu` / `closeContextMenu`; returns `openAt(cell)` for the keyboard. Needs the cell selection API and undo/redo (36a–36c) | | 38 | `useGridAriaRows` (in `useGridRenderWindow`) | `hooks/core/useGridAriaRows.ts` | `aria-rowcount` and `aria-rowindex` bases | | 39 | `useGridSpanRowWindow` (in `useGridRenderWindow`) | `hooks/features/useGridSpanRenderWindow.ts` | Render window widened to whole row spans | | 40 | `useGridVisibleRows` (in `useGridRenderWindow`) | `hooks/core/useGridVisibleRows.ts` | Pinned + virtual centre rows to render | diff --git a/docs/components/datagrid.md b/docs/components/datagrid.md index 679d1b1..cb12606 100644 --- a/docs/components/datagrid.md +++ b/docs/components/datagrid.md @@ -57,6 +57,8 @@ function MyGrid() { | Prop | Type | Description | | :--- | :--- | :--- | +| `contextMenu` | `boolean` | v3.11. Right-click / Shift+F10 menu over body cells: Copy, Copy with headers, Undo / Redo (with `undoRedo`). Off by default. See [Context Menu](../features/context-menu.md). | +| `getContextMenuItems` | `(params: GridContextMenuParams, defaultItems) => GridContextMenuEntry[]` | v3.11. Builds the menu's items; an empty list shows no menu. Needs `contextMenu`. | | `onRowClick` | `(params: GridRowParams) => void` | Fired when clicking a row body (not for synthetic group rows, which toggle instead). | | `onRowDoubleClick` | `(params: GridRowParams) => void` | Fired when double-clicking a row. | | `onCellClick` | `(params: GridCellParams) => void` | Fired when clicking a specific cell. | diff --git a/docs/features/context-menu.md b/docs/features/context-menu.md new file mode 100644 index 0000000..d41f9d0 --- /dev/null +++ b/docs/features/context-menu.md @@ -0,0 +1,103 @@ +# Context Menu + +`contextMenu` (v3.11) shows a menu of cell actions on right-click, or on Shift+F10 / the Menu key over a focused body cell. It is off by default: until you enable it, the browser's own menu shows as before. Demo: **Context Menu** (`/context-menu`). + +```tsx +import { DataGrid, exportToCsv, type GridContextMenuEntry, type GridContextMenuParams } from '@opencorestack/opengridx'; + +, defaultItems): GridContextMenuEntry[] => [ + ...defaultItems, + 'divider', + { id: 'export', label: 'Export CSV', onClick: () => exportToCsv(params.api.getAllFilteredRows(), columns) }, + { id: 'delete', label: 'Delete row', danger: true, onClick: () => removeRow(params.id) }, + ]} +/> +``` + +## Built-in items + +| Item (`id`) | Shown | What it does | +| :--- | :--- | :--- | +| Copy (`'copy'`) | always | Copies the range, or the clicked cell (see below) | +| Copy with headers (`'copyWithHeaders'`) | always | The same, with a header line of `headerName ?? field` | +| Undo (`'undo'`) | with `undoRedo` | `apiRef.undo()`; disabled when there is nothing to undo | +| Redo (`'redo'`) | with `undoRedo` | `apiRef.redo()`; disabled when there is nothing to redo | + +There is no divider between the copy items and Undo / Redo. + +## `getContextMenuItems(params, defaultItems)` + +Called each time the menu opens, with the built-in items, and returns the entries to show: items (`GridContextMenuItem`) and `'divider'` lines. + +- **`params`** (`GridContextMenuParams`): `id`, `field`, `row`, `colDef`, `value` (read through `valueGetter`), `rowMeta`, `cellRange` (the current range, `null` without `cellSelection` or with no range) and `api` (the grid API). +- **Item** (`GridContextMenuItem`): `id` (stable key), `label`, `onClick`, `disabled`, `shortcut` (display only, e.g. `'Ctrl+C'`), `icon` and `danger` (styling only). +- **Leading, trailing and consecutive dividers are dropped.** A list with no items (empty, or only dividers) shows no menu, and the browser's own menu is then not suppressed either. Use it to switch the menu off for some cells (a synthetic row, a column). +- **A throwing callback** (or a throwing `onClick`) is contained: the grid falls back to the built-in items (or does nothing for the click) and warns once in development. +- Setting `getContextMenuItems` without `contextMenu` does nothing; in development it warns once. + +## Copy semantics + +| Where the right-click lands | Focus and range | Copy writes | +| :--- | :--- | :--- | +| Inside the current cell range | Both stay as they are | The range: the same text as Ctrl/Cmd+C. Synthetic rows (group, subtotal, auto-parent) are skipped | +| Elsewhere (or no range) | Focus moves to the cell and the range collapses | The clicked cell | + +"Copy with headers" adds a first line of column headers (`headerName ?? field`). A range right-clicked with `cellSelection` keeps its outline, so the menu acts on what the user sees selected. + +## Opening and closing + +Opens on: + +- right-click on a body cell; +- **Shift+F10** or the **Menu** key on a focused body cell (the menu appears at the cell); +- **Ctrl+click** on Apple platforms (macOS, iPadOS). + +Keeps the browser menu: column headers, the header filter row, the footer, detail panels, open cell editors, the checkbox, expand and reorder cells, and nested grids. + +Closes on: choosing an item, **Escape** or **Tab** (focus returns to the cell), a press outside, grid scroll, window resize, and the row leaving the rendered rows (a filter, a sort, new rows). Arrow keys, Home, End and Enter move through and activate the items. + +The menu opens towards the inline end of the pointer and flips to stay inside the window; in a `dir="rtl"` grid it opens towards the inline start. + +## API + +```ts +apiRef.current.openContextMenu(id, field); // does nothing without `contextMenu`; v3.11 +apiRef.current.closeContextMenu(); +``` + +`openContextMenu` scrolls the cell into view and opens the menu on a later animation frame. If the cell is not rendered within a few frames, or its row moved in the meantime (a sort, a filter, new rows), no menu opens. Tests should wait for `role="menu"` rather than expect it at once. + +## Locale text + +| Key | Default | +| :--- | :--- | +| `contextMenuCopy` | `'Copy'` | +| `contextMenuCopyWithHeaders` | `'Copy with headers'` | +| `contextMenuUndo` | `'Undo'` | +| `contextMenuRedo` | `'Redo'` | +| `contextMenuLabel` | `'Cell actions'` (the menu's `aria-label`) | + +Labels you add in `getContextMenuItems` are yours to translate. + +## Styling + +Public classes: `ogx-context-menu`, `ogx-context-menu__item`, `ogx-context-menu__item--danger`, `ogx-context-menu__item--disabled` and `ogx-context-menu__shortcut`. The menu also carries the column menu's `ogx-column-menu` / `ogx-menu-item` classes and shares its theme tokens (`overlays.*` theme keys), so a theme or CSS aimed at `.ogx-column-menu` styles both. + +## Known issues + +- On Windows the ContextMenu (Menu) key may also fire the browser's `contextmenu` event on key-up; the grid's menu may then re-open at the same cell. +- Calling `closeContextMenu()` before a pending `openContextMenu` has opened does not cancel it. + +## Not covered in v1 + +- The column header menu (the column menu already has its own button). +- Long-press on touch devices (iOS Safari fires no `contextmenu` event). Call `apiRef.openContextMenu` from your own gesture if you need it. +- Paste and export items. Add them through `getContextMenuItems`, as the demo does for CSV export. + +See also [Cell Range Selection](cell-selection.md), [Clipboard](clipboard.md) and [Undo & Redo](undo-redo.md). diff --git a/docs/roadmap.md b/docs/roadmap.md index 1a22afc..c3b3781 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -95,6 +95,7 @@ This document tracks the current status of features in `OpenGridX` and outlines | **3.9.1** ✅ | shipped 13 Oct | **npm package no longer ships 1.6 MB of test baselines** | `check:package` guard in CI; version badges read `package.json`; docs brought up to date | | **3.10.0** ✅ | shipped 13 Oct | **Backlog cleared**: refactors (R4–R6), CI guards, select-all ~2× faster | AI change chips translatable; demo syntax highlighting | | **3.10.1** ✅ | shipped 13 Oct | **1M-row performance**: select-all ~60–90 ms, grouping ~190 ms, quick filter first search ~1.6–2 s | Next.js smoke fixture; RTL operator symbols not mirrored | +| **3.11.0** | 26 Oct | **Context menu** (`contextMenu`, `getContextMenuItems`): Copy, Copy with headers, Undo / Redo, custom items | Menu keyboard handling shared with the column menu | | Freeze | 19 Dec – 5 Jan | Bug fixes only | — | ### Now: backlog first (feature hold since 13 Oct; backlog cleared in 3.10) diff --git a/docs/superpowers/plans/2026-10-13-audit-tracker.md b/docs/superpowers/plans/2026-10-13-audit-tracker.md index f6049e3..deeb571 100644 --- a/docs/superpowers/plans/2026-10-13-audit-tracker.md +++ b/docs/superpowers/plans/2026-10-13-audit-tracker.md @@ -82,3 +82,4 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil - 2026-10-13 — Release 3.10.0 via PR. - 2026-10-13 — 3.10.1 work: Next.js smoke fixture; perf #5 select-all ~60–90 ms / deselect <50 ms (live-check markers), #2 grouping 540 → 190 ms (lazy leaf maps, index partition), #3 quick filter first search 2.7 → ~1.6–2 s (per-day date memo, shown-text lower-case cache). check:compiler caught a getter-in-hook regression during #2 (fixed before push). - 2026-10-16 — B8/B9 closed as won't fix (by design). Inline-function `localeText`: freezing the functions would bring back B9-style stale row labels, so added a dev-only warning instead (`useGridLocaleTextWarning`, in `GridRoot`). Branch `fix/localetext-functions`. +- 2026-10-26 — Context menu (3.11.0) on `feat/context-menu`: `contextMenu` + `getContextMenuItems`, guide, demo page and release notes written; version bumped to 3.11.0. Awaiting the release PR. diff --git a/docs/superpowers/plans/2026-10-26-context-menu.md b/docs/superpowers/plans/2026-10-26-context-menu.md new file mode 100644 index 0000000..55a00e4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-context-menu.md @@ -0,0 +1,183 @@ +# Context Menu Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** An opt-in right-click / Shift+F10 menu on body cells with built-in Copy, Copy with headers, Undo, Redo and consumer items, shipped in 3.11.0. + +**Architecture:** A feature hook `useGridContextMenu` (orchestration step 37a, right after `useGridColumnMenuHandlers`) owns `{ x, y, id, field } | null`, listens for `contextmenu` natively on the grid root and installs two `apiRef` methods. `GridContextMenu` renders the menu in a portal from `GridRoot`. Menu keyboard behaviour is shared with `ColumnMenu` through `useMenuKeyboard`. No new prop reaches `Row` or `Cell`. + +**Tech Stack:** TypeScript 5.9 strict, React 19, Vitest 4 (jsdom + Playwright browser project). + +**Spec:** `docs/superpowers/specs/2026-10-26-context-menu-design.md` + +## Global Constraints + +- Opt-in: nothing changes (DOM, behaviour, look) when `contextMenu` is not set. +- DataGrid gzip growth ≤ +2.15 KB (raised from 2 KB on 2026-10-26: the range-keeping fixes for right press and Apple Ctrl+click found in Task 6b; raised to 2.15 KB on 2026-10-26 for the row-identity check in the delayed openContextMenu); `DATAGRID_GZIP_BUDGET` (92 KB) is not raised. +- No `any`, no `eslint-disable`, no `@ts-ignore`, no new `as unknown as`. React Compiler rules from CLAUDE.md v3.3.0; `npm run check:compiler` output unchanged. +- No row re-renders on menu open/close (v3.9.0 rule); handlers reaching rows keep identity. +- Consumer callbacks (`getContextMenuItems`, item `onClick`) are contained: a throw falls back (to `defaultItems` / no-op) with one dev warning. +- Cell values go through `getCellValue`; RTL through `isRtl` / `lib/utils/direction.ts`. +- Built-in item ids: `'copy'`, `'copyWithHeaders'`, `'undo'`, `'redo'`. +- Locale keys: `contextMenuCopy`, `contextMenuCopyWithHeaders`, `contextMenuUndo`, `contextMenuRedo`, `contextMenuLabel`. +- Header line in copies uses `headerName ?? field`. +- Version 3.11.0: `package.json`, `llms.txt` + demo copy, CLAUDE.md "Current version", `opengridx-ai-context.md`, CHANGELOG (`check:versions`). +- Gates per task: `npm run lint && npm run typecheck && npm run build`, plus the task's tests. + +## Commits + +Each task ends in a commit, and each commit needs the user's approval first (standing rule). Task 4 (the ColumnMenu refactor) stays its own commit so it can be reverted alone. + +## Review Focus + +1. Right-click near the right/bottom viewport edge or in a scrolled page → menu stays fully on screen (Task 3 tests). +2. Right-click on a top-pinned row, a bottom-pinned row, a centre row on page 2, a group row, or a cell covered by a row span → correct row id / copies the span origin's text, never a wrong row (Task 6a tests). +3. Right-click on header, header filter, footer, detail panel, a nested grid, or an open editor → browser menu untouched (Task 6a/6b tests). +4. Grid scrolls or rows change while the menu is open → menu closes; a removed row never gets an action (Task 6a/6b tests). +5. `getContextMenuItems` throws or returns `[]` → defaults with a warning / no menu and native menu allowed (Task 6a/6b tests). + +--- + +### Task 1: Copy text with headers and single-cell copy + +**Files:** +- Modify: `lib/hooks/features/useGridClipboard.ts` (`buildRangeTsv`, new `buildCellCopyText`) +- Modify: `lib/hooks/core/useGridCellSelection.ts:121,338` (`getCopyText`) +- Test: `lib/hooks/features/useGridClipboard.test.ts`, `lib/hooks/core/useGridCellSelection.test.ts` + +**Interfaces:** +- Produces: `buildRangeTsv(rows, columns, isCovered?, options?: { includeHeaders?: boolean }): string`; `buildCellCopyText(row: GridRowModel, col: GridColDef, includeHeaders: boolean): string`; `getCopyText(options?: { includeHeaders?: boolean }): string | null`. + +- [ ] **Step 1: Failing tests** + - `buildRangeTsv includes one header line when includeHeaders`: rows `[{a:1,b:'x'}]`, cols `a` (headerName `'A'`), `b` (no headerName) → `'A\tb\n1\tx'`. + - `buildRangeTsv default output unchanged` → `'1\tx'`. + - `buildCellCopyText` → `'1'`; with headers → `'A\n1'`; a value containing a tab is escaped exactly as `escapeTsvField` does. + - `getCopyText({ includeHeaders: true })` on a 2×2 range starts with the header line; `getCopyText()` does not. +- [ ] **Step 2: Run** `npx vitest run --project unit lib/hooks/features/useGridClipboard.test.ts lib/hooks/core/useGridCellSelection.test.ts` → new tests FAIL. +- [ ] **Step 3: Implement.** Header cells go through `escapeTsvField`. `buildCellCopyText` reuses `formatCopyValue`. `getCopyText` forwards the option; Ctrl+C keeps calling it without arguments. +- [ ] **Step 4: Run** the same command → PASS; `npm test` → all pass. +- [ ] **Step 5: Commit** `feat(clipboard): optional header line for range and single-cell copy text` + +### Task 2: Public types, props and locale + +**Files:** +- Create: `lib/types/contextMenu.ts` +- Modify: `lib/types/index.ts`, `lib/types/props.ts`, `lib/types/api.ts`, `lib/types/locale.ts`, `lib/utils/gridLocale.ts` +- Test: existing `publicApi` test (update its snapshot / list) + +**Interfaces:** +- Produces (exact, from the spec): `GridContextMenuParams` `{ id, field, row, colDef, value, rowMeta, cellRange: GridCellSelectionModel | null, api: GridApi }`; `GridContextMenuItem` `{ id, label, onClick?, disabled?, shortcut?, icon?: React.ReactNode, danger? }`; `GridContextMenuEntry = GridContextMenuItem | 'divider'`; props `contextMenu?: boolean`, `getContextMenuItems?: (params, defaultItems: readonly GridContextMenuEntry[]) => readonly GridContextMenuEntry[]`; `GridApi.openContextMenu(id: GridRowId, field: string): void`, `GridApi.closeContextMenu(): void` (both `@since v3.11`). +- English defaults: `'Copy'`, `'Copy with headers'`, `'Undo'`, `'Redo'`, `contextMenuLabel: 'Cell actions'`. + +- [ ] **Step 1:** Add the types; run `npm run typecheck` → FAIL where `GridApi` objects are built (missing methods). +- [ ] **Step 2:** Add no-op placeholders to the api builders so typecheck passes (Task 6a replaces them). +- [ ] **Step 3: Run** `npm run typecheck && npx vitest run --project unit lib/publicApi` → PASS after updating the public list. +- [ ] **Step 4: Commit** `feat(types): context menu types, props, api and locale strings` + +### Task 3: Point menu position + +**Files:** +- Modify: `lib/utils/direction.ts` +- Test: `lib/utils/direction.test.ts` + +**Interfaces:** +- Produces: `getPointMenuPosition(x: number, y: number, menu: { width: number; height: number }, viewport: { width: number; height: number }, rtl: boolean): { top: number; left: number }` (viewport coordinates, 8 px margin, same as `ColumnMenu`). + +- [ ] **Step 1: Failing tests** (viewport 1000×800, menu 200×300): + - LTR at (100,100) → `{ left: 100, top: 100 }`. + - LTR at (900,100) → `{ left: 700, top: 100 }` (flips to end at the pointer). + - RTL at (500,100) → `{ left: 300, top: 100 }` (opens towards inline start). + - RTL at (100,100) → `{ left: 100, top: 100 }` (cannot flip, kept on screen). + - At (100,700) → `top: 400` (flips up). Menu taller than viewport → `top: 8`. +- [ ] **Step 2: Run** `npx vitest run --project unit lib/utils/direction.test.ts` → FAIL. +- [ ] **Step 3: Implement.** Clamp to `[8, viewport - size - 8]` after flipping. +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** `feat(direction): getPointMenuPosition for menus opened at a point` + +### Task 4: Shared menu keyboard hook + +**Files:** +- Create: `lib/components/ui/useMenuKeyboard.ts` +- Modify: `lib/components/Header/ColumnMenu.tsx:122-210` +- Test: `lib/components/ui/useMenuKeyboard.test.tsx`; existing `ColumnMenu` tests unchanged + +**Interfaces:** +- Produces: `useMenuKeyboard({ menuRef, isPositioned, onClose, getRestoreTarget, ignoreTarget? }): { close: (restoreFocus: boolean) => void; handleMenuKeyDown: (e: React.KeyboardEvent) => void }`. Covers: focus first item once positioned, ArrowUp/Down wrap, Home/End, Tab and Escape close with focus restore, outside `mousedown` closes (except on `ignoreTarget`), scroll/resize close. `onClose` read through a ref (like `useToolbarPanel`). + +- [ ] **Step 1:** Write `useMenuKeyboard.test.tsx` with a 3-item menu: ArrowDown from last wraps to first; Escape calls `onClose` and focuses the restore target; outside mousedown calls `onClose`; scroll calls `onClose`. +- [ ] **Step 2: Run** → FAIL (module missing). +- [ ] **Step 3:** Move the logic out of `ColumnMenu` into the hook; `ColumnMenu` calls it with `ignoreTarget: anchorEl` and `getRestoreTarget` = the header cell. +- [ ] **Step 4: Run** the new test plus `npx vitest run --project unit lib/components/Header` and `npx vitest run --project browser lib/components/Header` → PASS with no ColumnMenu test edits. +- [ ] **Step 5: Commit** `refactor(menu): share menu keyboard handling between menus` + +### Task 5: `GridContextMenu` component + +**Files:** +- Create: `lib/components/ContextMenu/GridContextMenu.tsx`, `lib/components/ContextMenu/ContextMenu.css` (imported from `lib/styles/opengridx.css`) +- Test: `lib/components/ContextMenu/GridContextMenu.test.tsx` + +**Interfaces:** +- Consumes: `useMenuKeyboard` (Task 4), `getPointMenuPosition` (Task 3), `GridContextMenuEntry` (Task 2). +- Produces: `GridContextMenu({ entries: readonly GridContextMenuEntry[]; x: number; y: number; anchorCell: HTMLElement | null; onClose: () => void; label: string })`. Items: `role="menuitem"`, `aria-disabled` for disabled (focusable, click ignored), `'divider'` → `role="separator"`. Clicking an enabled item closes first, then runs `onClick` inside a contained call (`attempt()`), focus restored to `anchorCell`. Classes `ogx-context-menu`, `ogx-context-menu__item`, `--danger`, `--disabled`, `ogx-context-menu__shortcut`, sharing `.ogx-column-menu` tokens. + +- [ ] **Step 1: Failing tests:** renders labels, shortcut text and separator; disabled item click does nothing; enabled click calls `onClose` then `onClick` (order asserted); a throwing `onClick` does not throw out and warns once; `aria-label` is `label`; leading/trailing/double dividers are dropped. +- [ ] **Step 2: Run** `npx vitest run --project unit lib/components/ContextMenu` → FAIL. +- [ ] **Step 3: Implement.** Portal target = closest `.ogx-theme-provider` of `anchorCell`, else `document.body`. +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** `feat(context-menu): GridContextMenu component` + +### Task 6a: `useGridContextMenu` — pointer menu and DataGrid wiring + +**Files:** +- Create: `lib/hooks/features/useGridContextMenu.ts` +- Modify: `lib/components/DataGrid/DataGrid.tsx` (call after `useGridColumnMenuHandlers`, pass result to `GridRoot`), `lib/components/DataGrid/GridRoot.tsx` (render `GridContextMenu` as one new child position after overlays), `lib/hooks/core/useGridDevWarnings.ts` (`getContextMenuItems` without `contextMenu`), the api builders from Task 2, `docs/architecture/datagrid-orchestration.md` (row 37a) +- Test: `lib/components/DataGrid/DataGrid.contextMenu.test.tsx` + +**Interfaces:** +- Consumes: Tasks 1–5; `containerRef`, `apiRef`, the visible row list (top-pinned + centre rows of the current page + bottom-pinned, the list `useGridVisibleRows` indexes into), `columns` lookup, `rowMetaMap`, `cellSelection` (its API incl. `getCopyText`, model setter), undo/redo `canUndo`/`canRedo`/`undo`/`redo`, `setFocusedCell`, `localeText`. +- Produces: `useGridContextMenu(params): { menu: { x: number; y: number; entries: readonly GridContextMenuEntry[]; anchorCell: HTMLElement | null } | null; close: () => void; openAt: (cell: FocusedCell) => void }`. `openAt` is used by Task 6b. + +**Row resolution (exact rule).** `Row` writes `data-rowindex={rowIndex}`, and `rowIndex` is already an index into the visible row list `[...topPinned, ...centreRowsOfPage, ...bottomPinned]`: top-pinned rows use `0..n-1` (`GridPinnedRows`, offset 0), centre rows `topPinnedCount + centerStartIndex + i` (`useGridVisibleRows`), bottom-pinned rows `bottomRowIndexOffset = pinnedTopCount + pageRowCount` (`lib/utils/aria`). So: `id = getRowId(visibleRowList[Number(rowEl.dataset.rowindex)])`, with **no** `+ pinnedTopRows.length` adjustment. Ignore skeleton rows (`.ogx__skeleton-group` / skeleton `Row`s) and a missing or out-of-range index (no menu, not prevented). Read the list from a ref updated with the rendered list (latest-ref pattern) so the index and the list come from the same render; `Row` has no id attribute and none is added. + +Behaviour to implement (spec "Copy semantics", "Public API"): +- Native `contextmenu` listener on `containerRef` only when `contextMenu` is true. Target must be a body cell of **this** grid (`closest('.ogx')` is the root; not inside an editor, header, footer, detail panel). Covered span cells resolve to the span origin. +- Inside the current range → keep it; otherwise set focus to the cell and collapse the range to it (when `cellSelection`). Then compute `defaultItems`, call `getContextMenuItems` (contained), and if the result is empty do **not** `preventDefault`. +- Copy: inside a range → `getCopyText({ includeHeaders })`; else `buildCellCopyText`. Written through `writeToClipboard`. +- Undo/Redo items only when `undoRedo` is on; `disabled` from `canUndo()` / `canRedo()`. +- Close on: item chosen, Escape, outside press, grid scroll, window resize, the open row leaving the visible row list. +- `apiRef.openContextMenu(id, field)` scrolls the cell into view and opens at its corner; `closeContextMenu()` closes. + +- [ ] **Step 1: Failing unit tests** (`DataGrid.contextMenu.test.tsx`, jsdom): no `contextMenu` prop → `fireEvent.contextMenu(cell)` is not default-prevented and no `role="menu"`; with it → menu with Copy, Copy with headers; `undoRedo` adds Undo/Redo, Undo disabled with empty history; `getContextMenuItems` receives params (`id`, `field`, `value` via `valueGetter`, `rowMeta` on a group row) and its return replaces items; returns `[]` → no menu, not prevented; throws → defaults + one warning; right-click on header / header filter / editor input → not prevented; Copy writes the cell text, Copy inside a 2×2 range writes the range; `openContextMenu`/`closeContextMenu` via `apiRef`; dev warning for `getContextMenuItems` alone; rows change so the row is removed → menu closes. +- [ ] **Step 2: Failing row-resolution tests** (same file): with 2 top-pinned rows, right-click on top-pinned row 0, centre row 0, and a bottom-pinned row → each reports its own `id` (top-pinned row 0 and centre row 0 must differ); with `pagination` on page 2, right-click on the first centre row → that page's row, not page 1's; a covered row-span cell → the span origin's `id` and text. +- [ ] **Step 3: Run** `npx vitest run --project unit lib/components/DataGrid/DataGrid.contextMenu.test.tsx` → FAIL. +- [ ] **Step 4: Implement** the hook, wiring, doc row 37a. +- [ ] **Step 5: Run** step 3's command → PASS; `npm test` → all pass; `npm run lint && npm run typecheck && npm run build && npm run check:compiler` → pass. +- [ ] **Step 6: Commit** `feat(context-menu): right-click cell menu (contextMenu, getContextMenuItems)` + +### Task 6b: Keyboard opening, row memo guard, browser tests, budget + +**Files:** +- Modify: `lib/hooks/core/gridKeyDown.ts` (`GridKeyDownContext.openContextMenuAt?: (cell: FocusedCell) => void`; Shift+F10 / `ContextMenu` key on a body cell calls it and `preventDefault`s; header / header-filter focus: return without `preventDefault`), `lib/hooks/core/useGridKeyboardNavigation.ts` (pass `openAt` from 6a through the context; keep the `useCallback` dependency list complete) +- Test: `lib/hooks/core/gridKeyDown.test.ts` (new cases), `lib/components/DataGrid/DataGrid.rowMemo.test.tsx` (new cases), `lib/components/DataGrid/DataGrid.contextMenu.browser.test.tsx` + +**Ordering note:** `useGridKeyboardNavigation` runs before step 37a, so it cannot take `openAt` directly. Pass it through a ref filled by `useGridContextMenu` (latest-ref pattern, as `useGridRowInteractions` does); the keydown handler reads `ref.current` at event time. Do not move hooks. + +- [ ] **Step 1: Failing `gridKeyDown` tests:** Shift+F10 and `ContextMenu` on a body cell call `openContextMenuAt` and prevent default; on header / header-filter focus neither; without `openContextMenuAt` (prop off) neither. +- [ ] **Step 2: Failing rowMemo cases:** (a) opening and closing the menu on the already-focused cell renders no `Row`; (b) right-clicking a cell in another row renders at most 2 `Row`s (the old and new focused rows) and closing renders none; (c) with `contextMenu` on but the menu closed, a grid re-render renders no `Row` (no prop identity changes). +- [ ] **Step 3: Run** `npx vitest run --project unit lib/hooks/core/gridKeyDown.test.ts lib/components/DataGrid/DataGrid.rowMemo.test.tsx` → FAIL; implement; → PASS. +- [ ] **Step 4: Failing browser tests** (`DataGrid.contextMenu.browser.test.tsx`, import `lib/styles/opengridx.css`; right-click with `userEvent.click(el, { button: 'right' })`): menu opens at the pointer; near right/bottom edge stays inside `window.innerWidth/innerHeight`; `dir="rtl"` opens towards inline start; Shift+F10 opens on the focused cell, Escape closes and focus returns to the cell; right-click inside a range keeps it, outside collapses it; right-click on a top-pinned row, a centre row after scrolling and a grouped child row reports the right `id`; grid scroll closes the menu; release the mouse over the page in `afterEach` (`commands.pointerUp()`). +- [ ] **Step 5: Run** `npx vitest run --project browser lib/components/DataGrid/DataGrid.contextMenu.browser.test.tsx` in all three engines → PASS. +- [ ] **Step 6:** `npm run lint && npm run typecheck && npm run build && npm run build:lib && npm run check:package && npm run check:compiler` → all pass; note the gzip delta (≤ 2.15 KB; raised from 2.1 KB on 2026-10-26 for the row-identity check in the delayed openContextMenu). +- [ ] **Step 7: Commit** `feat(context-menu): Shift+F10 opening and browser coverage` + +### Task 7: Docs, demo, release prep + +**Files:** +- Create: `docs/features/context-menu.md`, `demo/examples/ContextMenuDemo.tsx` +- Modify: `scripts/build-wiki.mjs` (`SECTIONS`), `docs/API_REFERENCE.md`, `docs/components/datagrid.md`, `docs/roadmap.md`, `demo/App.tsx` (`examplesConfig` route), `CHANGELOG.md`, `package.json` (3.11.0), `llms.txt` + demo copy, `opengridx-ai-context.md`, `CLAUDE.md` (Current version + a short "v3.11.0 — significant changes" section: step 37a, native listener, `useMenuKeyboard`, Copy semantics), `docs/superpowers/plans/2026-10-13-audit-tracker.md` (log line) + +- [ ] **Step 1:** Feature guide: opt-in, defaults, `getContextMenuItems` example that adds "Export CSV" with `exportToCsv` and a "Delete row", Copy semantics table, keyboard, locale keys, what is not covered (header, touch long-press, paste). +- [ ] **Step 2:** Demo page with `cellSelection`, `undoRedo`, a custom Export item and a danger Delete item; explicit `height`; code sample through `CodeBlock`. +- [ ] **Step 3: Run** `npm run wiki:build` (no unlisted-doc warning), `npm run check:versions`, `npm run test:demo` → PASS. +- [ ] **Step 4: Run** `npm run test:all` and `npm run test:smoke` → PASS. +- [ ] **Step 5: Commit** `docs(context-menu): guide, demo page and 3.11.0 release notes` diff --git a/docs/superpowers/specs/2026-10-26-context-menu-design.md b/docs/superpowers/specs/2026-10-26-context-menu-design.md new file mode 100644 index 0000000..72b70ba --- /dev/null +++ b/docs/superpowers/specs/2026-10-26-context-menu-design.md @@ -0,0 +1,135 @@ +# Context menu — design note + +Status: draft for review (2026-10-26), builder check done. Target: 3.11.0 (additive, no breaking change). + +## Intent + +A right-click (and keyboard) menu on grid cells, so users can act on the cell, row or range they are looking at +without going to the toolbar, and so consumers can add their own row-level actions ("Open", "Delete", "Export +selection") in one place. It is the first feature after the 2026-10-13 freeze; the user left the scope to me. + +## Goals + +- Opt-in: `contextMenu` prop. Off by default, so the browser's own menu keeps working for existing consumers. +- Built-in items: **Copy**, **Copy with headers**, and **Undo** / **Redo** when `undoRedo` history exists (see + "Copy semantics"). +- Consumer items through one callback, `getContextMenuItems(params, defaultItems)`, which receives the row, + column, value, row meta and the current cell range, and returns the full list (it may drop, reorder or wrap the + defaults). +- Opens from: right-click on a body cell; **Shift+F10** and the **ContextMenu** key on the focused cell. +- Accessible like the existing `ColumnMenu`: `role="menu"`, `menuitem` items, arrow keys / Home / End, Escape and + Tab close, focus returns to the cell. +- RTL-correct positioning and themable through `--ogx-*` variables. + +## Non-goals + +- **No built-in Export items.** Exporters are standalone functions; putting them in the menu would pull them into + the core bundle (86.6 / 92 KB gzip). The docs show how to add them as consumer items. +- **No Paste item.** Reading the clipboard from a menu click needs the async Clipboard API and a permission + prompt in Chromium (and is not allowed in Firefox for page content). Paste stays on the keyboard shortcut. +- No context menu on the header row, header filters, pinned/aggregation footer or the list view in v1. Header + right-click keeps the browser menu; the column menu button stays the header's menu. +- No nested submenus in v1 (types leave room: see "Open for the builder"). + +## Anti-goals + +- Re-rendering every row when the menu opens or closes. Menu state lives outside `Row` props (v3.9.0 rule). +- A second copy of the menu keyboard logic. `ColumnMenu`'s navigation, focus-in and focus-return move into a + shared hook used by both menus. +- Any change to the default look or behaviour when `contextMenu` is not set. + +## Public API (decided) + +```ts +// lib/types/contextMenu.ts (new domain file, re-exported from types/index.ts) +export interface GridContextMenuParams { + id: GridRowId; + field: string; + row: R; + colDef: GridColDef; + value: unknown; // through getCellValue + rowMeta: GridRowMeta | undefined; + cellRange: GridCellSelectionModel | null; // null without cellSelection or with no range + api: GridApi; +} + +export interface GridContextMenuItem { + id: string; // stable key; built-ins use 'copy', 'copyWithHeaders', 'undo', 'redo' + label: string; + onClick?: () => void; // contained: a throw is caught and warned once in dev + disabled?: boolean; + shortcut?: string; // display only, e.g. 'Ctrl+C' + icon?: React.ReactNode; + danger?: boolean; // styling only +} +export type GridContextMenuEntry = GridContextMenuItem | 'divider'; + +// DataGridProps additions +contextMenu?: boolean; +getContextMenuItems?: ( + params: GridContextMenuParams, + defaultItems: readonly GridContextMenuEntry[], +) => readonly GridContextMenuEntry[]; +``` + +No `onContextMenuOpen` in v1 (my call): `getContextMenuItems` already runs on every open, and a callback is easy +to add later but hard to remove. + +- Passing `getContextMenuItems` without `contextMenu` does nothing (dev warning, `useGridDevWarnings`). +- An empty list returned from `getContextMenuItems` means: no menu, browser default not suppressed either. +- Synthetic rows (group, subtotal, auto-parent, pivot Grand Total) get the menu; `params.row` is the synthetic row + and `rowMeta` tells them apart, as everywhere else. +- Right-click on a cell **inside** the current range keeps the range; outside it, focus moves to that cell and the + range collapses to it (spreadsheet behaviour; fires `onCellSelectionModelChange` as a click would). Without + `cellSelection`, focus moves to the cell. Row selection is never changed by right-click. Moving focus re-renders + the old and new focused rows only (accepted, my call). + +### Copy semantics (decided) + +- With `cellSelection` and the clicked cell inside a range: Copy = the range, exactly what Ctrl/Cmd+C copies + (`getCellRangeCopyText`), so synthetic rows are skipped as they are today. +- Otherwise (no `cellSelection`, or the cell was outside the range): Copy = **that one cell's formatted text**. + It does not copy the selected rows, even though Ctrl+C does without `cellSelection` (user delegated, my call: + the right-click points at one cell). On a synthetic row the single cell's shown text is copied. +- Copy with headers: the same rows/cells with one header line first, using `headerName ?? field` (my call). This + needs a header option in `buildRangeTsv` (new code, default off, so Ctrl+C is unchanged). +- Strings: `contextMenuCopy`, `contextMenuCopyWithHeaders`, `contextMenuUndo`, `contextMenuRedo`, + `contextMenuLabel` in `GridLocaleText`. +- New `GridApi` methods: `openContextMenu(id, field)`, `closeContextMenu()`. + +## Architecture (decided) + +- `useGridContextMenu` — new feature hook in `lib/hooks/features/`, called **after `useGridRangeEditing`** (it + needs the cell selection API and undo/redo) and before `useGridRenderWindow`, as its own numbered step in + `docs/architecture/datagrid-orchestration.md`. It owns `{ x, y, id, field } | null`. +- The `contextmenu` listener is a **native listener on the grid root** (like paste and undo). The DOM has no row + id: it reads the field from `closest('[data-field]')` and `data-rowindex` from the row, then maps the index to + an id through the visible row list `[...topPinned, ...centreRowsOfPage, ...bottomPinned]`. `rowIndex` is + already an index into that list (top-pinned from 0, centre rows after them, bottom-pinned at + `bottomRowIndexOffset`), so no `pinnedTopRows.length` offset is added. No attribute or handler is added to `Row` or `Cell`. Shift+F10 / ContextMenu key go through + `handleGridKeyDown`'s context as one new branch, on body cells only; on header or header-filter focus they do + nothing (the browser default is not prevented). +- `GridContextMenu` component in `lib/components/ContextMenu/`, rendered by `GridRoot` (one child position, after + overlays). Portal target and theme scope as in `useToolbarPanel` (closest `.ogx-theme-provider`, else body). +- Positioning: a pure `getPointMenuPosition(x, y, menuSize, viewport, rtl)` in `lib/utils/direction.ts` (flip + left/up at the edges; in RTL open towards inline start). Keyboard-opened menus anchor at the cell's corner. +- Shared `useMenuKeyboard(menuRef, onClose, restoreFocusTo)` extracted from `ColumnMenu` (arrow/Home/End/Tab, + focus first item once positioned, Escape, outside press, close on scroll/resize). `ColumnMenu` switches to it + with no behaviour change (its existing tests must pass unchanged). + +## Constraints + +- Bundle: target ≤ +2 KB gzip on DataGrid; the 92 KB budget is not raised for this. +- React Compiler rules (no `?.`/`??` in `try`, refs in dependency arrays), `check:compiler` unchanged. +- Rows stay memoised: `DataGrid.rowMemo.test.tsx` gets a case that opening/closing the menu renders no `Row`. +- Browser tests (all three engines) for: right-click opens at the pointer and stays on screen near edges, RTL, + keyboard open/close and focus return, inside/outside-range behaviour. WebKit: right-click via + `userEvent.click(el, { button: 'right' })` (`pointer({ keys: '[MouseRight]' })` does not exist in Vitest's Playwright provider). +- Docs: `docs/features/context-menu.md` (+ wiki `SECTIONS`), API reference, CHANGELOG, a demo page with an Export + consumer item, `llms.txt`. + +## Open for the builder + +- Exact visual style (reuse `.ogx-column-menu` tokens unless they clash). +- Whether `GridContextMenuItem` reserves `children` for submenus now (typed but unused) or later. +- Long-press on touch devices: Safari iOS fires no `contextmenu`; acceptable to leave for a later version. diff --git a/lib/components/ContextMenu/ContextMenu.css b/lib/components/ContextMenu/ContextMenu.css new file mode 100644 index 0000000..ee887ed --- /dev/null +++ b/lib/components/ContextMenu/ContextMenu.css @@ -0,0 +1,25 @@ +/* Layered on .ogx-column-menu / .ogx-menu-item, which supply the shared menu tokens. */ +.ogx-context-menu { + min-width: 200px; +} +.ogx-context-menu__label { + flex: 1 1 auto; +} +.ogx-context-menu__shortcut { + flex: 0 0 auto; + margin-inline-start: 24px; + color: var(--ogx-overlay-section-label-text, var(--ogx-color-gray-400, #94a3b8)); + font-size: 11px; +} +.ogx-context-menu__item--danger, +.ogx-context-menu__item--danger .ogx-menu-item__icon { + color: var(--ogx-overlay-item-danger-text, #dc2626); +} +.ogx-context-menu__item--disabled { + opacity: 0.5; + cursor: default; +} +.ogx-context-menu__item--disabled:hover { + background: transparent; + color: var(--ogx-overlay-text, #334155); +} diff --git a/lib/components/ContextMenu/GridContextMenu.test.tsx b/lib/components/ContextMenu/GridContextMenu.test.tsx new file mode 100644 index 0000000..f274bd5 --- /dev/null +++ b/lib/components/ContextMenu/GridContextMenu.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { GridContextMenu } from './GridContextMenu'; +import { cleanContextMenuEntries } from './menuEntries'; +import type { GridContextMenuEntry } from '../../types'; + +afterEach(() => vi.restoreAllMocks()); + +function setup(entries: GridContextMenuEntry[], onClose = vi.fn(), anchorCell: HTMLElement | null = null) { + render(); + return onClose; +} + +describe('GridContextMenu', () => { + it('renders labels, shortcut text, a separator and the aria-label', () => { + setup([{ id: 'a', label: 'Copy', shortcut: 'Ctrl+C' }, 'divider', { id: 'b', label: 'Paste' }]); + expect(screen.getByRole('menu', { name: 'Cell menu' })).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: /Copy/ })).toBeTruthy(); + expect(screen.getByText('Ctrl+C').className).toContain('ogx-context-menu__shortcut'); + expect(screen.getByRole('separator')).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: 'Paste' })).toBeTruthy(); + }); + + it('ignores a click on a disabled item', () => { + const onClick = vi.fn(); + const onClose = setup([{ id: 'a', label: 'Copy', disabled: true, onClick }]); + const item = screen.getByRole('menuitem', { name: 'Copy' }); + expect(item.getAttribute('aria-disabled')).toBe('true'); + fireEvent.click(item); + expect(onClick).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('closes first, then runs onClick', () => { + const order: string[] = []; + const onClose = vi.fn(() => order.push('close')); + setup([{ id: 'a', label: 'Copy', onClick: () => order.push('click') }], onClose); + fireEvent.click(screen.getByRole('menuitem', { name: 'Copy' })); + expect(order).toEqual(['close', 'click']); + }); + + it('restores focus to the anchor cell', () => { + const cell = document.createElement('div'); + cell.tabIndex = 0; + document.body.appendChild(cell); + setup([{ id: 'a', label: 'Copy' }], vi.fn(), cell); + fireEvent.click(screen.getByRole('menuitem', { name: 'Copy' })); + expect(document.activeElement).toBe(cell); + cell.remove(); + }); + + it('contains a throwing onClick and warns once', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + setup([{ id: 'boom-once', label: 'Boom', onClick: () => { throw new Error('x'); } }]); + const item = screen.getByRole('menuitem', { name: 'Boom' }); + expect(() => fireEvent.click(item)).not.toThrow(); + expect(() => fireEvent.click(item)).not.toThrow(); + expect(warn).toHaveBeenCalledTimes(1); + }); +}); + +describe('cleanContextMenuEntries', () => { + const a = { id: 'a', label: 'A' }; + const b = { id: 'b', label: 'B' }; + it('removes leading, trailing and consecutive dividers', () => { + expect(cleanContextMenuEntries(['divider', a, 'divider', 'divider', b, 'divider'])).toEqual([a, 'divider', b]); + }); + it('returns an empty list for only dividers', () => { + expect(cleanContextMenuEntries(['divider', 'divider'])).toEqual([]); + }); +}); diff --git a/lib/components/ContextMenu/GridContextMenu.tsx b/lib/components/ContextMenu/GridContextMenu.tsx new file mode 100644 index 0000000..1caf970 --- /dev/null +++ b/lib/components/ContextMenu/GridContextMenu.tsx @@ -0,0 +1,88 @@ +import React, { useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; +import type { GridContextMenuEntry, GridContextMenuItem } from '../../types'; +import { getViewportWidth, getViewportHeight } from '../../utils/viewport'; +import { getPointMenuPosition, isRtl } from '../../utils/direction'; +import { attempt } from '../../utils/attempt'; +import { warnCallbackFailure } from '../../utils/values'; +import { useMenuKeyboard } from '../ui/useMenuKeyboard'; + +export interface GridContextMenuProps { + entries: readonly GridContextMenuEntry[]; + /** Viewport coordinates the menu opens at. */ + x: number; + y: number; + /** The cell the menu was opened on: focus returns to it and its theme provider hosts the menu. */ + anchorCell: HTMLElement | null; + onClose: () => void; + /** Accessible name of the menu. */ + label: string; +} + +const HIDDEN_STYLE: React.CSSProperties = { position: 'fixed', zIndex: 1300, top: 0, left: 0, visibility: 'hidden' }; + +/** A menu rendered at a point (the grid's context menu). Presentational: the grid decides when it opens. */ +export function GridContextMenu({ entries, x, y, anchorCell, onClose, label }: GridContextMenuProps) { + const menuRef = useRef(null); + const [style, setStyle] = useState(HIDDEN_STYLE); + + React.useLayoutEffect(() => { + const menu = menuRef.current; + if (!menu) return; + const rect = menu.getBoundingClientRect(); + const { top, left } = getPointMenuPosition( + x, y, + { width: rect.width, height: rect.height }, + { width: getViewportWidth(), height: getViewportHeight() }, + isRtl(anchorCell), + ); + setStyle({ position: 'fixed', zIndex: 1300, top, left, visibility: 'visible' }); + }, [x, y, anchorCell]); + + const { close, handleMenuKeyDown } = useMenuKeyboard({ + menuRef, + isPositioned: style.visibility === 'visible', + onClose, + getRestoreTarget: () => anchorCell, + }); + + const handleItemClick = (item: GridContextMenuItem) => { + if (item.disabled) return; + close(true); + const result = attempt(() => item.onClick?.()); + if (!result.ok) { + warnCallbackFailure(`contextMenu:${item.id}`, `context menu item "${item.id}" onClick threw`, result.error); + } + }; + + return ReactDOM.createPortal( +
+ {entries.map((entry, index) => { + if (entry === 'divider') { + return
; + } + const className = [ + 'ogx-menu-item', + 'ogx-context-menu__item', + entry.danger ? 'ogx-context-menu__item--danger' : '', + entry.disabled ? 'ogx-context-menu__item--disabled' : '', + ].filter(Boolean).join(' '); + return ( + + ); + })} +
, + (anchorCell?.closest('.ogx-theme-provider') || document.body), + ); +} diff --git a/lib/components/ContextMenu/menuEntries.ts b/lib/components/ContextMenu/menuEntries.ts new file mode 100644 index 0000000..fda6163 --- /dev/null +++ b/lib/components/ContextMenu/menuEntries.ts @@ -0,0 +1,12 @@ +import type { GridContextMenuEntry } from '../../types'; + +/** Drops leading, trailing and consecutive dividers so a menu never shows an empty section. */ +export function cleanContextMenuEntries(entries: readonly GridContextMenuEntry[]): GridContextMenuEntry[] { + const result: GridContextMenuEntry[] = []; + for (const entry of entries) { + if (entry === 'divider' && (result.length === 0 || result[result.length - 1] === 'divider')) continue; + result.push(entry); + } + if (result[result.length - 1] === 'divider') result.pop(); + return result; +} diff --git a/lib/components/DataGrid/DataGrid.contextMenu.apple.test.tsx b/lib/components/DataGrid/DataGrid.contextMenu.apple.test.tsx new file mode 100644 index 0000000..dd598c8 --- /dev/null +++ b/lib/components/DataGrid/DataGrid.contextMenu.apple.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, fireEvent, act, cleanup } from '@testing-library/react'; +import type React from 'react'; +import type { GridApi, GridColDef } from '../../types'; + +// On Apple platforms Ctrl+click opens the context menu, so a Ctrl+left press inside the cell range +// keeps the range (no focus move, no range drag) as a right press does. Elsewhere Ctrl+click is a +// selection gesture and is left alone. The platform is read once when the module loads, so each +// case loads the grid afresh with a stubbed `navigator.platform`. + +type Row = { id: number; name: string; age: number }; +const ROWS: Row[] = [{ id: 1, name: 'a', age: 1 }, { id: 2, name: 'b', age: 2 }, { id: 3, name: 'c', age: 3 }]; +const COLS: GridColDef[] = [{ field: 'name', width: 100 }, { field: 'age', type: 'number', width: 100 }]; + +const realPlatform = Object.getOwnPropertyDescriptor(Navigator.prototype, 'platform'); +afterEach(() => { + cleanup(); + if (realPlatform) Object.defineProperty(navigator, 'platform', realPlatform); + vi.resetModules(); +}); + +async function setup(platform: string) { + Object.defineProperty(navigator, 'platform', { value: platform, configurable: true }); + vi.resetModules(); + const { DataGrid } = await import('./DataGrid'); + const { useGridApiRef } = await import('../../hooks/core/useGridApiRef'); + const holder: { api: React.MutableRefObject | null } = { api: null }; + function Harness() { + const apiRef = useGridApiRef(); + holder.api = apiRef; + return rows={ROWS} columns={COLS} apiRef={apiRef} height={400} contextMenu cellSelection />; + } + const { container } = render(); + act(() => { + holder.api!.current.setCellSelectionModel([{ anchor: { id: 1, field: 'name' }, head: { id: 2, field: 'age' } }]); + }); + const cell = (rowIndex: number, field: string) => + container.querySelector(`[role="row"][data-rowindex="${rowIndex}"] [data-field="${field}"]`) as HTMLElement; + /** Fires mousedown; true when the grid suppressed its default. */ + const press = (el: Element, init: MouseEventInit) => !fireEvent.mouseDown(el, init); + return { cell, press }; +} + +describe('context menu press inside a cell range', () => { + it('on an Apple platform a Ctrl+left press inside the range is a menu press', async () => { + const { cell, press } = await setup('MacIntel'); + expect(press(cell(1, 'age'), { button: 0, ctrlKey: true })).toBe(true); + expect(press(cell(1, 'age'), { button: 2 })).toBe(true); + // A plain left press and a press outside the range are untouched. + expect(press(cell(1, 'age'), { button: 0 })).toBe(false); + expect(press(cell(2, 'age'), { button: 0, ctrlKey: true })).toBe(false); + }); + + it('elsewhere a Ctrl+left press is left to selection; a right press still keeps the range', async () => { + const { cell, press } = await setup('Win32'); + expect(press(cell(1, 'age'), { button: 0, ctrlKey: true })).toBe(false); + expect(press(cell(1, 'age'), { button: 2 })).toBe(true); + }); +}); diff --git a/lib/components/DataGrid/DataGrid.contextMenu.browser.test.tsx b/lib/components/DataGrid/DataGrid.contextMenu.browser.test.tsx new file mode 100644 index 0000000..d3480c3 --- /dev/null +++ b/lib/components/DataGrid/DataGrid.contextMenu.browser.test.tsx @@ -0,0 +1,253 @@ +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { render, cleanup, act } from '@testing-library/react'; +import { userEvent, commands, page } from 'vitest/browser'; +import { useLayoutEffect } from 'react'; +import type { ReactNode } from 'react'; +import '../../styles/opengridx.css'; +import { DataGrid } from '../../index'; +import { useGridApiRef } from '../../hooks/core/useGridApiRef'; +import type { DataGridProps, GridApi, GridColDef, GridContextMenuEntry, GridContextMenuParams } from '../../types'; + +// The cell context menu (`contextMenu`) in Chromium, Firefox and WebKit: real right-clicks +// (Playwright mouse), menu placement against the window edges, RTL, Shift+F10 and focus return, +// the cell range, and which row a right-click reports with pinned rows, scrolling and grouping. + +declare module 'vitest/browser' { + interface BrowserCommands { + pointerUp: () => Promise; + } +} + +type Row = { id: number; name: string; group: string; n: number }; + +const makeRows = (count: number): Row[] => + Array.from({ length: count }, (_, i) => ({ id: i + 1, name: `r${i + 1}`, group: i % 2 ? 'odd' : 'even', n: i })); +const COLUMNS: GridColDef[] = [ + { field: 'name', headerName: 'Name', width: 140 }, + { field: 'group', headerName: 'Group', width: 140 }, + { field: 'n', headerName: 'N', width: 140, type: 'number' }, +]; + +const frame = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); +const settle = async () => { for (let i = 0; i < 4; i++) await frame(); }; +const menuEl = () => document.querySelector('.ogx-context-menu'); +/** The menu once it is positioned (it renders hidden first, measures, then shows). */ +const openMenu = async () => { + await expect.poll(() => menuEl()?.style.visibility).toBe('visible'); + return menuEl()!; +}; +const cellAt = (c: HTMLElement, rowIndex: number, field: string) => + c.querySelector(`.ogx__viewport [role="row"][data-rowindex="${rowIndex}"] [data-field="${field}"]`); +// Vitest's Playwright provider has no userEvent.pointer: a right button click is Playwright's own +// mouse input in every engine (WebKit included), at the element's centre. +const rightClick = async (el: Element) => { + await userEvent.click(el, { button: 'right' }); +}; +const rangeCells = (c: HTMLElement) => + Array.from(c.querySelectorAll('.ogx__cell--range')).map( + el => `${el.closest('[role="row"]')?.getAttribute('data-rowindex')}:${el.dataset.field}`, + ); + +let apiOut: { current: GridApi | null } = { current: null }; +function Grid(props: Partial> & { box?: React.CSSProperties; dir?: 'rtl' | 'ltr' }) { + const apiRef = useGridApiRef(); + useLayoutEffect(() => { apiOut.current = apiRef.current; }); + const { box, dir, ...rest } = props; + return ( +
+ {/* A text input outside the grid: a Tab-order anchor that every engine stops on. */} + + rows={makeRows(40)} columns={COLUMNS} height={260} contextMenu apiRef={apiRef} {...rest} /> +
+ ); +} +const renderGrid = async (ui: ReactNode) => { + const r = render(<>{ui}); + await settle(); + return r; +}; + +beforeEach(async () => { + await page.viewport(1000, 700); + apiOut = { current: null }; +}); +afterEach(async () => { + await commands.pointerUp().catch(() => {}); + cleanup(); +}); + +describe('DataGrid context menu (browser)', () => { + it('a right-click opens the menu at the pointer', async () => { + const { container } = await renderGrid(); + const cell = cellAt(container, 1, 'name')!; + await rightClick(cell); + const menu = await openMenu(); + const cellRect = cell.getBoundingClientRect(); + const rect = menu.getBoundingClientRect(); + // Playwright presses at the cell's centre. + expect(Math.abs(rect.left - (cellRect.left + cellRect.width / 2))).toBeLessThanOrEqual(2); + expect(Math.abs(rect.top - (cellRect.top + cellRect.height / 2))).toBeLessThanOrEqual(2); + expect(menu.querySelectorAll('[role="menuitem"]').length).toBeGreaterThan(0); + }); + + it('near the right and bottom edges of the window the menu stays inside it', async () => { + const { container } = await renderGrid( +
, + ); + // The last column whose centre is inside the grid (the press lands there), in the last row whose + // centre is inside the viewport: near the bottom-right corner of the window. + const viewportRect = container.querySelector('.ogx__viewport')!.getBoundingClientRect(); + const centreInside = (el: Element) => { + const r = el.getBoundingClientRect(); + const x = r.left + r.width / 2; + const y = r.top + r.height / 2; + return x > viewportRect.left && x < viewportRect.right - 20 && y > viewportRect.top && y < viewportRect.bottom - 20; + }; + const cell = Array.from(container.querySelectorAll('.ogx__rows [role="row"] .ogx__cell')).filter(centreInside).pop()!; + expect(window.innerWidth - cell.getBoundingClientRect().right).toBeLessThan(250); + expect(window.innerHeight - cell.getBoundingClientRect().bottom).toBeLessThan(80); + await rightClick(cell); + const rect = (await openMenu()).getBoundingClientRect(); + expect(rect.right).toBeLessThanOrEqual(window.innerWidth); + expect(rect.bottom).toBeLessThanOrEqual(window.innerHeight); + expect(rect.left).toBeGreaterThanOrEqual(0); + expect(rect.top).toBeGreaterThanOrEqual(0); + }); + + it('in a right-to-left grid the menu opens towards the inline start', async () => { + const { container } = await renderGrid(); + const cell = cellAt(container, 1, 'group')!; + await rightClick(cell); + const rect = (await openMenu()).getBoundingClientRect(); + const pointerX = cell.getBoundingClientRect().left + cell.getBoundingClientRect().width / 2; + expect(Math.abs(rect.right - pointerX)).toBeLessThanOrEqual(2); + }); + + it('Shift+F10 opens the menu on the focused cell; Escape closes it and focus returns to the cell', async () => { + const { container } = await renderGrid(); + const cell = cellAt(container, 2, 'group')!; + await userEvent.click(cell); + await settle(); + await userEvent.keyboard('{Shift>}{F10}{/Shift}'); + const menu = await openMenu(); + const cellRect = cellAt(container, 2, 'group')!.getBoundingClientRect(); + // Keyboard-opened menus anchor at the cell's corner. + expect(Math.abs(menu.getBoundingClientRect().top - cellRect.bottom)).toBeLessThanOrEqual(2); + await expect.poll(() => menu.contains(document.activeElement)).toBe(true); + await userEvent.keyboard('{Escape}'); + await expect.poll(() => menuEl()).toBeNull(); + await expect.poll(() => document.activeElement === cellAt(container, 2, 'group')).toBe(true); + }); + + it('a right-click inside a cell range keeps it; outside collapses it', async () => { + const { container } = await renderGrid(); + act(() => { + apiOut.current!.setCellSelectionModel([{ anchor: { id: 1, field: 'name' }, head: { id: 2, field: 'group' } }]); + }); + await settle(); + const range = ['0:name', '0:group', '1:name', '1:group']; + expect(rangeCells(container)).toEqual(range); + + await rightClick(cellAt(container, 1, 'group')!); + await openMenu(); + expect(rangeCells(container)).toEqual(range); + await userEvent.keyboard('{Escape}'); + await expect.poll(() => menuEl()).toBeNull(); + + await rightClick(cellAt(container, 4, 'n')!); + await openMenu(); + // Collapsed to the clicked cell (a one-cell range draws no range outline). + expect(rangeCells(container)).toEqual([]); + expect(apiOut.current!.getCellSelectionModel()).toEqual([{ anchor: { id: 5, field: 'n' }, head: { id: 5, field: 'n' } }]); + }); + + describe('reports the row that was right-clicked', () => { + const lastParams = (fn: ReturnType) => fn.mock.calls[fn.mock.calls.length - 1][0]; + const spy = () => vi.fn((_p: GridContextMenuParams, defaults: readonly GridContextMenuEntry[]) => defaults); + + it('a top-pinned row', async () => { + const getItems = spy(); + const { container } = await renderGrid(); + const cell = container.querySelector('.ogx__viewport [role="row"][data-rowindex="0"] [data-field="name"]')!; + expect(cell.textContent).toBe('r30'); + await rightClick(cell); + await openMenu(); + expect(lastParams(getItems).id).toBe(30); + }); + + it('a centre row after scrolling', async () => { + const getItems = spy(); + const { container } = await renderGrid(); + const viewport = container.querySelector('.ogx__viewport')!; + viewport.scrollTop = 600; + await settle(); + const cell = Array.from(container.querySelectorAll('.ogx__rows [role="row"] [data-field="name"]')) + .find(el => { + const r = el.getBoundingClientRect(); + const v = viewport.getBoundingClientRect(); + return r.top > v.top + 60 && r.bottom < v.bottom; + })!; + const expected = Number(cell.textContent!.slice(1)); + expect(expected).toBeGreaterThan(10); + await rightClick(cell); + await openMenu(); + expect(lastParams(getItems).id).toBe(expected); + }); + + it('a grouped child row', async () => { + const getItems = spy(); + const { container } = await renderGrid( + , + ); + const cell = Array.from(container.querySelectorAll('.ogx__viewport [data-field="name"]')) + .find(el => el.textContent === 'r3')!; + await rightClick(cell); + await openMenu(); + const call = lastParams(getItems); + expect(call.id).toBe(3); + expect(call.row.name).toBe('r3'); + }); + }); + + it('scrolling the grid closes the menu', async () => { + const { container } = await renderGrid(); + await rightClick(cellAt(container, 1, 'name')!); + await openMenu(); + container.querySelector('.ogx__viewport')!.scrollTop = 200; + await expect.poll(() => menuEl()).toBeNull(); + }); + + it('openContextMenu on a row far below the viewport scrolls to it and opens anchored there', async () => { + const { container } = await renderGrid(); + act(() => { apiOut.current!.openContextMenu(1500, 'group'); }); + const menu = await openMenu(); + const rect = menu.getBoundingClientRect(); + expect(rect.left).toBeGreaterThanOrEqual(0); + expect(rect.top).toBeGreaterThanOrEqual(0); + expect(rect.right).toBeLessThanOrEqual(window.innerWidth); + expect(rect.bottom).toBeLessThanOrEqual(window.innerHeight); + // Still open after the scroll has settled: the scroll event did not close it. + await settle(); + expect(menuEl()).not.toBeNull(); + await expect.poll(() => menu.contains(document.activeElement)).toBe(true); + await userEvent.keyboard('{Escape}'); + await expect.poll(() => menuEl()).toBeNull(); + await expect.poll(() => document.activeElement?.closest('[role="row"]')?.getAttribute('data-rowindex')).toBe('1499'); + expect((document.activeElement as HTMLElement).dataset.field).toBe('group'); + expect(container.contains(document.activeElement)).toBe(true); + }); + + it('Shift+F10 on a focused cell scrolled out of view opens the menu and it stays open', async () => { + const { container } = await renderGrid(); + await userEvent.click(cellAt(container, 1, 'name')!); + await settle(); + container.querySelector('.ogx__viewport')!.scrollTop = 600; + await settle(); + await userEvent.keyboard('{Shift>}{F10}{/Shift}'); + await openMenu(); + await settle(); + expect(menuEl()).not.toBeNull(); + await userEvent.keyboard('{Escape}'); + await expect.poll(() => document.activeElement === cellAt(container, 1, 'name')).toBe(true); + }); +}); diff --git a/lib/components/DataGrid/DataGrid.contextMenu.test.tsx b/lib/components/DataGrid/DataGrid.contextMenu.test.tsx new file mode 100644 index 0000000..f9e67ce --- /dev/null +++ b/lib/components/DataGrid/DataGrid.contextMenu.test.tsx @@ -0,0 +1,277 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, fireEvent, act, waitFor } from '@testing-library/react'; +import React from 'react'; +import { DataGrid } from './DataGrid'; +import { useGridApiRef } from '../../hooks/core/useGridApiRef'; +import type { DataGridProps, GridApi, GridColDef, GridContextMenuEntry, GridContextMenuParams } from '../../types'; + +// The cell context menu (`contextMenu`) opened by a right-click, through the public props. +// Positioning and focus need a real browser: the browser tests of Task 6b. + +type Row = { id: number; dept: string; name: string; age: number }; + +const ROWS: Row[] = [ + { id: 1, dept: 'A', name: 'Alice', age: 30 }, + { id: 2, dept: 'A', name: 'Bob', age: 40 }, + { id: 3, dept: 'B', name: 'Carol', age: 50 }, + { id: 4, dept: 'B', name: 'Dan', age: 60 }, + { id: 5, dept: 'C', name: 'Eve', age: 70 }, +]; + +const COLS: GridColDef[] = [ + { field: 'name', headerName: 'Name', width: 100 }, + { field: 'age', headerName: 'Age', type: 'number', width: 100 }, +]; + +let writeText: ReturnType Promise>>; + +function setClipboard(value: unknown): void { + Object.defineProperty(navigator, 'clipboard', { value, configurable: true, writable: true }); +} + +beforeEach(() => { + writeText = vi.fn<(text: string) => Promise>(() => Promise.resolve()); + setClipboard({ writeText }); +}); +afterEach(() => { + setClipboard(undefined); + vi.restoreAllMocks(); +}); + +type GridProps = Partial>; + +function renderGrid(props: GridProps = {}) { + const holder: { api: React.MutableRefObject | null } = { api: null }; + function Harness(p: GridProps) { + const apiRef = useGridApiRef(); + holder.api = apiRef; + return rows={ROWS} columns={COLS} apiRef={apiRef} height={400} {...p} />; + } + const utils = render(); + return { + ...utils, + api: () => holder.api!.current, + rerenderGrid: (next: GridProps) => utils.rerender(), + }; +} + +const rowEl = (c: HTMLElement, rowIndex: number) => c.querySelector(`[role="row"][data-rowindex="${rowIndex}"]`) as HTMLElement; +const cellAt = (c: HTMLElement, rowIndex: number, field: string) => + rowEl(c, rowIndex)?.querySelector(`[data-field="${field}"]`) as HTMLElement; +const menu = () => document.querySelector('[role="menu"]'); +const menuLabels = () => Array.from(document.querySelectorAll('[role="menuitem"]')).map(el => el.textContent); +const menuItem = (label: string) => + Array.from(document.querySelectorAll('[role="menuitem"]')).find(el => el.textContent === label) as HTMLElement; + +/** Fires `contextmenu` and returns whether the browser default would be suppressed. */ +function rightClick(el: Element): boolean { + let prevented = false; + act(() => { + prevented = !fireEvent.contextMenu(el, { clientX: 20, clientY: 30 }); + }); + return prevented; +} + +describe('DataGrid contextMenu', () => { + it('without contextMenu: no menu and the browser menu is kept', () => { + const { container } = renderGrid(); + expect(rightClick(cellAt(container, 0, 'name'))).toBe(false); + expect(menu()).toBeNull(); + }); + + it('opens with Copy and Copy with headers', () => { + const { container } = renderGrid({ contextMenu: true }); + expect(rightClick(cellAt(container, 0, 'name'))).toBe(true); + expect(menu()).not.toBeNull(); + expect(menuLabels()).toEqual(['Copy', 'Copy with headers']); + }); + + it('undoRedo adds Undo and Redo, disabled with an empty history', () => { + const { container } = renderGrid({ contextMenu: true, undoRedo: true }); + rightClick(cellAt(container, 0, 'name')); + expect(menuLabels()).toEqual(['Copy', 'Copy with headers', 'Undo', 'Redo']); + expect(menuItem('Undo').getAttribute('aria-disabled')).toBe('true'); + expect(menuItem('Redo').getAttribute('aria-disabled')).toBe('true'); + }); + + it('getContextMenuItems receives the cell params and replaces the items', () => { + const getItems = vi.fn((_p: GridContextMenuParams, defaults: readonly GridContextMenuEntry[]) => + [{ id: 'mine', label: 'Mine' }, ...defaults.slice(0, 1)]); + const cols: GridColDef[] = [ + ...COLS, + { field: 'label', headerName: 'Label', width: 100, valueGetter: ({ row }) => `${row.name}!` }, + ]; + const { container } = renderGrid({ contextMenu: true, columns: cols, getContextMenuItems: getItems }); + rightClick(cellAt(container, 1, 'label')); + expect(getItems).toHaveBeenCalledTimes(1); + const [params, defaults] = getItems.mock.calls[0]; + expect(params.id).toBe(2); + expect(params.field).toBe('label'); + expect(params.value).toBe('Bob!'); + expect(params.row).toBe(ROWS[1]); + expect(params.colDef.field).toBe('label'); + expect(params.cellRange).toBeNull(); + expect(defaults.map(d => (d === 'divider' ? d : d.id))).toEqual(['copy', 'copyWithHeaders']); + expect(menuLabels()).toEqual(['Mine', 'Copy']); + }); + + it('passes rowMeta on a group row', () => { + const getItems = vi.fn((_p: GridContextMenuParams, d: readonly GridContextMenuEntry[]) => d); + const { container } = renderGrid({ + contextMenu: true, rowGroupingModel: ['dept'], getContextMenuItems: getItems, + }); + const groupRow = Array.from(container.querySelectorAll('[role="row"][data-rowindex]'))[0]; + const cell = groupRow.querySelector('[role="gridcell"][data-field="name"]') as HTMLElement; + rightClick(cell); + expect(getItems).toHaveBeenCalledTimes(1); + expect(getItems.mock.calls[0][0].rowMeta?.isGroupRow).toBe(true); + }); + + it('an empty list: no menu and the browser menu is kept', () => { + const { container } = renderGrid({ contextMenu: true, getContextMenuItems: () => [] }); + expect(rightClick(cellAt(container, 0, 'name'))).toBe(false); + expect(menu()).toBeNull(); + }); + + it('a throwing getContextMenuItems falls back to the defaults with one warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { container } = renderGrid({ + contextMenu: true, + getContextMenuItems: () => { throw new Error('boom'); }, + }); + expect(rightClick(cellAt(container, 0, 'name'))).toBe(true); + expect(menuLabels()).toEqual(['Copy', 'Copy with headers']); + act(() => { fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }); }); + rightClick(cellAt(container, 1, 'name')); + expect(warn.mock.calls.filter(([m]) => String(m).includes('getContextMenuItems'))).toHaveLength(1); + }); + + it('header, header filter and editor input keep the browser menu', () => { + const editable: GridColDef[] = [{ ...COLS[0], editable: true }, COLS[1]]; + const { container } = renderGrid({ contextMenu: true, headerFilters: true, columns: editable }); + const header = container.querySelector('.ogx__header [data-field="name"]') as HTMLElement; + expect(rightClick(header)).toBe(false); + const filterCell = container.querySelector('.ogx__header-filter-row [data-field="name"]') as HTMLElement; + expect(rightClick(filterCell)).toBe(false); + act(() => { fireEvent.doubleClick(cellAt(container, 0, 'name')); }); + const input = cellAt(container, 0, 'name').querySelector('input') as HTMLInputElement; + expect(input).not.toBeNull(); + expect(rightClick(input)).toBe(false); + expect(menu()).toBeNull(); + }); + + it('Copy writes the cell text', async () => { + const { container } = renderGrid({ contextMenu: true }); + rightClick(cellAt(container, 1, 'name')); + await act(async () => { fireEvent.click(menuItem('Copy')); }); + expect(writeText).toHaveBeenLastCalledWith('Bob'); + expect(menu()).toBeNull(); + }); + + it('Copy with headers writes the header line first', async () => { + const { container } = renderGrid({ contextMenu: true }); + rightClick(cellAt(container, 1, 'age')); + await act(async () => { fireEvent.click(menuItem('Copy with headers')); }); + expect(writeText).toHaveBeenLastCalledWith('Age\n40'); + }); + + it('Copy inside a 2×2 range writes the range', async () => { + const { container, api } = renderGrid({ contextMenu: true, cellSelection: true }); + act(() => { api().selectCellRange({ id: 1, field: 'name' }, { id: 2, field: 'age' }); }); + rightClick(cellAt(container, 1, 'age')); + expect(api().getCellSelectionModel()).toEqual([{ anchor: { id: 1, field: 'name' }, head: { id: 2, field: 'age' } }]); + await act(async () => { fireEvent.click(menuItem('Copy')); }); + expect(writeText).toHaveBeenLastCalledWith('Alice\t30\nBob\t40'); + }); + + it('right-click outside the range collapses it to the cell', async () => { + const { container, api } = renderGrid({ contextMenu: true, cellSelection: true }); + act(() => { api().selectCellRange({ id: 1, field: 'name' }, { id: 2, field: 'age' }); }); + rightClick(cellAt(container, 3, 'name')); + expect(api().getCellSelectionModel()).toEqual([{ anchor: { id: 4, field: 'name' }, head: { id: 4, field: 'name' } }]); + await act(async () => { fireEvent.click(menuItem('Copy')); }); + expect(writeText).toHaveBeenLastCalledWith('Dan'); + }); + + it('only dividers: no menu and the browser menu is kept', () => { + const { container } = renderGrid({ contextMenu: true, getContextMenuItems: () => ['divider', 'divider'] }); + expect(rightClick(cellAt(container, 0, 'name'))).toBe(false); + expect(menu()).toBeNull(); + }); + + it('openContextMenu does not open when the row moves before the next frame', async () => { + const { api, rerenderGrid } = renderGrid({ contextMenu: true }); + act(() => { api().openContextMenu(3, 'name'); }); + // Same rows, rotated, before the frame that opens the menu: index 2 is now another row. + rerenderGrid({ contextMenu: true, rows: [...ROWS.slice(3), ...ROWS.slice(0, 3)] }); + await act(() => new Promise(r => setTimeout(r, 300))); + expect(menu()).toBeNull(); + }); + + it('openContextMenu and closeContextMenu through apiRef', async () => { + const { api } = renderGrid({ contextMenu: true }); + act(() => { api().openContextMenu(3, 'name'); }); + await waitFor(() => expect(menu()).not.toBeNull()); + act(() => { api().closeContextMenu(); }); + expect(menu()).toBeNull(); + }); + + it('warns in development when getContextMenuItems is passed without contextMenu', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderGrid({ getContextMenuItems: (_p, d) => d }); + expect(warn.mock.calls.some(([m]) => String(m).includes('getContextMenuItems'))).toBe(true); + }); + + it('closes when its row is removed', () => { + const { container, rerenderGrid } = renderGrid({ contextMenu: true }); + rightClick(cellAt(container, 1, 'name')); + expect(menu()).not.toBeNull(); + rerenderGrid({ contextMenu: true, rows: ROWS.filter(r => r.id !== 2) }); + expect(menu()).toBeNull(); + rerenderGrid({ contextMenu: true, rows: ROWS }); + expect(menu()).toBeNull(); + }); + + describe('row resolution', () => { + function idsFrom(props: GridProps) { + const ids: unknown[] = []; + const utils = renderGrid({ + contextMenu: true, + getContextMenuItems: (p, d) => { ids.push(p.id); return d; }, + ...props, + }); + return { ...utils, ids }; + } + + it('top-pinned, centre and bottom-pinned rows report their own ids', () => { + const { container, ids } = idsFrom({ pinnedRows: { top: [4, 5], bottom: [3] } }); + // Visible list: [4, 5 | 1, 2 | 3]. + rightClick(cellAt(container, 0, 'name')); + rightClick(cellAt(container, 2, 'name')); + rightClick(cellAt(container, 4, 'name')); + expect(ids).toEqual([4, 1, 3]); + }); + + it('on page 2 the first centre row is that page\'s row', () => { + const { container, ids } = idsFrom({ + pagination: true, paginationModel: { page: 1, pageSize: 2 }, pageSizeOptions: [2], + }); + rightClick(cellAt(container, 0, 'name')); + expect(ids).toEqual([3]); + }); + + it('a covered row-span cell resolves to the span origin', async () => { + const cols: GridColDef[] = [ + { field: 'dept', headerName: 'Dept', width: 100, rowSpan: ({ row }) => (row.id === 1 ? 2 : 1) }, + ...COLS, + ]; + const { container, ids } = idsFrom({ columns: cols }); + const covered = cellAt(container, 1, 'dept'); + expect(covered.classList.contains('ogx__cell--hidden')).toBe(true); + rightClick(covered); + expect(ids).toEqual([1]); + await act(async () => { fireEvent.click(menuItem('Copy')); }); + expect(writeText).toHaveBeenLastCalledWith('A'); + }); + }); +}); diff --git a/lib/components/DataGrid/DataGrid.rowMemo.test.tsx b/lib/components/DataGrid/DataGrid.rowMemo.test.tsx index 5fdfe5a..cb548f0 100644 --- a/lib/components/DataGrid/DataGrid.rowMemo.test.tsx +++ b/lib/components/DataGrid/DataGrid.rowMemo.test.tsx @@ -67,4 +67,64 @@ describe('row memoisation', () => { expect(renders.get(0) ?? 0).toBe(0); expect(renders.get(4) ?? 0).toBe(0); }); + + describe('context menu', () => { + function setupMenu() { + const renders = new Map(); + const isCellEditable = ({ row }: { row: Row }) => { + renders.set(row.id, (renders.get(row.id) ?? 0) + 1); + return true; + }; + const columns: GridColDef[] = [{ field: 'name', editable: true }]; + let bump = () => {}; + function Parent() { + const [n, setN] = useState(0); + bump = () => setN(n + 1); + return ( +
+ rows={ROWS} columns={columns} height={400} isCellEditable={isCellEditable} contextMenu /> +
+ ); + } + const { container } = render(); + const cell = (index: number) => container.querySelectorAll('.ogx__rows .ogx__row [data-field="name"]')[index]; + const total = () => [...renders.values()].reduce((a, b) => a + b, 0); + const closeMenu = () => { + const menu = document.querySelector('[role="menu"]'); + expect(menu).not.toBeNull(); + act(() => { fireEvent.keyDown(document, { key: 'Escape' }); }); + expect(document.querySelector('[role="menu"]')).toBeNull(); + }; + return { renders, cell, total, closeMenu, bump: () => bump() }; + } + + it('opening and closing the menu on the focused cell renders no row', () => { + const { renders, cell, total, closeMenu } = setupMenu(); + act(() => { fireEvent.click(cell(1)); }); + renders.clear(); + act(() => { fireEvent.contextMenu(cell(1)); }); + closeMenu(); + expect(total()).toBe(0); + }); + + it('right-clicking a cell in another row renders at most the old and new focused rows', () => { + const { renders, cell, total, closeMenu } = setupMenu(); + act(() => { fireEvent.click(cell(1)); }); + renders.clear(); + act(() => { fireEvent.contextMenu(cell(3)); }); + expect(total()).toBeLessThanOrEqual(2); + expect(renders.get(0) ?? 0).toBe(0); + expect(renders.get(4) ?? 0).toBe(0); + renders.clear(); + closeMenu(); + expect(total()).toBe(0); + }); + + it('with the menu closed, a grid re-render renders no row', () => { + const { renders, total, bump } = setupMenu(); + renders.clear(); + act(() => { bump(); }); + expect(total()).toBe(0); + }); + }); }); diff --git a/lib/components/DataGrid/DataGrid.tsx b/lib/components/DataGrid/DataGrid.tsx index 5a455fa..09452ba 100644 --- a/lib/components/DataGrid/DataGrid.tsx +++ b/lib/components/DataGrid/DataGrid.tsx @@ -24,6 +24,7 @@ import { useGridEditPipeline } from '../../hooks/core/useGridEditPipeline'; import { useGridSpanning } from '../../hooks/features/useGridSpanning'; import { useColumnGroupReorderGuard } from '../../hooks/features/useColumnGroupReorderGuard'; import { useGridDataSource } from '../../hooks/features/useGridDataSource'; +import { useGridContextMenu } from '../../hooks/features/useGridContextMenu'; import { useServerTreeChildren } from '../../hooks/features/useServerTreeChildren'; import { useAggregation, useServerAggregationResults } from '../../hooks/features/useAggregation'; import { useGridPivot } from '../../hooks/features/useGridPivot'; @@ -45,6 +46,7 @@ import { getPaginationRowCount } from '../../utils/pagination'; import { useGridToolbarStack } from '../../hooks/core/useGridToolbarStack'; import { DEFAULT_HEADER_FILTER_HEIGHT } from '../../utils/headerFilters'; import type { DataGridProps, DataGridUntypedColumnsProps, GridValidRowModel, GridRowModel, GridColDef } from '../../types'; +import type { FocusedCell } from '../../utils/focus'; import { untypedColumns } from '../../utils/untypedColumns'; /** @@ -463,6 +465,8 @@ export function DataGrid(props: DataGridP const { handleSort, handleSortAdd } = useGridSortHandlers({ sortModel, onSortModelChange: handleSortModelChange }); const rowSelectionEnabled = checkboxSelection || !disableRowSelectionOnClick; + // Filled by useGridContextMenu (step 37a) for Shift+F10 / the ContextMenu key. + const openContextMenuAtRef = useRef<((cell: FocusedCell) => void) | null>(null); const { focusedCell, setFocusedCell, viewportTabIndex, ensureGridFocus, handleFocus, handleBlur, handleKeyDown, handleMouseDownCapture, @@ -487,6 +491,7 @@ export function DataGrid(props: DataGridP rowHeight: effectiveRowHeight, cellSelection: cellSelection.keyboard, headerFilters: showHeaderFilters, + openContextMenuAtRef, }); const { handleCellClick, handleHeaderClick } = useGridPointerFocusHandlers({ @@ -518,6 +523,22 @@ export function DataGrid(props: DataGridP onPinnedColumnsChange: handlePinnedColumnsChange, }); + // Step 37a: the cell context menu (contextMenu), opened by a right-click on a body cell. + const contextMenu = useGridContextMenu({ + enabled: Boolean(props.contextMenu) && !listView, + getContextMenuItems: props.getContextMenuItems, + undoRedo: Boolean(props.undoRedo), + apiRef, containerRef, viewportRef, allRenderableRows, + getRowId: getRowIdOf, + navigationColumns, renderedDataColumns, + pinnedTopRowCount: pinnedTopRows.length, + rowMetaMap, + getSpanOrigin: spanning.getSpanOrigin, + selection: cellSelection, + setFocusedCell, localeText, + openAtRef: openContextMenuAtRef, + }); + // Steps 38–41: aria row bases, the span-widened row window, the rows and the columns to render. const { ariaRows, spanRowWindow, visibleRows, renderColumns } = useGridRenderWindow({ aria: { @@ -551,6 +572,7 @@ export function DataGrid(props: DataGridP unpinnedRowCount: virtualization.cumulativeHeights.length, listViewWithoutColumn: listViewRequested && !listViewColumn, listViewRowCount: listView && listViewColumn ? allRenderableRows.length : 0, + contextMenuItemsWithoutMenu: Boolean(props.getContextMenuItems) && !props.contextMenu, }); // Header drag-reorder keeps a column inside its column group (and ungrouped columns outside groups). @@ -615,6 +637,7 @@ export function DataGrid(props: DataGridP toggleExpansion: activeHierarchyHandlers?.toggleExpansion, showCellSelectionStats, apiRef: gridData.apiRef, hasAggregation, isHierarchyEnabled, dataSourceError: state.dataSource.error, filteredRowCount: filteredRows.length, onRetry: dataSource ? dataSourceHandlers.fetchRows : undefined, + contextMenu: contextMenu.menu, closeContextMenu: contextMenu.close, }; return {...rootProps} />; diff --git a/lib/components/DataGrid/GridRoot.tsx b/lib/components/DataGrid/GridRoot.tsx index 8aaf6a3..cf1badc 100644 --- a/lib/components/DataGrid/GridRoot.tsx +++ b/lib/components/DataGrid/GridRoot.tsx @@ -14,6 +14,8 @@ import { GridPaginationArea } from './GridPaginationArea'; import { GridLiveRegion } from './GridLiveRegion'; import { GridLoadingOverlay } from './GridLoadingOverlay'; import { GridErrorOverlay } from './GridErrorOverlay'; +import { GridContextMenu } from '../ContextMenu/GridContextMenu'; +import type { GridContextMenuState } from '../../hooks/features/useGridContextMenu'; import { pickPaginationLocaleText } from '../../utils/pagination'; import type { DataGridProps, GridApi, GridColDef, GridRowModel } from '../../types'; import type { UseGridAiAssistantResult } from '../../hooks/features/useGridAiAssistant'; @@ -65,6 +67,9 @@ export interface GridRootProps extends Omit void) | undefined; + /** The open cell context menu, or null. */ + contextMenu: GridContextMenuState | null; + closeContextMenu: () => void; } /** @@ -80,7 +85,7 @@ export function GridRoot(props: GridRootProps) { pagination, effectivePaginationModel, currentPage, pageSizeOptions, columnsByField, paginationRowCount, handlePaginationModelChange, onRowsScrollEnd, toggleExpansion, showCellSelectionStats, apiRef, hasAggregation, isHierarchyEnabled, - dataSourceError, filteredRowCount, onRetry, + dataSourceError, filteredRowCount, onRetry, contextMenu, closeContextMenu, ...viewport } = props; const { @@ -242,6 +247,17 @@ export function GridRoot(props: GridRootProps) { )} + + {contextMenu && ( + + )}
); diff --git a/lib/components/Header/ColumnMenu.tsx b/lib/components/Header/ColumnMenu.tsx index 24b701b..eb17fe8 100644 --- a/lib/components/Header/ColumnMenu.tsx +++ b/lib/components/Header/ColumnMenu.tsx @@ -1,9 +1,10 @@ -import React, { useRef, useEffect, useState } from 'react'; +import React, { useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import type { GridColDef, GridRowModel, GridSortDirection, GridColumnPinning } from '../../types'; import { getViewportWidth, getViewportHeight } from '../../utils/viewport'; import { isRtl } from '../../utils/direction'; import { useGridLocale } from '../DataGrid/gridLocaleContext'; +import { useMenuKeyboard } from '../ui/useMenuKeyboard'; interface ColumnMenuProps { @@ -119,42 +120,10 @@ function UnpinIcon() { ); } -const menuItemsOf = (menu: HTMLElement | null): HTMLElement[] => - menu ? Array.from(menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"]')) : []; - export function ColumnMenu({ colDef, sortModel, onSort, onHide, onPin, pinnedColumns, onManageColumns, onClose, anchorEl }: ColumnMenuProps) { const menuRef = useRef(null); const locale = useGridLocale(); - // Closes the menu; when it was left from the keyboard or by choosing an item, focus goes back - // to the header cell that owns it (WAI-ARIA menu button pattern). - const close = React.useCallback((restoreFocus: boolean) => { - if (restoreFocus) { - const headerCell = anchorEl?.closest('[role="columnheader"]') ?? anchorEl; - headerCell?.focus({ preventScroll: true }); - } - onClose(); - }, [anchorEl, onClose]); - - const handleMenuKeyDown = (event: React.KeyboardEvent) => { - const items = menuItemsOf(menuRef.current); - const current = items.indexOf(event.target as HTMLElement); - let next = -1; - if (event.key === 'ArrowDown') next = current + 1 >= items.length ? 0 : current + 1; - else if (event.key === 'ArrowUp') next = current <= 0 ? items.length - 1 : current - 1; - else if (event.key === 'Home') next = 0; - else if (event.key === 'End') next = items.length - 1; - else if (event.key === 'Tab') { - event.preventDefault(); - close(true); - return; - } else { - return; - } - event.preventDefault(); - items[next]?.focus(); - }; - const [style, setStyle] = useState({ position: 'fixed', zIndex: 1300, top: 0, left: 0, visibility: 'hidden' }); React.useLayoutEffect(() => { @@ -180,35 +149,16 @@ export function ColumnMenu({ colDef, sortModel, onSort, onHide, onPin, pinnedCol setStyle({ position: 'fixed', zIndex: 1300, top, left, visibility: 'visible' }); }, [anchorEl]); - // Move focus into the menu once it is positioned (a visibility:hidden element cannot take focus). const isPositioned = style.visibility === 'visible'; - React.useLayoutEffect(() => { - if (isPositioned) menuItemsOf(menuRef.current)[0]?.focus({ preventScroll: true }); - }, [isPositioned]); - - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - menuRef.current && !menuRef.current.contains(event.target as Node) && - anchorEl && !anchorEl.contains(event.target as Node) - ) onClose(); - } - function handleKey(e: KeyboardEvent) { - if (e.key === 'Escape') close(true); - } - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleKey); - // Close menu on any scroll or resize so it doesn't float detached from its anchor - window.addEventListener('scroll', onClose, true); - window.addEventListener('resize', onClose); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleKey); - window.removeEventListener('scroll', onClose, true); - window.removeEventListener('resize', onClose); - }; - }, [anchorEl, onClose, close]); + // Closing from the keyboard or by choosing an item returns focus to the header cell that owns + // the menu (WAI-ARIA menu button pattern). + const { close, handleMenuKeyDown } = useMenuKeyboard({ + menuRef, + isPositioned, + onClose, + getRestoreTarget: () => anchorEl?.closest('[role="columnheader"]') ?? anchorEl, + ignoreTarget: anchorEl, + }); const handleSort = (direction: GridSortDirection) => { onSort?.(colDef.field, direction); close(true); }; // `hideable: false` / `pinnable: false` mean the column cannot be hidden / pinned from the UI, diff --git a/lib/components/ui/useMenuKeyboard.test.tsx b/lib/components/ui/useMenuKeyboard.test.tsx new file mode 100644 index 0000000..728f4a2 --- /dev/null +++ b/lib/components/ui/useMenuKeyboard.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useRef, useState } from 'react'; +import { useMenuKeyboard } from './useMenuKeyboard'; + +interface HarnessProps { + onClose: () => void; + isPositioned?: boolean; +} + +function Harness({ onClose, isPositioned = true }: HarnessProps) { + const menuRef = useRef(null); + const restoreRef = useRef(null); + const [anchor, setAnchor] = useState(null); + const { handleMenuKeyDown } = useMenuKeyboard({ + menuRef, + isPositioned, + onClose, + getRestoreTarget: () => restoreRef.current, + ignoreTarget: anchor, + }); + return ( +
+ + +
+ + + +
+

outside

+
+ ); +} + +describe('useMenuKeyboard', () => { + it('focuses the first item once positioned', () => { + render(); + expect(document.activeElement).toBe(screen.getByText('one')); + }); + + it('does not focus before it is positioned', () => { + render(); + expect(document.activeElement).not.toBe(screen.getByText('one')); + }); + + it('ArrowDown from the last item wraps to the first, ArrowUp from the first to the last', () => { + render(); + screen.getByText('three').focus(); + fireEvent.keyDown(screen.getByText('three'), { key: 'ArrowDown' }); + expect(document.activeElement).toBe(screen.getByText('one')); + fireEvent.keyDown(screen.getByText('one'), { key: 'ArrowUp' }); + expect(document.activeElement).toBe(screen.getByText('three')); + }); + + it('Home and End jump to the ends', () => { + render(); + fireEvent.keyDown(screen.getByText('one'), { key: 'End' }); + expect(document.activeElement).toBe(screen.getByText('three')); + fireEvent.keyDown(screen.getByText('three'), { key: 'Home' }); + expect(document.activeElement).toBe(screen.getByText('one')); + }); + + it('Escape closes and focuses the restore target', () => { + const onClose = vi.fn(); + render(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(screen.getByText('restore')); + }); + + it('Tab closes with focus restore', () => { + const onClose = vi.fn(); + render(); + fireEvent.keyDown(screen.getByText('one'), { key: 'Tab' }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(screen.getByText('restore')); + }); + + it('an outside mousedown closes, but not on the ignored target or inside the menu', () => { + const onClose = vi.fn(); + render(); + fireEvent.mouseDown(screen.getByText('anchor')); + fireEvent.mouseDown(screen.getByText('two')); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.mouseDown(screen.getByText('outside')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('scroll and resize close', () => { + const onClose = vi.fn(); + render(); + fireEvent.scroll(window); + fireEvent(window, new Event('resize')); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it('calls the latest onClose without re-registering', () => { + const first = vi.fn(); + const second = vi.fn(); + const { rerender } = render(); + rerender(); + fireEvent.mouseDown(screen.getByText('outside')); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/components/ui/useMenuKeyboard.ts b/lib/components/ui/useMenuKeyboard.ts new file mode 100644 index 0000000..e7bab21 --- /dev/null +++ b/lib/components/ui/useMenuKeyboard.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; + +export interface UseMenuKeyboardOptions { + /** The menu's root element; its `menuitem` / `menuitemradio` descendants are the navigable items. */ + menuRef: React.RefObject; + /** True once the menu is placed and visible: the first item takes focus then (a hidden element cannot). */ + isPositioned: boolean; + onClose: () => void; + /** The element that gets focus when the menu closes from the keyboard or by choosing an item. */ + getRestoreTarget: () => HTMLElement | null; + /** A press on this element does not count as "outside" (the control that opened the menu). */ + ignoreTarget?: HTMLElement | null; +} + +export interface UseMenuKeyboardResult { + /** Closes the menu; with `restoreFocus`, focus first goes to the restore target. */ + close: (restoreFocus: boolean) => void; + handleMenuKeyDown: (e: React.KeyboardEvent) => void; +} + +const menuItemsOf = (menu: HTMLElement | null): HTMLElement[] => + menu ? Array.from(menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"]')) : []; + +/** + * Keyboard and dismissal behaviour shared by the grid's menus (WAI-ARIA menu pattern): focus the + * first item once positioned, ArrowUp/Down (wrapping) / Home / End, Tab and Escape close with focus + * restore, a press outside closes, scroll and resize close. `onClose` and `getRestoreTarget` are + * read through refs, so listeners are registered once and never miss or double a close. + */ +export function useMenuKeyboard({ menuRef, isPositioned, onClose, getRestoreTarget, ignoreTarget }: UseMenuKeyboardOptions): UseMenuKeyboardResult { + const onCloseRef = useRef(onClose); + const getRestoreTargetRef = useRef(getRestoreTarget); + useLayoutEffect(() => { + onCloseRef.current = onClose; + getRestoreTargetRef.current = getRestoreTarget; + }); + + const close = useCallback((restoreFocus: boolean) => { + if (restoreFocus) getRestoreTargetRef.current()?.focus({ preventScroll: true }); + onCloseRef.current(); + }, []); + + const handleMenuKeyDown = useCallback((event: React.KeyboardEvent) => { + const items = menuItemsOf(menuRef.current); + const current = items.indexOf(event.target as HTMLElement); + let next = -1; + if (event.key === 'ArrowDown') next = current + 1 >= items.length ? 0 : current + 1; + else if (event.key === 'ArrowUp') next = current <= 0 ? items.length - 1 : current - 1; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = items.length - 1; + else if (event.key === 'Tab') { + event.preventDefault(); + close(true); + return; + } else { + return; + } + event.preventDefault(); + items[next]?.focus(); + }, [menuRef, close]); + + useLayoutEffect(() => { + if (isPositioned) menuItemsOf(menuRef.current)[0]?.focus({ preventScroll: true }); + }, [isPositioned, menuRef]); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + const target = event.target as Node; + if (menuRef.current && !menuRef.current.contains(target) && !ignoreTarget?.contains(target)) onCloseRef.current(); + } + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') close(true); + } + // Closing on any scroll or resize keeps the menu from floating detached from where it opened + function handleDismiss() { + onCloseRef.current(); + } + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKey); + window.addEventListener('scroll', handleDismiss, true); + window.addEventListener('resize', handleDismiss); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKey); + window.removeEventListener('scroll', handleDismiss, true); + window.removeEventListener('resize', handleDismiss); + }; + }, [menuRef, ignoreTarget, close]); + + return { close, handleMenuKeyDown }; +} diff --git a/lib/hooks/core/gridApiPlaceholder.ts b/lib/hooks/core/gridApiPlaceholder.ts index 7f5c001..82bc75c 100644 --- a/lib/hooks/core/gridApiPlaceholder.ts +++ b/lib/hooks/core/gridApiPlaceholder.ts @@ -53,5 +53,8 @@ export function createGridApiPlaceholder ({}), openAiAssistant: noop, closeAiAssistant: noop, + // Replaced by useGridContextMenu (step 37a). + openContextMenu: noop, + closeContextMenu: noop, }; } diff --git a/lib/hooks/core/gridKeyDown.test.ts b/lib/hooks/core/gridKeyDown.test.ts index 3ae5c72..ced8c71 100644 --- a/lib/hooks/core/gridKeyDown.test.ts +++ b/lib/hooks/core/gridKeyDown.test.ts @@ -70,4 +70,35 @@ describe('handleGridKeyDown', () => { handleGridKeyDown(keyEvent('ArrowDown', { nativeEvent: { isComposing: true } as KeyboardEvent }), ctx); expect(ctx.setFocusedCell).not.toHaveBeenCalled(); }); + + describe('context menu keys', () => { + it.each([ + ['Shift+F10', keyEvent('F10', { shiftKey: true })], + ['ContextMenu', keyEvent('ContextMenu')], + ])('%s on a body cell opens the menu there', (_, event) => { + const openContextMenuAt = vi.fn(); + handleGridKeyDown(event, context({ openContextMenuAt })); + expect(openContextMenuAt).toHaveBeenCalledWith({ id: 1, field: 'name' }); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it.each([ + ['header', { id: null, field: 'name' }], + ['header filter', { id: null, field: 'name', headerFilter: true }], + ] as const)('does nothing on a %s cell', (_, focusedCell) => { + const openContextMenuAt = vi.fn(); + const event = keyEvent('ContextMenu'); + handleGridKeyDown(event, context({ openContextMenuAt, focusedCell, headerFilters: true })); + expect(openContextMenuAt).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('does nothing without openContextMenuAt (contextMenu off)', () => { + const event = keyEvent('F10', { shiftKey: true }); + const ctx = context(); + handleGridKeyDown(event, ctx); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(ctx.setFocusedCell).not.toHaveBeenCalled(); + }); + }); }); diff --git a/lib/hooks/core/gridKeyDown.ts b/lib/hooks/core/gridKeyDown.ts index 0b8ce2b..be840c0 100644 --- a/lib/hooks/core/gridKeyDown.ts +++ b/lib/hooks/core/gridKeyDown.ts @@ -38,13 +38,15 @@ export interface GridKeyDownContext extends Pick FocusedCell | null; scrollCellIntoView: (rowIndex: number, field: string) => void; setFocusedCell: React.Dispatch>; + /** Opens the cell context menu (`contextMenu`); absent while it is off. */ + openContextMenuAt?: ((cell: FocusedCell) => void) | null; } /** Keys that move focus between cells. */ export const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']); export function handleGridKeyDown(event: React.KeyboardEvent, context: GridKeyDownContext): void { - const { focusedCell, focusedRowIndex, allRenderableRows, navigationColumns, editingHandlers, selectedRowIds, handleSelectionChange, handleDetailPanelToggle, setKeyboardMode, sortModel, handleSort, handleSortAdd, multiSort, isCellEditable, pagination, pageSize, rowMetaMap, onRowActivate, toggleRowExpansion, handleSelectAll, rowSelectionEnabled, multipleRowSelectionEnabled, getDefaultFocus, scrollCellIntoView, getSpanOrigin, getRowId, cellSelection, headerFilters, setFocusedCell } = context; + const { focusedCell, focusedRowIndex, allRenderableRows, navigationColumns, editingHandlers, selectedRowIds, handleSelectionChange, handleDetailPanelToggle, setKeyboardMode, sortModel, handleSort, handleSortAdd, multiSort, isCellEditable, pagination, pageSize, rowMetaMap, onRowActivate, toggleRowExpansion, handleSelectAll, rowSelectionEnabled, multipleRowSelectionEnabled, getDefaultFocus, scrollCellIntoView, getSpanOrigin, getRowId, cellSelection, headerFilters, setFocusedCell, openContextMenuAt } = context; const viewport = event.currentTarget as HTMLElement; const targetKind = classifyKeyTarget(event.target, viewport); if (targetKind === 'foreign') return; @@ -81,6 +83,13 @@ export function handleGridKeyDown(event: React.KeyboardE } const { id, field } = focusedCell; + // Shift+F10 / the ContextMenu key: the cell context menu on a body cell. + if (key === 'ContextMenu' || (key === 'F10' && event.shiftKey)) { + if (id === null || !openContextMenuAt || isEditing) return; + event.preventDefault(); + openContextMenuAt({ id, field }); + return; + } const onHeaderFilter = id === null && Boolean(focusedCell.headerFilter); const rowIndex = id !== null ? focusedRowIndex : onHeaderFilter ? -1 : headerRow; const colIndex = navigationColumns.findIndex(c => c.field === field); diff --git a/lib/hooks/core/useGridCellSelection.test.ts b/lib/hooks/core/useGridCellSelection.test.ts new file mode 100644 index 0000000..91ed55e --- /dev/null +++ b/lib/hooks/core/useGridCellSelection.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useGridCellSelection } from './useGridCellSelection'; +import type { UseGridCellSelectionParams } from './useGridCellSelection'; +import type { GridSpanningResult } from '../features/useGridSpanning'; +import type { LayoutResult } from './useLayout'; +import type { GridCellSelectionModel, GridColDef, GridRowModel } from '../../types'; + +const ROWS: GridRowModel[] = [ + { id: 1, a: 'a1', b: 'b1' }, + { id: 2, a: 'a2', b: 'b2' }, +]; +const COLUMNS: GridColDef[] = [ + { field: 'a', headerName: 'Col A' }, + { field: 'b', headerName: 'Col B' }, +]; +const MODEL: GridCellSelectionModel = [{ anchor: { id: 1, field: 'a' }, head: { id: 2, field: 'b' } }]; + +function makeParams(): UseGridCellSelectionParams { + const spanning = { hasColSpan: false, hasRowSpan: false } as GridSpanningResult; + const layout = {} as LayoutResult; + return { + enabled: true, + model: MODEL, + onModelChange: () => {}, + getPendingModel: () => null, + allRenderableRows: ROWS, + getRowId: row => row.id, + navigationColumns: COLUMNS, + columnIndexMap: new Map(COLUMNS.map((c, i) => [c.field, i])), + spanning, + rowMetaMap: new Map(), + layout, + rowHeight: 32, + pinnedTopRowCount: 0, + viewportRef: { current: null }, + containerRef: { current: null }, + showStats: false, + }; +} + +describe('useGridCellSelection getCopyText', () => { + it('adds the header line only when includeHeaders is set', () => { + const { result } = renderHook(() => useGridCellSelection(makeParams())); + expect(result.current.getCopyText({ includeHeaders: true })).toBe('Col A\tCol B\na1\tb1\na2\tb2'); + expect(result.current.getCopyText()).toBe('a1\tb1\na2\tb2'); + }); +}); diff --git a/lib/hooks/core/useGridCellSelection.ts b/lib/hooks/core/useGridCellSelection.ts index 775668d..3478e72 100644 --- a/lib/hooks/core/useGridCellSelection.ts +++ b/lib/hooks/core/useGridCellSelection.ts @@ -118,7 +118,7 @@ export interface UseGridCellSelectionReturn { /** The three keyboard handlers for useGridKeyboardNavigation (stable), undefined while off. */ keyboard: GridKeyboardCellSelection | undefined; /** The text Ctrl/Cmd+C copies: the range, else the focused cell; null when there is nothing. */ - getCopyText: () => string | null; + getCopyText: (options?: { includeHeaders?: boolean }) => string | null; getSelectedCells: () => GridSelectedCell[]; /** Live-region text, debounced. Empty when nothing is to be said. */ announcement: string; @@ -335,7 +335,7 @@ export function useGridCellSelection(params: UseGridCell return single; }, [currentRange]); - const getCopyText = useCallback((): string | null => { + const getCopyText = useCallback((options?: { includeHeaders?: boolean }): string | null => { const rect = getTargetRect(); if (!rect) return null; const { rangeColumns: cols, forEachCell: visit } = latestRef.current; @@ -354,7 +354,7 @@ export function useGridCellSelection(params: UseGridCell return origin !== null && (origin.rowId !== id || origin.field !== col.field); } : undefined; - return buildRangeTsv(rows as GridRowModel[], columns, isCovered); + return buildRangeTsv(rows as GridRowModel[], columns, isCovered, options); }, [getTargetRect]); const getSelectedCells = useCallback((): GridSelectedCell[] => { diff --git a/lib/hooks/core/useGridDevWarnings.ts b/lib/hooks/core/useGridDevWarnings.ts index 1d52915..a060d01 100644 --- a/lib/hooks/core/useGridDevWarnings.ts +++ b/lib/hooks/core/useGridDevWarnings.ts @@ -24,6 +24,8 @@ export interface UseGridDevWarningsParams { listViewWithoutColumn?: boolean; /** Number of items the list view renders (0 when the list view is not shown). It does not virtualize. */ listViewRowCount?: number; + /** `getContextMenuItems` is set without `contextMenu`, so it is never called. */ + contextMenuItemsWithoutMenu?: boolean; } // Below this many rows, rendering everything is harmless, so an unbounded container is not worth a warning. @@ -39,7 +41,8 @@ export const LIST_VIEW_WARN_THRESHOLD = 2000; export const MAX_SCROLLABLE_HEIGHT = 17_800_000; export function useGridDevWarnings(params: UseGridDevWarningsParams): void { - const { paginationRequested, isRowGrouping, autoHeight, viewportHeight, renderedRowCount, totalRowCount, pinnedRowsIgnored = false, scrollHeight = 0, maxContentHeight, reachableRowCount, unpinnedRowCount, listViewWithoutColumn = false, listViewRowCount = 0 } = params; + const { paginationRequested, isRowGrouping, autoHeight, viewportHeight, renderedRowCount, totalRowCount, pinnedRowsIgnored = false, scrollHeight = 0, maxContentHeight, reachableRowCount, unpinnedRowCount, listViewWithoutColumn = false, listViewRowCount = 0, contextMenuItemsWithoutMenu = false } = params; + const warnedContextMenuRef = useRef(false); const warnedPaginationRef = useRef(false); const warnedUnboundedRef = useRef(false); const warnedPinnedRowsRef = useRef(false); @@ -47,6 +50,12 @@ export function useGridDevWarnings(params: UseGridDevWarningsParams): void { const warnedListViewRef = useRef(false); const warnedListViewSizeRef = useRef(false); + useEffect(() => { + if (process.env.NODE_ENV === 'production' || warnedContextMenuRef.current || !contextMenuItemsWithoutMenu) return; + warnedContextMenuRef.current = true; + console.warn('[OpenGridX] `getContextMenuItems` has no effect without `contextMenu`: set `contextMenu` to show the cell menu.'); + }, [contextMenuItemsWithoutMenu]); + useEffect(() => { if (process.env.NODE_ENV === 'production' || warnedListViewSizeRef.current || listViewRowCount <= LIST_VIEW_WARN_THRESHOLD) return; warnedListViewSizeRef.current = true; diff --git a/lib/hooks/core/useGridKeyboardNavigation.test.ts b/lib/hooks/core/useGridKeyboardNavigation.test.ts index e5706ae..3dfa741 100644 --- a/lib/hooks/core/useGridKeyboardNavigation.test.ts +++ b/lib/hooks/core/useGridKeyboardNavigation.test.ts @@ -69,6 +69,7 @@ const BASE_PARAMS = { pageSize: 100, virtualization: MOCK_VIRTUALIZATION, viewportRef: { current: null } as React.RefObject, + openContextMenuAtRef: { current: null }, }; describe('useGridKeyboardNavigation — arrow keys', () => { diff --git a/lib/hooks/core/useGridKeyboardNavigation.ts b/lib/hooks/core/useGridKeyboardNavigation.ts index fac7d06..5cdde1a 100644 --- a/lib/hooks/core/useGridKeyboardNavigation.ts +++ b/lib/hooks/core/useGridKeyboardNavigation.ts @@ -117,6 +117,11 @@ export interface UseGridKeyboardNavigationParams { * moves to its filter cell, and ArrowDown there into the first row. */ headerFilters?: boolean; + /** + * The context menu's `openAt` (`contextMenu`), filled after render by `useGridContextMenu`, which + * runs later: Shift+F10 / the ContextMenu key read it at event time. `null` while the menu is off. + */ + openContextMenuAtRef: React.RefObject<((cell: FocusedCell) => void) | null>; } export interface GridKeyboardCellSelection { @@ -207,6 +212,7 @@ export function useGridKeyboardNavigation( getSpanOrigin, cellSelection, headerFilters = false, + openContextMenuAtRef, } = params; const [storedFocus, setFocusedCell] = useState(null); @@ -398,6 +404,7 @@ export function useGridKeyboardNavigation( cellSelection, headerFilters, setFocusedCell, + openContextMenuAt: openContextMenuAtRef.current, }); }, [ focusedCell, @@ -428,6 +435,7 @@ export function useGridKeyboardNavigation( getRowId, cellSelection, headerFilters, + openContextMenuAtRef, ]); return { diff --git a/lib/hooks/features/useGridClipboard.test.ts b/lib/hooks/features/useGridClipboard.test.ts index 5fbfcde..bf42397 100644 --- a/lib/hooks/features/useGridClipboard.test.ts +++ b/lib/hooks/features/useGridClipboard.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; -import { useGridClipboard } from './useGridClipboard'; +import { useGridClipboard, buildRangeTsv, buildCellCopyText, escapeTsvField } from './useGridClipboard'; import type { GridColDef, GridRowId, GridRowModel } from '../../types'; type Params = Parameters[0]; @@ -537,3 +537,26 @@ describe('useGridClipboard — keyboard shortcut', () => { expect(addSpy.mock.calls.filter(call => call[0] === 'keydown')).toHaveLength(1); }); }); + +describe('copy text helpers', () => { + const cols: GridColDef[] = [{ field: 'a', headerName: 'A' }, { field: 'b' }]; + const rows: GridRowModel[] = [{ id: 1, a: 1, b: 'x' }]; + + it('buildRangeTsv includes one header line when includeHeaders', () => { + expect(buildRangeTsv(rows, cols, undefined, { includeHeaders: true })).toBe('A\tb\n1\tx'); + }); + + it('buildRangeTsv default output unchanged', () => { + expect(buildRangeTsv(rows, cols)).toBe('1\tx'); + }); + + it('buildCellCopyText copies one cell, with an optional header', () => { + expect(buildCellCopyText(rows[0], cols[0], false)).toBe('1'); + expect(buildCellCopyText(rows[0], cols[0], true)).toBe('A\n1'); + }); + + it('buildCellCopyText escapes like escapeTsvField', () => { + const row: GridRowModel = { id: 1, a: 'p\tq', b: '' }; + expect(buildCellCopyText(row, cols[0], false)).toBe(escapeTsvField('p\tq')); + }); +}); diff --git a/lib/hooks/features/useGridClipboard.ts b/lib/hooks/features/useGridClipboard.ts index 5dfb5ad..a9bdd68 100644 --- a/lib/hooks/features/useGridClipboard.ts +++ b/lib/hooks/features/useGridClipboard.ts @@ -75,13 +75,14 @@ function formatCopyValue(row: GridRowModel, col: GridColDef): string { /** * A cell range as Excel and Google Sheets copy it: tabs between columns, line breaks between rows and - * no header line. `rows` are the data rows of the range (synthetic rows already left out); a position + * no header line (one is added first with `options.includeHeaders`, `headerName ?? field` per column). `rows` are the data rows of the range (synthetic rows already left out); a position * for which `isCovered` returns true (covered by a span whose origin sits elsewhere) is left empty. */ export function buildRangeTsv( rows: readonly GridRowModel[], columns: readonly GridColDef[], isCovered?: (row: GridRowModel, col: GridColDef) => boolean, + options?: { includeHeaders?: boolean }, ): string { const lines: string[] = new Array(rows.length); for (let r = 0; r < rows.length; r++) { @@ -93,9 +94,22 @@ export function buildRangeTsv( } lines[r] = cells.join('\t'); } + if (options?.includeHeaders) { + lines.unshift(columns.map(copyHeaderText).join('\t')); + } return lines.join('\n'); } +function copyHeaderText(col: GridColDef): string { + return escapeTsvField(col.headerName ?? col.field); +} + +/** One cell as copied text, with its column header on a line above when `includeHeaders`. */ +export function buildCellCopyText(row: GridRowModel, col: GridColDef, includeHeaders: boolean): string { + const value = escapeTsvField(formatCopyValue(row, col)); + return includeHeaders ? `${copyHeaderText(col)}\n${value}` : value; +} + /** * The textarea + execCommand fallback, for browsers without the async Clipboard API (plain http) * or when it rejects. It has to focus the textarea, so focus and the page selection are put back diff --git a/lib/hooks/features/useGridContextMenu.ts b/lib/hooks/features/useGridContextMenu.ts new file mode 100644 index 0000000..966b8c2 --- /dev/null +++ b/lib/hooks/features/useGridContextMenu.ts @@ -0,0 +1,249 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { MutableRefObject, RefObject } from 'react'; +import type { + GridApi, GridCellSelectionModel, GridColDef, GridContextMenuEntry, GridContextMenuParams, GridLocaleText, + GridRowId, GridRowMeta, GridRowModel, +} from '../../types'; +import type { UseGridCellSelectionReturn } from '../core/useGridCellSelection'; +import type { GridSpanOrigin } from './useGridSpanning'; +import { CHECKBOX_FIELD, EXPAND_FIELD, REORDER_FIELD, findFocusTarget, type FocusedCell } from '../../utils/focus'; +import { isRangeColumnField } from '../../utils/cellSelection'; +import { getCellValue, warnCallbackFailure } from '../../utils/values'; +import { attempt } from '../../utils/attempt'; +import { isRtl } from '../../utils/direction'; +import { resolveGridLocale } from '../../utils/gridLocale'; +import { untypedColumn } from '../../utils/untypedColumns'; +import { buildCellCopyText, writeToClipboard } from './useGridClipboard'; +import { cleanContextMenuEntries } from '../../components/ContextMenu/menuEntries'; + +export interface UseGridContextMenuParams { + /** The `contextMenu` prop: without it no listener is attached. */ + enabled: boolean; + getContextMenuItems?: ( + params: GridContextMenuParams, + defaultItems: readonly GridContextMenuEntry[], + ) => readonly GridContextMenuEntry[]; + /** `undoRedo` is on: the menu gets Undo and Redo. */ + undoRedo: boolean; + apiRef: MutableRefObject; + containerRef: RefObject; + viewportRef: RefObject; + /** The visible row list `data-rowindex` indexes: top-pinned, centre rows of the page, bottom-pinned. */ + allRenderableRows: R[]; + getRowId: (row: R) => GridRowId; + navigationColumns: GridColDef[]; + /** Rendered data columns in render order: `scrollToIndexes` column indices. */ + renderedDataColumns: GridColDef[]; + pinnedTopRowCount: number; + rowMetaMap: ReadonlyMap; + getSpanOrigin: (rowId: GridRowId, field: string) => GridSpanOrigin | null; + selection: UseGridCellSelectionReturn; + setFocusedCell: React.Dispatch>; + localeText?: GridLocaleText; + /** Filled with `openAt` while enabled: keyboard navigation runs earlier and reads it at key time. */ + openAtRef: MutableRefObject<((cell: FocusedCell) => void) | null>; +} + +export interface GridContextMenuState { + /** Row the menu was opened on. */ + id: GridRowId; + x: number; + y: number; + entries: readonly GridContextMenuEntry[]; + anchorCell: HTMLElement | null; +} + +export interface UseGridContextMenuResult { + menu: GridContextMenuState | null; + close: () => void; + /** Opens the menu at a cell's corner (keyboard and `apiRef.openContextMenu`). Stable. */ + openAt: (cell: FocusedCell) => void; +} + + +const SYSTEM_FIELDS = new Set([CHECKBOX_FIELD, EXPAND_FIELD, REORDER_FIELD]); +/** Apple platforms: Ctrl+click opens the context menu (elsewhere it is a selection gesture). */ +const APPLE = typeof navigator !== 'undefined' && /Mac|iP/.test(navigator.platform); + +/** Calls `run` on each of the next `tries` animation frames until it returns something truthy. */ +function whenRendered(run: () => unknown, tries: number): void { + requestAnimationFrame(() => { + if (!run() && tries > 1) whenRendered(run, tries - 1); + }); +} + +/** Whether a cell (`rowIndex` into the visible rows) lies inside the current cell range. */ +function isInRange(p: UseGridContextMenuParams, field: string, rowIndex: number): boolean { + const { selection } = p; + if (!selection.enabled || !isRangeColumnField(field)) return false; + const { resolved } = selection.getCurrentRange(); + const colIndex = p.navigationColumns.filter(c => isRangeColumnField(c.field)).findIndex(c => c.field === field); + return resolved !== null && rowIndex >= resolved.top && rowIndex <= resolved.bottom + && colIndex >= resolved.left && colIndex <= resolved.right; +} + +/** + * Step 37a of `DataGrid`: the cell context menu (`contextMenu`). A native `contextmenu` listener on the + * grid root resolves the body cell (`data-field` and the row's `data-rowindex`, an index into the visible + * row list), moves focus / the range there, builds the items and opens the menu at the pointer. + * Installs `apiRef.openContextMenu` / `closeContextMenu`. + */ +export function useGridContextMenu(params: UseGridContextMenuParams): UseGridContextMenuResult { + const { enabled, apiRef, containerRef, allRenderableRows, getRowId, openAtRef } = params; + const latestRef = useRef(params); + useLayoutEffect(() => { + latestRef.current = params; + }); + + const [menu, setMenu] = useState(null); + const close = useCallback(() => setMenu(null), []); + + /** Builds the items for a cell and opens the menu. Returns whether a menu opened. */ + const open = useCallback((id: GridRowId, field: string, rowIndex: number, x: number, y: number, anchorCell: HTMLElement | null): boolean => { + const p = latestRef.current; + const row = p.allRenderableRows[rowIndex]; + const colDef = p.navigationColumns.find(c => c.field === field); + // A delayed open (openAt) whose row moved in the meantime opens nothing. + if (!row || !colDef || p.getRowId(row) !== id) return false; + + const { selection } = p; + const inRange = isInRange(p, field, rowIndex); + if (selection.enabled && isRangeColumnField(field) && !inRange) { + const cell = { id, field }; + selection.setModel([{ anchor: cell, head: cell }], 'pointer'); + } + // Focus moved inside a range would collapse it (useGridCellSelectionApi): it stays where it is. + if (!inRange) p.setFocusedCell({ id, field, rowIndex }); + + const copy = (includeHeaders: boolean) => { + const rangeText = inRange ? latestRef.current.selection.getCopyText({ includeHeaders }) : null; + const text = rangeText ?? buildCellCopyText(row, untypedColumn(colDef), includeHeaders); + writeToClipboard(text).catch(() => {}); + }; + const locale = resolveGridLocale(p.localeText); + const api = p.apiRef.current; + const defaults: GridContextMenuEntry[] = [ + { id: 'copy', label: locale.contextMenuCopy, onClick: () => copy(false) }, + { id: 'copyWithHeaders', label: locale.contextMenuCopyWithHeaders, onClick: () => copy(true) }, + ]; + if (p.undoRedo) { + defaults.push( + { id: 'undo', label: locale.contextMenuUndo, disabled: !api.canUndo(), onClick: () => { void api.undo(); } }, + { id: 'redo', label: locale.contextMenuRedo, disabled: !api.canRedo(), onClick: () => { void api.redo(); } }, + ); + } + + let entries: readonly GridContextMenuEntry[] = defaults; + const getItems = p.getContextMenuItems; + if (getItems) { + const range = selection.enabled ? selection.getCurrentRange().range : null; + const cellRange: GridCellSelectionModel | null = range ? [range] : null; + const menuParams: GridContextMenuParams = { + id, field, row, colDef, + value: getCellValue(row, field, colDef), + rowMeta: p.rowMetaMap.get(id), + cellRange, + api: api as GridApi, + }; + const result = attempt(() => getItems(menuParams, defaults)); + if (result.ok) { + entries = result.value; + } else { + warnCallbackFailure('getContextMenuItems', 'getContextMenuItems threw; the default items are shown', result.error); + } + } + // Cleaned here so a list of only dividers opens nothing and keeps the browser menu. + entries = cleanContextMenuEntries(entries); + if (entries.length === 0) return false; + setMenu({ id, x, y, entries, anchorCell }); + return true; + }, []); + + /** The span origin of a cell and its index in the visible rows (`rowIndex` when already known, else -1). */ + const resolveCell = useCallback((rowId: GridRowId, cellField: string, rowIndex: number) => { + const p = latestRef.current; + const origin = p.getSpanOrigin(rowId, cellField); + const id = origin ? origin.rowId : rowId; + const index = origin || rowIndex < 0 ? p.allRenderableRows.findIndex(r => p.getRowId(r) === id) : rowIndex; + return index < 0 ? null : { id, field: origin ? origin.field : cellField, rowIndex: index }; + }, []); + + const openAt = useCallback((cell: FocusedCell) => { + const p = latestRef.current; + if (!p.enabled || cell.id === null) return; + const target = resolveCell(cell.id, cell.field, -1); + if (!target) return; + const { id, field, rowIndex } = target; + const centerIndex = rowIndex - p.pinnedTopRowCount; + const centerCount = p.allRenderableRows.length - p.pinnedTopRowCount; + p.apiRef.current.scrollToIndexes({ + rowIndex: centerIndex >= 0 && centerIndex < centerCount ? centerIndex : undefined, + colIndex: p.renderedDataColumns.findIndex(c => c.field === field), + }); + // Opened from a later frame: the scrolled-to row is rendered by then, and the scroll event has + // fired before the menu's close-on-scroll listener attaches. Gives up if the cell never appears. + whenRendered(() => { + const viewport = latestRef.current.viewportRef.current; + const el = viewport && findFocusTarget(viewport, target, rowIndex); + if (el) { + const rect = el.getBoundingClientRect(); + open(id, field, rowIndex, isRtl(el) ? rect.right : rect.left, rect.bottom, el); + } + return el; + }, 10); + }, [open, resolveCell]); + + // Right-click on a body cell of this grid. + useEffect(() => { + const root = containerRef.current; + if (!root || !enabled) return; + const onContextMenu = (event: MouseEvent) => { + const p = latestRef.current; + const viewport = p.viewportRef.current; + const target = event.target; + if (!viewport || !(target instanceof Element)) return; + const cell = target.closest('.ogx__cell'); + if (!cell || cell.closest('.ogx__viewport') !== viewport) return; + if (cell.classList.contains('ogx__cell--editing')) return; + const field = cell.dataset.field; + const rowEl = cell.parentElement; + const indexText = rowEl ? rowEl.getAttribute('data-rowindex') : null; + if (!field || SYSTEM_FIELDS.has(field) || indexText === null) return; + const index = Number(indexText); + const row = Number.isInteger(index) ? p.allRenderableRows[index] : undefined; + if (!row) return; + const resolved = resolveCell(p.getRowId(row), field, index); + if (!resolved) return; + const anchor = resolved.rowIndex === index && resolved.field === field + ? cell : findFocusTarget(viewport, resolved, resolved.rowIndex); + if (open(resolved.id, resolved.field, resolved.rowIndex, event.clientX, event.clientY, anchor ?? cell)) event.preventDefault(); + }; + // A menu press (right button, or Ctrl+click on Apple platforms) inside the range must not move + // focus there or start a range drag: either collapses the range before contextmenu arrives. + // Mousedown targets are Elements. + const onMouseDown = (event: MouseEvent) => { + const menuPress = event.button === 2 || (APPLE && event.button === 0 && event.ctrlKey); + const cell = menuPress ? (event.target as Element).closest('.ogx__cell') : null; + const row = cell && cell.closest('[data-rowindex]'); + if (row && isInRange(latestRef.current, cell.dataset.field as string, Number(row.dataset.rowindex))) event.preventDefault(); + }; + root.addEventListener('contextmenu', onContextMenu); + root.addEventListener('mousedown', onMouseDown, true); + return () => { + root.removeEventListener('contextmenu', onContextMenu); + root.removeEventListener('mousedown', onMouseDown, true); + }; + }, [containerRef, enabled, open, resolveCell]); + + useLayoutEffect(() => { + const api = apiRef.current; + api.openContextMenu = (id, field) => openAt({ id, field }); + api.closeContextMenu = close; + openAtRef.current = enabled ? openAt : null; + }, [apiRef, openAt, close, openAtRef, enabled]); + + // The menu closes when its row leaves the visible row list or the feature is turned off. + // Cleared during render (state derived from props), so the same row coming back does not reopen it. + if (menu !== null && (!enabled || !allRenderableRows.some(r => getRowId(r) === menu.id))) setMenu(null); + return { menu, close, openAt }; +} diff --git a/lib/index.ts b/lib/index.ts index 480f765..8758585 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -150,6 +150,9 @@ export type { GridPaginationSlotProps, GridOverlaySlotProps, GridFooterSlotProps, + GridContextMenuParams, + GridContextMenuItem, + GridContextMenuEntry, GridValidRowModel, DataGridUntypedColumnsProps, GridRowMeta, diff --git a/lib/styles/opengridx.css b/lib/styles/opengridx.css index 1dcbfa4..8f40f89 100644 --- a/lib/styles/opengridx.css +++ b/lib/styles/opengridx.css @@ -24,6 +24,7 @@ @import '../components/FilterPanel/FilterPanel.css'; @import '../components/Toolbar/GlobalSearch.css'; @import '../components/Header/ColumnMenu.css'; +@import '../components/ContextMenu/ContextMenu.css'; @import '../components/ListView/ListView.css'; @import '../components/ColumnGroupHeader/ColumnGroupHeader.css'; @import '../components/Tooltip/Tooltip.css'; diff --git a/lib/types/api.ts b/lib/types/api.ts index 9d46001..0f19ae8 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -163,6 +163,10 @@ export interface GridApi { openAiAssistant: () => void; /** Closes the `aiAssistant` panel. @since v3.5 */ closeAiAssistant: () => void; + /** Opens the context menu on a cell (does nothing without `contextMenu`). @since v3.11 */ + openContextMenu: (id: GridRowId, field: string) => void; + /** Closes the context menu. @since v3.11 */ + closeContextMenu: () => void; } // ── AI toolkit (v3.4 state types; v3.5 assistant) ────────────────────────────────────────────────── diff --git a/lib/types/contextMenu.ts b/lib/types/contextMenu.ts new file mode 100644 index 0000000..27c0748 --- /dev/null +++ b/lib/types/contextMenu.ts @@ -0,0 +1,42 @@ +import type { GridRowId, GridRowModel, GridValidRowModel, GridRowMeta } from './rows'; +import type { GridColDef } from './columns'; +import type { GridCellSelectionModel } from './selection'; +import type { GridApi } from './api'; + +/** What `getContextMenuItems` receives for the cell the menu was opened on. @since v3.11 */ +export interface GridContextMenuParams { + /** Id of the row. */ + id: GridRowId; + /** Field of the column. */ + field: string; + /** The row object (the synthetic row for group, subtotal, auto-parent and pivot Grand Total rows). */ + row: R; + /** The column definition. */ + colDef: GridColDef; + /** The cell value, read through `valueGetter`. */ + value: unknown; + /** Hierarchy metadata of the row, when it has any. */ + rowMeta: GridRowMeta | undefined; + /** The current cell range; `null` without `cellSelection` or with no range. */ + cellRange: GridCellSelectionModel | null; + /** The grid API. */ + api: GridApi; +} + +/** One action of the context menu. @since v3.11 */ +export interface GridContextMenuItem { + /** Stable key. Built-in items use `'copy'`, `'copyWithHeaders'`, `'undo'` and `'redo'`. */ + id: string; + label: string; + /** Contained: a throw is caught and warned once in development. */ + onClick?: () => void; + disabled?: boolean; + /** Display only, e.g. `'Ctrl+C'`. */ + shortcut?: string; + icon?: React.ReactNode; + /** Styling only. */ + danger?: boolean; +} + +/** A menu item or a separator line. @since v3.11 */ +export type GridContextMenuEntry = GridContextMenuItem | 'divider'; diff --git a/lib/types/index.ts b/lib/types/index.ts index 331ea40..b6ffecc 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -17,3 +17,4 @@ export type * from './props'; export type * from './slots'; export type * from './api'; export type * from './ai'; +export type * from './contextMenu'; diff --git a/lib/types/locale.ts b/lib/types/locale.ts index fe4b5b1..dc5d487 100644 --- a/lib/types/locale.ts +++ b/lib/types/locale.ts @@ -414,4 +414,16 @@ export interface GridLocaleText { cellSelectionStatsSum?: string; /** Default: `"Average"` @since v3.9 */ cellSelectionStatsAverage?: string; + + // ── Context menu (v3.11) ────────────────────────────────────────────────── + /** Default: `"Copy"` @since v3.11 */ + contextMenuCopy?: string; + /** Default: `"Copy with headers"` @since v3.11 */ + contextMenuCopyWithHeaders?: string; + /** Default: `"Undo"` @since v3.11 */ + contextMenuUndo?: string; + /** Default: `"Redo"` @since v3.11 */ + contextMenuRedo?: string; + /** Accessible name of the menu. Default: `"Cell actions"` @since v3.11 */ + contextMenuLabel?: string; } diff --git a/lib/types/props.ts b/lib/types/props.ts index b24f907..951f47b 100644 --- a/lib/types/props.ts +++ b/lib/types/props.ts @@ -10,6 +10,7 @@ import type { GridRowGroupingModel, GridAggregationModel, GridPivotModel } from import type { GridLocaleText } from './locale'; import type { GridToolbarSlotProps, GridPaginationSlotProps, GridOverlaySlotProps, GridFooterSlotProps } from './slots'; import type { GridApi } from './api'; +import type { GridContextMenuParams, GridContextMenuEntry } from './contextMenu'; import type { GridAiPromptResult, GridAiAssistantOptions, GridAiAssistantPanelProps } from './ai'; /** @@ -392,6 +393,21 @@ export interface DataGridProps { onAiAssistantApply?: (result: GridAiPromptResult) => void; /** Fired when `onPrompt` fails or returns a result that was not validated. @since v3.5 */ onAiAssistantError?: (error: unknown) => void; + + /** + * Shows a context menu (Copy, Copy with headers, Undo, Redo) on right-click or Shift+F10 / the Menu + * key over a body cell. @since v3.11 + */ + contextMenu?: boolean; + /** + * Builds the menu for the cell it opens on, given the built-in items. Return an empty list to show + * no menu (the browser menu is then not suppressed either). Does nothing without `contextMenu`. + * @since v3.11 + */ + getContextMenuItems?: ( + params: GridContextMenuParams, + defaultItems: readonly GridContextMenuEntry[], + ) => readonly GridContextMenuEntry[]; } /** diff --git a/lib/utils/direction.test.ts b/lib/utils/direction.test.ts index bd4cda2..d6bbde7 100644 --- a/lib/utils/direction.test.ts +++ b/lib/utils/direction.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getPanelPosition, getScrollStart, isRtl, logicalArrowKey, setScrollStart } from './direction'; +import { getPanelPosition, getPointMenuPosition, getScrollStart, isRtl, logicalArrowKey, setScrollStart } from './direction'; describe('direction utils', () => { it('swaps ArrowLeft and ArrowRight only for right-to-left', () => { @@ -42,4 +42,24 @@ describe('direction utils', () => { expect(getPanelPosition(button, 1000)).toEqual({ top: 46, left: 100 }); button.remove(); }); + + it('places a point menu at the pointer, flipping towards the end when it would overflow', () => { + const menu = { width: 200, height: 300 }; + const viewport = { width: 1000, height: 800 }; + expect(getPointMenuPosition(100, 100, menu, viewport, false)).toEqual({ left: 100, top: 100 }); + expect(getPointMenuPosition(900, 100, menu, viewport, false)).toEqual({ left: 700, top: 100 }); + expect(getPointMenuPosition(100, 700, menu, viewport, false)).toEqual({ left: 100, top: 400 }); + }); + + it('opens a right-to-left point menu towards the inline start, keeping it on screen', () => { + const menu = { width: 200, height: 300 }; + const viewport = { width: 1000, height: 800 }; + expect(getPointMenuPosition(500, 100, menu, viewport, true)).toEqual({ left: 300, top: 100 }); + expect(getPointMenuPosition(100, 100, menu, viewport, true)).toEqual({ left: 100, top: 100 }); + }); + + it('uses the 8 px margin when a point menu is taller than the viewport', () => { + const position = getPointMenuPosition(100, 100, { width: 200, height: 900 }, { width: 1000, height: 800 }, false); + expect(position.top).toBe(8); + }); }); diff --git a/lib/utils/direction.ts b/lib/utils/direction.ts index 45e1ba8..4877862 100644 --- a/lib/utils/direction.ts +++ b/lib/utils/direction.ts @@ -50,3 +50,41 @@ export function getPanelPosition(anchor: HTMLElement | null, viewportWidth: numb ? { top: rect.bottom + 6, left: rect.left } : { top: rect.bottom + 6, right: viewportWidth - rect.right }; } + +/** Gap kept between a point menu and the viewport edges, in pixels. */ +const POINT_MENU_MARGIN = 8; + +/** Clamps `value` so a box of `size` stays inside `[margin, viewport - margin]`; oversized boxes sit at the margin. */ +function clampToViewport(value: number, size: number, viewport: number): number { + return Math.max(POINT_MENU_MARGIN, Math.min(value, viewport - size - POINT_MENU_MARGIN)); +} + +/** + * Viewport position of a menu opened at the pointer `(x, y)`. Left-to-right it opens to the right + * of the pointer and flips to end at it when it would overflow; right-to-left it opens towards the + * inline start and stays at the pointer when that would overflow. Vertically it opens below the + * pointer and flips above it. The result is clamped to an 8 px margin on every side. + */ +export function getPointMenuPosition( + x: number, + y: number, + menu: { width: number; height: number }, + viewport: { width: number; height: number }, + rtl: boolean, +): { top: number; left: number } { + let left = x; + if (rtl) { + if (x - menu.width < POINT_MENU_MARGIN) left = x; + else left = x - menu.width; + } else if (x + menu.width + POINT_MENU_MARGIN > viewport.width) { + left = x - menu.width; + } + + let top = y; + if (y + menu.height + POINT_MENU_MARGIN > viewport.height) top = y - menu.height; + + return { + top: clampToViewport(top, menu.height, viewport.height), + left: clampToViewport(left, menu.width, viewport.width), + }; +} diff --git a/lib/utils/gridLocale.ts b/lib/utils/gridLocale.ts index 5e2e55d..1f6418a 100644 --- a/lib/utils/gridLocale.ts +++ b/lib/utils/gridLocale.ts @@ -223,6 +223,11 @@ export const GRID_LOCALE_EN: GridLocaleScalars = { cellSelectionStatsCount: 'Count', cellSelectionStatsSum: 'Sum', cellSelectionStatsAverage: 'Average', + contextMenuCopy: 'Copy', + contextMenuCopyWithHeaders: 'Copy with headers', + contextMenuUndo: 'Undo', + contextMenuRedo: 'Redo', + contextMenuLabel: 'Cell actions', }; const SCALAR_KEYS = Object.keys(GRID_LOCALE_EN) as GridLocaleScalarKey[]; diff --git a/llms.txt b/llms.txt index eb15d2d..4997c05 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # OpenGridX — AI Agent Context # Package: @opencorestack/opengridx -# Version: 3.10.1 +# Version: 3.11.0 # Docs: https://opencorestack.github.io/OpenGridX/ # This file is machine-readable. AI developers: start here. @@ -736,3 +736,4 @@ Arrows move between cells (skipping hidden columns); Home/End, Ctrl+Home/End, Pa 28. **3.10**: the AI assistant's change chips are `localeText` keys too (`aiAssistantChipSort(field, direction)`, `aiAssistantChipSummary(fn, field)`, …). Select-all at 1M rows takes ~130 ms. The library forbids `as unknown as` outside documented boundaries and `any` everywhere (ESLint), and CI checks that `llms.txt`, CLAUDE.md and the CHANGELOG name the `package.json` version. 29. **Large client-side datasets**: sorting or filtering 1M rows blocks for ~0.4–0.5 s (one native sort). There is no time-sliced mode (measured and rejected: 1.5–2× slower overall). For interactive sorting and filtering at that size use a `dataSource`. 30. **3.10.1**: at 1M rows select-all ~60–90 ms, deselect-all < 50 ms, applying a grouping ~190 ms, the quick filter's first search ~1.6–2 s (later keystrokes ~210 ms). Grouping creates leaf tree nodes and row metadata only when read, still one `params.rowMeta` object per row. A Next.js 16 App Router fixture in the package smoke suite re-checks the Next.js guide on every CI run. +31. **Context menu (3.11+)**: `contextMenu` shows Copy, Copy with headers and (with `undoRedo`) Undo / Redo on right-click, Shift+F10 / the Menu key or macOS Ctrl+click over a body cell. `getContextMenuItems(params, defaultItems)` returns items and `'divider'`; an empty list shows no menu and the browser menu shows. Right-click inside a cell range copies the range and keeps it; elsewhere it copies the clicked cell. `apiRef.openContextMenu(id, field)` / `closeContextMenu()`; `localeText` keys `contextMenu*`. Types `GridContextMenuParams`, `GridContextMenuItem`, `GridContextMenuEntry`. Headers, footer, editors and detail panels keep the browser menu; no touch long-press. Guide: `docs/features/context-menu.md`. diff --git a/opengridx-ai-context.md b/opengridx-ai-context.md index ecceb6d..97c7092 100644 --- a/opengridx-ai-context.md +++ b/opengridx-ai-context.md @@ -8,7 +8,7 @@ that uses the `@opencorestack/opengridx` library. Read it in full before writing ## What this library is `@opencorestack/opengridx` is a zero-dependency, high-performance React DataGrid component. -Current version: **3.10.1**. It is a full custom implementation — not a wrapper around MUI or any +Current version: **3.11.0**. It is a full custom implementation — not a wrapper around MUI or any other library. --- @@ -262,6 +262,22 @@ after `processRowUpdate` succeeded and replays it through `processRowUpdate`, so like any edit; cells changed since are skipped with reason `'changed'`. Opt out with `disableClipboardPaste` / `disableRangeClear`. Guides: `docs/features/clipboard.md`, `docs/features/undo-redo.md`. +### Context menu (3.11.0+) + +```tsx + [ + ...defaultItems, 'divider', + { id: 'delete', label: 'Delete row', danger: true, onClick: () => removeRow(params.id) }, + ]} /> +``` + +Right-click, Shift+F10 / the Menu key or macOS Ctrl+click on a body cell opens Copy, Copy with headers and, with `undoRedo`, +Undo / Redo. Inside the cell range Copy writes the range (like Ctrl+C) and the range stays; elsewhere it copies the clicked +cell. An empty list from `getContextMenuItems` shows no menu (the browser menu shows); a throw falls back to the defaults. +`apiRef.openContextMenu(id, field)` / `closeContextMenu()`, `localeText.contextMenu*`. No header menu, touch long-press or +Paste item. Guide: `docs/features/context-menu.md`. + ### Fill handle (3.5.0+) ```tsx diff --git a/package-lock.json b/package-lock.json index d1e0683..b9ee16e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opencorestack/opengridx", - "version": "3.10.1", + "version": "3.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencorestack/opengridx", - "version": "3.10.1", + "version": "3.11.0", "license": "MIT", "devDependencies": { "@babel/core": "^7.29.7", diff --git a/package.json b/package.json index ba9b906..d092ff6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencorestack/opengridx", - "version": "3.10.1", + "version": "3.11.0", "description": "OpenGridX: High-performance React data infrastructure. Unlock advanced Row Grouping, Excel Export, and Column Pinning without the usual \"Pro\" gatekeeping. Built for speed, scale, and complete architectural freedom. Fully open, virtualization-ready, and feature-complete.", "type": "module", "main": "./dist/opengridx.umd.js", diff --git a/scripts/build-wiki.mjs b/scripts/build-wiki.mjs index fff741d..bb98c30 100644 --- a/scripts/build-wiki.mjs +++ b/scripts/build-wiki.mjs @@ -52,6 +52,7 @@ const SECTIONS = [ ['docs/features/cell-selection.md', 'Cell-Range-Selection', 'Cell Range Selection'], ['docs/features/undo-redo.md', 'Undo-Redo', 'Undo & Redo'], ['docs/features/fill-handle.md', 'Fill-Handle', 'Fill Handle'], + ['docs/features/context-menu.md', 'Context-Menu', 'Context Menu'], ['docs/features/pinning.md', 'Pinning', 'Pinning'], ['docs/features/state-persistence.md', 'State-Persistence', 'State Persistence'], ['docs/features/aggregation-pivot.md', 'Aggregation-and-Pivot', 'Aggregation & Pivot'],