mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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");
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user