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 = () => ( + + + Shared row +
Native 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) => {