Label alert schedule toggles by section

This commit is contained in:
rcourtman
2026-05-20 14:57:52 +01:00
parent 142236d797
commit 6ebf0c6886
9 changed files with 82 additions and 7 deletions
@@ -4,6 +4,7 @@ import { Card } from '@/components/shared/Card';
type SettingsPanelProps = {
title: JSX.Element;
description?: JSX.Element;
titleId?: string;
action?: JSX.Element;
bodyClass?: string;
tone?: 'default' | 'muted' | 'info' | 'success' | 'warning' | 'danger';
@@ -15,6 +16,7 @@ export function SettingsPanel(props: SettingsPanelProps) {
const [local, rest] = splitProps(props, [
'title',
'description',
'titleId',
'action',
'bodyClass',
'children',
@@ -35,13 +37,14 @@ export function SettingsPanel(props: SettingsPanelProps) {
<div class="px-3 py-3 sm:px-6 sm:py-4 border-b border-border bg-surface-alt">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-col gap-1 flex-1">
<h2 class="text-sm sm:text-base tracking-tight font-semibold text-base-content dark:text-slate-100">
<h2
id={local.titleId}
class="text-sm sm:text-base tracking-tight font-semibold text-base-content dark:text-slate-100"
>
{local.title}
</h2>
<Show when={local.description}>
<p class="text-xs sm:text-sm text-muted dark:text-slate-200">
{local.description}
</p>
<p class="text-xs sm:text-sm text-muted dark:text-slate-200">{local.description}</p>
</Show>
</div>
<Show when={local.action}>
@@ -81,4 +81,22 @@ describe('Toggle', () => {
expect(button).toHaveAttribute('aria-describedby', 'external-toggle-description');
expect(button).toHaveAccessibleDescription('Run when alerts fire.');
});
it('combines external context with its visible status label', () => {
render(() => (
<>
<span id="schedule-section-label">Quiet hours</span>
<Toggle
checked={false}
ariaLabelledBy="schedule-section-label"
label={<span>Disabled</span>}
/>
</>
));
const button = screen.getByRole('button', { name: 'Quiet hours Disabled' });
const labelledBy = button.getAttribute('aria-labelledby')?.split(/\s+/) ?? [];
expect(labelledBy).toContain('schedule-section-label');
expect(labelledBy).toHaveLength(2);
});
});
@@ -53,14 +53,21 @@ export function Toggle(props: LabeledToggleProps) {
const size = resolveToggleSize(props.size, 'md');
const labelId = createUniqueId();
const descriptionId = createUniqueId();
const labelledBy = () =>
[props.ariaLabelledBy, props.label ? labelId : undefined].filter(Boolean).join(' ') ||
undefined;
const describedBy = () =>
[props.ariaDescribedBy, props.description ? descriptionId : undefined]
.filter(Boolean)
.join(' ') || undefined;
return (
<div class={getToggleContainerClass(props.containerClass)}>
<TogglePrimitive
{...props}
size={size}
ariaLabelledBy={props.label ? labelId : props.ariaLabelledBy}
ariaDescribedBy={props.description ? descriptionId : props.ariaDescribedBy}
ariaLabelledBy={labelledBy()}
ariaDescribedBy={describedBy()}
/>
{(props.label || props.description) && (
<span class={getToggleLabelClass()}>
@@ -27,12 +27,14 @@ interface AlertCooldownSectionProps {
export function AlertCooldownSection(props: AlertCooldownSectionProps) {
const fieldIdPrefix = `alert-cooldown-${createUniqueId()}`;
const fieldIds = {
title: `${fieldIdPrefix}-title`,
minutes: `${fieldIdPrefix}-minutes`,
maxAlerts: `${fieldIdPrefix}-max-alerts`,
};
return (
<SettingsPanel
titleId={fieldIds.title}
title={ALERT_CONFIG_COOLDOWN_TITLE}
description={ALERT_CONFIG_COOLDOWN_DESCRIPTION}
action={
@@ -40,6 +42,7 @@ export function AlertCooldownSection(props: AlertCooldownSectionProps) {
checked={props.cooldown.enabled}
onChange={(event) => props.setCooldownEnabled(event.currentTarget.checked)}
containerClass="sm:self-start"
ariaLabelledBy={fieldIds.title}
label={
<span class="text-xs font-medium text-muted">
{getAlertConfigToggleStatusLabel(props.cooldown.enabled)}
@@ -30,9 +30,11 @@ interface AlertEscalationSectionProps {
export function AlertEscalationSection(props: AlertEscalationSectionProps) {
const fieldIdPrefix = `alert-escalation-${createUniqueId()}`;
const titleId = `${fieldIdPrefix}-title`;
return (
<SettingsPanel
titleId={titleId}
title={ALERT_CONFIG_ESCALATION_TITLE}
description={ALERT_CONFIG_ESCALATION_DESCRIPTION}
action={
@@ -40,6 +42,7 @@ export function AlertEscalationSection(props: AlertEscalationSectionProps) {
checked={props.escalation.enabled}
onChange={(event) => props.setEscalationEnabled(event.currentTarget.checked)}
containerClass="sm:self-start"
ariaLabelledBy={titleId}
label={
<span class="text-xs font-medium text-muted">
{getAlertConfigToggleStatusLabel(props.escalation.enabled)}
@@ -31,11 +31,13 @@ interface AlertGroupingSectionProps {
export function AlertGroupingSection(props: AlertGroupingSectionProps) {
const fieldIdPrefix = `alert-grouping-${createUniqueId()}`;
const fieldIds = {
title: `${fieldIdPrefix}-title`,
window: `${fieldIdPrefix}-window`,
};
return (
<SettingsPanel
titleId={fieldIds.title}
title={ALERT_CONFIG_GROUPING_TITLE}
description={ALERT_CONFIG_GROUPING_DESCRIPTION}
action={
@@ -43,6 +45,7 @@ export function AlertGroupingSection(props: AlertGroupingSectionProps) {
checked={props.grouping.enabled}
onChange={(event) => props.setGroupingEnabled(event.currentTarget.checked)}
containerClass="sm:self-start"
ariaLabelledBy={fieldIds.title}
label={
<span class="text-xs font-medium text-muted">
{getAlertConfigToggleStatusLabel(props.grouping.enabled)}
@@ -10,6 +10,7 @@ import {
ALERT_CONFIG_QUIET_HOURS_START_TIME_LABEL,
ALERT_CONFIG_QUIET_HOURS_TIMEZONE_LABEL,
ALERT_CONFIG_QUIET_HOURS_TITLE,
getAlertConfigToggleStatusLabel,
} from '@/utils/alertConfigPresentation';
import {
getAlertQuietDayButtonClass,
@@ -42,12 +43,14 @@ interface AlertQuietHoursSectionProps {
export function AlertQuietHoursSection(props: AlertQuietHoursSectionProps) {
const fieldIdPrefix = `alert-quiet-hours-${createUniqueId()}`;
const fieldIds = {
title: `${fieldIdPrefix}-title`,
start: `${fieldIdPrefix}-start`,
end: `${fieldIdPrefix}-end`,
};
return (
<SettingsPanel
titleId={fieldIds.title}
title={ALERT_CONFIG_QUIET_HOURS_TITLE}
description={ALERT_CONFIG_QUIET_HOURS_DESCRIPTION}
action={
@@ -55,9 +58,10 @@ export function AlertQuietHoursSection(props: AlertQuietHoursSectionProps) {
checked={props.quietHours.enabled}
onChange={(event) => props.setQuietHoursEnabled(event.currentTarget.checked)}
containerClass="sm:self-start"
ariaLabelledBy={fieldIds.title}
label={
<span class="text-xs font-medium text-muted">
{props.quietHours.enabled ? 'Enabled' : 'Disabled'}
{getAlertConfigToggleStatusLabel(props.quietHours.enabled)}
</span>
}
/>
@@ -1,3 +1,5 @@
import { createUniqueId } from 'solid-js';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import { Toggle } from '@/components/shared/Toggle';
import { formHelpText } from '@/components/shared/Form';
@@ -14,8 +16,11 @@ interface AlertRecoverySectionProps {
}
export function AlertRecoverySection(props: AlertRecoverySectionProps) {
const titleId = `alert-recovery-${createUniqueId()}-title`;
return (
<SettingsPanel
titleId={titleId}
title={ALERT_CONFIG_RECOVERY_TITLE}
description={ALERT_CONFIG_RECOVERY_DESCRIPTION}
action={
@@ -23,6 +28,7 @@ export function AlertRecoverySection(props: AlertRecoverySectionProps) {
checked={props.notifyOnResolve}
onChange={(event) => props.setNotifyOnResolveEnabled(event.currentTarget.checked)}
containerClass="sm:self-start"
ariaLabelledBy={titleId}
label={
<span class="text-xs font-medium text-muted">
{getAlertConfigToggleStatusLabel(props.notifyOnResolve)}
@@ -4,6 +4,7 @@ import { AlertCooldownSection } from './AlertCooldownSection';
import { AlertEscalationSection } from './AlertEscalationSection';
import { AlertGroupingSection } from './AlertGroupingSection';
import { AlertQuietHoursSection } from './AlertQuietHoursSection';
import { AlertRecoverySection } from './AlertRecoverySection';
import type { CooldownConfig, EscalationConfig, GroupingConfig, QuietHoursConfig } from './types';
function makeQuietHours(overrides: Partial<QuietHoursConfig> = {}): QuietHoursConfig {
@@ -55,6 +56,10 @@ describe('Alert schedule sections', () => {
/>
));
expect(screen.getByRole('button', { name: 'Quiet hours Enabled' })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(screen.getByLabelText('Start time')).toHaveAttribute('type', 'time');
expect(screen.getByLabelText('End time')).toHaveAttribute('type', 'time');
expect(screen.getByRole('combobox', { name: 'Timezone' })).toBeInTheDocument();
@@ -77,6 +82,10 @@ describe('Alert schedule sections', () => {
/>
));
expect(screen.getByRole('button', { name: 'Alert cooldown Enabled' })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(screen.getByRole('spinbutton', { name: 'Cooldown period' })).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: 'Max alerts / hour' })).toBeInTheDocument();
});
@@ -94,6 +103,10 @@ describe('Alert schedule sections', () => {
/>
));
expect(screen.getByRole('button', { name: 'Smart grouping Enabled' })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(screen.getByRole('slider', { name: 'Grouping window' })).toHaveAttribute(
'aria-valuetext',
'10 minutes',
@@ -119,8 +132,23 @@ describe('Alert schedule sections', () => {
/>
));
expect(screen.getByRole('button', { name: 'Alert escalation Enabled' })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(screen.getByRole('spinbutton', { name: 'After' })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'Notify' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove escalation level' })).toBeInTheDocument();
});
it('associates the visible recovery heading with its status toggle', () => {
render(() => (
<AlertRecoverySection notifyOnResolve={false} setNotifyOnResolveEnabled={vi.fn()} />
));
expect(screen.getByRole('button', { name: 'Recovery notifications Disabled' })).toHaveAttribute(
'aria-pressed',
'false',
);
});
});