a11y: semantic headings (UI33) + command-palette listbox semantics (UI27)
UI33 — render titles as real headings so Narrator heading-navigation works: the shared ScreenHeader title is now <Subtitle2 as="h1"> (one h1 per screen in one place — Downloads/History/Library/Settings/Terminal); DownloadsView "Queue (N)" and all 11 settings cards are <Subtitle2 as="h2"> sections; Onboarding "Welcome" is <Title2 as="h1">. A minimal h1–h6 margin reset in base.css zeroes the UA heading margin (Fluent's typography classes out-specify the element selector, so the visual ramp is unchanged) so layout matches the former spans. UI27 — give the command palette proper combobox/listbox semantics: the input is role="combobox" with aria-controls/aria-activedescendant/aria-autocomplete, the list is role="listbox", and each row is role="option" with aria-selected, so the active row is announced to screen readers via active-descendant. Focus stays on the input (standard combobox pattern), so the rows became non-focusable divs; onMouseEnter is kept to unify pointer + keyboard highlight on one sel state. Also corrected UI30's checkbox in the audit (already implemented via the shared SegmentedControl radiogroup; only the marker lagged). typecheck + 253 tests + eslint + prettier + production build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,24 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Reset the UA heading styles (UI33). Titles are rendered as real headings
|
||||
* (h1/h2) for Narrator heading-navigation, but they carry Fluent's typography
|
||||
* classes for their visual ramp; those classes out-specify this element
|
||||
* selector, so only the browser's default heading margin needs zeroing to keep
|
||||
* the layout identical to the former styled-span titles.
|
||||
*/
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
@@ -98,6 +98,9 @@ export function CommandPalette({
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
|
||||
const listId = 'cmdpalette-list'
|
||||
const optionId = (id: string): string => `cmdpalette-option-${id}`
|
||||
const activeId = filtered[sel] ? optionId(filtered[sel].id) : undefined
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
@@ -106,8 +109,8 @@ export function CommandPalette({
|
||||
setSel(0)
|
||||
}, [q])
|
||||
useEffect(() => {
|
||||
const btn = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`)
|
||||
btn?.scrollIntoView({ block: 'nearest' })
|
||||
const row = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`)
|
||||
row?.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel])
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent): void {
|
||||
@@ -144,22 +147,29 @@ export function CommandPalette({
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Type a command…"
|
||||
role="combobox"
|
||||
aria-expanded={filtered.length > 0}
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={activeId}
|
||||
aria-autocomplete="list"
|
||||
aria-label="Command palette search"
|
||||
/>
|
||||
<div className={styles.list} ref={listRef}>
|
||||
<div className={styles.list} ref={listRef} id={listId} role="listbox" aria-label="Commands">
|
||||
{filtered.length === 0 ? (
|
||||
<div className={styles.empty}>No matching commands</div>
|
||||
) : (
|
||||
filtered.map((a, i) => (
|
||||
<button
|
||||
// A non-focusable option: focus stays on the combobox input and the
|
||||
// active row is announced via aria-activedescendant (UI27), the
|
||||
// standard combobox+listbox pattern. onMouseEnter keeps the pointer
|
||||
// and keyboard highlight unified on one `sel` state.
|
||||
<div
|
||||
key={a.id}
|
||||
type="button"
|
||||
id={optionId(a.id)}
|
||||
role="option"
|
||||
aria-selected={i === sel}
|
||||
data-sel={i === sel ? 'true' : undefined}
|
||||
className={mergeClasses(
|
||||
styles.item,
|
||||
i === sel && styles.itemActive,
|
||||
focus.focusRing
|
||||
)}
|
||||
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
|
||||
onClick={() => {
|
||||
a.run()
|
||||
onClose()
|
||||
@@ -168,7 +178,7 @@ export function CommandPalette({
|
||||
>
|
||||
<span>{a.label}</span>
|
||||
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function DownloadsView(): React.JSX.Element {
|
||||
)}
|
||||
|
||||
<div className={styles.queueHeader}>
|
||||
<Subtitle2>Queue ({queueCount})</Subtitle2>
|
||||
<Subtitle2 as="h2">Queue ({queueCount})</Subtitle2>
|
||||
<div className={styles.headerActions}>
|
||||
{summary.failed > 0 && (
|
||||
<Button
|
||||
|
||||
@@ -142,7 +142,7 @@ export function Onboarding(): React.JSX.Element {
|
||||
<div className={styles.mark}>
|
||||
<ArrowDownloadFilled />
|
||||
</div>
|
||||
<Title2>Welcome to AeroFetch</Title2>
|
||||
<Title2 as="h1">Welcome to AeroFetch</Title2>
|
||||
</div>
|
||||
|
||||
<Body1>
|
||||
|
||||
@@ -94,7 +94,7 @@ export function AboutCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<InfoRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>About</Subtitle2>
|
||||
<Subtitle2 as="h2">About</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
|
||||
|
||||
@@ -24,7 +24,7 @@ export function AppearanceCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<PaintBucketRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Appearance</Subtitle2>
|
||||
<Subtitle2 as="h2">Appearance</Subtitle2>
|
||||
</div>
|
||||
|
||||
<Field label="Theme">
|
||||
|
||||
@@ -51,7 +51,7 @@ export function BackupCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<DocumentArrowDownRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Backup & restore</Subtitle2>
|
||||
<Subtitle2 as="h2">Backup & restore</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Save your settings and custom-command templates to a JSON file, or restore them on another
|
||||
|
||||
@@ -68,7 +68,7 @@ export function CookiesCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<CookiesRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Cookies</Subtitle2>
|
||||
<Subtitle2 as="h2">Cookies</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Some sites only serve full quality, age-restricted, or members-only video to a logged-in
|
||||
|
||||
@@ -17,7 +17,7 @@ export function CustomCommandsCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<CodeRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Custom commands</Subtitle2>
|
||||
<Subtitle2 as="h2">Custom commands</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
|
||||
|
||||
@@ -28,7 +28,7 @@ export function DiagnosticsCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<BugRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Diagnostics</Subtitle2>
|
||||
<Subtitle2 as="h2">Diagnostics</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Failed downloads are logged here even after you clear the queue, so you can copy the details
|
||||
|
||||
@@ -55,7 +55,7 @@ export function DownloadsCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<ArrowDownloadRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Downloads</Subtitle2>
|
||||
<Subtitle2 as="h2">Downloads</Subtitle2>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
|
||||
@@ -14,7 +14,7 @@ export function FilenamesCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<DocumentRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Filenames</Subtitle2>
|
||||
<Subtitle2 as="h2">Filenames</Subtitle2>
|
||||
</div>
|
||||
<Field
|
||||
label="Filename template"
|
||||
|
||||
@@ -20,7 +20,7 @@ export function NetworkCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<GlobeRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Network</Subtitle2>
|
||||
<Subtitle2 as="h2">Network</Subtitle2>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
|
||||
@@ -14,7 +14,7 @@ export function PostProcessingCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<OptionsRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Format & post-processing</Subtitle2>
|
||||
<Subtitle2 as="h2">Format & post-processing</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Defaults applied to every new download (yt-dlp and ffmpeg do the work).
|
||||
|
||||
@@ -78,7 +78,7 @@ export function SoftwareUpdateCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<ArrowSyncRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Software update</Subtitle2>
|
||||
<Subtitle2 as="h2">Software update</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
{appVersion
|
||||
|
||||
@@ -65,7 +65,7 @@ export function ScreenHeader({
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleBlock}>
|
||||
<Subtitle2>{title}</Subtitle2>
|
||||
<Subtitle2 as="h1">{title}</Subtitle2>
|
||||
{description && <Caption1 className={styles.description}>{description}</Caption1>}
|
||||
</div>
|
||||
{actions && <div className={styles.actions}>{actions}</div>}
|
||||
|
||||
Reference in New Issue
Block a user