mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Merge reviewed platform table accessibility repair
Retain the exact browser-verified web candidate commit and integrate native disclosure ownership across platform tables. Change-source: pulse-maintainer
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = () => (
|
||||
<table><tbody>
|
||||
<tr tabindex="0"><td>Native row</td></tr>
|
||||
<TableRow tabIndex={0}><TableCell>Shared row</TableCell></TableRow>
|
||||
</tbody></table>
|
||||
);`,
|
||||
);
|
||||
|
||||
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 = () => (
|
||||
<table><tbody><tr><td>
|
||||
<button type="button" aria-expanded="false" aria-controls="details">Details</button>
|
||||
</td></tr></tbody></table>
|
||||
);`,
|
||||
);
|
||||
|
||||
const result = runAudit(frontendRoot);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Table row accessibility audit passed');
|
||||
});
|
||||
});
|
||||
@@ -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).`);
|
||||
@@ -35,7 +35,6 @@ export const SearchInput: Component<SearchInputProps> = (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={
|
||||
<SearchInputTrailingControls state={search.enhancements} tips={props.tips} />
|
||||
|
||||
@@ -348,6 +348,9 @@ describe('SearchInput', () => {
|
||||
|
||||
const { container } = render(() => <InlineCompletionHarness />);
|
||||
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' } });
|
||||
|
||||
|
||||
@@ -208,12 +208,8 @@ export const DockerAlertsTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-docker-alert-row={incident.id}
|
||||
onClick={() => drawer.toggle(incident)}
|
||||
onKeyDown={drawer.handleActivationKey(incident)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -171,12 +171,8 @@ export const DockerConfigsTable: Component<DockerNativeTableProps> = (props) =>
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-docker-config-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<DockerResourceNameCell
|
||||
resource={resource}
|
||||
|
||||
@@ -674,12 +674,8 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-docker-container-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<For each={visibleColumns()}>{(column) => renderColumnCell(column)}</For>
|
||||
</TableRow>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<TableRow
|
||||
class={`cursor-pointer text-[11px] outline-none sm:text-xs ${
|
||||
class={`cursor-pointer text-[11px] sm:text-xs ${
|
||||
isSelected() ? 'bg-surface-hover' : hostAlertBg()
|
||||
} focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface`}
|
||||
aria-controls={isSelected() ? detailRowId() : undefined}
|
||||
aria-expanded={isSelected() ? 'true' : 'false'}
|
||||
}`}
|
||||
data-docker-host-row={host.id}
|
||||
onClick={toggleDrawer}
|
||||
onKeyDown={handleActivationKey}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -197,12 +197,8 @@ export const DockerImagesTable: Component<
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-docker-image-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<DockerResourceNameCell
|
||||
resource={resource}
|
||||
|
||||
@@ -526,12 +526,8 @@ export const DockerNetworksTable: Component<DockerNetworksTableProps> = (props)
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-docker-network-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<DockerResourceNameCell
|
||||
resource={resource}
|
||||
|
||||
@@ -181,12 +181,8 @@ export const DockerSecretsTable: Component<DockerNativeTableProps> = (props) =>
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-docker-secret-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<DockerResourceNameCell
|
||||
resource={resource}
|
||||
|
||||
@@ -300,12 +300,8 @@ export const DockerServicesTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-docker-service-row={service.id}
|
||||
onClick={() => drawer.toggle(service)}
|
||||
onKeyDown={drawer.handleActivationKey(service)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -162,12 +162,8 @@ export const DockerStorageUsageTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-docker-storage-row={host.id}
|
||||
onClick={() => drawer.toggle(host)}
|
||||
onKeyDown={drawer.handleActivationKey(host)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -233,12 +233,8 @@ export const DockerSwarmNodesTable: Component<DockerNativeTableProps> = (props)
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-docker-swarm-node-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<DockerResourceNameCell
|
||||
resource={resource}
|
||||
|
||||
@@ -203,12 +203,8 @@ export const DockerTasksTable: Component<DockerNativeTableProps> = (props) => {
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-docker-task-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<DockerResourceNameCell
|
||||
resource={resource}
|
||||
|
||||
@@ -208,12 +208,8 @@ export const DockerVolumesTable: Component<DockerNativeTableProps> = (props) =>
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-docker-volume-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<DockerResourceNameCell
|
||||
resource={resource}
|
||||
|
||||
@@ -201,7 +201,11 @@ describe('DockerHostsTable', () => {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<HTMLElement>('[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<HTMLElement>('[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();
|
||||
|
||||
@@ -218,12 +218,8 @@ export const KubernetesAlertsTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-alert-row={incident.id}
|
||||
onClick={() => drawer.toggle(incident)}
|
||||
onKeyDown={drawer.handleActivationKey(incident)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -248,12 +248,8 @@ export const KubernetesAutoscalingTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-autoscaling-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -292,12 +292,8 @@ export const KubernetesClustersTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-cluster-row={cluster.id}
|
||||
onClick={() => drawer.toggle(cluster)}
|
||||
onKeyDown={drawer.handleActivationKey(cluster)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -334,12 +334,8 @@ export const KubernetesConfigTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-config-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -370,12 +370,8 @@ export const KubernetesControllersTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-controller-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -256,12 +256,8 @@ export const KubernetesDeploymentsTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-deployment-row={deployment.id}
|
||||
onClick={() => drawer.toggle(deployment)}
|
||||
onKeyDown={drawer.handleActivationKey(deployment)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -172,12 +172,8 @@ export const KubernetesEventsTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-event-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -276,12 +276,8 @@ export const KubernetesNetworkingTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-networking-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -343,12 +343,8 @@ export const KubernetesNodesTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs ${nodeAlertBg()}`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-node-row={node.id}
|
||||
onClick={() => drawer.toggle(node)}
|
||||
onKeyDown={drawer.handleActivationKey(node)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -303,12 +303,8 @@ export const KubernetesPodsTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-pod-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -300,12 +300,8 @@ export const KubernetesPolicyTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-policy-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -238,12 +238,8 @@ export const KubernetesServicesTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-service-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -356,12 +356,8 @@ export const KubernetesStorageTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-kubernetes-storage-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ type ResourceLike = Pick<Resource, 'id'>;
|
||||
|
||||
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<HTMLTableRowElement, KeyboardEvent>;
|
||||
};
|
||||
|
||||
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<HTMLTableRowElement> {
|
||||
@@ -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<HTMLTableRowElement, KeyboardEvent> =>
|
||||
(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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -13,7 +13,6 @@ describe('getPlatformResourceDetailRowInteractionProps', () => {
|
||||
<TableRow
|
||||
{...getPlatformResourceDetailRowInteractionProps({
|
||||
expanded: false,
|
||||
detailRowId: 'detail-row',
|
||||
onToggle,
|
||||
})}
|
||||
>
|
||||
@@ -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', () => {
|
||||
|
||||
+6
-3
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -357,7 +357,6 @@ export function ProxmoxBackupServersTable(props: {
|
||||
<TableRow
|
||||
{...getPlatformResourceDetailRowInteractionProps({
|
||||
expanded: isExpanded(),
|
||||
detailRowId: detailRowId(),
|
||||
onToggle: () => detail.toggle(rowIdentity),
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -191,18 +191,7 @@ export const ProxmoxCephClusterDrawer: Component<{
|
||||
setExpandedPoolKey((current) => (current === poolKey() ? null : poolKey()));
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
class="cursor-pointer"
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded()}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableRow class="cursor-pointer" onClick={toggle}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('name')} font-medium text-base-content`}
|
||||
>
|
||||
|
||||
@@ -418,7 +418,6 @@ export const ProxmoxCephTable: Component<{
|
||||
<TableRow
|
||||
{...getPlatformResourceDetailRowInteractionProps({
|
||||
expanded: isOpen(),
|
||||
detailRowId: detailRowId(),
|
||||
onToggle: () => toggleSelected(cluster.id),
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -315,7 +315,6 @@ export function ProxmoxCoverageTable(props: {
|
||||
<TableRow
|
||||
{...getPlatformResourceDetailRowInteractionProps({
|
||||
expanded: isExpanded(),
|
||||
detailRowId: detailRowId(),
|
||||
onToggle: () => props.onToggleExpand(row.key),
|
||||
})}
|
||||
data-proxmox-backup-row="coverage"
|
||||
|
||||
@@ -246,7 +246,6 @@ export const ProxmoxMailGatewayTable: Component<{
|
||||
<TableRow
|
||||
{...getPlatformResourceDetailRowInteractionProps({
|
||||
expanded: isOpen(),
|
||||
detailRowId: detailRowId(),
|
||||
onToggle: () => detail.toggle(instance),
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -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<HTMLTableRowElement, KeyboardEvent> = (
|
||||
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 (
|
||||
<>
|
||||
<TableRow
|
||||
class={`host-row cursor-pointer text-[11px] outline-none sm:text-xs ${
|
||||
class={`host-row cursor-pointer text-[11px] sm:text-xs ${
|
||||
isSelected() ? 'bg-surface-hover' : rowAlertBg()
|
||||
} ${isOnline() ? '' : 'opacity-60'} focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface`}
|
||||
aria-controls={isSelected() ? detailRowId() : undefined}
|
||||
aria-expanded={isSelected() ? 'true' : 'false'}
|
||||
} ${isOnline() ? '' : 'opacity-60'}`}
|
||||
data-proxmox-host-row={node.id}
|
||||
data-workload-alert-accent={alertAccentTone()}
|
||||
onClick={toggleNodeDrawer}
|
||||
onKeyDown={handleActivationKey}
|
||||
tabIndex={0}
|
||||
>
|
||||
<For each={visibleColumns()}>{(column) => renderColumnCell(column)}</For>
|
||||
</TableRow>
|
||||
|
||||
@@ -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(() => (
|
||||
<ProxmoxCephTable
|
||||
resources={[makeCluster('ceph-main')]}
|
||||
@@ -107,7 +107,7 @@ describe('ProxmoxCephTable', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-agents-machine-row={machine.id}
|
||||
onClick={toggleDetails}
|
||||
onKeyDown={handleDetailsActivationKey}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('name')} ${machineColumnWidthClass('machine')}`}
|
||||
|
||||
@@ -313,11 +313,7 @@ export const AvailabilityChecksTable: Component<{
|
||||
<TableRow
|
||||
data-availability-check-row={check.id}
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
onClick={() => drawer.toggle(check)}
|
||||
onKeyDown={drawer.handleActivationKey(check)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -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"]',
|
||||
|
||||
@@ -211,12 +211,8 @@ export const TrueNASAlertsTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-truenas-alert-row={incident.id}
|
||||
onClick={() => drawer.toggle(incident)}
|
||||
onKeyDown={drawer.handleActivationKey(incident)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -349,12 +349,8 @@ export const TrueNASAppsTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-truenas-app-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -256,12 +256,8 @@ export const TrueNASNetworkSharesTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-truenas-share-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -510,14 +510,10 @@ export const TrueNASProtectionTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-truenas-protection-row={point.id}
|
||||
data-truenas-protection-kind={mapTrueNASProtectionKind(point)}
|
||||
data-truenas-protection-outcome={mapTrueNASProtectionStatus(point)}
|
||||
onClick={() => detail.toggle(point)}
|
||||
onKeyDown={detail.handleActivationKey(point)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<DatasetCell
|
||||
|
||||
@@ -308,12 +308,8 @@ export const TrueNASServicesTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-truenas-service-row={row.id}
|
||||
onClick={() => detail.toggle(row)}
|
||||
onKeyDown={detail.handleActivationKey(row)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -522,15 +522,11 @@ export const TrueNASStorageTopologyTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-truenas-storage-row={row.id}
|
||||
data-truenas-storage-kind={row.kind}
|
||||
data-truenas-storage-resource={resource().id}
|
||||
data-truenas-storage-depth={row.depth}
|
||||
onClick={() => drawer.toggle(resource())}
|
||||
onKeyDown={drawer.handleActivationKey(resource())}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<ResourceCell
|
||||
|
||||
@@ -356,12 +356,8 @@ export const TrueNASSystemsTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs ${sysAlertBg()}`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-truenas-system-row={system.id}
|
||||
onClick={() => drawer.toggle(system)}
|
||||
onKeyDown={drawer.handleActivationKey(system)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -289,12 +289,8 @@ export const TrueNASVirtualMachinesTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-truenas-vm-row={resource.id}
|
||||
onClick={() => drawer.toggle(resource)}
|
||||
onKeyDown={drawer.handleActivationKey(resource)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -377,12 +377,8 @@ export const VsphereActivityTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-vsphere-activity-row={activity.id}
|
||||
onClick={() => drawer.toggle(activity)}
|
||||
onKeyDown={drawer.handleActivationKey(activity)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -217,12 +217,8 @@ export const VsphereAlertsTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-vsphere-alert-row={incident.id}
|
||||
onClick={() => drawer.toggle(incident)}
|
||||
onKeyDown={drawer.handleActivationKey(incident)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -282,12 +282,8 @@ export const VsphereDatastoresTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-vsphere-datastore-row={datastore.id}
|
||||
onClick={() => drawer.toggle(datastore)}
|
||||
onKeyDown={drawer.handleActivationKey(datastore)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -385,12 +385,8 @@ export const VsphereHostsTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs ${hostAlertBg()}`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-vsphere-host-row={host.id}
|
||||
onClick={() => drawer.toggle(host)}
|
||||
onKeyDown={drawer.handleActivationKey(host)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -247,12 +247,8 @@ export const VsphereNetworksTable: Component<{
|
||||
<>
|
||||
<TableRow
|
||||
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
|
||||
aria-controls={isExpanded() ? detailRowId() : undefined}
|
||||
aria-expanded={isExpanded() ? 'true' : 'false'}
|
||||
data-vsphere-network-row={network.id}
|
||||
onClick={() => drawer.toggle(network)}
|
||||
onKeyDown={drawer.handleActivationKey(network)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user