diff --git a/changelog.d/fixes/11610-datatable-keyboard-activation.md b/changelog.d/fixes/11610-datatable-keyboard-activation.md new file mode 100644 index 0000000000..a51f2340d7 --- /dev/null +++ b/changelog.d/fixes/11610-datatable-keyboard-activation.md @@ -0,0 +1 @@ +- **fix(dashboard):** Enable Enter and Space activation for clickable data-table rows without hijacking nested controls ([#11610](https://github.com/diegosouzapw/OmniRoute/pull/11610)) — thanks @pacocartones diff --git a/src/shared/components/DataTable.tsx b/src/shared/components/DataTable.tsx index b10961ac61..7f1ce71c96 100644 --- a/src/shared/components/DataTable.tsx +++ b/src/shared/components/DataTable.tsx @@ -2,6 +2,21 @@ import { useTranslations } from "next-intl"; +const INTERACTIVE_ELEMENT_SELECTOR = + 'a[href], button, input, select, textarea, summary, [role="button"], [role="link"], ' + + '[contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])'; + +function targetsNestedInteractiveElement( + row: HTMLTableRowElement, + target: EventTarget | null +): boolean { + if (!(target instanceof Element)) return false; + const interactiveElement = target.closest(INTERACTIVE_ELEMENT_SELECTOR); + return ( + interactiveElement !== null && interactiveElement !== row && row.contains(interactiveElement) + ); +} + /** * DataTable — Shared UI primitive (T-29) * @@ -148,7 +163,25 @@ export default function DataTable({ {data.map((row, idx) => ( onRowClick?.(row)} + onClick={(event) => { + if ( + !onRowClick || + targetsNestedInteractiveElement(event.currentTarget, event.target) + ) + return; + onRowClick(row); + }} + onKeyDown={ + onRowClick + ? (event) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onRowClick(row); + } + : undefined + } + tabIndex={onRowClick ? 0 : undefined} style={{ cursor: onRowClick ? "pointer" : "default", background: diff --git a/tests/unit/ui/data-table-keyboard-activation.test.tsx b/tests/unit/ui/data-table-keyboard-activation.test.tsx new file mode 100644 index 0000000000..ff0b5e1d02 --- /dev/null +++ b/tests/unit/ui/data-table-keyboard-activation.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DataTableRow } from "../../../src/shared/components/DataTable"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const { default: DataTable } = await import("../../../src/shared/components/DataTable"); + +const cleanups: Array<() => void> = []; +const columns = [{ key: "name", label: "Name" }]; +const data: DataTableRow[] = [{ id: "row-1", name: "Alpha" }]; + +function renderTable(onRowClick?: (row: DataTableRow) => void, withButton = false, rows = data) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + + withButton ? : String(row.name) + } + /> + ); + }); + cleanups.push(() => { + act(() => root.unmount()); + container.remove(); + }); + return container; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (cleanups.length) cleanups.pop()!(); +}); + +describe("DataTable keyboard row activation", () => { + it.each(["Enter", " "])("makes clickable rows focusable and activates them with %j", (key) => { + const onRowClick = vi.fn(); + const container = renderTable(onRowClick); + const row = container.querySelector("tbody tr")!; + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + + expect(row.tabIndex).toBe(0); + act(() => { + row.dispatchEvent(event); + }); + + expect(event.defaultPrevented).toBe(true); + expect(onRowClick).toHaveBeenCalledOnce(); + expect(onRowClick).toHaveBeenCalledWith(data[0]); + }); + + it("does not make passive rows keyboard-interactive", () => { + const container = renderTable(); + const row = container.querySelector("tbody tr")!; + + expect(row.getAttribute("tabindex")).toBeNull(); + }); + + it("does not hijack keyboard events from controls rendered inside a row", () => { + const onRowClick = vi.fn(); + const container = renderTable(onRowClick, true); + const button = container.querySelector("tbody button")!; + + act(() => { + button.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }) + ); + button.click(); + }); + + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it("preserves mouse activation and ignores unrelated keys", () => { + const onRowClick = vi.fn(); + const container = renderTable(onRowClick); + const row = container.querySelector("tbody tr")!; + + act(() => { + row.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }) + ); + }); + expect(onRowClick).not.toHaveBeenCalled(); + + const cell = row.querySelector("td")!; + act(() => cell.click()); + expect(onRowClick).toHaveBeenCalledOnce(); + expect(onRowClick).toHaveBeenCalledWith(data[0]); + }); + + it("activates the focused row instead of another row", () => { + const onRowClick = vi.fn(); + const rows: DataTableRow[] = [ + { id: "row-1", name: "Alpha" }, + { id: "row-2", name: "Beta" }, + ]; + const container = renderTable(onRowClick, false, rows); + const renderedRows = container.querySelectorAll("tbody tr"); + + act(() => { + renderedRows[1]!.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }) + ); + }); + + expect(onRowClick).toHaveBeenCalledOnce(); + expect(onRowClick).toHaveBeenCalledWith(rows[1]); + }); +});