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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
isCellEditableUtil,
isCtrlKeyHeldDown,
isDefaultCellInput,
isElement,
renderMeasuringCells,
scrollIntoView
} from './utils';
Expand Down Expand Up @@ -616,7 +617,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr

const { target } = event;

if (!(target instanceof Element)) return;
if (!isElement(target)) return;

const cell = target.closest('.rdg-cell');
const isCellEvent = cell !== null;
Expand Down
48 changes: 27 additions & 21 deletions src/EditCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,15 @@ import type {
* so `mousedown` is used instead of `click`.
*
* We must also rely on React's event capturing/bubbling to handle elements rendered in a portal.
*
* The grid may be portaled into another window, like an iframe or a popup window,
* so we listen to events and schedule tasks on the window the editor is rendered in.
*/

// TODO: remove when all browsers support the scheduler APIs
const canUsePostTask = typeof scheduler === 'object' && typeof scheduler.postTask === 'function';
function canUsePostTask({ scheduler }: Window & typeof globalThis) {
return typeof scheduler === 'object' && typeof scheduler.postTask === 'function';
}

const cellEditing = css`
@layer rdg.EditCell {
Expand Down Expand Up @@ -59,9 +64,9 @@ export default function EditCell<R, SR>({
onKeyDown,
navigate
}: EditCellProps<R, SR>) {
const editCellRef = useRef<HTMLDivElement>(null);
const captureEventRef = useRef<MouseEvent | undefined>(undefined);
const abortControllerRef = useRef<AbortController>(undefined);
const frameRequestRef = useRef<number>(undefined);
const cancelScheduledTaskRef = useRef<() => void>(undefined);
const commitOnOutsideClick = column.editorOptions?.commitOnOutsideClick ?? true;

// We need to prevent the `useLayoutEffect` from cleaning up between re-renders,
Expand All @@ -74,24 +79,30 @@ export default function EditCell<R, SR>({
useLayoutEffect(() => {
if (!commitOnOutsideClick) return;

const ownerWindow = editCellRef.current!.ownerDocument.defaultView!;

function onWindowCaptureMouseDown(event: MouseEvent) {
captureEventRef.current = event;

if (canUsePostTask) {
if (canUsePostTask(ownerWindow)) {
const abortController = new AbortController();
const { signal } = abortController;
abortControllerRef.current = abortController;
cancelScheduledTaskRef.current = () => {
abortController.abort();
};
// Use postTask to ensure that the event is not called in the middle of a React render
// and that it is called before the next paint.
scheduler
ownerWindow.scheduler
.postTask(commitOnOutsideMouseDown, {
priority: 'user-blocking',
signal
signal: abortController.signal
})
// ignore abort errors
.catch(() => {});
} else {
frameRequestRef.current = requestAnimationFrame(commitOnOutsideMouseDown);
const frameRequest = ownerWindow.requestAnimationFrame(commitOnOutsideMouseDown);
cancelScheduledTaskRef.current = () => {
ownerWindow.cancelAnimationFrame(frameRequest);
};
}
}

Expand All @@ -101,12 +112,12 @@ export default function EditCell<R, SR>({
}
}

globalThis.addEventListener('mousedown', onWindowCaptureMouseDown, { capture: true });
globalThis.addEventListener('mousedown', onWindowMouseDown);
ownerWindow.addEventListener('mousedown', onWindowCaptureMouseDown, { capture: true });
ownerWindow.addEventListener('mousedown', onWindowMouseDown);

return () => {
globalThis.removeEventListener('mousedown', onWindowCaptureMouseDown, { capture: true });
globalThis.removeEventListener('mousedown', onWindowMouseDown);
ownerWindow.removeEventListener('mousedown', onWindowCaptureMouseDown, { capture: true });
ownerWindow.removeEventListener('mousedown', onWindowMouseDown);
cancelTask();
};
}, [commitOnOutsideClick]);
Expand All @@ -115,14 +126,8 @@ export default function EditCell<R, SR>({
// oxlint-disable-next-line react/invariant
function cancelTask() {
captureEventRef.current = undefined;
if (abortControllerRef.current !== undefined) {
abortControllerRef.current.abort();
abortControllerRef.current = undefined;
}
if (frameRequestRef.current !== undefined) {
cancelAnimationFrame(frameRequestRef.current);
frameRequestRef.current = undefined;
}
cancelScheduledTaskRef.current?.();
cancelScheduledTaskRef.current = undefined;
}

function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
Expand Down Expand Up @@ -176,6 +181,7 @@ export default function EditCell<R, SR>({

return (
<div
ref={editCellRef}
role="gridcell"
aria-colindex={column.idx + 1} // aria-colindex is 1-based
aria-colspan={colSpan}
Expand Down
22 changes: 19 additions & 3 deletions src/hooks/useGridDimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,24 @@ const sizeMap = new WeakMap<RefObject<HTMLDivElement | null>, ResizeObserverSize
const targetToRefMap = new WeakMap<HTMLDivElement, RefObject<HTMLDivElement | null>>();
const subscribers = new Map<RefObject<HTMLDivElement | null>, () => void>();

// don't break in Node.js (SSR), jsdom, and environments that don't support ResizeObserver
const resizeObserver =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resizeObserverCallback);
// one ResizeObserver per window, so that resizes are observed in the window the grid is rendered in,
// for example when the grid is portaled into an iframe or a popup window
const resizeObservers = new WeakMap<Window, ResizeObserver>();

function getResizeObserver(target: HTMLDivElement): ResizeObserver | null {
const ownerWindow = target.ownerDocument.defaultView;

// don't break in jsdom, and environments that don't support ResizeObserver
if (ownerWindow?.ResizeObserver === undefined) return null;

let resizeObserver = resizeObservers.get(ownerWindow);
if (resizeObserver === undefined) {
resizeObserver = new ownerWindow.ResizeObserver(resizeObserverCallback);
resizeObservers.set(ownerWindow, resizeObserver);
}

return resizeObserver;
}

function resizeObserverCallback(entries: ResizeObserverEntry[]) {
for (const entry of entries) {
Expand Down Expand Up @@ -66,6 +81,7 @@ export function useGridDimensions(gridRef: React.RefObject<HTMLDivElement | null

useLayoutEffect(() => {
const target = gridRef.current!;
const resizeObserver = getResizeObserver(target);

targetToRefMap.set(target, gridRef);
resizeObserver?.observe(target);
Expand Down
6 changes: 6 additions & 0 deletions src/utils/domUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { Maybe } from '../types';

// `instanceof Element` fails for elements from another window,
// for example when the grid is portaled into an iframe or a popup window
export function isElement(target: EventTarget | null): target is Element {
return (target as Node | null)?.nodeType === Node.ELEMENT_NODE;
}

export function stopPropagation(event: React.SyntheticEvent) {
event.stopPropagation();
}
Expand Down
8 changes: 2 additions & 6 deletions src/utils/keyboardUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Direction, Maybe } from '../types';
import { isElement } from './domUtils';

// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
const nonInputKeys = new Set([
Expand Down Expand Up @@ -74,12 +75,7 @@ export function isDefaultCellInput(
* - The editor element must be the only immediate child of the editor container/a label.
*/
export function onEditorNavigation({ key, target }: React.KeyboardEvent<HTMLDivElement>): boolean {
if (
key === 'Tab' &&
(target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement)
) {
if (key === 'Tab' && isElement(target) && target.matches('input, textarea, select')) {
return (
target.closest('.rdg-editor-container')?.querySelectorAll('input, textarea, select')
.length === 1
Expand Down
145 changes: 145 additions & 0 deletions test/browser/crossWindow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { page, userEvent } from 'vitest/browser';

import { DataGrid, type Column, type RenderEditCellProps } from '../../src';

// The grid can be portaled into another window, like an iframe or a popup window.
// Its DOM nodes then belong to that other window, while its JS still runs in this window.
// https://github.com/Comcast/react-data-grid/issues/4184

interface Row {
a: string;
b: string;
}

const columns: readonly Column<unknown>[] = Array.from({ length: 20 }, (_, i) => ({
key: String(i),
name: String(i),
width: 100
}));

const noRows: readonly unknown[] = [];

// unlike `renderTextEditor`, this editor does not commit on blur
function renderEditCell({ row, column, onRowChange }: RenderEditCellProps<Row>) {
const key = column.key as keyof Row;

return (
<input
autoFocus
aria-label="editor"
value={row[key]}
onChange={(event) => onRowChange({ ...row, [key]: event.target.value })}
/>
);
}

const editableColumns: readonly Column<Row>[] = [
{ key: 'a', name: 'A', renderEditCell },
{ key: 'b', name: 'B', renderEditCell }
];

const initialRows: readonly Row[] = [
{ a: 'a1', b: 'b1' },
{ a: 'a2', b: 'b2' }
];

function EditableGrid() {
const [rows, setRows] = useState(initialRows);

return (
<>
<div>outside</div>
<DataGrid columns={editableColumns} rows={rows} onRowsChange={setRows} />
</>
);
}

async function createIframe(width = 400) {
const iframe = document.createElement('iframe');
iframe.style.width = `${width}px`;
iframe.srcdoc = '<!doctype html><body style="margin: 0"></body>';

await new Promise((resolve) => {
iframe.addEventListener('load', resolve, { once: true });
document.body.append(iframe);
});

onTestFinished(() => {
iframe.remove();
});

const iframeDocument = iframe.contentDocument!;

// copy the grid styles into the iframe
for (const style of document.head.querySelectorAll('style')) {
iframeDocument.head.append(style.cloneNode(true));
}

return {
iframe,
iframeDocument,
iframeWindow: iframeDocument.defaultView!,
frame: page.frameLocator(page.elementLocator(iframe))
};
}

test('should observe grid resizes with the ResizeObserver of the window the grid is rendered in', async () => {
const { iframe, iframeDocument, iframeWindow } = await createIframe();
const observeSpy = vi.spyOn(iframeWindow.ResizeObserver.prototype, 'observe');

await page.render(
createPortal(<DataGrid columns={columns} rows={noRows} />, iframeDocument.body)
);

const grid = iframeDocument.querySelector('[role="grid"]');
expect(grid).not.toBeNull();
expect(observeSpy).toHaveBeenCalledWith(grid);

function getLastHeaderCellColIndex() {
return iframeDocument.querySelector('[role="columnheader"]:last-child')?.ariaColIndex;
}

await expect.poll(getLastHeaderCellColIndex).toBe('5');

iframe.style.width = '800px';

await expect.poll(getLastHeaderCellColIndex).toBe('9');
});

test('should navigate between cells with the keyboard', async () => {
const { iframeDocument, frame } = await createIframe();
await page.render(createPortal(<EditableGrid />, iframeDocument.body));

await userEvent.click(frame.getCell({ name: 'a1' }));
await expect.element(frame.getActiveCell()).toHaveTextContent('a1');
await userEvent.keyboard('{ArrowRight}');
await expect.element(frame.getActiveCell()).toHaveTextContent('b1');
});

test('should commit changes and navigate out of the editor on Tab', async () => {
const { iframeDocument, frame } = await createIframe();
await page.render(createPortal(<EditableGrid />, iframeDocument.body));
const editor = frame.getByRole('textbox', { name: 'editor' });

await userEvent.dblClick(frame.getCell({ name: 'a1' }));
await expect.element(editor).toHaveValue('a1');
await userEvent.keyboard('new{Tab}');
await expect.element(editor).not.toBeInTheDocument();
await expect.element(frame.getActiveCell()).toHaveTextContent('b1');
await expect.element(frame.getCell({ name: 'a1new' })).toBeInTheDocument();
});

test('should commit changes and close the editor when clicked outside', async () => {
const { iframeDocument, frame } = await createIframe();
await page.render(createPortal(<EditableGrid />, iframeDocument.body));
const editor = frame.getByRole('textbox', { name: 'editor' });

await userEvent.dblClick(frame.getCell({ name: 'a1' }));
await expect.element(editor).toHaveValue('a1');
await userEvent.keyboard('new');
await userEvent.click(frame.getByText('outside'));
await expect.element(editor).not.toBeInTheDocument();
await expect.element(frame.getCell({ name: 'a1new' })).toBeInTheDocument();
});
Loading