a11y: SettingTile labels its switch/select with the tile title (#185)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -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<HTMLDivElement>(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<HTMLElement>('[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 (
|
||||
<Box alignItems="Center" gap="300">
|
||||
{before && <Box shrink="No">{before}</Box>}
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
{title && (
|
||||
<Text className={BreakWord} size="T300">
|
||||
<Text id={titleId} className={BreakWord} size="T300">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
@@ -26,7 +49,11 @@ export function SettingTile({ title, description, before, after, children }: Set
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
{after && <Box shrink="No">{after}</Box>}
|
||||
{after && (
|
||||
<Box shrink="No" ref={afterRef}>
|
||||
{after}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user