Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/fixes/11869-data-table-loading-a11y.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(ui):** the shared `DataTable` loading state no longer reads its decorative ⏳ glyph out to assistive technology, and now carries the same `role="status"` / `aria-live="polite"` / `aria-busy="true"` semantics as `PageLoading` ([#11869](https://github.com/diegosouzapw/OmniRoute/pull/11869)) — thanks @pacocartones
10 changes: 9 additions & 1 deletion src/shared/components/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export default function DataTable({
if (loading) {
return (
<div
role="status"
aria-live="polite"
aria-busy="true"
style={{
display: "flex",
alignItems: "center",
Expand All @@ -88,7 +91,12 @@ export default function DataTable({
fontSize: "14px",
}}
>
<span style={{ animation: "spin 1s linear infinite", marginRight: "8px" }}>⏳</span>
<span
aria-hidden="true"
style={{ animation: "spin 1s linear infinite", marginRight: "8px" }}
>
⏳
</span>
{t("loading")}
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
Expand Down
127 changes: 127 additions & 0 deletions tests/unit/ui/data-table-loading-a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// @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(props: { loading?: boolean; rows?: DataTableRow[] } = {}) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<DataTable
columns={columns}
data={props.rows ?? data}
loading={props.loading}
renderCell={(row) => 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 loading state accessibility", () => {
it("marks the loading container as a busy status region", () => {
const container = renderTable({ loading: true });
const status = container.querySelector<HTMLDivElement>('[role="status"]')!;

expect(status).not.toBeNull();
expect(status.getAttribute("aria-live")).toBe("polite");
expect(status.getAttribute("aria-busy")).toBe("true");
});

it("hides the decorative spinner glyph from assistive technology", () => {
const container = renderTable({ loading: true });
const glyph = [...container.querySelectorAll("span")].find((el) =>
el.textContent?.includes("⏳")
)!;

expect(glyph).toBeDefined();
expect(glyph.getAttribute("aria-hidden")).toBe("true");
});

it("leaves only the loading label as readable text in the status region", () => {
const container = renderTable({ loading: true });
const status = container.querySelector<HTMLDivElement>('[role="status"]')!;
const readable = [...status.childNodes]
.filter(
(node) =>
!(node instanceof Element) ||
(node.getAttribute("aria-hidden") !== "true" && node.tagName !== "STYLE")
)
.map((node) => node.textContent ?? "")
.join("")
.trim();

expect(readable).toBe("loading");
expect(readable).not.toContain("⏳");
});

it("keeps the spinner animation and layout untouched", () => {
const container = renderTable({ loading: true });
const status = container.querySelector<HTMLDivElement>('[role="status"]')!;
const glyph = status.querySelector<HTMLSpanElement>('span[aria-hidden="true"]')!;

expect(status.style.display).toBe("flex");
expect(status.style.alignItems).toBe("center");
expect(status.style.justifyContent).toBe("center");
expect(glyph.style.animation).toContain("spin");
expect(glyph.style.marginRight).toBe("8px");
expect(status.querySelector("style")?.textContent).toContain("@keyframes spin");
});

it("renders no table while loading", () => {
const container = renderTable({ loading: true });

expect(container.querySelector("table")).toBeNull();
});

it("does not expose a status region once rows have rendered", () => {
const container = renderTable();

expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector("[aria-busy]")).toBeNull();
expect(container.querySelector("tbody tr")).not.toBeNull();
});

it("does not expose a status region for the empty state", () => {
const container = renderTable({ rows: [] });

expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector("[aria-busy]")).toBeNull();
expect(container.textContent).toContain("noData");
});

it("prefers the loading state over the empty state", () => {
const container = renderTable({ loading: true, rows: [] });

expect(container.querySelector('[role="status"]')).not.toBeNull();
expect(container.textContent).toContain("loading");
expect(container.textContent).not.toContain("noData");
});
});
Loading