mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
fix(dashboard): enable keyboard activation for data-table rows (#11610)
Merged via /merge-batch (lote 2026-08-26 batch 2, v3.8.51). Boarded no worktree combinado junto com outras ~20 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e 292 testes focados (unit) + 18 (vitest) passando. Obrigado pela contribuição.
This commit is contained in:
1
changelog.d/fixes/11610-datatable-keyboard-activation.md
Normal file
1
changelog.d/fixes/11610-datatable-keyboard-activation.md
Normal file
@@ -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
|
||||
@@ -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) => (
|
||||
<tr
|
||||
key={row.id || idx}
|
||||
onClick={() => 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:
|
||||
|
||||
125
tests/unit/ui/data-table-keyboard-activation.test.tsx
Normal file
125
tests/unit/ui/data-table-keyboard-activation.test.tsx
Normal file
@@ -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(
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
onRowClick={onRowClick}
|
||||
renderCell={(row) =>
|
||||
withButton ? <button type="button">{String(row.name)}</button> : 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<HTMLTableRowElement>("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<HTMLTableRowElement>("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<HTMLButtonElement>("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<HTMLTableRowElement>("tbody tr")!;
|
||||
|
||||
act(() => {
|
||||
row.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true })
|
||||
);
|
||||
});
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
|
||||
const cell = row.querySelector<HTMLTableCellElement>("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<HTMLTableRowElement>("tbody tr");
|
||||
|
||||
act(() => {
|
||||
renderedRows[1]!.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })
|
||||
);
|
||||
});
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledOnce();
|
||||
expect(onRowClick).toHaveBeenCalledWith(rows[1]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user