4aec32e3c2
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>
189 lines
5.6 KiB
TypeScript
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>
|
|
)
|
|
}
|