From d7fdafcca55b37a1174d5e0435a6174c39553456 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Wed, 2 Sep 2026 13:17:07 +0100
Subject: [PATCH] Keep platform table disclosures on native buttons
Remove duplicate keyboard and disclosure semantics from static data-table rows across the platform surfaces. Preserve whole-row pointer convenience while leaving focus, accessible naming and expanded state on each existing native disclosure button.
The populated TrueNAS browser check also exposed an invalid combobox role on inline-only search completion, so keep it as a native textbox with aria-autocomplete.
Change-source: pulse-maintainer
Contract-Neutral: Accessibility semantics and regression coverage only; no product or API boundary changed
---
frontend-modern/browser-verification.json | 129 +++++++++++++++---
frontend-modern/package.json | 3 +-
.../__tests__/table-row-a11y-audit.test.mjs | 66 +++++++++
.../scripts/table-row-a11y-audit.mjs | 73 ++++++++++
.../src/components/shared/SearchInput.tsx | 1 -
.../shared/__tests__/SearchInput.test.tsx | 3 +
.../src/features/docker/DockerAlertsTable.tsx | 4 -
.../features/docker/DockerConfigsTable.tsx | 4 -
.../features/docker/DockerContainersTable.tsx | 4 -
.../src/features/docker/DockerHostsTable.tsx | 16 +--
.../src/features/docker/DockerImagesTable.tsx | 4 -
.../features/docker/DockerNetworksTable.tsx | 4 -
.../features/docker/DockerSecretsTable.tsx | 4 -
.../features/docker/DockerServicesTable.tsx | 4 -
.../docker/DockerStorageUsageTable.tsx | 4 -
.../features/docker/DockerSwarmNodesTable.tsx | 4 -
.../src/features/docker/DockerTasksTable.tsx | 4 -
.../features/docker/DockerVolumesTable.tsx | 4 -
.../__tests__/DockerHostsTable.test.tsx | 12 +-
.../__tests__/DockerNativeTables.test.tsx | 16 ++-
.../kubernetes/KubernetesAlertsTable.tsx | 4 -
.../kubernetes/KubernetesAutoscalingTable.tsx | 4 -
.../kubernetes/KubernetesClustersTable.tsx | 4 -
.../kubernetes/KubernetesConfigTable.tsx | 4 -
.../kubernetes/KubernetesControllersTable.tsx | 4 -
.../kubernetes/KubernetesDeploymentsTable.tsx | 4 -
.../kubernetes/KubernetesEventsTable.tsx | 4 -
.../kubernetes/KubernetesNetworkingTable.tsx | 4 -
.../kubernetes/KubernetesNodesTable.tsx | 4 -
.../kubernetes/KubernetesPodsTable.tsx | 4 -
.../kubernetes/KubernetesPolicyTable.tsx | 4 -
.../kubernetes/KubernetesServicesTable.tsx | 4 -
.../kubernetes/KubernetesStorageTable.tsx | 4 -
.../__tests__/KubernetesNodesTable.test.tsx | 12 +-
.../PlatformResourceDetailTableRow.tsx | 33 +----
.../PlatformResourceDetailTableRow.test.tsx | 11 +-
.../platformResourceTableDrawers.test.tsx | 9 +-
.../proxmox/ProxmoxBackupServersTable.tsx | 1 -
.../proxmox/ProxmoxCephClusterDrawer.tsx | 13 +-
.../src/features/proxmox/ProxmoxCephTable.tsx | 1 -
.../features/proxmox/ProxmoxCoverageTable.tsx | 1 -
.../proxmox/ProxmoxMailGatewayTable.tsx | 1 -
.../features/proxmox/ProxmoxNodesTable.tsx | 15 +-
.../__tests__/ProxmoxCephTable.test.tsx | 4 +-
.../__tests__/ProxmoxNodesTable.test.tsx | 15 +-
.../standalone/AgentsMachinesTable.tsx | 12 --
.../standalone/AvailabilityChecksTable.tsx | 4 -
.../AvailabilityChecksTable.test.tsx | 9 +-
.../features/truenas/TrueNASAlertsTable.tsx | 4 -
.../src/features/truenas/TrueNASAppsTable.tsx | 4 -
.../truenas/TrueNASNetworkSharesTable.tsx | 4 -
.../truenas/TrueNASProtectionTable.tsx | 4 -
.../features/truenas/TrueNASServicesTable.tsx | 4 -
.../truenas/TrueNASStorageTopologyTable.tsx | 4 -
.../features/truenas/TrueNASSystemsTable.tsx | 4 -
.../truenas/TrueNASVirtualMachinesTable.tsx | 4 -
.../__tests__/TrueNASAlertsTable.test.tsx | 15 +-
.../__tests__/TrueNASProtectionTable.test.tsx | 15 +-
.../__tests__/TrueNASServicesTable.test.tsx | 15 +-
.../features/vmware/VsphereActivityTable.tsx | 4 -
.../features/vmware/VsphereAlertsTable.tsx | 4 -
.../vmware/VsphereDatastoresTable.tsx | 4 -
.../src/features/vmware/VsphereHostsTable.tsx | 4 -
.../features/vmware/VsphereNetworksTable.tsx | 4 -
.../__tests__/VsphereActivityTable.test.tsx | 15 +-
.../__tests__/VsphereAlertsTable.test.tsx | 15 +-
.../__tests__/VsphereDatastoresTable.test.tsx | 9 +-
.../__tests__/VsphereNetworksTable.test.tsx | 9 +-
.../83-product-trust-accessibility.spec.ts | 39 ++++++
69 files changed, 438 insertions(+), 302 deletions(-)
create mode 100644 frontend-modern/scripts/__tests__/table-row-a11y-audit.test.mjs
create mode 100644 frontend-modern/scripts/table-row-a11y-audit.mjs
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 144c4d492..45a98cb0f 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,20 +1,110 @@
{
"version": 1,
- "base_sha": "fc0adf7073d99447e8042d5053d331d323b8a372",
- "verified_at": "2026-09-02T10:25:49Z",
+ "base_sha": "3016bc72a0e64c43a1c4e49d805b59874e875643",
+ "verified_at": "2026-09-02T12:15:21Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/components/AI/Chat/MentionAutocomplete.tsx",
- "frontend-modern/src/components/AI/Chat/SlashCommandAutocomplete.tsx",
- "frontend-modern/src/components/AI/Chat/index.tsx"
+ "frontend-modern/src/components/shared/SearchInput.tsx",
+ "frontend-modern/src/features/docker/DockerAlertsTable.tsx",
+ "frontend-modern/src/features/docker/DockerConfigsTable.tsx",
+ "frontend-modern/src/features/docker/DockerContainersTable.tsx",
+ "frontend-modern/src/features/docker/DockerHostsTable.tsx",
+ "frontend-modern/src/features/docker/DockerImagesTable.tsx",
+ "frontend-modern/src/features/docker/DockerNetworksTable.tsx",
+ "frontend-modern/src/features/docker/DockerSecretsTable.tsx",
+ "frontend-modern/src/features/docker/DockerServicesTable.tsx",
+ "frontend-modern/src/features/docker/DockerStorageUsageTable.tsx",
+ "frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx",
+ "frontend-modern/src/features/docker/DockerTasksTable.tsx",
+ "frontend-modern/src/features/docker/DockerVolumesTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx",
+ "frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx",
+ "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
+ "frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx",
+ "frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx",
+ "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
+ "frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx",
+ "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
+ "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx",
+ "frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx",
+ "frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx",
+ "frontend-modern/src/features/truenas/TrueNASAppsTable.tsx",
+ "frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx",
+ "frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx",
+ "frontend-modern/src/features/truenas/TrueNASServicesTable.tsx",
+ "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx",
+ "frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx",
+ "frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx",
+ "frontend-modern/src/features/vmware/VsphereActivityTable.tsx",
+ "frontend-modern/src/features/vmware/VsphereAlertsTable.tsx",
+ "frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx",
+ "frontend-modern/src/features/vmware/VsphereHostsTable.tsx",
+ "frontend-modern/src/features/vmware/VsphereNetworksTable.tsx"
],
"content_sha256": {
- "frontend-modern/src/components/AI/Chat/MentionAutocomplete.tsx": "fde53ef7a82b0ff854a759087a0fc7dfaafd89e7e8933b68335598100adb8955",
- "frontend-modern/src/components/AI/Chat/SlashCommandAutocomplete.tsx": "2073d3c388590dad8c291dfc7a0d8a3132e4c18a50014918c0a2a90510401c28",
- "frontend-modern/src/components/AI/Chat/index.tsx": "010def979665a1db9be3dfa3537ebfa8714125fb7ce9212102fb49465d5d22db"
+ "frontend-modern/src/components/shared/SearchInput.tsx": "ba88362103f5479034a79a9fb888e87187d39876209e624421088bfb1bced26a",
+ "frontend-modern/src/features/docker/DockerAlertsTable.tsx": "5ea264b2f1bcdd5d2561646e9e19d704fcb948fbcebd34f7ec7a2c91b564c626",
+ "frontend-modern/src/features/docker/DockerConfigsTable.tsx": "4abd9a32b9540dbf049e0a7a9ebc914c33519525ee5ddf43cf50c4713767a7b1",
+ "frontend-modern/src/features/docker/DockerContainersTable.tsx": "64d9092d9b0537a42352b21ce038865251a80d1a23ee90d4defced0f50e4dbd3",
+ "frontend-modern/src/features/docker/DockerHostsTable.tsx": "addda2de56331ada4002c81a73e2005a9b5cb336d7575a912216bdf794a2667c",
+ "frontend-modern/src/features/docker/DockerImagesTable.tsx": "fb8a45ef41eaa926b18a1a2d0f5e5586730431cd9b2ab7db79dbf57f343e3a6f",
+ "frontend-modern/src/features/docker/DockerNetworksTable.tsx": "e824f88e2b09c3afe843d7ddc236c26ee1218a5bf1506a8aad256d2550e6cd97",
+ "frontend-modern/src/features/docker/DockerSecretsTable.tsx": "c3fdbc2976a84967d0682e078e5f5f8943f5658b194e6b4a30b9c7208cf56f8f",
+ "frontend-modern/src/features/docker/DockerServicesTable.tsx": "b936d55463a05b50ca60e6dccc2471dc142f02863fe077833a299aabfff308b3",
+ "frontend-modern/src/features/docker/DockerStorageUsageTable.tsx": "25c821633dd0d82223968a0d29a196e5afc7da8105f35eb897488a40bfba965d",
+ "frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx": "9b702a777e0572b6014b32fe64773bac469c84140f925eecdc86911495da0222",
+ "frontend-modern/src/features/docker/DockerTasksTable.tsx": "d8b52db2416a11ac76ede1407c3ab7521f7633a160052049a6033bd253890608",
+ "frontend-modern/src/features/docker/DockerVolumesTable.tsx": "41793497dcef26c328de418807bdab3e42b583631452ed164edc89a5dbd41add",
+ "frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx": "59339fbd307b7e77386df84db34fe59cbb47bd8f8e9ea478afeab90f8ae9e094",
+ "frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx": "5f521812a318b54e5d3db1ed81b3e778c59cedd93e81ed535c9d3e5f4c9df1a7",
+ "frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx": "1f50a7ff4ddc2acbb4162ccecddc08bfbd6b39d7d8f32bf6bf68a63b5864a20b",
+ "frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx": "61a8629ec241659a178760b6e97f4a137204ddbc9f52f4e4baecb6b719df527d",
+ "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "171a22ff20c69edd59be65bf4dcf2af1e2765fa4156e511e1689b0c9eab73db5",
+ "frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx": "e98b2480e091269dfa342d7bbef6976663f626e6bd4335d38a85a40edb3feb26",
+ "frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx": "1fa633f5dfb149d9a8f08182b08632fa6fdcc38ba91cd20c6ab5c17cd9244e36",
+ "frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx": "318d20f8b896ae94f9c34ce521b9f105bfeebc8c2b1d06489930e055f4986710",
+ "frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx": "cbbee7fcad2e6c17c405b70c69c8dd476e36a6dcdaa937a9372446336bcee277",
+ "frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx": "a94c17b87e120be4051e70c20e7a63716309e94adc6c7b48550ab8857be169cf",
+ "frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx": "eaefd6a7c9ef5e4e43c29afc9c7de1a1fec513a02bf17f110f1c3d31962054b5",
+ "frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx": "1cd2c8fd0e5edc4db005a8d3f9fa0a49732b2aac3d829288e1e6c2ec5b8c9bdd",
+ "frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx": "ff8de65244d1f36ccb9e628be2c51404c6c3aea3a5f57905a98cd218754fcb76",
+ "frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx": "fe9669116eb81930f93b6a978ecfd7b9a9fa75d96d02843f3f2fe02256bf2902",
+ "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "97db101cb72d4813e75442da69398397a8b2d36501bfe13b7826820499aaf540",
+ "frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx": "f52833e1b5fb978b560925eaaa2a70583e0e43e400c3de58962b6c423aed143b",
+ "frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx": "a66016ab4fac48e7f1d5fa01c320cfbcb5ee062477d8cc93e22e42277a023fc3",
+ "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "0ee88b539ef6fbd73844c09be2a23e6eb032b6098f557c7d50c7d052148c0577",
+ "frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx": "fe75fa3c4764b9d878a53594919e2dc352d2761a46d0c291205c48ebedf8877e",
+ "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "e68c504226a0f63b07d84c2200f18770a1afd3eac064289fdbfb68365c838757",
+ "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "2c8750d9dbf0e70c6b39aff9b4c8f2797b1b738150a0c2741bd3cad854a6864c",
+ "frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx": "bff8eddc760ff714cffc366ec0c4f7fb36a3d16250b11373d1dc05e724ea8ffd",
+ "frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx": "4e81749f64327e2f0ce383f5d4f5065cdbb707ad4b719284e6d85c3e95d74c45",
+ "frontend-modern/src/features/truenas/TrueNASAppsTable.tsx": "323f06f58b0f24aac4fb5fc17b46c464262dcb313923f22d38ba25822cf34bbe",
+ "frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx": "e94c62f1bc8b944fb5937e0035d5db4940a4d3995faeb826d9f9cc46bc891ade",
+ "frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx": "766fdb139db983be5c3630dbc361459eaebfc74c6e71ef4be4bce8516b5a084c",
+ "frontend-modern/src/features/truenas/TrueNASServicesTable.tsx": "3763d567a22b5a5c3f3516529be7ed1ccf51108a8c19537a291de0c44f8471e0",
+ "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "eab67145c573d37fa1e91385b7deda97c3e4a97aa14e1eaf58aa56ea940f60a4",
+ "frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx": "c40be5911f961215ed4ceb3e4838d6efa52684d1bda550068c2502bdfdb838ec",
+ "frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx": "48117aa58a96ea3b0c1c46e624621b6dced88bf368cc9affcd675884037f65bf",
+ "frontend-modern/src/features/vmware/VsphereActivityTable.tsx": "2f613f2ae6ad4a299cec849d237f22af20bf1dd2a41bf6859dce70af464c1b5f",
+ "frontend-modern/src/features/vmware/VsphereAlertsTable.tsx": "8d77ce4fb029efde83e71006703f0b6c74db18295ce0dfdeee1931a2c780f0b2",
+ "frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx": "de897d462823484d2167785cafbf0e4f029a243fdc98f6ff00082e1b458adf3f",
+ "frontend-modern/src/features/vmware/VsphereHostsTable.tsx": "63891a9f1f8f798bfc91a0c917da207b8a077c362483d1ebe07d3709afeeeeaf",
+ "frontend-modern/src/features/vmware/VsphereNetworksTable.tsx": "b667f8183ccb5a763d9af82b6b6dfc4b5a8db109cfdf4069e3383ca2d588a028"
},
"routes": [
- "/"
+ "/truenas/overview"
],
"viewports": [
{
@@ -27,20 +117,15 @@
}
],
"states": [
- "Closed named Assistant composer with no listbox relationship in desktop docked and narrow overlay layouts",
- "Open slash-command list with the selected active option and the wrapped final option scrolled into view",
- "Filtered slash-command empty result with listbox ownership and no stale active descendant",
- "Slash command selected with Tab and popup closed with composer focus restored",
- "Open resource mention list with the selected active option and the wrapped final option scrolled into view",
- "Mention selected by pointer and popup closed with composer focus retained",
- "Autocomplete closed by Escape and outside pointer dismissal without stale relationships"
+ "Populated TrueNAS Systems table at desktop and narrow widths with reduced motion",
+ "Collapsed static resource row without tabindex or disclosure aria, with one named native disclosure button",
+ "Expanded inline system detail controlled by the disclosure button after keyboard and pointer activation",
+ "Platform inline-completion search exposed as a native textbox without unsupported combobox popup semantics"
],
"interactions": [
- "opened Pulse Assistant from the current-view launcher and verified the composer accessible name and focus",
- "opened slash commands and mentions by typing, then checked aria-controls, aria-activedescendant, aria-selected, and option tab exclusion",
- "moved active options with ArrowDown and wrapped with ArrowUp, hovered options by pointer, and verified active options remained visible",
- "selected /new with Tab and selected a mention by pointer while verifying focus remained in the composer",
- "exercised Escape and outside pointer dismissal, including the no-match slash-command state",
- "inspected desktop and narrow pixels for popup placement, viewport containment, clipping, stacking, scrolling, and visible composer focus"
+ "focused the named disclosure button and expanded it with Enter at desktop and narrow widths",
+ "collapsed the disclosure with Space and confirmed aria-expanded and aria-controls stayed on the button",
+ "expanded the same detail by clicking non-interactive row content while retaining whole-row pointer convenience",
+ "confirmed the controlled detail row remained visible and ran the axe WCAG A/AA scan in the expanded state"
]
}
diff --git a/frontend-modern/package.json b/frontend-modern/package.json
index c5d9ca354..b4e463dfa 100644
--- a/frontend-modern/package.json
+++ b/frontend-modern/package.json
@@ -35,11 +35,12 @@
"test:coverage": "vitest run --coverage --coverage.provider=v8 --coverage.include=src/**/*.ts --coverage.include=src/**/*.tsx --coverage.exclude=src/index.tsx",
"test:coverage:ai": "vitest run --coverage --coverage.provider=v8 --coverage.thresholds.100 --coverage.thresholds.perFile --coverage.include=src/components/AI/aiChatUtils.ts",
"type-check": "tsc --noEmit",
- "lint": "npm run lint:eslint && npm run lint:theme && npm run lint:copy && npm run lint:canonical-platforms && npm run lint:form-labels && npm run lint:external-domains && npm run lint:planning-docs",
+ "lint": "npm run lint:eslint && npm run lint:theme && npm run lint:copy && npm run lint:canonical-platforms && npm run lint:form-labels && npm run lint:table-rows && npm run lint:external-domains && npm run lint:planning-docs",
"lint:canonical-platforms": "node scripts/canonical-platform-audit.mjs",
"lint:eslint": "eslint \"src/**/*.{ts,tsx}\"",
"lint:copy": "node scripts/copy-style-audit.mjs",
"lint:form-labels": "node scripts/form-label-audit.mjs",
+ "lint:table-rows": "node scripts/table-row-a11y-audit.mjs",
"lint:external-domains": "node scripts/external-domain-audit.mjs",
"lint:planning-docs": "node scripts/planning-doc-status-audit.mjs",
"lint:theme": "node scripts/theme-audit.mjs",
diff --git a/frontend-modern/scripts/__tests__/table-row-a11y-audit.test.mjs b/frontend-modern/scripts/__tests__/table-row-a11y-audit.test.mjs
new file mode 100644
index 000000000..7802995d6
--- /dev/null
+++ b/frontend-modern/scripts/__tests__/table-row-a11y-audit.test.mjs
@@ -0,0 +1,66 @@
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import { afterEach, describe, expect, it } from 'vitest';
+
+const auditPath = path.join(process.cwd(), 'scripts', 'table-row-a11y-audit.mjs');
+const temporaryRoots = [];
+
+const writeSource = (frontendRoot, source) => {
+ const sourcePath = path.join(frontendRoot, 'src', 'Fixture.tsx');
+ fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
+ fs.writeFileSync(sourcePath, source);
+};
+
+const runAudit = (frontendRoot) =>
+ spawnSync(process.execPath, [auditPath], { cwd: frontendRoot, encoding: 'utf8' });
+
+const makeFrontendRoot = () => {
+ const frontendRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-table-row-a11y-'));
+ temporaryRoots.push(frontendRoot);
+ return frontendRoot;
+};
+
+afterEach(() => {
+ while (temporaryRoots.length > 0) {
+ fs.rmSync(temporaryRoots.pop(), { force: true, recursive: true });
+ }
+});
+
+describe('table row accessibility audit', () => {
+ it('rejects native and shared table rows in the tab sequence', () => {
+ const frontendRoot = makeFrontendRoot();
+ writeSource(
+ frontendRoot,
+ `export const Fixture = () => (
+
+ | Native row |
+ Shared row
+
+ );`,
+ );
+
+ const result = runAudit(frontendRoot);
+ expect(result.status).toBe(1);
+ expect(result.stderr).toContain('src/Fixture.tsx:3');
+ expect(result.stderr).toContain('src/Fixture.tsx:4');
+ });
+
+ it('accepts a static row with a native disclosure button', () => {
+ const frontendRoot = makeFrontendRoot();
+ writeSource(
+ frontendRoot,
+ `export const Fixture = () => (
+
+ );`,
+ );
+
+ const result = runAudit(frontendRoot);
+ expect(result.status).toBe(0);
+ expect(result.stdout).toContain('Table row accessibility audit passed');
+ });
+});
diff --git a/frontend-modern/scripts/table-row-a11y-audit.mjs b/frontend-modern/scripts/table-row-a11y-audit.mjs
new file mode 100644
index 000000000..f2c8ae5bd
--- /dev/null
+++ b/frontend-modern/scripts/table-row-a11y-audit.mjs
@@ -0,0 +1,73 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import ts from 'typescript';
+
+const sourceRoot = path.resolve('src');
+const sourceFiles = [];
+
+const collectSourceFiles = (directory) => {
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const entryPath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name !== '__tests__') collectSourceFiles(entryPath);
+ } else if (
+ entry.isFile() &&
+ entry.name.endsWith('.tsx') &&
+ !entry.name.includes('.test.')
+ ) {
+ sourceFiles.push(entryPath);
+ }
+ }
+};
+
+collectSourceFiles(sourceRoot);
+
+const diagnostics = [];
+const isZeroTabIndex = (attribute, sourceFile) => {
+ const value = attribute.initializer?.getText(sourceFile) ?? '';
+ return /^(?:['"]0['"]|\{0\})$/.test(value);
+};
+
+for (const sourcePath of sourceFiles.sort()) {
+ const sourceText = fs.readFileSync(sourcePath, 'utf8');
+ const sourceFile = ts.createSourceFile(
+ sourcePath,
+ sourceText,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TSX,
+ );
+
+ const visit = (node) => {
+ const element =
+ ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)
+ ? ts.isJsxElement(node)
+ ? node.openingElement
+ : node
+ : undefined;
+ if (element && ['tr', 'TableRow'].includes(element.tagName.getText(sourceFile))) {
+ const tabIndex = element.attributes.properties.find(
+ (attribute) =>
+ ts.isJsxAttribute(attribute) &&
+ ['tabIndex', 'tabindex'].includes(attribute.name.getText(sourceFile)),
+ );
+ if (tabIndex && ts.isJsxAttribute(tabIndex) && isZeroTabIndex(tabIndex, sourceFile)) {
+ const position = sourceFile.getLineAndCharacterOfPosition(tabIndex.getStart(sourceFile));
+ diagnostics.push(
+ `${path.relative(process.cwd(), sourcePath)}:${position.line + 1}:${position.character + 1} ` +
+ 'native data-table rows must not enter the tab sequence; put disclosure behaviour on a button inside the row',
+ );
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(sourceFile);
+}
+
+if (diagnostics.length > 0) {
+ console.error('Table row accessibility audit failed:\n');
+ diagnostics.forEach((diagnostic) => console.error(`- ${diagnostic}`));
+ process.exit(1);
+}
+
+console.log(`Table row accessibility audit passed (${sourceFiles.length} TSX files checked).`);
diff --git a/frontend-modern/src/components/shared/SearchInput.tsx b/frontend-modern/src/components/shared/SearchInput.tsx
index 10382597b..54722d632 100644
--- a/frontend-modern/src/components/shared/SearchInput.tsx
+++ b/frontend-modern/src/components/shared/SearchInput.tsx
@@ -35,7 +35,6 @@ export const SearchInput: Component = (props) => {
onFocus={search.enhancements.onFieldFocus}
onBlur={search.enhancements.onFieldBlur}
completionSuffix={search.enhancements.completionSuffix()}
- role={search.enhancements.hasSuggestions() ? 'combobox' : undefined}
ariaAutocomplete={search.enhancements.hasSuggestions() ? 'inline' : undefined}
trailingControls={
diff --git a/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx b/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx
index 1b158f7c7..6ea88150e 100644
--- a/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx
+++ b/frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx
@@ -348,6 +348,9 @@ describe('SearchInput', () => {
const { container } = render(() => );
const input = screen.getByPlaceholderText('Infrastructure search');
+ expect(screen.getByRole('textbox', { name: 'Infrastructure search' })).toBe(input);
+ expect(input).not.toHaveAttribute('role');
+ expect(input).toHaveAttribute('aria-autocomplete', 'inline');
input.focus();
fireEvent.input(input, { target: { value: 'pv' } });
diff --git a/frontend-modern/src/features/docker/DockerAlertsTable.tsx b/frontend-modern/src/features/docker/DockerAlertsTable.tsx
index 6798350ef..95168b276 100644
--- a/frontend-modern/src/features/docker/DockerAlertsTable.tsx
+++ b/frontend-modern/src/features/docker/DockerAlertsTable.tsx
@@ -208,12 +208,8 @@ export const DockerAlertsTable: Component<{
<>
drawer.toggle(incident)}
- onKeyDown={drawer.handleActivationKey(incident)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/docker/DockerConfigsTable.tsx b/frontend-modern/src/features/docker/DockerConfigsTable.tsx
index c5f99ceea..d02bee768 100644
--- a/frontend-modern/src/features/docker/DockerConfigsTable.tsx
+++ b/frontend-modern/src/features/docker/DockerConfigsTable.tsx
@@ -171,12 +171,8 @@ export const DockerConfigsTable: Component
= (props) =>
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
= (pro
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
{(column) => renderColumnCell(column)}
diff --git a/frontend-modern/src/features/docker/DockerHostsTable.tsx b/frontend-modern/src/features/docker/DockerHostsTable.tsx
index c61137dde..5bcd8add7 100644
--- a/frontend-modern/src/features/docker/DockerHostsTable.tsx
+++ b/frontend-modern/src/features/docker/DockerHostsTable.tsx
@@ -372,14 +372,6 @@ export const DockerHostsTable: Component<{
const isSelected = () => selectedHostId() === host.id;
const toggleDrawer = () =>
setSelectedHostId((current) => (current === host.id ? null : host.id));
- const handleActivationKey: JSX.EventHandler<
- HTMLTableRowElement,
- KeyboardEvent
- > = (event) => {
- if (event.key !== 'Enter' && event.key !== ' ') return;
- event.preventDefault();
- toggleDrawer();
- };
const hostAlertStyles = createMemo(() =>
getAlertStyles(host.id, activeAlerts, alertsEnabled(), name()),
);
@@ -393,15 +385,11 @@ export const DockerHostsTable: Component<{
return (
<>
diff --git a/frontend-modern/src/features/docker/DockerImagesTable.tsx b/frontend-modern/src/features/docker/DockerImagesTable.tsx
index c8a663580..88345be44 100644
--- a/frontend-modern/src/features/docker/DockerImagesTable.tsx
+++ b/frontend-modern/src/features/docker/DockerImagesTable.tsx
@@ -197,12 +197,8 @@ export const DockerImagesTable: Component<
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
= (props)
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
= (props) =>
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
drawer.toggle(service)}
- onKeyDown={drawer.handleActivationKey(service)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/docker/DockerStorageUsageTable.tsx b/frontend-modern/src/features/docker/DockerStorageUsageTable.tsx
index 31193cddd..b76c7553f 100644
--- a/frontend-modern/src/features/docker/DockerStorageUsageTable.tsx
+++ b/frontend-modern/src/features/docker/DockerStorageUsageTable.tsx
@@ -162,12 +162,8 @@ export const DockerStorageUsageTable: Component<{
<>
drawer.toggle(host)}
- onKeyDown={drawer.handleActivationKey(host)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx b/frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx
index 62ae64f98..6609f082b 100644
--- a/frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx
+++ b/frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx
@@ -233,12 +233,8 @@ export const DockerSwarmNodesTable: Component
= (props)
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
= (props) => {
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
= (props) =>
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
{
fireEvent.click(hostRow!);
- expect(hostRow).toHaveAttribute('aria-expanded', 'true');
+ expect(hostRow).not.toHaveAttribute('aria-expanded');
+ expect(hostRow?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'true',
+ );
expect(screen.getByTestId('docker-host-drawer')).toBeInTheDocument();
expect(window.location.pathname).toBe('/docker/overview');
expect(window.location.search).toBe('');
@@ -230,7 +234,11 @@ describe('DockerHostsTable', () => {
fireEvent.click(link);
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
expect(screen.queryByTestId('docker-host-drawer')).not.toBeInTheDocument();
});
diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
index 05bc670e3..1d66f0e5b 100644
--- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
+++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
@@ -1229,11 +1229,16 @@ describe('Docker native tables', () => {
expect(screen.getByText('10.88.0.0/24 via 10.88.0.1')).toBeInTheDocument();
const row = document.querySelector('[data-docker-network-row="network-1"]');
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
fireEvent.click(row!);
- expect(row).toHaveAttribute('aria-expanded', 'true');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Addressing')).toBeInTheDocument();
expect(screen.getByText('Flags')).toBeInTheDocument();
expect(screen.getByText('IPv4')).toBeInTheDocument();
@@ -1711,8 +1716,11 @@ describe('Docker native tables', () => {
const configRow = document.querySelector('[data-docker-config-row="config-1"]');
expect(configRow).not.toBeNull();
- fireEvent.keyDown(configRow!, { key: 'Enter' });
- expect(configRow).toHaveAttribute('aria-expanded', 'true');
+ const configDisclosure = configRow!.querySelector('[data-row-action="true"]');
+ expect(configDisclosure).not.toBeNull();
+ fireEvent.click(configDisclosure!);
+ expect(configRow).not.toHaveAttribute('aria-expanded');
+ expect(configDisclosure).toHaveAttribute('aria-expanded', 'true');
expect(
document.querySelector('[data-inline-docker-config-detail-for="config-1"]'),
).not.toBeNull();
diff --git a/frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx
index bada09fd2..f27019667 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx
@@ -218,12 +218,8 @@ export const KubernetesAlertsTable: Component<{
<>
drawer.toggle(incident)}
- onKeyDown={drawer.handleActivationKey(incident)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx
index 1e51fd089..93134c71e 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx
@@ -248,12 +248,8 @@ export const KubernetesAutoscalingTable: Component<{
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx
index 5cc8f3af7..173d61cd4 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx
@@ -292,12 +292,8 @@ export const KubernetesClustersTable: Component<{
<>
drawer.toggle(cluster)}
- onKeyDown={drawer.handleActivationKey(cluster)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx
index 941179376..5a9b08ba9 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx
@@ -334,12 +334,8 @@ export const KubernetesConfigTable: Component<{
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx
index 0c6622253..738dd78c9 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx
@@ -370,12 +370,8 @@ export const KubernetesControllersTable: Component<{
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx
index 7ff4945fe..5407855ca 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx
@@ -256,12 +256,8 @@ export const KubernetesDeploymentsTable: Component<{
<>
drawer.toggle(deployment)}
- onKeyDown={drawer.handleActivationKey(deployment)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx
index 8772ec01f..7803fb73c 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx
@@ -172,12 +172,8 @@ export const KubernetesEventsTable: Component<{
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx
index cf71f01bf..32f0e94bc 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx
@@ -276,12 +276,8 @@ export const KubernetesNetworkingTable: Component<{
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx
index d4e66d96a..1964daa05 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx
@@ -343,12 +343,8 @@ export const KubernetesNodesTable: Component<{
<>
drawer.toggle(node)}
- onKeyDown={drawer.handleActivationKey(node)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx
index fdc6b5345..834aacf84 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx
@@ -303,12 +303,8 @@ export const KubernetesPodsTable: Component<{
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx
index a508dda80..96b15c612 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx
@@ -300,12 +300,8 @@ export const KubernetesPolicyTable: Component<{
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx
index f49dacac9..f5435d262 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx
@@ -238,12 +238,8 @@ export const KubernetesServicesTable: Component<{
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx
index 3828bb400..9f3a634d9 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx
@@ -356,12 +356,8 @@ export const KubernetesStorageTable: Component<{
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesNodesTable.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesNodesTable.test.tsx
index 9c2fdbe83..56c1fbdbd 100644
--- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesNodesTable.test.tsx
+++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesNodesTable.test.tsx
@@ -105,9 +105,17 @@ describe('KubernetesNodesTable', () => {
expect(launchLink).toHaveAttribute('href', 'https://worker-01.internal');
expect(launchLink).toHaveAttribute('target', '_blank');
expect(launchLink).toHaveAttribute('rel', 'noopener noreferrer');
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
fireEvent.click(launchLink);
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
});
});
diff --git a/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx b/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx
index b4926aa3d..443602013 100644
--- a/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx
+++ b/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx
@@ -9,7 +9,6 @@ type ResourceLike = Pick
;
export type PlatformResourceDetailRowInteractionOptions = {
expanded: boolean;
- detailRowId: string;
onToggle: () => void;
class?: string;
};
@@ -21,18 +20,14 @@ export type PlatformResourceDetailState = {
open: (resource: ResourceLike) => void;
toggle: (resource: ResourceLike) => void;
close: (resource?: ResourceLike) => void;
- handleActivationKey: (
- resource: ResourceLike,
- ) => JSX.EventHandler;
};
-export const PLATFORM_RESOURCE_DETAIL_ROW_CLASS =
- 'cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface';
+export const PLATFORM_RESOURCE_DETAIL_ROW_CLASS = 'cursor-pointer';
export const getPlatformResourceDetailRowClass = (expanded: boolean): string =>
`${PLATFORM_RESOURCE_DETAIL_ROW_CLASS}${expanded ? ' bg-surface-hover' : ''}`;
-const isInteractiveDetailRowDescendant = (event: MouseEvent | KeyboardEvent): boolean => {
+const isInteractiveDetailRowDescendant = (event: MouseEvent): boolean => {
const target = event.target;
const currentTarget = event.currentTarget;
if (
@@ -47,9 +42,9 @@ const isInteractiveDetailRowDescendant = (event: MouseEvent | KeyboardEvent): bo
);
};
-// Canonical whole-row disclosure contract. Keeping pointer, keyboard, focus,
-// aria linkage, and interactive-child exclusion together prevents a table
-// from becoming mouse-only (or from opening when an embedded link is used).
+// Whole-row clicking remains a pointer convenience. The nested native
+// disclosure button owns keyboard focus and aria-expanded/aria-controls so a
+// static data-table row is not exposed as a second, unnamed control.
export function getPlatformResourceDetailRowInteractionProps(
options: PlatformResourceDetailRowInteractionOptions,
): JSX.HTMLAttributes {
@@ -58,15 +53,6 @@ export function getPlatformResourceDetailRowInteractionProps(
onClick: (event) => {
if (!isInteractiveDetailRowDescendant(event)) options.onToggle();
},
- onKeyDown: (event) => {
- if (isInteractiveDetailRowDescendant(event)) return;
- if (event.key !== 'Enter' && event.key !== ' ' && event.key !== 'Space') return;
- event.preventDefault();
- options.onToggle();
- },
- tabIndex: 0,
- 'aria-expanded': options.expanded,
- 'aria-controls': options.detailRowId,
};
}
@@ -106,14 +92,6 @@ export function createPlatformResourceDetailState(options: {
setExpandedResourceId(null);
}
};
- const handleActivationKey =
- (resource: ResourceLike): JSX.EventHandler =>
- (event) => {
- if (event.key !== 'Enter' && event.key !== ' ') return;
- event.preventDefault();
- toggle(resource);
- };
-
return {
expandedResourceId,
isExpanded,
@@ -121,7 +99,6 @@ export function createPlatformResourceDetailState(options: {
open,
toggle,
close,
- handleActivationKey,
};
}
diff --git a/frontend-modern/src/features/platformPage/__tests__/PlatformResourceDetailTableRow.test.tsx b/frontend-modern/src/features/platformPage/__tests__/PlatformResourceDetailTableRow.test.tsx
index 759b04f83..f5d369bac 100644
--- a/frontend-modern/src/features/platformPage/__tests__/PlatformResourceDetailTableRow.test.tsx
+++ b/frontend-modern/src/features/platformPage/__tests__/PlatformResourceDetailTableRow.test.tsx
@@ -13,7 +13,6 @@ describe('getPlatformResourceDetailRowInteractionProps', () => {
@@ -28,17 +27,17 @@ describe('getPlatformResourceDetailRowInteractionProps', () => {
return { onToggle, row: screen.getByRole('row'), childAction: screen.getByRole('button') };
};
- it('owns pointer, focus, keyboard, and aria disclosure semantics', () => {
+ it('keeps whole-row activation as a pointer convenience only', () => {
const { onToggle, row } = renderRow();
- expect(row).toHaveAttribute('tabindex', '0');
- expect(row).toHaveAttribute('aria-expanded', 'false');
- expect(row).toHaveAttribute('aria-controls', 'detail-row');
+ expect(row).not.toHaveAttribute('tabindex');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row).not.toHaveAttribute('aria-controls');
fireEvent.click(row);
fireEvent.keyDown(row, { key: 'Enter' });
fireEvent.keyDown(row, { key: ' ' });
- expect(onToggle).toHaveBeenCalledTimes(3);
+ expect(onToggle).toHaveBeenCalledTimes(1);
});
it('does not hijack embedded interactive controls', () => {
diff --git a/frontend-modern/src/features/platformPage/__tests__/platformResourceTableDrawers.test.tsx b/frontend-modern/src/features/platformPage/__tests__/platformResourceTableDrawers.test.tsx
index c64aef380..da156d265 100644
--- a/frontend-modern/src/features/platformPage/__tests__/platformResourceTableDrawers.test.tsx
+++ b/frontend-modern/src/features/platformPage/__tests__/platformResourceTableDrawers.test.tsx
@@ -71,12 +71,14 @@ const makeResource = ({
});
const expectRowOpensResourceDrawer = async (row: HTMLTableRowElement, resourceId: string) => {
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByTestId('resource-detail-drawer')).not.toBeInTheDocument();
await fireEvent.click(row);
- expect(row).toHaveAttribute('aria-expanded', 'true');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByTestId('resource-detail-drawer')).toHaveAttribute(
'data-resource-id',
resourceId,
@@ -88,7 +90,8 @@ const expectRowOpensResourceDrawer = async (row: HTMLTableRowElement, resourceId
await fireEvent.click(screen.getByRole('button', { name: 'Close resource drawer' }));
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByTestId('resource-detail-drawer')).not.toBeInTheDocument();
};
diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx
index 73a90bbe7..f72b5538f 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx
@@ -357,7 +357,6 @@ export function ProxmoxBackupServersTable(props: {
detail.toggle(rowIdentity),
})}
>
diff --git a/frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx b/frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx
index 3bde73c39..477620511 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx
@@ -191,18 +191,7 @@ export const ProxmoxCephClusterDrawer: Component<{
setExpandedPoolKey((current) => (current === poolKey() ? null : poolKey()));
return (
<>
- {
- if (event.key !== 'Enter' && event.key !== ' ') return;
- event.preventDefault();
- toggle();
- }}
- tabIndex={0}
- >
+
diff --git a/frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx
index c91598527..6ec964cff 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx
@@ -418,7 +418,6 @@ export const ProxmoxCephTable: Component<{
toggleSelected(cluster.id),
})}
>
diff --git a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx
index cadfe9399..1e861196b 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx
@@ -315,7 +315,6 @@ export function ProxmoxCoverageTable(props: {
props.onToggleExpand(row.key),
})}
data-proxmox-backup-row="coverage"
diff --git a/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx
index 4693204f9..15a80bfdf 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx
@@ -246,7 +246,6 @@ export const ProxmoxMailGatewayTable: Component<{
detail.toggle(instance),
})}
>
diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx
index 1ec4d8556..77df3e52a 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx
@@ -372,13 +372,6 @@ export const ProxmoxNodesTable: Component<{
const isSelected = () => selectedNodeId() === node.id;
const toggleNodeDrawer = () =>
setSelectedNodeId((current) => (current === node.id ? null : node.id));
- const handleActivationKey: JSX.EventHandler = (
- event,
- ) => {
- if (event.key !== 'Enter' && event.key !== ' ') return;
- event.preventDefault();
- toggleNodeDrawer();
- };
const version = () => asTrimmedString(getResourceVersion(node));
const cluster = () => getResourceClusterLabel(node);
const counts = () => countGuestsForNode(props.guests, node);
@@ -723,16 +716,12 @@ export const ProxmoxNodesTable: Component<{
return (
<>
{(column) => renderColumnCell(column)}
diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCephTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCephTable.test.tsx
index 82662c925..c30a97c97 100644
--- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCephTable.test.tsx
+++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCephTable.test.tsx
@@ -90,7 +90,7 @@ describe('ProxmoxCephTable', () => {
expect(disclosure).toHaveAccessibleName('Expand details for ceph-main');
});
- it('exposes complete nested pool values by touch and keyboard', async () => {
+ it('exposes complete nested pool values through the disclosure control', async () => {
render(() => (
{
expect(detail).toHaveTextContent('1,764,309');
expect(detail).toHaveTextContent('59.2%');
- await fireEvent.keyDown(disclosure.closest('tr')!, { key: 'Enter' });
+ await fireEvent.click(disclosure);
expect(document.querySelector('[data-inline-proxmox-ceph-pool-detail-for]')).toBeNull();
});
diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx
index 36419d8f0..7d37324f7 100644
--- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx
+++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx
@@ -631,12 +631,17 @@ describe('ProxmoxNodesTable', () => {
const row = screen.getByText('pve-node-1').closest('tr');
expect(row).toBeTruthy();
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
expect(screen.queryByTestId('node-drawer')).not.toBeInTheDocument();
await fireEvent.click(row!);
- expect(row).toHaveAttribute('aria-expanded', 'true');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByTestId('node-drawer')).toHaveTextContent('pve-node-1');
expect(nodeDrawerMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -647,7 +652,11 @@ describe('ProxmoxNodesTable', () => {
await fireEvent.click(row!);
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
expect(screen.queryByTestId('node-drawer')).not.toBeInTheDocument();
});
});
diff --git a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx
index 0e9421159..698aeb37c 100644
--- a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx
+++ b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx
@@ -1585,25 +1585,13 @@ export const AgentsMachinesTable: Component<{
const toggleDetails = () => {
drawer.toggle(machine);
};
- const handleDetailsActivationKey: JSX.EventHandler<
- HTMLTableRowElement,
- KeyboardEvent
- > = (event) => {
- if (event.key !== 'Enter' && event.key !== ' ') return;
- event.preventDefault();
- toggleDetails();
- };
return (
<>
drawer.toggle(check)}
- onKeyDown={drawer.handleActivationKey(check)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/standalone/__tests__/AvailabilityChecksTable.test.tsx b/frontend-modern/src/features/standalone/__tests__/AvailabilityChecksTable.test.tsx
index 3a6566fbf..2d00701c8 100644
--- a/frontend-modern/src/features/standalone/__tests__/AvailabilityChecksTable.test.tsx
+++ b/frontend-modern/src/features/standalone/__tests__/AvailabilityChecksTable.test.tsx
@@ -99,10 +99,15 @@ describe('AvailabilityChecksTable', () => {
const { container } = renderTable([availabilityResource()]);
const row = container.querySelector('[data-availability-check-row]');
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
fireEvent.click(screen.getByRole('button', { name: 'Expand details for MQTT power meter' }));
- expect(row).toHaveAttribute('aria-expanded', 'true');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
expect(
container.querySelector(
'[data-inline-platform-resource-detail-for="availability:mock-availability-mqtt-meter"]',
diff --git a/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx b/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx
index 88222dc97..6fce7bebc 100644
--- a/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx
@@ -211,12 +211,8 @@ export const TrueNASAlertsTable: Component<{
<>
drawer.toggle(incident)}
- onKeyDown={drawer.handleActivationKey(incident)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/truenas/TrueNASAppsTable.tsx b/frontend-modern/src/features/truenas/TrueNASAppsTable.tsx
index 5df5f85b4..d2d1cec0c 100644
--- a/frontend-modern/src/features/truenas/TrueNASAppsTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASAppsTable.tsx
@@ -349,12 +349,8 @@ export const TrueNASAppsTable: Component<{
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx b/frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx
index dc82dad0e..78e15a1cc 100644
--- a/frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx
@@ -256,12 +256,8 @@ export const TrueNASNetworkSharesTable: Component<{
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx b/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx
index b962cf68e..d6b94745e 100644
--- a/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx
@@ -510,14 +510,10 @@ export const TrueNASProtectionTable: Component<{
<>
detail.toggle(point)}
- onKeyDown={detail.handleActivationKey(point)}
- tabIndex={0}
>
detail.toggle(row)}
- onKeyDown={detail.handleActivationKey(row)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx
index d76afb7eb..2e5405b67 100644
--- a/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx
@@ -522,15 +522,11 @@ export const TrueNASStorageTopologyTable: Component<{
<>
drawer.toggle(resource())}
- onKeyDown={drawer.handleActivationKey(resource())}
- tabIndex={0}
>
drawer.toggle(system)}
- onKeyDown={drawer.handleActivationKey(system)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx b/frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx
index 7d8b3dae7..21062df9a 100644
--- a/frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx
@@ -289,12 +289,8 @@ export const TrueNASVirtualMachinesTable: Component<{
<>
drawer.toggle(resource)}
- onKeyDown={drawer.handleActivationKey(resource)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/truenas/__tests__/TrueNASAlertsTable.test.tsx b/frontend-modern/src/features/truenas/__tests__/TrueNASAlertsTable.test.tsx
index 9f1693007..8249e7867 100644
--- a/frontend-modern/src/features/truenas/__tests__/TrueNASAlertsTable.test.tsx
+++ b/frontend-modern/src/features/truenas/__tests__/TrueNASAlertsTable.test.tsx
@@ -58,11 +58,16 @@ describe('TrueNASAlertsTable', () => {
const row = screen.getByText('Device /dev/sdc has SMART test failures.').closest('tr');
expect(row).toBeTruthy();
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
await fireEvent.click(row!);
- expect(row).toHaveAttribute('aria-expanded', 'true');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
expect(screen.queryByTestId('resource-detail-drawer')).not.toBeInTheDocument();
const detail = within(screen.getByTestId('truenas-alert-detail'));
expect(detail.getByText('Alert detail')).toBeInTheDocument();
@@ -83,6 +88,10 @@ describe('TrueNASAlertsTable', () => {
await fireEvent.click(detail.getByRole('button', { name: /^Collapse .* details$/ }));
expect(screen.queryByTestId('truenas-alert-detail')).not.toBeInTheDocument();
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
});
});
diff --git a/frontend-modern/src/features/truenas/__tests__/TrueNASProtectionTable.test.tsx b/frontend-modern/src/features/truenas/__tests__/TrueNASProtectionTable.test.tsx
index 269cbfd16..f927b6a05 100644
--- a/frontend-modern/src/features/truenas/__tests__/TrueNASProtectionTable.test.tsx
+++ b/frontend-modern/src/features/truenas/__tests__/TrueNASProtectionTable.test.tsx
@@ -102,11 +102,16 @@ describe('TrueNASProtectionTable', () => {
const row = screen.getByText('tank/apps').closest('tr');
expect(row).toBeTruthy();
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
await fireEvent.click(row!);
- expect(row).toHaveAttribute('aria-expanded', 'true');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
const detail = within(screen.getByTestId('truenas-protection-detail'));
expect(detail.getByText('Protection detail')).toBeInTheDocument();
expect(detail.getByText('Protection')).toBeInTheDocument();
@@ -119,6 +124,10 @@ describe('TrueNASProtectionTable', () => {
await fireEvent.click(detail.getByRole('button', { name: /^Collapse .* details$/ }));
expect(screen.queryByTestId('truenas-protection-detail')).not.toBeInTheDocument();
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
});
});
diff --git a/frontend-modern/src/features/truenas/__tests__/TrueNASServicesTable.test.tsx b/frontend-modern/src/features/truenas/__tests__/TrueNASServicesTable.test.tsx
index e5890ed0a..8a26157cf 100644
--- a/frontend-modern/src/features/truenas/__tests__/TrueNASServicesTable.test.tsx
+++ b/frontend-modern/src/features/truenas/__tests__/TrueNASServicesTable.test.tsx
@@ -42,11 +42,16 @@ describe('TrueNASServicesTable', () => {
const row = screen.getByText('SMB').closest('tr');
expect(row).toBeTruthy();
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
await fireEvent.click(row!);
- expect(row).toHaveAttribute('aria-expanded', 'true');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
const detail = within(screen.getByTestId('truenas-service-detail'));
expect(detail.getByText('Service detail')).toBeInTheDocument();
expect(detail.getByText('Service')).toBeInTheDocument();
@@ -61,6 +66,10 @@ describe('TrueNASServicesTable', () => {
await fireEvent.click(detail.getByRole('button', { name: `Collapse ${rows[0].id} details` }));
expect(screen.queryByTestId('truenas-service-detail')).not.toBeInTheDocument();
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
});
});
diff --git a/frontend-modern/src/features/vmware/VsphereActivityTable.tsx b/frontend-modern/src/features/vmware/VsphereActivityTable.tsx
index 32cb1ea25..34a21c61c 100644
--- a/frontend-modern/src/features/vmware/VsphereActivityTable.tsx
+++ b/frontend-modern/src/features/vmware/VsphereActivityTable.tsx
@@ -377,12 +377,8 @@ export const VsphereActivityTable: Component<{
<>
drawer.toggle(activity)}
- onKeyDown={drawer.handleActivationKey(activity)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx b/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx
index 027e90367..b3e5434ba 100644
--- a/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx
+++ b/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx
@@ -217,12 +217,8 @@ export const VsphereAlertsTable: Component<{
<>
drawer.toggle(incident)}
- onKeyDown={drawer.handleActivationKey(incident)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx b/frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx
index a51288568..9f25ff78f 100644
--- a/frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx
+++ b/frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx
@@ -282,12 +282,8 @@ export const VsphereDatastoresTable: Component<{
<>
drawer.toggle(datastore)}
- onKeyDown={drawer.handleActivationKey(datastore)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/vmware/VsphereHostsTable.tsx b/frontend-modern/src/features/vmware/VsphereHostsTable.tsx
index 218bbf3d2..268cb4cc1 100644
--- a/frontend-modern/src/features/vmware/VsphereHostsTable.tsx
+++ b/frontend-modern/src/features/vmware/VsphereHostsTable.tsx
@@ -385,12 +385,8 @@ export const VsphereHostsTable: Component<{
<>
drawer.toggle(host)}
- onKeyDown={drawer.handleActivationKey(host)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx b/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx
index 8ea1ae853..f3d25e5c5 100644
--- a/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx
+++ b/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx
@@ -247,12 +247,8 @@ export const VsphereNetworksTable: Component<{
<>
drawer.toggle(network)}
- onKeyDown={drawer.handleActivationKey(network)}
- tabIndex={0}
>
diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereActivityTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereActivityTable.test.tsx
index 6fe4f53a6..7c4d27f89 100644
--- a/frontend-modern/src/features/vmware/__tests__/VsphereActivityTable.test.tsx
+++ b/frontend-modern/src/features/vmware/__tests__/VsphereActivityTable.test.tsx
@@ -107,11 +107,16 @@ describe('VsphereActivityTable', () => {
expect(screen.getByText('administrator@vsphere.local')).toBeInTheDocument();
const row = screen.getByText('Reconfigure virtual machine').closest('tr');
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
await fireEvent.click(row!);
- expect(row).toHaveAttribute('aria-expanded', 'true');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
const detail = within(screen.getByTestId('vsphere-activity-detail'));
expect(detail.getByText('vSphere activity detail')).toBeInTheDocument();
expect(detail.getByText('Managed object')).toBeInTheDocument();
@@ -124,6 +129,10 @@ describe('VsphereActivityTable', () => {
await fireEvent.click(detail.getByRole('button', { name: /^Collapse .* details$/ }));
expect(screen.queryByTestId('vsphere-activity-detail')).not.toBeInTheDocument();
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
});
});
diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx
index 6a91c6f3a..81c10176d 100644
--- a/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx
+++ b/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx
@@ -105,11 +105,16 @@ describe('VsphereAlertsTable', () => {
const row = screen
.getByText('Host host-101 has VMware alarm Host connection and power state (red)')
.closest('tr');
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
await fireEvent.click(row!);
- expect(row).toHaveAttribute('aria-expanded', 'true');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
const detail = within(screen.getByTestId('vsphere-alert-detail'));
expect(detail.getByText('vSphere health detail')).toBeInTheDocument();
expect(detail.getByText('Managed object')).toBeInTheDocument();
@@ -122,6 +127,10 @@ describe('VsphereAlertsTable', () => {
await fireEvent.click(detail.getByRole('button', { name: /^Collapse .* details$/ }));
expect(screen.queryByTestId('vsphere-alert-detail')).not.toBeInTheDocument();
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
});
});
diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereDatastoresTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereDatastoresTable.test.tsx
index 017a79f70..6af1cc036 100644
--- a/frontend-modern/src/features/vmware/__tests__/VsphereDatastoresTable.test.tsx
+++ b/frontend-modern/src/features/vmware/__tests__/VsphereDatastoresTable.test.tsx
@@ -85,10 +85,15 @@ describe('VsphereDatastoresTable', () => {
expect(screen.getAllByTestId('stacked-disk-bar').length).toBeGreaterThan(0);
const row = screen.getByText('nvme-primary').closest('tr');
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
await fireEvent.click(row!);
- expect(row).toHaveAttribute('aria-expanded', 'true');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
});
});
diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx
index e86535323..b5ca05683 100644
--- a/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx
+++ b/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx
@@ -75,10 +75,15 @@ describe('VsphereNetworksTable', () => {
expect(screen.getByText('warehouse-api-01, etl-batch-01')).toBeInTheDocument();
const row = screen.getByText('VM Network').closest('tr');
- expect(row).toHaveAttribute('aria-expanded', 'false');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
await fireEvent.click(row!);
- expect(row).toHaveAttribute('aria-expanded', 'true');
+ expect(row).not.toHaveAttribute('aria-expanded');
+ expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
});
});
diff --git a/tests/integration/tests/83-product-trust-accessibility.spec.ts b/tests/integration/tests/83-product-trust-accessibility.spec.ts
index 4c19429fe..8022ef4c3 100644
--- a/tests/integration/tests/83-product-trust-accessibility.spec.ts
+++ b/tests/integration/tests/83-product-trust-accessibility.spec.ts
@@ -206,6 +206,45 @@ test("representative authenticated surfaces have no automatically detectable WCA
}
});
+test("populated platform tables expose one named disclosure control per resource", async ({
+ page,
+}) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+
+ for (const viewport of [
+ { width: 1280, height: 800 },
+ { width: 390, height: 844 },
+ ]) {
+ await page.setViewportSize(viewport);
+ await page.goto("/truenas/overview", { waitUntil: "domcontentloaded" });
+
+ const row = page.locator("[data-truenas-system-row]").first();
+ await expect(row).toBeVisible({ timeout: 30_000 });
+ await expect(row).not.toHaveAttribute("tabindex");
+ await expect(row).not.toHaveAttribute("aria-expanded");
+ await expect(row).not.toHaveAttribute("aria-controls");
+
+ await expect(row.getByRole("button", { name: /Expand details for/ })).toBeAttached();
+ const disclosure = row.locator('button[data-row-action="true"]').first();
+ const detailRowId = await disclosure.getAttribute("aria-controls");
+ expect(detailRowId).toBeTruthy();
+
+ await disclosure.focus();
+ await page.keyboard.press("Enter");
+ await expect(disclosure).toHaveAttribute("aria-expanded", "true");
+ await expect(page.locator(`#${detailRowId}`)).toBeVisible();
+
+ await page.keyboard.press("Space");
+ await expect(disclosure).toHaveAttribute("aria-expanded", "false");
+
+ const rowBox = await row.boundingBox();
+ expect(rowBox).not.toBeNull();
+ await row.click({ position: { x: Math.min(30, rowBox!.width / 2), y: rowBox!.height / 2 } });
+ await expect(disclosure).toHaveAttribute("aria-expanded", "true");
+ expect(await scanForWcagViolations(page)).toEqual([]);
+ }
+});
+
test("the logged-out entry surface has no automatically detectable WCAG A/AA violations", async ({
browser,
}, testInfo) => {