fix(a11y): accessible names for switches, swatches, selects and inputs

Lighthouse a11y across the authenticated module pages flagged: the clickable
Siftlode logo had aria-label='Feed' (accessible name didn't include its visible
text); the shared Switch, theme swatches, sort selects and several number/range
inputs had no accessible name/label. Give Switch role='switch'+aria-checked and
an optional label (passed at every call site), aria-label the swatches, selects
(feed/playlists sort) and the settings/config/scheduler inputs, and drop the
mismatched logo aria-label so its visible text is the name. All 11 module pages
now score 100 accessibility (settings 86->100, playlists 89->100, others 92-95->100).
This commit is contained in:
npeter83 2026-07-04 19:13:11 +02:00
parent 2ff517fb74
commit 92eed1ec9b
8 changed files with 40 additions and 9 deletions

View file

@ -224,7 +224,11 @@ function Field({
<div className="flex flex-col items-end gap-1.5 shrink-0"> <div className="flex flex-col items-end gap-1.5 shrink-0">
{isBool ? ( {isBool ? (
<Switch checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} /> <Switch
label={t(`config.fields.${item.key}.label`, item.key)}
checked={value === "true"}
onChange={(v) => onChange(v ? "true" : "false")}
/>
) : ( ) : (
<input <input
type={item.secret ? "password" : isNum ? "number" : "text"} type={item.secret ? "password" : isNum ? "number" : "text"}
@ -234,6 +238,7 @@ function Field({
min={isNum && item.min != null ? item.min : undefined} min={isNum && item.min != null ? item.min : undefined}
max={isNum && item.max != null ? item.max : undefined} max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")} placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
aria-label={t(`config.fields.${item.key}.label`, item.key)}
className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50" className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50"
/> />
)} )}

View file

@ -579,6 +579,7 @@ export default function Feed({
seed: key === "shuffle" ? rollSeed() : undefined, seed: key === "shuffle" ? rollSeed() : undefined,
}); });
}} }}
aria-label={t("feed.sortLabel")}
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent" className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
> >
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */} {/* Relevance only ranks meaningfully when there's a search term — offer it then. */}

View file

@ -258,7 +258,6 @@ export default function NavSidebar({
onClick={() => setPage("feed")} onClick={() => setPage("feed")}
className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition" className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition"
title={t("header.feed")} title={t("header.feed")}
aria-label={t("header.feed")}
> >
Sift<span className="text-accent">lode</span> Sift<span className="text-accent">lode</span>
</button> </button>

View file

@ -140,6 +140,7 @@ function Row({
<span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span> <span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span>
<button <button
onClick={onPlay} onClick={onPlay}
aria-label={`${t("card.play")}${video.title}`}
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group" className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
> >
{video.thumbnail_url && ( {video.thumbnail_url && (
@ -540,6 +541,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
onChange={(e) => onChange={(e) =>
setPlSort({ ...plSort, key: e.target.value as PlSort["key"] }) setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })
} }
aria-label={t("playlists.sortLabel")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent" className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent"
> >
<option value="custom">{t("playlists.railSortCustom")}</option> <option value="custom">{t("playlists.railSortCustom")}</option>

View file

@ -259,6 +259,7 @@ function MaintenanceSettings({
value={val} value={val}
onChange={(e) => setVal(e.target.value)} onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && save()} onKeyDown={(e) => e.key === "Enter" && save()}
aria-label={t("scheduler.maintenance.batchLabel")}
className="w-28 bg-card border border-border rounded px-2 py-1 text-sm tabular-nums outline-none focus:border-accent" className="w-28 bg-card border border-border rounded px-2 py-1 text-sm tabular-nums outline-none focus:border-accent"
/> />
<button <button

View file

@ -138,6 +138,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
<Tooltip key={s.id} hint={s.name}> <Tooltip key={s.id} hint={s.name}>
<button <button
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })} onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
aria-label={s.name}
aria-pressed={theme.scheme === s.id}
className={`h-9 w-full rounded-lg border-2 transition ${ className={`h-9 w-full rounded-lg border-2 transition ${
theme.scheme === s.id ? "border-fg" : "border-transparent" theme.scheme === s.id ? "border-fg" : "border-transparent"
}`} }`}
@ -151,24 +153,29 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
<Section title={t("settings.appearance.display")}> <Section title={t("settings.appearance.display")}>
<SettingRow label={t("settings.appearance.darkMode")}> <SettingRow label={t("settings.appearance.darkMode")}>
<Switch <Switch
label={t("settings.appearance.darkMode")}
checked={theme.mode === "dark"} checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })} onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/> />
</SettingRow> </SettingRow>
<SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}> <SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} /> <Switch
label={t("settings.appearance.listView")}
checked={view === "list"}
onChange={(v) => setView(v ? "list" : "grid")}
/>
</SettingRow> </SettingRow>
<SettingRow <SettingRow
label={t("settings.appearance.performanceMode")} label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")} hint={t("settings.appearance.performanceModeHint")}
> >
<Switch checked={perf} onChange={setPerf} /> <Switch label={t("settings.appearance.performanceMode")} checked={perf} onChange={setPerf} />
</SettingRow> </SettingRow>
<SettingRow <SettingRow
label={t("settings.appearance.showHints")} label={t("settings.appearance.showHints")}
hint={t("settings.appearance.showHintsHint")} hint={t("settings.appearance.showHintsHint")}
> >
<Switch checked={hints} onChange={setHints} /> <Switch label={t("settings.appearance.showHints")} checked={hints} onChange={setHints} />
</SettingRow> </SettingRow>
</Section> </Section>
@ -180,6 +187,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
step={0.02} step={0.02}
value={theme.fontScale} value={theme.fontScale}
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })} onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
aria-label={t("settings.appearance.textSize")}
className="w-full accent-accent" className="w-full accent-accent"
/> />
<div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div> <div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div>
@ -202,13 +210,21 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
label={t("settings.notifications.sound")} label={t("settings.notifications.sound")}
hint={t("settings.notifications.soundHint")} hint={t("settings.notifications.soundHint")}
> >
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} /> <Switch
label={t("settings.notifications.sound")}
checked={cfg.sound}
onChange={(v) => update({ sound: v })}
/>
</SettingRow> </SettingRow>
<SettingRow <SettingRow
label={t("settings.notifications.autoDismiss")} label={t("settings.notifications.autoDismiss")}
hint={t("settings.notifications.autoDismissHint")} hint={t("settings.notifications.autoDismissHint")}
> >
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} /> <Switch
label={t("settings.notifications.autoDismiss")}
checked={autoOn}
onChange={(v) => update({ durationMs: v ? 6000 : 0 })}
/>
</SettingRow> </SettingRow>
{autoOn && ( {autoOn && (
<div className="py-1.5 text-sm"> <div className="py-1.5 text-sm">
@ -223,6 +239,7 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
step={1000} step={1000}
value={cfg.durationMs} value={cfg.durationMs}
onChange={(e) => update({ durationMs: Number(e.target.value) })} onChange={(e) => update({ durationMs: Number(e.target.value) })}
aria-label={t("settings.notifications.dismissAfter")}
className="w-full accent-accent mt-1" className="w-full accent-accent mt-1"
/> />
</div> </div>

View file

@ -733,7 +733,7 @@ function Toggle({
return ( return (
<label className="flex items-center justify-between py-1 cursor-pointer text-sm"> <label className="flex items-center justify-between py-1 cursor-pointer text-sm">
<span>{label}</span> <span>{label}</span>
<Switch checked={checked} onChange={onChange} /> <Switch label={label} checked={checked} onChange={onChange} />
</label> </label>
); );
} }

View file

@ -4,17 +4,23 @@ import Tooltip from "../Tooltip";
// Shared form/settings primitives, factored out of the many panels that each re-declared // Shared form/settings primitives, factored out of the many panels that each re-declared
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged. // them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
/** Pill on/off switch — the bare control; compose it with your own label/row. */ /** Pill on/off switch the bare control; compose it with your own label/row. Pass `label` for
* the accessible name (the visible row text) since the control itself has no inner text. */
export function Switch({ export function Switch({
checked, checked,
onChange, onChange,
label,
}: { }: {
checked: boolean; checked: boolean;
onChange: (v: boolean) => void; onChange: (v: boolean) => void;
label?: string;
}) { }) {
return ( return (
<button <button
type="button" type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`} className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
> >