fix(a11y): keep disclosure panels in the tree so aria-controls resolves

The encryption column table and the source-priority groups, overrides
and device ladder rendered their panel only while open, so a collapsed
toggle's aria-controls pointed at nothing. The panels now stay mounted,
hidden and empty, while closed.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:57:47 +02:00
1 parent ef1976832b
commit 8f5b5d6e54
4 files changed
+274 -235

No files matched your search

@@ -131,6 +131,12 @@ describe("encryption coverage — the per-column table", () => {
expect(html).not.toContain('data-slot="encryption-column-rows"');
});
it("keeps the toggle's aria-controls target in the tree while collapsed", () => {
const html = renderCoverage(false);
expect(html).toContain('aria-controls="encryption-columns-panel"');
expect(html).toMatch(/<div id="encryption-columns-panel" hidden=""/);
});
it("lists only columns with rows when opened, with a toggle for all", () => {
const html = renderCoverage(true);
expect(html).toContain("User.withRows");
+85 -79
View File
@@ -410,87 +410,93 @@ export function ColumnCoverage({
count: withRows.length,
})}
</button>
{open ? (
<div id="encryption-columns-panel" className="space-y-3">
{shown.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.section.encryption.columnsNoneWithRows")}
</p>
) : (
/* Focusable, named scroll region: on a phone the table scrolls
{/* The panel stays in the tree, empty and hidden, while closed, so
the toggle's aria-controls always names an element. */}
<div id="encryption-columns-panel" hidden={!open} className="space-y-3">
{open ? (
<>
{shown.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.section.encryption.columnsNoneWithRows")}
</p>
) : (
/* Focusable, named scroll region: on a phone the table scrolls
sideways, and a keyboard user has to be able to reach it
(axe scrollable-region-focusable). */
<div
className="overflow-x-auto"
tabIndex={0}
role="region"
aria-label={t("admin.section.encryption.columnsTitle")}
>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-left">
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colColumn")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colTotal")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colActive")}
</th>
<th className="py-2 font-medium">
{t("admin.section.encryption.colStale")}
</th>
</tr>
</thead>
<tbody data-slot="encryption-column-rows">
{shown.map((c) => {
const active = c.byKeyId[activeKeyId] ?? 0;
const stale = c.total - active;
return (
<tr key={`${c.model}.${c.field}`} className="border-b">
<td className="py-2 pr-3 font-mono text-xs">
{c.model}.{c.field}
</td>
<td className="py-2 pr-3">
{c.total.toLocaleString()}
</td>
<td className="py-2 pr-3">{active.toLocaleString()}</td>
<td className="py-2">
{stale > 0 ? (
<Badge variant="secondary">
{stale.toLocaleString()}
</Badge>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{withRows.length < columns.length ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setShowAll((prev) => !prev)}
aria-pressed={showAll}
data-testid="encryption-columns-show-all"
>
{showAll
? t("admin.section.encryption.columnsShowWithRows")
: t("admin.section.encryption.columnsShowAll", {
count: columns.length,
})}
</Button>
) : null}
</div>
) : null}
<div
className="overflow-x-auto"
tabIndex={0}
role="region"
aria-label={t("admin.section.encryption.columnsTitle")}
>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-left">
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colColumn")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colTotal")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colActive")}
</th>
<th className="py-2 font-medium">
{t("admin.section.encryption.colStale")}
</th>
</tr>
</thead>
<tbody data-slot="encryption-column-rows">
{shown.map((c) => {
const active = c.byKeyId[activeKeyId] ?? 0;
const stale = c.total - active;
return (
<tr key={`${c.model}.${c.field}`} className="border-b">
<td className="py-2 pr-3 font-mono text-xs">
{c.model}.{c.field}
</td>
<td className="py-2 pr-3">
{c.total.toLocaleString()}
</td>
<td className="py-2 pr-3">
{active.toLocaleString()}
</td>
<td className="py-2">
{stale > 0 ? (
<Badge variant="secondary">
{stale.toLocaleString()}
</Badge>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{withRows.length < columns.length ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setShowAll((prev) => !prev)}
aria-pressed={showAll}
data-testid="encryption-columns-show-all"
>
{showAll
? t("admin.section.encryption.columnsShowWithRows")
: t("admin.section.encryption.columnsShowAll", {
count: columns.length,
})}
</Button>
) : null}
</>
) : null}
</div>
</div>
);
}
@@ -45,6 +45,15 @@ describe("Settings → Source priority, sources in use", () => {
expect(html).toContain('aria-expanded="false"');
});
it("names an element with every toggle's aria-controls, open or not", () => {
const html = render(["WITHINGS", "MANUAL"]);
const controls = [...html.matchAll(/aria-controls="([^"]+)"/g)].map(
(m) => m[1]!,
);
expect(controls.length).toBeGreaterThan(1);
for (const id of controls) expect(html).toContain(`id="${id}"`);
});
it("says so when no source is in use at all", () => {
expect(render([])).toContain('data-testid="sources-none-in-use"');
});
+174 -156
View File
@@ -420,55 +420,66 @@ export function SourcesSection() {
</span>
) : null}
</button>
{open ? (
<ul id={panelId} className="space-y-1 px-3 pb-3">
{list.map((source, index) => (
<li
key={`${metric}-${source}`}
className="border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5"
>
<span className="text-muted-foreground w-5 text-xs font-medium tabular-nums">
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])}
</span>
{/* Up/down sit side by side at every width: two
{/* The list stays in the tree, empty and hidden, while
the group is closed, so the toggle's aria-controls
always names an element. */}
<ul
id={panelId}
hidden={!open}
className="space-y-1 px-3 pb-3"
>
{open
? list.map((source, index) => (
<li
key={`${metric}-${source}`}
className="border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5"
>
<span className="text-muted-foreground w-5 text-xs font-medium tabular-nums">
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(
MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source],
)}
</span>
{/* Up/down sit side by side at every width: two
44 px buttons leave a 390 px row ~170 px for the
source name. */}
<div className="flex shrink-0 gap-1 sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, source, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, source, 1)}
disabled={
index === list.length - 1 ||
saveMutation.isPending
}
aria-label={t(
"settings.sections.sources.moveDown",
)}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
) : null}
<div className="flex shrink-0 gap-1 sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, source, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t(
"settings.sections.sources.moveUp",
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, source, 1)}
disabled={
index === list.length - 1 ||
saveMutation.isPending
}
aria-label={t(
"settings.sections.sources.moveDown",
)}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))
: null}
</ul>
</div>
);
})}
@@ -497,46 +508,49 @@ export function SourcesSection() {
count: overriddenMetrics.length,
})}
</button>
{showPerMetric && (
<div
id="sources-per-metric-panel"
role="region"
className="bg-background/30 border-border space-y-3 rounded-md border p-3"
>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.perMetricHelp")}
</p>
{overriddenMetrics.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("settings.sections.sources.perMetricEmpty")}
/>
) : (
<ul className="space-y-2">
{overriddenMetrics.map((metric) => (
<li
key={metric}
className="border-border bg-card flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<span className="text-sm">
{t(METRIC_LABEL_KEYS[metric])}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => resetMetricToDefault(metric)}
disabled={saveMutation.isPending}
<div
id="sources-per-metric-panel"
role="region"
hidden={!showPerMetric}
className="bg-background/30 border-border space-y-3 rounded-md border p-3"
>
{showPerMetric ? (
<>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.perMetricHelp")}
</p>
{overriddenMetrics.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("settings.sections.sources.perMetricEmpty")}
/>
) : (
<ul className="space-y-2">
{overriddenMetrics.map((metric) => (
<li
key={metric}
className="border-border bg-card flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
{t("settings.sections.sources.resetMetric")}
</Button>
</li>
))}
</ul>
)}
</div>
)}
<span className="text-sm">
{t(METRIC_LABEL_KEYS[metric])}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => resetMetricToDefault(metric)}
disabled={saveMutation.isPending}
>
{t("settings.sections.sources.resetMetric")}
</Button>
</li>
))}
</ul>
)}
</>
) : null}
</div>
</div>
)}
@@ -563,78 +577,82 @@ export function SourcesSection() {
count: deviceTypeOverrideCount,
})}
</button>
{showDeviceType && (
<div
id="sources-device-type-panel"
role="region"
className="bg-background/30 border-border space-y-3 rounded-md border p-3"
>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.deviceTypeHelp")}
</p>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{t("settings.sections.sources.deviceTypeDefault")}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={resetDeviceTypeAxis}
disabled={saveMutation.isPending}
>
<RotateCcw className="h-3 w-3" />
{t("settings.sections.sources.resetDeviceTypes")}
</Button>
</div>
<ul className="space-y-1">
{(
priority.deviceTypePriority.default ??
DEFAULT_DEVICE_TYPE_PRIORITY
).map((deviceType, index, list) => (
<li
key={`device-default-${deviceType}-${index}`}
className="border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5"
<div
id="sources-device-type-panel"
role="region"
hidden={!showDeviceType}
className="bg-background/30 border-border space-y-3 rounded-md border p-3"
>
{showDeviceType ? (
<>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.deviceTypeHelp")}
</p>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{t("settings.sections.sources.deviceTypeDefault")}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={resetDeviceTypeAxis}
disabled={saveMutation.isPending}
>
<span className="text-muted-foreground w-5 text-xs font-medium tabular-nums">
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(DEVICE_TYPE_LABEL_KEYS[deviceType])}
</span>
{/* v1.4.27 R3d MB2 — same stacked-on-mobile shape
<RotateCcw className="h-3 w-3" />
{t("settings.sections.sources.resetDeviceTypes")}
</Button>
</div>
<ul className="space-y-1">
{(
priority.deviceTypePriority.default ??
DEFAULT_DEVICE_TYPE_PRIORITY
).map((deviceType, index, list) => (
<li
key={`device-default-${deviceType}-${index}`}
className="border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5"
>
<span className="text-muted-foreground w-5 text-xs font-medium tabular-nums">
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(DEVICE_TYPE_LABEL_KEYS[deviceType])}
</span>
{/* v1.4.27 R3d MB2 — same stacked-on-mobile shape
as the metric source rows above. */}
<div className="flex flex-col gap-1 sm:flex-row sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveDeviceType(null, index, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveDeviceType(null, index, 1)}
disabled={
index === list.length - 1 || saveMutation.isPending
}
aria-label={t("settings.sections.sources.moveDown")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
</div>
)}
<div className="flex flex-col gap-1 sm:flex-row sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveDeviceType(null, index, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveDeviceType(null, index, 1)}
disabled={
index === list.length - 1 ||
saveMutation.isPending
}
aria-label={t("settings.sections.sources.moveDown")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
</>
) : null}
</div>
</div>
)}