fix(a11y): fix traces page accessibility violations — rowgroup tabbable, button name, and heading order

This commit is contained in:
Olayinka Adelakun
2026-06-18 13:55:24 -04:00
parent d82723ecc8
commit e6fbeb14df
10 changed files with 132 additions and 7 deletions

View File

@ -32,10 +32,10 @@ const Alert = React.forwardRef<
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<h5
<div
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}

View File

@ -1938,6 +1938,8 @@
"trace.clearAll": "Alles löschen",
"trace.clearAllConfirm": "Möchten Sie wirklich alle Datensätze löschen? Dadurch werden alle zugehörigen Flow-Aktivitätsverläufe endgültig gelöscht; dieser Vorgang kann nicht rückgängig gemacht werden.",
"trace.clearAllRecords": "Alle Datensätze löschen",
"trace.tableAriaLabel": "Trace runs table",
"trace.statusFilterLabel": "Filter by status",
"trace.clearDates": "Termine löschen",
"trace.clearError": "Fehler beim Löschen von Datensätzen",
"trace.clearedSuccess": "Datensätze wurden erfolgreich gelöscht",

View File

@ -1325,6 +1325,8 @@
"trace.dateRangeLabel": "{{start}} - {{end}}",
"trace.invalidDateRangeMessage": "End date cannot be earlier than start date.",
"trace.clearDates": "Clear dates",
"trace.tableAriaLabel": "Trace runs table",
"trace.statusFilterLabel": "Filter by status",
"flow.restoreVersion": "Restore this version of your flow",
"flow.saveVersion": "Save a version of your flow",
"flow.moreOptions": "More options",

View File

@ -1938,6 +1938,8 @@
"trace.clearAll": "Borrar todo",
"trace.clearAllConfirm": "¿Estás seguro de que quieres borrar todos los registros? Esto eliminará de forma permanente todos los registros de actividad de Flow relacionados y no se podrá deshacer.",
"trace.clearAllRecords": "Borrar todos los registros",
"trace.tableAriaLabel": "Trace runs table",
"trace.statusFilterLabel": "Filter by status",
"trace.clearDates": "Borrar fechas",
"trace.clearError": "Error al borrar registros",
"trace.clearedSuccess": "Los registros se han borrado correctamente",

View File

@ -1938,6 +1938,8 @@
"trace.clearAll": "Effacer tout",
"trace.clearAllConfirm": "Êtes-vous sûr de vouloir effacer tous les enregistrements? Cette opération supprimera définitivement toutes les traces d'activité de flux associées et ne pourra pas être annulée.",
"trace.clearAllRecords": "Effacer tous les enregistrements",
"trace.tableAriaLabel": "Trace runs table",
"trace.statusFilterLabel": "Filter by status",
"trace.clearDates": "Effacer les dates",
"trace.clearError": "Erreur lors de la suppression des enregistrements",
"trace.clearedSuccess": "Les enregistrements ont été effacés avec succès",

View File

@ -1938,6 +1938,8 @@
"trace.clearAll": "すべて消去",
"trace.clearAllConfirm": "すべての記録を削除してもよろしいですか? これにより、関連するすべてのフローアクティビティトレースが完全に削除され、元に戻すことはできません。",
"trace.clearAllRecords": "すべての記録を消去",
"trace.tableAriaLabel": "Trace runs table",
"trace.statusFilterLabel": "Filter by status",
"trace.clearDates": "日付を消去",
"trace.clearError": "レコードの削除中にエラーが発生しました",
"trace.clearedSuccess": "レコードのクリアに成功しました",

View File

@ -1938,6 +1938,8 @@
"trace.clearAll": "Limpar Tudo",
"trace.clearAllConfirm": "Tem certeza de que deseja apagar todos os registros? Isso excluirá permanentemente todos os rastros de atividade do fluxo relacionados e não poderá ser desfeito.",
"trace.clearAllRecords": "Limpar todos os registros",
"trace.tableAriaLabel": "Trace runs table",
"trace.statusFilterLabel": "Filter by status",
"trace.clearDates": "Limpar datas",
"trace.clearError": "Erro ao limpar registros",
"trace.clearedSuccess": "Os registros foram apagados com sucesso",

View File

@ -1938,6 +1938,8 @@
"trace.clearAll": "全部清除",
"trace.clearAllConfirm": "您确定要清除所有记录吗? 这将永久删除所有相关的流程活动跟踪记录,且无法撤销。",
"trace.clearAllRecords": "清除所有记录",
"trace.tableAriaLabel": "Trace runs table",
"trace.statusFilterLabel": "Filter by status",
"trace.clearDates": "清除日期",
"trace.clearError": "清除记录时发生错误",
"trace.clearedSuccess": "记录已成功清除",

View File

@ -1,5 +1,5 @@
import type { CellClickedEvent } from "ag-grid-community";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { CellClickedEvent, CellKeyDownEvent } from "ag-grid-community";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import IconComponent from "@/components/common/genericIconComponent";
@ -182,6 +182,30 @@ export function FlowInsightsContent({
setTracePanelOpen(true);
}, []);
const handleCellKeyDown = useCallback(
(event: CellKeyDownEvent) => {
const keyboardEvent = event.event as KeyboardEvent | undefined;
if (keyboardEvent?.key === "Enter") {
const rowData = event.data as TraceListItem | undefined;
setTracePanelTraceId(rowData?.id ?? null);
setTracePanelOpen(true);
}
},
[],
);
const traceTableRef = useRef<HTMLDivElement>(null);
const applyRowTabIndices = useCallback((containerEl: HTMLElement | null) => {
if (!containerEl) return;
const bodyRows = containerEl.querySelectorAll<HTMLElement>(
".ag-center-cols-container [role='row']",
);
bodyRows.forEach((row, idx) => {
row.setAttribute("tabindex", idx === 0 ? "0" : "-1");
});
}, []);
const totalRuns = tracesData?.total ?? rows.length;
const totalPages = Math.max(
1,
@ -306,7 +330,10 @@ export function FlowInsightsContent({
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-8 w-[130px] [&>span]:truncate">
<SelectTrigger
className="h-8 w-[130px] [&>span]:truncate"
aria-label={t("trace.statusFilterLabel")}
>
<SelectValue placeholder={t("trace.allStatus")} />
</SelectTrigger>
<SelectContent>
@ -391,7 +418,10 @@ export function FlowInsightsContent({
</div>
</div>
<div className="ag-flush-mode flex-1 overflow-hidden">
<div
ref={traceTableRef}
className="ag-flush-mode flex-1 overflow-hidden"
>
{groupBySession ? (
renderGroupedSessionContent({
groupedRows,
@ -404,6 +434,7 @@ export function FlowInsightsContent({
<TableComponent
key="Executions"
readOnlyEdit
aria-label={t("trace.tableAriaLabel")}
className="h-max-full h-full w-full"
data-testid="flow-insights-trace-table"
pagination={false}
@ -412,6 +443,13 @@ export function FlowInsightsContent({
rowData={rows}
headerHeight={rows.length === 0 ? 0 : undefined}
onCellClicked={handleCellClicked}
onCellKeyDown={handleCellKeyDown}
onFirstDataRendered={() =>
applyRowTabIndices(traceTableRef.current)
}
onRowDataUpdated={() =>
applyRowTabIndices(traceTableRef.current)
}
/>
)}
</div>

View File

@ -0,0 +1,73 @@
// Unit tests for the applyRowTabIndices logic used in FlowInsightsContent.
// The fix: after AG Grid renders rows, we set tabindex="0" on the first row
// and tabindex="-1" on all others so that the IBM accessibility checker finds
// a tabbable descendant inside role="rowgroup".
function applyRowTabIndices(containerEl: HTMLElement | null): void {
if (!containerEl) return;
const bodyRows = containerEl.querySelectorAll<HTMLElement>(
".ag-center-cols-container [role='row']",
);
bodyRows.forEach((row, idx) => {
row.setAttribute("tabindex", idx === 0 ? "0" : "-1");
});
}
function makeGrid(rowCount: number): HTMLElement {
const container = document.createElement("div");
const colsContainer = document.createElement("div");
colsContainer.className = "ag-center-cols-container";
for (let i = 0; i < rowCount; i++) {
const row = document.createElement("div");
row.setAttribute("role", "row");
colsContainer.appendChild(row);
}
container.appendChild(colsContainer);
return container;
}
describe("applyRowTabIndices — rowgroup tabbable fix", () => {
it("sets tabindex=0 on the first row and -1 on all others", () => {
const container = makeGrid(3);
applyRowTabIndices(container);
const rows = container.querySelectorAll<HTMLElement>("[role='row']");
expect(rows[0].getAttribute("tabindex")).toBe("0");
expect(rows[1].getAttribute("tabindex")).toBe("-1");
expect(rows[2].getAttribute("tabindex")).toBe("-1");
});
it("handles a single row — that row gets tabindex=0", () => {
const container = makeGrid(1);
applyRowTabIndices(container);
const rows = container.querySelectorAll<HTMLElement>("[role='row']");
expect(rows[0].getAttribute("tabindex")).toBe("0");
});
it("does nothing when containerEl is null", () => {
expect(() => applyRowTabIndices(null)).not.toThrow();
});
it("does nothing when there are no rows", () => {
const container = makeGrid(0);
applyRowTabIndices(container);
const rows = container.querySelectorAll("[role='row']");
expect(rows.length).toBe(0);
});
it("overwrites any existing tabindex on rows", () => {
const container = makeGrid(2);
const rows = container.querySelectorAll<HTMLElement>("[role='row']");
rows[0].setAttribute("tabindex", "5");
rows[1].setAttribute("tabindex", "5");
applyRowTabIndices(container);
expect(rows[0].getAttribute("tabindex")).toBe("0");
expect(rows[1].getAttribute("tabindex")).toBe("-1");
});
});