fix: restore font family keyboard navigation in settings

This commit is contained in:
Doan Bac Tam 2026-06-18 22:36:49 +07:00 committed by GitHub
parent c40a025378
commit fd536d3e48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 153 additions and 19 deletions

View File

@ -280,6 +280,10 @@ function Settings(): React.JSX.Element {
const [fontSuggestions, setFontSuggestions] = useState<string[]>(
Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()]))
)
const terminalFontSuggestions = useMemo(
() => fontSuggestions.filter((font) => font !== DEFAULT_APP_FONT_FAMILY),
[fontSuggestions]
)
const [activeSectionId, setActiveSectionId] = useState('general')
const [mountedSectionIds, setMountedSectionIds] = useState<Set<string>>(
getInitialMountedSectionIds
@ -1342,9 +1346,7 @@ function Settings(): React.JSX.Element {
updateSettings={updateSettings}
applyTheme={applyTheme}
fontSuggestions={fontSuggestions}
terminalFontSuggestions={fontSuggestions.filter(
(font) => font !== DEFAULT_APP_FONT_FAMILY
)}
terminalFontSuggestions={terminalFontSuggestions}
systemPrefersDark={systemPrefersDark}
ghostty={ghostty}
warpThemes={warpThemes}

View File

@ -0,0 +1,110 @@
// @vitest-environment happy-dom
import { act, useState, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FontAutocomplete } from './SettingsFormControls'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, defaultValue: string) => defaultValue
}))
describe('FontAutocomplete', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
document.body.replaceChildren()
})
function getInput(): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>('input[role="combobox"]')
if (!input) {
throw new Error('Font autocomplete input not found')
}
return input
}
function getOptionLabels(): string[] {
return Array.from(container.querySelectorAll<HTMLElement>('[role="option"]')).map(
(option) => option.textContent?.trim() ?? ''
)
}
async function typeIntoInput(input: HTMLInputElement, value: string): Promise<void> {
await act(async () => {
// Why: React tracks controlled input values through the native setter, so
// direct assignment can be ignored by the synthetic input event.
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
setValue?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
it('arrow keys move through the full list when the input shows the committed font', async () => {
function Harness(): ReactNode {
const [value, setValue] = useState('Geist')
return (
<FontAutocomplete
value={value}
suggestions={['Arial', 'Courier New', 'Geist', 'JetBrains Mono', 'SF Mono']}
onChange={setValue}
/>
)
}
await act(async () => {
root.render(<Harness />)
})
const input = getInput()
await act(async () => {
input.focus()
})
await act(async () => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
})
expect(
container
.querySelector<HTMLButtonElement>('[role="option"][aria-selected="true"]')
?.textContent?.trim()
).toBe('JetBrains Mono')
})
it('keeps typed searches filtered even after the value updates', async () => {
function Harness(): ReactNode {
const [value, setValue] = useState('Geist')
return (
<FontAutocomplete
value={value}
suggestions={['Arial', 'Courier New', 'Geist', 'JetBrains Mono', 'SF Mono']}
onChange={setValue}
/>
)
}
await act(async () => {
root.render(<Harness />)
})
const input = getInput()
await act(async () => {
input.focus()
})
await typeIntoInput(input, 'Jet')
expect(getOptionLabels()).toEqual(['JetBrains Mono'])
})
})

View File

@ -578,6 +578,7 @@ export function FontAutocomplete({
const [prevValue, setPrevValue] = useState(value)
const [open, setOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [isFilteringQuery, setIsFilteringQuery] = useState(false)
const inputRef = useRef<HTMLInputElement | null>(null)
const rootRef = useRef<HTMLDivElement | null>(null)
const previewFontFamilyRef = useRef(onPreviewFontFamily)
@ -597,6 +598,9 @@ export function FontAutocomplete({
if (value !== prevValue) {
setPrevValue(value)
setQuery(value)
if (value !== query) {
setIsFilteringQuery(false)
}
}
useEffect(() => {
@ -607,6 +611,7 @@ export function FontAutocomplete({
const handlePointerDown = (event: MouseEvent): void => {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false)
setIsFilteringQuery(false)
}
}
@ -615,6 +620,7 @@ export function FontAutocomplete({
}, [open])
const normalizedQuery = query.trim().toLowerCase()
const normalizedValue = value.trim().toLowerCase()
const filteredSuggestions = useMemo(() => {
const startsWith = suggestions.filter((font) => font.toLowerCase().startsWith(normalizedQuery))
const includes = suggestions.filter(
@ -624,25 +630,31 @@ export function FontAutocomplete({
)
return normalizedQuery ? [...startsWith, ...includes] : suggestions
}, [suggestions, normalizedQuery])
// Why: a committed font fills the input, but typed searches can also mirror
// into `value`; only expand exact matches outside an active search session.
const visibleSuggestions =
!isFilteringQuery && normalizedQuery === normalizedValue && filteredSuggestions.length <= 1
? suggestions
: filteredSuggestions
// Why: sync the highlighted index during render rather than via useEffect so
// the correct item is highlighted on the very first paint after open/filter
// changes — useEffect would leave one render with the stale index visible.
const [prevFilteredSuggestions, setPrevFilteredSuggestions] = useState(filteredSuggestions)
const [prevVisibleSuggestions, setPrevVisibleSuggestions] = useState(visibleSuggestions)
const [prevOpen, setPrevOpen] = useState(open)
const [prevHighlightedValue, setPrevHighlightedValue] = useState(value)
if (
filteredSuggestions !== prevFilteredSuggestions ||
visibleSuggestions !== prevVisibleSuggestions ||
open !== prevOpen ||
value !== prevHighlightedValue
) {
setPrevFilteredSuggestions(filteredSuggestions)
setPrevVisibleSuggestions(visibleSuggestions)
setPrevOpen(open)
setPrevHighlightedValue(value)
if (!open || filteredSuggestions.length === 0) {
if (!open || visibleSuggestions.length === 0) {
setHighlightedIndex(-1)
} else {
const selectedIndex = filteredSuggestions.findIndex((font) => font === value)
const selectedIndex = visibleSuggestions.findIndex((font) => font === value)
setHighlightedIndex(Math.max(selectedIndex, 0))
}
}
@ -658,11 +670,12 @@ export function FontAutocomplete({
onPreviewFontFamily(null)
return
}
onPreviewFontFamily(filteredSuggestions[highlightedIndex] ?? null)
}, [filteredSuggestions, highlightedIndex, onPreviewFontFamily, open])
onPreviewFontFamily(visibleSuggestions[highlightedIndex] ?? null)
}, [visibleSuggestions, highlightedIndex, onPreviewFontFamily, open])
const commitValue = (nextValue: string): void => {
setQuery(nextValue)
setIsFilteringQuery(false)
onChange(nextValue)
setOpen(false)
}
@ -680,15 +693,20 @@ export function FontAutocomplete({
onChange={(e) => {
const next = e.target.value
setQuery(next)
setIsFilteringQuery(true)
onChange(next)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onFocus={() => {
setIsFilteringQuery(false)
setOpen(true)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
if (open) {
e.preventDefault()
setOpen(false)
setIsFilteringQuery(false)
}
return
}
@ -696,9 +714,9 @@ export function FontAutocomplete({
if (e.key === 'ArrowDown') {
e.preventDefault()
setOpen(true)
if (filteredSuggestions.length > 0) {
if (visibleSuggestions.length > 0) {
setHighlightedIndex((current) =>
current < 0 ? 0 : Math.min(current + 1, filteredSuggestions.length - 1)
current < 0 ? 0 : Math.min(current + 1, visibleSuggestions.length - 1)
)
}
return
@ -707,16 +725,16 @@ export function FontAutocomplete({
if (e.key === 'ArrowUp') {
e.preventDefault()
setOpen(true)
if (filteredSuggestions.length > 0) {
if (visibleSuggestions.length > 0) {
setHighlightedIndex((current) =>
current < 0 ? filteredSuggestions.length - 1 : Math.max(current - 1, 0)
current < 0 ? visibleSuggestions.length - 1 : Math.max(current - 1, 0)
)
}
return
}
if (e.key === 'Enter' && open && highlightedIndex >= 0) {
const highlightedFont = filteredSuggestions[highlightedIndex]
const highlightedFont = visibleSuggestions[highlightedIndex]
if (highlightedFont) {
e.preventDefault()
commitValue(highlightedFont)
@ -740,6 +758,7 @@ export function FontAutocomplete({
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setQuery('')
setIsFilteringQuery(false)
onChange('')
setOpen(true)
focusInput()
@ -760,6 +779,9 @@ export function FontAutocomplete({
onClick={() => {
const nextOpen = !open
setOpen(nextOpen)
if (!nextOpen) {
setIsFilteringQuery(false)
}
if (nextOpen) {
focusInput()
}
@ -778,10 +800,10 @@ export function FontAutocomplete({
{open ? (
<div className="absolute top-full z-20 mt-2 w-full overflow-hidden rounded-md border border-border/50 bg-popover shadow-md">
<ScrollArea className={filteredSuggestions.length > 8 ? 'h-64' : undefined}>
<ScrollArea className={visibleSuggestions.length > 8 ? 'h-64' : undefined}>
<div id={listboxId} role="listbox" className="p-1">
{filteredSuggestions.length > 0 ? (
filteredSuggestions.map((font, index) => (
{visibleSuggestions.length > 0 ? (
visibleSuggestions.map((font, index) => (
<button
key={font}
type="button"