diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index ad622e23d1..018f6c4983 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -33,6 +33,7 @@ import { isCellEditableUtil, isCtrlKeyHeldDown, isDefaultCellInput, + isElement, renderMeasuringCells, scrollIntoView } from './utils'; @@ -616,7 +617,7 @@ export function DataGrid(props: DataGridPr const { target } = event; - if (!(target instanceof Element)) return; + if (!isElement(target)) return; const cell = target.closest('.rdg-cell'); const isCellEvent = cell !== null; diff --git a/src/EditCell.tsx b/src/EditCell.tsx index e9b04cbe56..b52fbe3b06 100644 --- a/src/EditCell.tsx +++ b/src/EditCell.tsx @@ -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 { @@ -59,9 +64,9 @@ export default function EditCell({ onKeyDown, navigate }: EditCellProps) { + const editCellRef = useRef(null); const captureEventRef = useRef(undefined); - const abortControllerRef = useRef(undefined); - const frameRequestRef = useRef(undefined); + const cancelScheduledTaskRef = useRef<() => void>(undefined); const commitOnOutsideClick = column.editorOptions?.commitOnOutsideClick ?? true; // We need to prevent the `useLayoutEffect` from cleaning up between re-renders, @@ -74,24 +79,30 @@ export default function EditCell({ 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); + }; } } @@ -101,12 +112,12 @@ export default function EditCell({ } } - 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]); @@ -115,14 +126,8 @@ export default function EditCell({ // 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) { @@ -176,6 +181,7 @@ export default function EditCell({ return (
, ResizeObserverSize const targetToRefMap = new WeakMap>(); const subscribers = new Map, () => 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(); + +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) { @@ -66,6 +81,7 @@ export function useGridDimensions(gridRef: React.RefObject { const target = gridRef.current!; + const resizeObserver = getResizeObserver(target); targetToRefMap.set(target, gridRef); resizeObserver?.observe(target); diff --git a/src/utils/domUtils.ts b/src/utils/domUtils.ts index 0d60d63b26..66d9cb083c 100644 --- a/src/utils/domUtils.ts +++ b/src/utils/domUtils.ts @@ -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(); } diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index 28e64872cf..a8d5ebf732 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -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([ @@ -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): 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 diff --git a/test/browser/crossWindow.test.tsx b/test/browser/crossWindow.test.tsx new file mode 100644 index 0000000000..b8a6e6464a --- /dev/null +++ b/test/browser/crossWindow.test.tsx @@ -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[] = 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) { + const key = column.key as keyof Row; + + return ( + onRowChange({ ...row, [key]: event.target.value })} + /> + ); +} + +const editableColumns: readonly Column[] = [ + { 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 ( + <> +
outside
+ + + ); +} + +async function createIframe(width = 400) { + const iframe = document.createElement('iframe'); + iframe.style.width = `${width}px`; + iframe.srcdoc = ''; + + 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(, 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(, 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(, 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(, 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(); +});