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:
pulse-triage[bot]
2026-09-02 13:28:10 +01:00
69 changed files with 438 additions and 302 deletions
+107 -22
View File
@@ -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"
]
}
+2 -1
View File
@@ -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,
};
}
@@ -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', () => {
@@ -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) => {