fix: restore font family keyboard navigation in settings
This commit is contained in:
parent
c40a025378
commit
fd536d3e48
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue