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
|
### 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,
|
`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.
|
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'`
|
- [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)*
|
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)
|
- [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.
|
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
|
- [ ] **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.
|
`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
|
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)*
|
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)
|
### Top cohesion-breakers (the "not one product" shortlist)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,24 @@
|
|||||||
outline-offset: 2px;
|
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,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export function CommandPalette({
|
|||||||
const listRef = useRef<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
|
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(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
@@ -106,8 +109,8 @@ export function CommandPalette({
|
|||||||
setSel(0)
|
setSel(0)
|
||||||
}, [q])
|
}, [q])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const btn = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`)
|
const row = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`)
|
||||||
btn?.scrollIntoView({ block: 'nearest' })
|
row?.scrollIntoView({ block: 'nearest' })
|
||||||
}, [sel])
|
}, [sel])
|
||||||
|
|
||||||
function onKeyDown(e: React.KeyboardEvent): void {
|
function onKeyDown(e: React.KeyboardEvent): void {
|
||||||
@@ -144,22 +147,29 @@ export function CommandPalette({
|
|||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder="Type a command…"
|
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"
|
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 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className={styles.empty}>No matching commands</div>
|
<div className={styles.empty}>No matching commands</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((a, i) => (
|
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}
|
key={a.id}
|
||||||
type="button"
|
id={optionId(a.id)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === sel}
|
||||||
data-sel={i === sel ? 'true' : undefined}
|
data-sel={i === sel ? 'true' : undefined}
|
||||||
className={mergeClasses(
|
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
|
||||||
styles.item,
|
|
||||||
i === sel && styles.itemActive,
|
|
||||||
focus.focusRing
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
a.run()
|
a.run()
|
||||||
onClose()
|
onClose()
|
||||||
@@ -168,7 +178,7 @@ export function CommandPalette({
|
|||||||
>
|
>
|
||||||
<span>{a.label}</span>
|
<span>{a.label}</span>
|
||||||
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
|
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
|
||||||
</button>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.queueHeader}>
|
<div className={styles.queueHeader}>
|
||||||
<Subtitle2>Queue ({queueCount})</Subtitle2>
|
<Subtitle2 as="h2">Queue ({queueCount})</Subtitle2>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
{summary.failed > 0 && (
|
{summary.failed > 0 && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function Onboarding(): React.JSX.Element {
|
|||||||
<div className={styles.mark}>
|
<div className={styles.mark}>
|
||||||
<ArrowDownloadFilled />
|
<ArrowDownloadFilled />
|
||||||
</div>
|
</div>
|
||||||
<Title2>Welcome to AeroFetch</Title2>
|
<Title2 as="h1">Welcome to AeroFetch</Title2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Body1>
|
<Body1>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function AboutCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<InfoRegular className={styles.sectionIcon} />
|
<InfoRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>About</Subtitle2>
|
<Subtitle2 as="h2">About</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
|
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}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<PaintBucketRegular className={styles.sectionIcon} />
|
<PaintBucketRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>Appearance</Subtitle2>
|
<Subtitle2 as="h2">Appearance</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Theme">
|
<Field label="Theme">
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function BackupCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<DocumentArrowDownRegular className={styles.sectionIcon} />
|
<DocumentArrowDownRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>Backup & restore</Subtitle2>
|
<Subtitle2 as="h2">Backup & restore</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Save your settings and custom-command templates to a JSON file, or restore them on another
|
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}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<CookiesRegular className={styles.sectionIcon} />
|
<CookiesRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>Cookies</Subtitle2>
|
<Subtitle2 as="h2">Cookies</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Some sites only serve full quality, age-restricted, or members-only video to a logged-in
|
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}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<CodeRegular className={styles.sectionIcon} />
|
<CodeRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>Custom commands</Subtitle2>
|
<Subtitle2 as="h2">Custom commands</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
|
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}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<BugRegular className={styles.sectionIcon} />
|
<BugRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>Diagnostics</Subtitle2>
|
<Subtitle2 as="h2">Diagnostics</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Failed downloads are logged here even after you clear the queue, so you can copy the details
|
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}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<ArrowDownloadRegular className={styles.sectionIcon} />
|
<ArrowDownloadRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>Downloads</Subtitle2>
|
<Subtitle2 as="h2">Downloads</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function FilenamesCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<DocumentRegular className={styles.sectionIcon} />
|
<DocumentRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>Filenames</Subtitle2>
|
<Subtitle2 as="h2">Filenames</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
label="Filename template"
|
label="Filename template"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function NetworkCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<GlobeRegular className={styles.sectionIcon} />
|
<GlobeRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>Network</Subtitle2>
|
<Subtitle2 as="h2">Network</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function PostProcessingCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<OptionsRegular className={styles.sectionIcon} />
|
<OptionsRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>Format & post-processing</Subtitle2>
|
<Subtitle2 as="h2">Format & post-processing</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Defaults applied to every new download (yt-dlp and ffmpeg do the work).
|
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}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<ArrowSyncRegular className={styles.sectionIcon} />
|
<ArrowSyncRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>Software update</Subtitle2>
|
<Subtitle2 as="h2">Software update</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
{appVersion
|
{appVersion
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function ScreenHeader({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.titleBlock}>
|
<div className={styles.titleBlock}>
|
||||||
<Subtitle2>{title}</Subtitle2>
|
<Subtitle2 as="h1">{title}</Subtitle2>
|
||||||
{description && <Caption1 className={styles.description}>{description}</Caption1>}
|
{description && <Caption1 className={styles.description}>{description}</Caption1>}
|
||||||
</div>
|
</div>
|
||||||
{actions && <div className={styles.actions}>{actions}</div>}
|
{actions && <div className={styles.actions}>{actions}</div>}
|
||||||
|
|||||||
Reference in New Issue
Block a user