From 2e8244dc67bb76af0a6f4c2c8aab9918fe96617c Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 18 Sep 2026 19:19:54 -0400 Subject: [PATCH] a11y: SettingTile labels its switch/select with the tile title (#185) Every settings toggle was an icon-only role=switch with no accessible name (36 axe 'button-name' criticals in User Settings, 3 in Room Settings). SettingTile now gives the title an id and points any unlabelled switch/input/select in its 'after' slot at it via aria-labelledby. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../components/setting-tile/SettingTile.tsx | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/app/components/setting-tile/SettingTile.tsx b/src/app/components/setting-tile/SettingTile.tsx index bcf63344a..f22fcd995 100644 --- a/src/app/components/setting-tile/SettingTile.tsx +++ b/src/app/components/setting-tile/SettingTile.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode, useEffect, useId, useRef } from 'react'; import { Box, Text } from 'folds'; import { BreakWord } from '../../styles/Text.css'; @@ -10,12 +10,35 @@ type SettingTileProps = { children?: ReactNode; }; export function SettingTile({ title, description, before, after, children }: SettingTileProps) { + const titleId = useId(); + const afterRef = useRef(null); + + // [Gitea #185] The control in `after` is almost always an icon-only Switch + // (or a bare select/input) whose only visible label is this tile's title. + // Point it at the title so screen readers announce "Show Notifications, + // switch, on" instead of "switch, on". Explicit labels are left alone. + useEffect(() => { + if (!title || !afterRef.current) return; + afterRef.current + .querySelectorAll('[role="switch"], input, select, textarea, [role="combobox"]') + .forEach((el) => { + if (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby')) return; + if ( + el.tagName === 'INPUT' && + el.id && + afterRef.current?.querySelector(`label[for="${el.id}"]`) + ) + return; + el.setAttribute('aria-labelledby', titleId); + }); + }); + return ( {before && {before}} {title && ( - + {title} )} @@ -26,7 +49,11 @@ export function SettingTile({ title, description, before, after, children }: Set )} {children} - {after && {after}} + {after && ( + + {after} + + )} ); }