mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
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:
@@ -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 ?? '';
|
||||
|
||||
Reference in New Issue
Block a user