From 5c0b68eeee7c222b90c5bec1bb3433d487a0693d Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:13:47 +0200 Subject: [PATCH] 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! --- .../fixes/11869-data-table-loading-a11y.md | 1 + src/shared/components/DataTable.tsx | 10 +- .../unit/ui/data-table-loading-a11y.test.tsx | 127 ++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/11869-data-table-loading-a11y.md create mode 100644 tests/unit/ui/data-table-loading-a11y.test.tsx diff --git a/changelog.d/fixes/11869-data-table-loading-a11y.md b/changelog.d/fixes/11869-data-table-loading-a11y.md new file mode 100644 index 0000000000..c94aad644b --- /dev/null +++ b/changelog.d/fixes/11869-data-table-loading-a11y.md @@ -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 diff --git a/src/shared/components/DataTable.tsx b/src/shared/components/DataTable.tsx index 7f1ce71c96..3b679d5414 100644 --- a/src/shared/components/DataTable.tsx +++ b/src/shared/components/DataTable.tsx @@ -79,6 +79,9 @@ export default function DataTable({ if (loading) { return (
- + {t("loading")}
diff --git a/tests/unit/ui/data-table-loading-a11y.test.tsx b/tests/unit/ui/data-table-loading-a11y.test.tsx new file mode 100644 index 0000000000..f0f3a0b187 --- /dev/null +++ b/tests/unit/ui/data-table-loading-a11y.test.tsx @@ -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( + 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('[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('[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('[role="status"]')!; + const glyph = status.querySelector('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"); + }); +});