Files
AeroFetch/src/renderer/src/components/CommandPalette.tsx
T
debont80 4aec32e3c2 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>
2026-07-01 08:02:54 -04:00

189 lines
5.6 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import { useFocusStyles } from './ui/focusRing'
export interface PaletteAction {
id: string
label: string
/** short right-aligned hint, e.g. a shortcut or category */
hint?: string
run: () => void
}
const useStyles = makeStyles({
// A plain fixed overlay -- NOT a Fluent Dialog, since this app avoids Fluent's
// portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx).
backdrop: {
position: 'fixed',
inset: 0,
zIndex: 1000,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
paddingTop: '14vh',
backgroundColor: 'rgba(0,0,0,0.32)'
},
panel: {
width: 'min(560px, 92vw)',
display: 'flex',
flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
boxShadow: tokens.shadow28,
overflow: 'hidden'
},
input: {
appearance: 'none',
border: 'none',
padding: '14px 16px',
fontSize: tokens.fontSizeBase400,
fontFamily: tokens.fontFamilyBase,
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground1,
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`
},
list: {
maxHeight: '50vh',
overflowY: 'auto',
padding: '6px'
},
item: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
width: '100%',
padding: '9px 12px',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground1,
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
textAlign: 'left',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
itemActive: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
itemHint: {
flexShrink: 0,
color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase200
},
empty: {
padding: '14px 12px',
color: tokens.colorNeutralForeground3
}
})
/**
* A Ctrl/Cmd+K command palette (Phase N). Filter by typing, ↑/↓ to move, Enter to
* run, Esc or a backdrop click to dismiss. Actions are supplied by App.
*/
export function CommandPalette({
actions,
onClose
}: {
actions: PaletteAction[]
onClose: () => void
}): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
const [q, setQ] = useState('')
const [sel, setSel] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
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()
}, [])
useEffect(() => {
setSel(0)
}, [q])
useEffect(() => {
const row = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`)
row?.scrollIntoView({ block: 'nearest' })
}, [sel])
function onKeyDown(e: React.KeyboardEvent): void {
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSel((s) => Math.min(filtered.length - 1, s + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSel((s) => Math.max(0, s - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
const a = filtered[sel]
if (a) {
a.run()
onClose()
}
}
}
return (
<div className={styles.backdrop} onClick={onClose}>
<div
className={styles.panel}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Command palette"
>
<input
ref={inputRef}
className={mergeClasses(styles.input, focus.focusRing)}
value={q}
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} id={listId} role="listbox" aria-label="Commands">
{filtered.length === 0 ? (
<div className={styles.empty}>No matching commands</div>
) : (
filtered.map((a, i) => (
// 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}
id={optionId(a.id)}
role="option"
aria-selected={i === sel}
data-sel={i === sel ? 'true' : undefined}
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
onClick={() => {
a.run()
onClose()
}}
onMouseEnter={() => setSel(i)}
>
<span>{a.label}</span>
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
</div>
))
)}
</div>
</div>
</div>
)
}