Name every alert threshold control for assistive tech (#2958)

## What changes for users

**Alerts › Thresholds** now works with a screen reader or voice control.
Before, every control on the page had a generic name or none, so nothing
said which metric or resource you were changing. On the Proxmox tab, for
example:

- **Inputs:** all 39 threshold inputs shared two generic names taken
from their tooltip: "Use the Off control to disable alerts for this
metric" and "Click to enable this metric".
- **Badges:** every On/Off badge said only "On" or "Off".
- **Value cells:** each one was announced as "Click to edit this
metric".
- **Mobile switches:** the global and per-resource alert switches had no
name at all.

Each control now says what it sets:

| Control | Name |
| --- | --- |
| Default input | `CPU % default` |
| Override input | `CPU % for web-01` |
| Metric On/Off badge | `CPU % default alerts: On` |
| Offline / Backup / Snapshot toggle | `Offline alerts for web-01: Warn`
|
| Value cell (not editing) | `Edit CPU % threshold for web-01: 80%` |
| Alert switch | `Alerts for web-01` |

Each name ends with the word or value shown on screen, so voice-control
users can still say what they see (WCAG 2.5.3, Label in Name). Nothing
changes visually.

## How

- `StatusBadge` takes an optional `ariaLabel`. `getStatusBadgeAriaLabel`
turns it into "<label>: <visible label>".
- `AlertResourceMetricEditor` names its input and badge from the column,
plus the resource for override rows (which pass
`getAlertResourceLabel`).
- The three-state Offline buttons (desktop row, desktop global row,
mobile), the binary Offline/Backup/Snapshot badges and the value buttons
all name their resource and repeat their visible state or value.

## Verification

- 20 new or updated test cases fail against main's code. The Alerts
components, shared primitives and alerts feature suites pass (3782
tests), and tsc, prettier and the copy, form-label and theme audits are
clean.
- Live sweep of all six Thresholds tabs, every section expanded, at
1280×800 and 375×812. No threshold control is left without a name, and
no named control's name leaves out its visible text. The receipt is
included.
This commit is contained in:
rcourtman
2026-10-15 01:13:00 +01:00
committed by GitHub
12 changed files with 402 additions and 36 deletions
@@ -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 "<column> default" or "<column> for <resource>" and its
On/Off badge "... alerts: On"; offline, backup and snapshot toggles and the
three-state Warn / Crit / Off control are "Offline alerts for <resource>: Warn";
display-mode value buttons are "Edit <column> threshold for <resource>: <value>";
and the alert switches are "Alerts for <resource>". 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`,
@@ -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 "<ariaLabel>: <visible label>" 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
@@ -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 \"<column> default\" or \"<column> for <resource>\"; On/Off badges \"<column> default alerts: On\"; Offline, Backup and Snapshot toggles and Warn/Crit/Off controls \"<kind> alerts for <resource>: <state>\"; value buttons \"Edit <column> threshold for <resource>: <value>\"; alert switches \"Alerts for <resource>\"",
"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"
]
}
@@ -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`}
/>
</div>
</div>
@@ -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}
</button>
@@ -113,6 +116,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
titleEnabled?: string;
titleDisabled?: string;
titleWhenDisabled?: string;
ariaLabel?: string;
}) => <StatusBadge {...config} />;
const resourceRows = createMemo<AlertResourceRenderItem[]>(() => {
@@ -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',
});
})()}
</Show>
@@ -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',
);
})()}
</Show>
</TableCell>
@@ -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}
</button>
);
};
// 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 (
<div
@@ -197,6 +203,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
props.table.onToggleGlobalDisable?.();
props.table.setHasUnsavedChanges?.(true);
}}
ariaLabel="Global alerts toggle"
/>
</Show>
</div>
@@ -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"
/>
</Show>
}
@@ -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',
);
})()}
</Show>
</div>
@@ -333,6 +346,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
!(props.table.globalDisableFlag?.() ?? false) &&
props.table.onToggleDisabled?.(resource.id)
}
ariaLabel={`Alerts for ${getAlertResourceLabel(resource)}`}
/>
</div>
</Show>
@@ -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)}`}
/>
</div>
);
@@ -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))}`}
>
<MetricValueWithHeat
resourceId={resource.id}
@@ -528,6 +543,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
layout="card"
value={thresholds()?.[metric]}
inheritedDefault={resource.defaults?.[metric]}
resourceName={getAlertResourceLabel(resource)}
onFocus={() =>
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)}`,
);
})()}
</Show>
</div>
@@ -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 (
<div
@@ -199,6 +202,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
titleEnabled?: string;
titleDisabled?: string;
titleWhenDisabled?: string;
ariaLabel?: string;
}) => <StatusBadge {...config} />;
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}
</button>
@@ -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()}`}
/>
</div>
);
@@ -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()}`}
/>
</div>
</Show>
@@ -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))}`}
>
<MetricValueWithHeat
resourceId={props.resource.id}
@@ -505,6 +510,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
layout="table"
value={getThresholds()?.[metric]}
inheritedDefault={props.resource.defaults?.[metric]}
resourceName={resourceLabel()}
onFocus={() =>
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()}`,
});
})()}
</TableCell>
@@ -39,6 +39,12 @@ vi.mock('@/components/shared/StatusBadge', () => ({
StatusBadge: (props: any) => (
<button
data-testid="status-badge"
aria-label={
props.ariaLabel
? `${props.ariaLabel}: ${props.isEnabled ? (props.labelEnabled ?? 'On') : (props.labelDisabled ?? 'Off')}`
: undefined
}
aria-pressed={props.isEnabled}
onClick={() => props.onToggle?.()}
title={props.isEnabled ? props.titleEnabled : props.titleDisabled}
>
@@ -767,8 +773,8 @@ describe('ResourceTable', () => {
});
render(() => <ResourceTable {...props} />);
const toggle = screen.getByRole('switch', { name: 'Alerts enabled for this resource' });
expect(toggle).toBeInTheDocument();
const toggle = screen.getByRole('switch', { name: 'Alerts for Test VM' });
expect(toggle).toHaveAttribute('aria-checked', 'true');
});
it('calls onToggleDisabled with resource id when toggle is clicked', () => {
@@ -779,7 +785,7 @@ describe('ResourceTable', () => {
});
render(() => <ResourceTable {...props} />);
const toggle = screen.getByRole('switch', { name: 'Alerts enabled for this resource' });
const toggle = screen.getByRole('switch', { name: 'Alerts for Test VM' });
fireEvent.click(toggle);
expect(onToggleDisabled).toHaveBeenCalledWith('vm-1');
});
@@ -793,7 +799,8 @@ describe('ResourceTable', () => {
});
render(() => <ResourceTable {...props} />);
const toggle = screen.getByRole('switch', { name: 'Alerts disabled for this resource' });
const toggle = screen.getByRole('switch', { name: 'Alerts for Test VM' });
expect(toggle).toHaveAttribute('aria-checked', 'false');
expect(toggle).toBeDisabled();
});
@@ -956,6 +963,196 @@ describe('ResourceTable', () => {
},
);
// Moving between form controls, assistive tech reads a control's own name,
// not its column header: every threshold input and On/Off toggle on the
// page used to sound alike ("Click to enable this metric", "On").
describe('threshold control names', () => {
it.each([false, true])(
'names each global default input and toggle by its metric (mobile=%s)',
(mobile) => {
mockIsMobile.mockReturnValue(mobile);
const props = makeProps({
columns: ['CPU %', 'Memory %'],
resources: [],
globalDefaults: { cpu: 80, memory: 85 },
setGlobalDefaults: vi.fn(),
setHasUnsavedChanges: vi.fn(),
});
render(() => <ResourceTable {...props} />);
expect(screen.getByRole('spinbutton', { name: 'CPU % default' })).toHaveValue(80);
expect(screen.getByRole('spinbutton', { name: 'Memory % default' })).toHaveValue(85);
expect(screen.getByRole('button', { name: 'CPU % default alerts: On' })).toHaveAttribute(
'aria-pressed',
'true',
);
},
);
it.each([false, true])(
'names an override input and toggle by metric and resource (mobile=%s)',
(mobile) => {
mockIsMobile.mockReturnValue(mobile);
const props = makeProps({
columns: ['CPU %'],
editingId: () => 'vm-1',
editingThresholds: () => ({ cpu: 90 }),
resources: [
makeResource({
id: 'vm-1',
name: 'web-01',
thresholds: { cpu: 90 },
defaults: { cpu: 80 },
}),
],
});
render(() => <ResourceTable {...props} />);
expect(screen.getByRole('spinbutton', { name: 'CPU % for web-01' })).toHaveValue(90);
expect(
screen.getByRole('button', { name: 'CPU % for web-01 alerts: On' }),
).toBeInTheDocument();
},
);
it.each([false, true])(
'names the offline alert toggles for the default and each resource (mobile=%s)',
(mobile) => {
mockIsMobile.mockReturnValue(mobile);
const props = makeProps({
columns: ['CPU %'],
resources: [makeResource({ id: 'node-a', name: 'pve-a', type: 'node' })],
globalDefaults: { cpu: 80 },
setGlobalDefaults: vi.fn(),
setHasUnsavedChanges: vi.fn(),
showOfflineAlertsColumn: true,
onToggleGlobalDisableOffline: vi.fn(),
globalDisableOfflineFlag: () => false,
onToggleNodeConnectivity: vi.fn(),
});
render(() => <ResourceTable {...props} />);
expect(
screen.getByRole('button', { name: /^Offline alerts default: On$/ }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /^Offline alerts for pve-a: / }),
).toBeInTheDocument();
},
);
it.each([false, true])(
'names each resource backup and snapshot toggle (mobile=%s)',
(mobile) => {
mockIsMobile.mockReturnValue(mobile);
const props = makeProps({
columns: ['Backup', 'Snapshot'],
resources: [
makeResource({
id: 'vm-1',
name: 'web-01',
backup: { enabled: true },
snapshot: { enabled: false },
}),
],
onToggleBackup: vi.fn(),
onToggleSnapshot: vi.fn(),
});
render(() => <ResourceTable {...props} />);
expect(screen.getByRole('button', { name: /^Backup alerts for web-01: / })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(
screen.getByRole('button', { name: /^Snapshot alerts for web-01: / }),
).toHaveAttribute('aria-pressed', 'false');
},
);
// Proxmox guests and Docker containers cycle Warn / Crit / Off instead
// of a binary badge; their names carry the context and the visible word.
it.each([false, true])(
'names the three-state offline controls for the default and each guest (mobile=%s)',
(mobile) => {
mockIsMobile.mockReturnValue(mobile);
const props = makeProps({
columns: ['CPU %'],
resources: undefined,
groupedResources: {
node1: [makeResource({ id: 'vm-1', name: 'VM1', type: 'guest' })],
},
globalDefaults: { cpu: 80 },
setGlobalDefaults: vi.fn(),
setHasUnsavedChanges: vi.fn(),
showOfflineAlertsColumn: true,
onSetGlobalOfflineState: vi.fn(),
onSetOfflineState: vi.fn(),
});
render(() => <ResourceTable {...props} />);
expect(
screen.getByRole('button', { name: 'Offline alerts default: Warn' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Offline alerts for VM1: Warn' }),
).toBeInTheDocument();
},
);
// A metric cell outside edit mode is a button showing the current value;
// its name says which threshold it edits and repeats that value.
it.each([false, true])(
'names each metric value button with its resource and shown value (mobile=%s)',
(mobile) => {
mockIsMobile.mockReturnValue(mobile);
const props = makeProps({
columns: ['CPU %', 'Memory %'],
resources: [
makeResource({
id: 'vm-1',
name: 'web-01',
thresholds: { cpu: 90, memory: -1 },
defaults: { cpu: 80, memory: 85 },
}),
],
});
render(() => <ResourceTable {...props} />);
expect(
screen.getByRole('button', { name: 'Edit CPU % threshold for web-01: 90' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Edit Memory % threshold for web-01: Off' }),
).toBeInTheDocument();
},
);
it.each([false, true])(
'names the global and per-resource alert switches (mobile=%s)',
(mobile) => {
mockIsMobile.mockReturnValue(mobile);
const props = makeProps({
columns: ['CPU %'],
resources: [makeResource({ id: 'vm-1', name: 'web-01' })],
globalDefaults: { cpu: 80 },
setGlobalDefaults: vi.fn(),
setHasUnsavedChanges: vi.fn(),
onToggleGlobalDisable: vi.fn(),
globalDisableFlag: () => false,
onToggleDisabled: vi.fn(),
});
render(() => <ResourceTable {...props} />);
expect(screen.getByRole('switch', { name: 'Global alerts toggle' })).toHaveAttribute(
'aria-checked',
'true',
);
expect(screen.getByRole('switch', { name: 'Alerts for web-01' })).toBeInTheDocument();
},
);
});
describe('metric off toggles', () => {
// The alert engine disables a metric whenever its trigger is <= 0, so every
// editor here has to agree on that reading and has to write a value the
@@ -1973,7 +2170,7 @@ describe('ResourceTable', () => {
'min-h-11',
'min-w-11',
);
expect(screen.getByLabelText('Edit CPU % threshold for Mobile VM')).toHaveClass(
expect(screen.getByLabelText(/^Edit CPU % threshold for Mobile VM: /)).toHaveClass(
'min-h-11',
'min-w-11',
);
@@ -2106,6 +2106,8 @@ describe('shared primitive guardrails', () => {
expect(statusBadgeSource).not.toContain('cursor-not-allowed');
expect(statusBadgeSource).not.toContain('props.onToggle?.()');
expect(statusBadgeSource).not.toContain('labelEnabled ??');
// Pages of alike On/Off badges name each one for assistive tech.
expect(statusBadgeSource).toContain('aria-label={getStatusBadgeAriaLabel(props)}');
expect(statusBadgeStateSource).toContain('export function useStatusBadgeState');
expect(statusBadgeStateSource).toContain('Boolean(props.disabled)');
@@ -1,5 +1,6 @@
import { JSX } from 'solid-js';
import {
getStatusBadgeAriaLabel,
getStatusBadgeClass,
getStatusBadgeLabel,
getStatusBadgeTitle,
@@ -19,6 +20,7 @@ export function StatusBadge(props: StatusBadgeProps): JSX.Element {
onClick={state.handleClick}
disabled={props.disabled}
aria-pressed={props.isEnabled}
aria-label={getStatusBadgeAriaLabel(props)}
title={getStatusBadgeTitle(props, state.isDisabled())}
>
{getStatusBadgeLabel(props)}
@@ -0,0 +1,31 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { StatusBadge } from '../StatusBadge';
afterEach(cleanup);
describe('StatusBadge', () => {
// The alert thresholds page holds dozens of these; each must say what it
// switches rather than announcing only "On" or "Off", and still contain its
// visible word so voice control can name it (WCAG 2.5.3).
it('names what it switches, then its visible state, and keeps its pressed state', () => {
const onToggle = vi.fn();
render(() => <StatusBadge isEnabled onToggle={onToggle} ariaLabel="CPU % default alerts" />);
const badge = screen.getByRole('button', { name: 'CPU % default alerts: Enabled' });
expect(badge).toHaveAttribute('aria-pressed', 'true');
expect(badge).toHaveTextContent('Enabled');
fireEvent.click(badge);
expect(onToggle).toHaveBeenCalledTimes(1);
});
it('falls back to its visible label when no ariaLabel is given', () => {
render(() => <StatusBadge isEnabled={false} />);
expect(screen.getByRole('button', { name: 'Disabled' })).toHaveAttribute(
'aria-pressed',
'false',
);
});
});
@@ -10,6 +10,8 @@ export interface StatusBadgeProps {
titleEnabled?: string;
titleDisabled?: string;
titleWhenDisabled?: string;
/** What the toggle switches, for a page with many alike On/Off badges. */
ariaLabel?: string;
}
const STATUS_BADGE_BASE_CLASS =
@@ -47,6 +49,15 @@ export function getStatusBadgeLabel(props: StatusBadgeProps): string {
return props.isEnabled ? (props.labelEnabled ?? 'Enabled') : (props.labelDisabled ?? 'Disabled');
}
/**
* A badge's accessible name: what it switches, then the visible state word,
* so voice control can still say what is on screen (WCAG 2.5.3).
*/
export function getStatusBadgeAriaLabel(props: StatusBadgeProps): string | undefined {
const context = props.ariaLabel?.trim();
return context ? `${context}: ${getStatusBadgeLabel(props)}` : undefined;
}
export function getStatusBadgeTitle(props: StatusBadgeProps, disabled: boolean): string {
if (disabled) {
return props.titleWhenDisabled ?? props.titleDisabled ?? props.titleEnabled ?? '';