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} + + )} ); }