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:
parent
2ff517fb74
commit
92eed1ec9b
8 changed files with 40 additions and 9 deletions
|
|
@ -224,7 +224,11 @@ function Field({
|
|||
|
||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||
{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
|
||||
type={item.secret ? "password" : isNum ? "number" : "text"}
|
||||
|
|
@ -234,6 +238,7 @@ function Field({
|
|||
min={isNum && item.min != null ? item.min : undefined}
|
||||
max={isNum && item.max != null ? item.max : undefined}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -579,6 +579,7 @@ export default function Feed({
|
|||
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"
|
||||
>
|
||||
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
|
||||
|
|
|
|||
|
|
@ -258,7 +258,6 @@ export default function NavSidebar({
|
|||
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"
|
||||
title={t("header.feed")}
|
||||
aria-label={t("header.feed")}
|
||||
>
|
||||
Sift<span className="text-accent">lode</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ function Row({
|
|||
<span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span>
|
||||
<button
|
||||
onClick={onPlay}
|
||||
aria-label={`${t("card.play")} — ${video.title}`}
|
||||
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
|
||||
>
|
||||
{video.thumbnail_url && (
|
||||
|
|
@ -540,6 +541,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
|||
onChange={(e) =>
|
||||
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"
|
||||
>
|
||||
<option value="custom">{t("playlists.railSortCustom")}</option>
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ function MaintenanceSettings({
|
|||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -138,6 +138,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||
<Tooltip key={s.id} hint={s.name}>
|
||||
<button
|
||||
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 ${
|
||||
theme.scheme === s.id ? "border-fg" : "border-transparent"
|
||||
}`}
|
||||
|
|
@ -151,24 +153,29 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||
<Section title={t("settings.appearance.display")}>
|
||||
<SettingRow label={t("settings.appearance.darkMode")}>
|
||||
<Switch
|
||||
label={t("settings.appearance.darkMode")}
|
||||
checked={theme.mode === "dark"}
|
||||
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<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
|
||||
label={t("settings.appearance.performanceMode")}
|
||||
hint={t("settings.appearance.performanceModeHint")}
|
||||
>
|
||||
<Switch checked={perf} onChange={setPerf} />
|
||||
<Switch label={t("settings.appearance.performanceMode")} checked={perf} onChange={setPerf} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.appearance.showHints")}
|
||||
hint={t("settings.appearance.showHintsHint")}
|
||||
>
|
||||
<Switch checked={hints} onChange={setHints} />
|
||||
<Switch label={t("settings.appearance.showHints")} checked={hints} onChange={setHints} />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
|
|
@ -180,6 +187,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||
step={0.02}
|
||||
value={theme.fontScale}
|
||||
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
|
||||
aria-label={t("settings.appearance.textSize")}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
<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")}
|
||||
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
|
||||
label={t("settings.notifications.autoDismiss")}
|
||||
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>
|
||||
{autoOn && (
|
||||
<div className="py-1.5 text-sm">
|
||||
|
|
@ -223,6 +239,7 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
|
|||
step={1000}
|
||||
value={cfg.durationMs}
|
||||
onChange={(e) => update({ durationMs: Number(e.target.value) })}
|
||||
aria-label={t("settings.notifications.dismissAfter")}
|
||||
className="w-full accent-accent mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -733,7 +733,7 @@ function Toggle({
|
|||
return (
|
||||
<label className="flex items-center justify-between py-1 cursor-pointer text-sm">
|
||||
<span>{label}</span>
|
||||
<Switch checked={checked} onChange={onChange} />
|
||||
<Switch label={label} checked={checked} onChange={onChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,23 @@ import Tooltip from "../Tooltip";
|
|||
// 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.
|
||||
|
||||
/** 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({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue