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:
+16
-2
@@ -952,9 +952,16 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
|
||||
|
||||
### Hover, focus, disabled, keyboard & accessibility
|
||||
|
||||
- [ ] **UI27 — Command palette uses JS hover, not CSS.** Items highlight via `onMouseEnter` setting
|
||||
- [x] **UI27 — Command palette uses JS hover, not CSS.** Items highlight via `onMouseEnter` setting
|
||||
`itemActive` (no `:hover`), unlike every other list, and expose no `aria-selected`/active-descendant,
|
||||
so the highlight is invisible to screen readers. **Standard:** CSS `:hover` + listbox/option semantics.
|
||||
*Fixed in [CommandPalette.tsx](src/renderer/src/components/CommandPalette.tsx): the input is now a
|
||||
`role="combobox"` with `aria-controls`/`aria-activedescendant`/`aria-autocomplete="list"`, the list is a
|
||||
`role="listbox"`, and each row is a `role="option"` with `aria-selected` — so the active row is announced
|
||||
to Narrator via active-descendant (the standard combobox pattern; focus stays on the input, so the rows
|
||||
became non-focusable `div`s rather than `button`s). `onMouseEnter` is kept deliberately: it unifies the
|
||||
pointer and keyboard highlight onto one `sel` state, which — now that `aria-activedescendant` tracks it —
|
||||
is more correct than a pure CSS `:hover` (which wouldn't move the announced selection).*
|
||||
- [x] **UI28 — Command palette input has no focus ring.** The search `<input>` sets `outline: 'none'`
|
||||
with no replacement. **Standard:** a visible focus ring (Fluent stroke token). *(a11y)*
|
||||
- [x] **UI29 — Fragmented focus treatment.** Fluent ring (Fluent controls) vs custom border (Select)
|
||||
@@ -968,9 +975,16 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
|
||||
so that control can't show a disabled state while Fluent controls can. **Standard:** add `disabled` + styling.
|
||||
- [ ] **UI32 — Native-control dark mode is uneven.** The DownloadBar `datetime-local` sets
|
||||
`colorScheme: 'light dark'` explicitly; the `Select` relies on the root `colorScheme`. **Standard:** set `colorScheme` consistently on both.
|
||||
- [ ] **UI33 — No semantic headings.** Titles render as `Subtitle2`/`Title2` (styled spans), so there's
|
||||
- [x] **UI33 — No semantic headings.** Titles render as `Subtitle2`/`Title2` (styled spans), so there's
|
||||
no h1–h6 hierarchy for screen-reader heading navigation (landmarks `<nav>`/`<main>` exist; headings
|
||||
don't). **Standard:** render titles as real headings (`as="h1"`/`"h2"` or `role="heading" aria-level`). *(a11y)*
|
||||
*Fixed by rendering the Fluent typography titles as real headings (the `as` prop keeps the visual ramp):
|
||||
the shared [ScreenHeader](src/renderer/src/components/ui/Screen.tsx) title is now `<Subtitle2 as="h1">`,
|
||||
giving every screen (Downloads, History, Library, Settings, Terminal) one `h1` in one place; the
|
||||
DownloadsView "Queue (N)" and all 11 settings cards are `<Subtitle2 as="h2">` sections beneath it; and the
|
||||
Onboarding "Welcome" title is `<Title2 as="h1">`. A minimal `h1–h6` margin reset in
|
||||
[base.css](src/renderer/src/assets/base.css) zeroes the UA heading margin (Fluent's typography classes
|
||||
out-specify the element selector, so the visual ramp is unchanged) so the layout matches the former spans.*
|
||||
|
||||
### Top cohesion-breakers (the "not one product" shortlist)
|
||||
|
||||
|
||||
@@ -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