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");
+ });
+});