From ad5ac674c5f2083d02f0a926045c528bc12f2aa0 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:52:25 +0100 Subject: [PATCH] Name every alert threshold control for assistive tech On Alerts > Thresholds a screen reader moving between controls heard the same few names over and over: 39 inputs on the Proxmox tab took one of two generic titles, every On/Off badge said only "On" or "Off", every value cell was "Click to edit this metric", and the mobile alert toggles had no name at all. The column header is not read when moving between form controls, so nothing said which metric or resource a control set. Each control now names what it sets: "CPU % for web-01", "CPU % default alerts: On", "Offline alerts for web-01: Warn", "Edit CPU % threshold for web-01: 80%", "Alerts for web-01". Names end with the visible word or value so voice control can still say what is on screen (WCAG 2.5.3); StatusBadge composes that from an optional ariaLabel. --- .../v6/internal/subsystems/alerts.md | 9 + .../subsystems/frontend-primitives.md | 4 + .../08b3ac05c4716f54.json | 51 +++++ .../Alerts/AlertResourceMetricEditor.tsx | 12 + .../Alerts/AlertResourceTableDesktop.tsx | 20 +- .../Alerts/AlertResourceTableMobile.tsx | 49 ++++- .../Alerts/AlertResourceTableRow.tsx | 40 ++-- .../components/Alerts/ResourceTable.test.tsx | 207 +++++++++++++++++- .../SharedPrimitives.guardrails.test.ts | 2 + .../src/components/shared/StatusBadge.tsx | 2 + .../shared/__tests__/StatusBadge.test.tsx | 31 +++ .../src/components/shared/statusBadgeModel.ts | 11 + 12 files changed, 402 insertions(+), 36 deletions(-) create mode 100644 frontend-modern/browser-verification/08b3ac05c4716f54.json create mode 100644 frontend-modern/src/components/shared/__tests__/StatusBadge.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 99400b8f1b..0613efe29e 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -740,6 +740,15 @@ and hide the resource identity. Backup, snapshot and offline controls keep their separate configuration contracts. Global default cells never route backup or snapshot into numeric thresholds. Host cards preserve the canonical cluster context shown by table rows, so identically named hosts remain distinguishable. +Every threshold control names itself for assistive tech, because moving +between form controls reads a control's own name, not its column header: +the editor's input is " default" or " for " and its +On/Off badge "... alerts: On"; offline, backup and snapshot toggles and the +three-state Warn / Crit / Off control are "Offline alerts for : Warn"; +display-mode value buttons are "Edit threshold for : "; +and the alert switches are "Alerts for ". Each name ends with the +control's visible word or value so voice control can say what is on screen +(WCAG 2.5.3). `ResourceTable.test.tsx` pins the names in both layouts. A per-metric threshold is off whenever its trigger is `<= 0`. That boundary is engine truth (`internal/alerts/canonical_metric.go`, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 88213dc17e..2b2d620684 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5702,6 +5702,10 @@ gating and click runtime, and label/title fallback policy, and status-badge class selection. Future status badge work should extend those owners instead of pushing label/title policy or disabled click handling back into the shell. +A badge given `ariaLabel` is named ": " through +`getStatusBadgeAriaLabel`, so pages of alike On/Off badges say what each +switches while keeping the visible word in the name (WCAG 2.5.3); without it +the visible label remains the name. Read-only health/state badges are a separate shared primitive. `frontend-modern/src/components/shared/StatusIndicatorBadge.tsx` owns status-to-tone mapping, optional dot wiring, sizing, shape, and label diff --git a/frontend-modern/browser-verification/08b3ac05c4716f54.json b/frontend-modern/browser-verification/08b3ac05c4716f54.json new file mode 100644 index 0000000000..3bfbe97cbd --- /dev/null +++ b/frontend-modern/browser-verification/08b3ac05c4716f54.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "base_sha": "21732ef252a631510e6f021c8e80b14b88871067", + "verified_at": "2026-10-14T23:52:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Alerts/AlertResourceMetricEditor.tsx", + "frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx", + "frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx", + "frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx", + "frontend-modern/src/components/shared/StatusBadge.tsx", + "frontend-modern/src/components/shared/statusBadgeModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Alerts/AlertResourceMetricEditor.tsx": "e5de869f7a32a934df4f21955bcf4a809073bf3c08754e9d210632985185472d", + "frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx": "24399ab16fa3485e79f0259a37a0cf6f1587871090be147925851519418c2cd9", + "frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx": "a5af1c582a1b9cf1e73a2b795c94de14cc07b799449f37320b049939bec777c2", + "frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx": "94d932de202cd7632c5e95ab9b8da86863a9dedcd425c9750f9bb80db7f59696", + "frontend-modern/src/components/shared/StatusBadge.tsx": "7bbe8227e0ebb76473833106f772ec1b4cf9c7aef3a54e3da4db3fd9c1fef0fc", + "frontend-modern/src/components/shared/statusBadgeModel.ts": "43b6848a4fa28ca3e09b28d1bac775de87bce9f62d977b58a9251812430bb8a2" + }, + "routes": [ + "/alerts/thresholds/proxmox", + "/alerts/thresholds/docker", + "/alerts/thresholds/kubernetes", + "/alerts/thresholds/truenas", + "/alerts/thresholds/vmware", + "/alerts/thresholds/systems" + ], + "viewports": [ + { + "width": 1280, + "height": 800 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Before (main, Proxmox tab, sections expanded): 39 threshold inputs shared two generic names from their title, 42 metric On/Off badges were named only \"On\"/\"Off\", value cells were all \"Click to edit this metric\"", + "After: every threshold input named \" default\" or \" for \"; On/Off badges \" default alerts: On\"; Offline, Backup and Snapshot toggles and Warn/Crit/Off controls \" alerts for : \"; value buttons \"Edit threshold for : \"; alert switches \"Alerts for \"", + "Sweep of all six thresholds tabs with every section expanded at 1280 and 375: 0 threshold inputs or state buttons without a name, 0 named buttons whose name omits their visible text", + "No visual change: names are accessible-name only; card and table layouts render as before" + ], + "interactions": [ + "Expanded all sections on each thresholds tab and enumerated inputs, buttons and switches with their computed names", + "Started editing a resource: focus lands on \"CPU % for Automation\" and its badges read \"... for Automation alerts: On\"", + "Repeated the sweep on a 375x812 phone viewport, including the mobile global and per-resource alert toggles" + ] +} diff --git a/frontend-modern/src/components/Alerts/AlertResourceMetricEditor.tsx b/frontend-modern/src/components/Alerts/AlertResourceMetricEditor.tsx index 2d19c19415..1c2534f5d8 100644 --- a/frontend-modern/src/components/Alerts/AlertResourceMetricEditor.tsx +++ b/frontend-modern/src/components/Alerts/AlertResourceMetricEditor.tsx @@ -24,6 +24,8 @@ interface AlertResourceMetricEditorProps { inheritedDefault?: number; fallback?: AlertResourceGlobalDefaultFallback; onChange: (value: number | undefined) => void; + /** The resource an override row edits, so its controls say whose they are. */ + resourceName?: string; autoFocus?: boolean; onFocus?: () => void; } @@ -39,6 +41,14 @@ export function AlertResourceMetricEditor(props: AlertResourceMetricEditorProps) ? defaultCell().isOff : isAlertResourceMetricOff(props.value, props.inheritedDefault); const follows = () => isDefault() && defaultCell().follows; + // A thresholds page holds dozens of alike inputs and On/Off badges. Each + // names its metric, and an override its resource, so assistive tech and + // voice control can tell them apart; the column header alone is not read + // when moving between form controls. + const controlName = () => + isDefault() + ? `${props.column} default` + : `${props.column} for ${props.resourceName?.trim() || 'this resource'}`; const enableValue = () => isDefault() ? defaultCell().enableValue @@ -71,6 +81,7 @@ export function AlertResourceMetricEditor(props: AlertResourceMetricEditorProps) }); }} type="number" + aria-label={controlName()} onFocus={props.onFocus} min={bounds().min} max={bounds().max} @@ -112,6 +123,7 @@ export function AlertResourceMetricEditor(props: AlertResourceMetricEditorProps) isEnabled={!isOff()} onToggle={() => props.onChange(isOff() ? enableValue() : ALERT_RESOURCE_METRIC_OFF_VALUE)} {...getAlertResourceTableMetricOffToggleProps()} + ariaLabel={`${controlName()} alerts`} /> diff --git a/frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx b/frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx index ec8426dda0..184f62a5b9 100644 --- a/frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx +++ b/frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx @@ -84,6 +84,8 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps) state: OfflineState, disabled: boolean, onToggle: () => void, + // What the button sets; its visible state word ends the accessible name. + name: string, ) => { const config = getAlertResourceTableOfflineStatePresentation(state); @@ -97,6 +99,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps) onToggle(); }} title={config.title} + aria-label={`${name}: ${config.label}`} > {config.label} @@ -113,6 +116,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps) titleEnabled?: string; titleDisabled?: string; titleWhenDisabled?: string; + ariaLabel?: string; }) => ; const resourceRows = createMemo(() => { @@ -339,6 +343,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps) 'Offline alerts currently enabled by default. Click to disable.', titleDisabled: 'Offline alerts currently disabled by default. Click to enable.', + ariaLabel: 'Offline alerts default', }); })()} @@ -354,11 +359,16 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps) ? 'critical' : 'warning'; - return renderOfflineStateButton(state, disabledGlobally, () => { - if (disabledGlobally) return; - const next = nextOfflineState(state); - props.table.onSetGlobalOfflineState?.(next); - }); + return renderOfflineStateButton( + state, + disabledGlobally, + () => { + if (disabledGlobally) return; + const next = nextOfflineState(state); + props.table.onSetGlobalOfflineState?.(next); + }, + 'Offline alerts default', + ); })()} diff --git a/frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx b/frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx index 67fb5990c7..e992af0caa 100644 --- a/frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx +++ b/frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx @@ -114,6 +114,8 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) { state: OfflineState, disabled: boolean, onToggle: () => void, + // What the button sets; its visible state word ends the accessible name. + name: string, ) => { const config = getAlertResourceTableOfflineStatePresentation(state); @@ -127,12 +129,18 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) { onToggle(); }} title={config.title} + aria-label={`${name}: ${config.label}`} > {config.label} ); }; + // The value a metric cell shows, which its accessible name repeats so a + // screen reader hears the current threshold and voice control can say it. + const metricValueText = (metric: string, value: number) => + value <= 0 ? 'Off' : props.table.formatMetricValue(metric, value); + const MetricValueWithHeat = (metricProps: { resourceId: string; metric: string; @@ -140,9 +148,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) { isOverridden: boolean; }) => { const isDisabledMetric = metricProps.value <= 0; - const displayText = isDisabledMetric - ? 'Off' - : props.table.formatMetricValue(metricProps.metric, metricProps.value); + const displayText = metricValueText(metricProps.metric, metricProps.value); return (
@@ -254,6 +261,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) { labelDisabled="Off" titleEnabled="Offline alerts currently enabled by default. Click to disable." titleDisabled="Offline alerts currently disabled by default. Click to enable." + ariaLabel="Offline alerts default" /> } @@ -268,10 +276,15 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) { ? 'critical' : 'warning'; - return renderOfflineStateButton(state, disabledGlobally, () => { - if (disabledGlobally) return; - props.table.onSetGlobalOfflineState?.(nextOfflineState(state)); - }); + return renderOfflineStateButton( + state, + disabledGlobally, + () => { + if (disabledGlobally) return; + props.table.onSetGlobalOfflineState?.(nextOfflineState(state)); + }, + 'Offline alerts default', + ); })()} @@ -333,6 +346,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) { !(props.table.globalDisableFlag?.() ?? false) && props.table.onToggleDisabled?.(resource.id) } + ariaLabel={`Alerts for ${getAlertResourceLabel(resource)}`} /> @@ -492,6 +506,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) { onToggle={() => onToggle(resource.id)} titleEnabled={`${titlePrefix} alerts enabled. Click to disable for this resource.`} titleDisabled={`${titlePrefix} alerts disabled. Click to enable for this resource.`} + ariaLabel={`${titlePrefix} alerts for ${getAlertResourceLabel(resource)}`} /> ); @@ -510,7 +525,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) { type="button" onClick={(e) => startEditing(resource, metric, e)} class="min-h-11 min-w-11 font-mono text-xs font-medium cursor-pointer rounded-sm px-1 -mx-1 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-400" - aria-label={`Edit ${column} threshold for ${getAlertResourceLabel(resource)}`} + aria-label={`Edit ${column} threshold for ${getAlertResourceLabel(resource)}: ${metricValueText(metric, displayValue(metric))}`} > props.setActiveMetricInput({ resourceId: resource.id, metric }) } @@ -577,6 +593,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) { titleEnabled="Offline alerts enabled. Click to disable for this resource." titleDisabled="Offline alerts disabled. Click to enable for this resource." titleWhenDisabled="Offline alerts controlled globally" + ariaLabel={`Offline alerts for ${getAlertResourceLabel(resource)}`} /> } > @@ -596,10 +613,18 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) { state = defaultSeverity === 'critical' ? 'critical' : 'warning'; } - return renderOfflineStateButton(state, disabledGlobally(), () => { - if (disabledGlobally()) return; - props.table.onSetOfflineState?.(resource.id, nextOfflineState(state)); - }); + return renderOfflineStateButton( + state, + disabledGlobally(), + () => { + if (disabledGlobally()) return; + props.table.onSetOfflineState?.( + resource.id, + nextOfflineState(state), + ); + }, + `Offline alerts for ${getAlertResourceLabel(resource)}`, + ); })()} diff --git a/frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx b/frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx index 01fd5c02a7..89c78a098c 100644 --- a/frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx +++ b/frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx @@ -155,6 +155,11 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) { }); }; + // The value a metric cell shows, which its accessible name repeats so a + // screen reader hears the current threshold and voice control can say it. + const metricValueText = (metric: string, value: number) => + value <= 0 ? 'Off' : props.formatMetricValue(metric, value); + const MetricValueWithHeat = (metricProps: { resourceId: string; metric: string; @@ -162,9 +167,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) { isOverridden: boolean; }) => { const isDisabledMetric = metricProps.value <= 0; - const displayText = isDisabledMetric - ? 'Off' - : props.formatMetricValue(metricProps.metric, metricProps.value); + const displayText = metricValueText(metricProps.metric, metricProps.value); return (
; const nextOfflineState = (state: OfflineState): OfflineState => { @@ -211,6 +215,8 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) { state: OfflineState, disabled: boolean, onToggle: () => void, + // What the button sets; its visible state word ends the accessible name. + name: string, ) => { const config = getAlertResourceTableOfflineStatePresentation(state); @@ -224,6 +230,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) { onToggle(); }} title={config.title} + aria-label={`${name}: ${config.label}`} > {config.label} @@ -280,11 +287,8 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) { ? 'Click to enable alerts' : 'Click to disable alerts' } - ariaLabel={ - isChecked - ? 'Alerts enabled for this resource' - : 'Alerts disabled for this resource' - } + // The switch role carries on/off; the name says whose alerts. + ariaLabel={`Alerts for ${resourceLabel()}`} />
); @@ -398,6 +402,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) { onToggle={() => onToggle?.(props.resource.id)} titleEnabled={`${titlePrefix} alerts enabled. Click to disable for this resource.`} titleDisabled={`${titlePrefix} alerts disabled. Click to enable for this resource.`} + ariaLabel={`${titlePrefix} alerts for ${resourceLabel()}`} /> @@ -436,7 +441,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) { }} class="cursor-pointer hover:bg-surface-hover rounded-sm px-1 py-0.5 transition-colors" title={getAlertResourceTableEditMetricTitle()} - aria-label={getAlertResourceTableEditMetricTitle()} + aria-label={`Edit ${column} threshold for ${resourceLabel()}: ${metricValueText(metric, displayValue(metric))}`} > props.setActiveMetricInput({ resourceId: props.resource.id, metric }) } @@ -546,11 +552,16 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) { state = defaultSeverity === 'critical' ? 'critical' : 'warning'; } - return renderOfflineStateButton(state, disabledGlobally, () => { - if (disabledGlobally) return; - const next = nextOfflineState(state); - props.onSetOfflineState?.(props.resource.id, next); - }); + return renderOfflineStateButton( + state, + disabledGlobally, + () => { + if (disabledGlobally) return; + const next = nextOfflineState(state); + props.onSetOfflineState?.(props.resource.id, next); + }, + `Offline alerts for ${resourceLabel()}`, + ); } if (!props.onToggleNodeConnectivity) { @@ -572,6 +583,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) { titleEnabled: 'Offline alerts enabled. Click to disable for this resource.', titleDisabled: 'Offline alerts disabled. Click to enable for this resource.', titleWhenDisabled: 'Offline alerts controlled globally', + ariaLabel: `Offline alerts for ${resourceLabel()}`, }); })()} diff --git a/frontend-modern/src/components/Alerts/ResourceTable.test.tsx b/frontend-modern/src/components/Alerts/ResourceTable.test.tsx index 42faa82cc7..edba838fe5 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.test.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.test.tsx @@ -39,6 +39,12 @@ vi.mock('@/components/shared/StatusBadge', () => ({ StatusBadge: (props: any) => (