mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
fix(ui): expose DataTable loading semantics and hide the decorative spinner (#11869)
DataTable's loading state now mirrors PageLoading's a11y convention (role=status, aria-live=polite, aria-busy=true on the container, aria-hidden on the decorative glyph) instead of announcing a bare emoji as content to assistive tech. Honest scope note in the PR body about when aria-live actually fires today. Thanks!
This commit is contained in:
1
changelog.d/fixes/11869-data-table-loading-a11y.md
Normal file
1
changelog.d/fixes/11869-data-table-loading-a11y.md
Normal file
@@ -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
|
||||
@@ -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",
|
||||
@@ -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>
|
||||
|
||||
127
tests/unit/ui/data-table-loading-a11y.test.tsx
Normal file
127
tests/unit/ui/data-table-loading-a11y.test.tsx
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user