fix: browser url preview with arrow navigation [#5113] (#5221)

* fix: browser url preview with arrow navigation

* Fix browser autocomplete preview cancel

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jingyue Wu 2026-06-12 17:44:25 -04:00 committed by GitHub
parent 1205457d53
commit b8a899edb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 272 additions and 21 deletions

View File

@ -0,0 +1,144 @@
// @vitest-environment happy-dom
import { act, type ReactNode, useRef, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrowserHistoryEntry } from '../../../../shared/types'
import BrowserAddressBar from './BrowserAddressBar'
const mocks = vi.hoisted(() => ({
browserUrlHistory: [] as BrowserHistoryEntry[],
browserDefaultSearchEngine: null as string | null,
browserKagiSessionLink: null as string | null
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: typeof mocks) => unknown) => selector(mocks)
}))
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/command', () => ({
Command: ({ children }: { children: ReactNode }) => <div>{children}</div>,
CommandGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
CommandItem: ({
children,
onSelect,
value
}: {
children: ReactNode
onSelect?: () => void
value?: string
}) => (
<button data-command-value={value} onClick={onSelect} type="button">
{children}
</button>
),
CommandList: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
function historyEntry(overrides: Partial<BrowserHistoryEntry>): BrowserHistoryEntry {
return {
url: 'http://localhost:3000/review-one',
normalizedUrl: 'http://localhost:3000/review-one',
title: 'Review one',
lastVisitedAt: 1_700_000_000_000,
visitCount: 4,
...overrides
}
}
function AddressBarHarness({
initialValue,
onNavigate,
onSubmit
}: {
initialValue: string
onNavigate: (url: string) => void
onSubmit: () => void
}): React.ReactElement {
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement | null>(null)
return (
<>
<BrowserAddressBar
value={value}
onChange={setValue}
onSubmit={onSubmit}
onNavigate={onNavigate}
inputRef={inputRef}
/>
<span data-current-address-value="true">{value}</span>
</>
)
}
describe('BrowserAddressBar autocomplete preview', () => {
let root: Root
let container: HTMLDivElement
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.useFakeTimers()
mocks.browserUrlHistory = [
historyEntry({
url: 'http://localhost:3000/review-one',
normalizedUrl: 'http://localhost:3000/review-one',
title: 'Review one'
})
]
mocks.browserDefaultSearchEngine = null
mocks.browserKagiSessionLink = null
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
document.body.replaceChildren()
vi.useRealTimers()
vi.clearAllMocks()
})
it('restores the typed query when a previewed suggestion is dismissed by blur', async () => {
const onNavigate = vi.fn()
const onSubmit = vi.fn()
await act(async () => {
root.render(
<AddressBarHarness initialValue="local" onNavigate={onNavigate} onSubmit={onSubmit} />
)
})
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')
expect(input).not.toBeNull()
await act(async () => {
input?.focus()
})
await act(async () => {
input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
})
expect(container.querySelector('[data-current-address-value="true"]')?.textContent).toBe(
'http://localhost:3000/review-one'
)
await act(async () => {
input?.blur()
vi.advanceTimersByTime(250)
})
expect(container.querySelector('[data-current-address-value="true"]')?.textContent).toBe(
'local'
)
expect(onNavigate).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
})
})

View File

@ -25,6 +25,10 @@ export default function BrowserAddressBar({
}: BrowserAddressBarProps): React.ReactElement {
const [open, setOpen] = useState(false)
const [selectedValueOverride, setSelectedValueOverride] = useState<string | null>(null)
// Why: while previewing a highlighted suggestion the input shows the full URL,
// but suggestions must keep matching the original typed query.
const [autocompleteQuery, setAutocompleteQuery] = useState(value)
const prePreviewValueRef = useRef<string | null>(null)
const browserUrlHistory = useAppStore((s) => s.browserUrlHistory)
const browserDefaultSearchEngine = useAppStore((s) => s.browserDefaultSearchEngine)
const browserKagiSessionLink = useAppStore((s) => s.browserKagiSessionLink)
@ -62,11 +66,76 @@ export default function BrowserAddressBar({
browserUrlHistory,
kagiSessionLink: browserKagiSessionLink,
searchEngine,
value
value: autocompleteQuery
}),
[browserUrlHistory, value, searchEngine, browserKagiSessionLink]
[browserUrlHistory, autocompleteQuery, searchEngine, browserKagiSessionLink]
)
useEffect(() => {
if (prePreviewValueRef.current === null) {
setAutocompleteQuery(value)
}
}, [value])
useEffect(() => {
if (open) {
return
}
prePreviewValueRef.current = null
setSelectedValueOverride(null)
}, [open])
const clearSuggestionPreview = useCallback((): void => {
prePreviewValueRef.current = null
setSelectedValueOverride(null)
}, [])
const previewSuggestion = useCallback(
(url: string): void => {
if (prePreviewValueRef.current === null) {
prePreviewValueRef.current = autocompleteQuery
}
setSelectedValueOverride(url)
onChange(url)
},
[autocompleteQuery, onChange]
)
const selectSuggestionAtIndex = useCallback(
(index: number): void => {
const suggestion = suggestions[index]
if (!suggestion) {
return
}
if (index === 0 && suggestion.isSearch) {
// Why: the search row mirrors what Enter already does with the typed
// query — keep the input on the typed text instead of the search URL.
prePreviewValueRef.current = null
setSelectedValueOverride(null)
onChange(autocompleteQuery)
return
}
previewSuggestion(suggestion.url)
},
[autocompleteQuery, onChange, previewSuggestion, suggestions]
)
const restoreTypedQuery = useCallback((): void => {
const typed = prePreviewValueRef.current
if (typed === null) {
return
}
prePreviewValueRef.current = null
setSelectedValueOverride(null)
setAutocompleteQuery(typed)
onChange(typed)
}, [onChange])
const cancelSuggestionPreview = useCallback((): void => {
restoreTypedQuery()
setOpen(false)
}, [restoreTypedQuery])
const selectedValue =
selectedValueOverride &&
suggestions.some((suggestion) => suggestion.url === selectedValueOverride)
@ -106,15 +175,16 @@ export default function BrowserAddressBar({
if (grace && inputRef.current && document.activeElement === inputRef.current) {
return
}
restoreTypedQuery()
setOpen(false)
}, 200)
}, [inputRef])
}, [inputRef, restoreTypedQuery])
const handleSelect = useCallback(
(url: string) => {
closingRef.current = true
setOpen(false)
setSelectedValueOverride(null)
clearSuggestionPreview()
onNavigate(url)
if (closingResetTimerRef.current !== null) {
window.clearTimeout(closingResetTimerRef.current)
@ -124,14 +194,24 @@ export default function BrowserAddressBar({
closingRef.current = false
}, 100)
},
[onNavigate]
[clearSuggestionPreview, onNavigate]
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
cancelSuggestionPreview()
return
}
if (event.key === 'Enter' && open) {
// Why: match Chrome — Enter always navigates to the current input text,
// not the highlighted dropdown row (click still picks a row directly).
event.preventDefault()
setOpen(false)
setSelectedValueOverride(null)
clearSuggestionPreview()
setAutocompleteQuery(value)
onSubmit()
return
}
@ -139,31 +219,47 @@ export default function BrowserAddressBar({
return
}
const isPreviewing = prePreviewValueRef.current !== null
if (event.key === 'ArrowDown') {
event.preventDefault()
const idx = suggestions.findIndex((s) => s.url === selectedValue)
const next = idx < suggestions.length - 1 ? idx + 1 : 0
setSelectedValueOverride(suggestions[next].url)
const startIdx = Math.max(idx, 0)
// Why: row 0 stays highlighted while the input still shows the typed
// query, so the first ArrowDown should advance to the next row instead
// of redundantly previewing the search row Enter already covers.
const next = startIdx < suggestions.length - 1 ? startIdx + 1 : 0
selectSuggestionAtIndex(next)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
const idx = suggestions.findIndex((s) => s.url === selectedValue)
const next = idx > 0 ? idx - 1 : suggestions.length - 1
setSelectedValueOverride(suggestions[next].url)
return
}
if (event.key === 'Enter' && selectedValue) {
const match = suggestions.find((s) => s.url === selectedValue)
if (match) {
event.preventDefault()
handleSelect(match.url)
const startIdx = Math.max(idx, 0)
if (!isPreviewing) {
const next = startIdx > 0 ? startIdx - 1 : suggestions.length - 1
selectSuggestionAtIndex(next)
return
}
if (startIdx <= 0) {
restoreTypedQuery()
return
}
selectSuggestionAtIndex(startIdx - 1)
}
},
[open, suggestions, selectedValue, handleSelect]
[
open,
suggestions,
selectedValue,
selectSuggestionAtIndex,
restoreTypedQuery,
cancelSuggestionPreview,
clearSuggestionPreview,
onSubmit,
value
]
)
// Why: close the dropdown only when the input has lost focus AND there are
@ -176,9 +272,10 @@ export default function BrowserAddressBar({
if (inputRef.current && document.activeElement === inputRef.current) {
return
}
restoreTypedQuery()
setOpen(false)
}
}, [open, suggestions.length, inputRef])
}, [open, suggestions.length, inputRef, restoreTypedQuery])
return (
<Popover
@ -191,6 +288,9 @@ export default function BrowserAddressBar({
if (!next && inputRef.current && document.activeElement === inputRef.current) {
return
}
if (!next) {
restoreTypedQuery()
}
setOpen(next)
}}
>
@ -201,6 +301,8 @@ export default function BrowserAddressBar({
onSubmit={(event) => {
event.preventDefault()
setOpen(false)
clearSuggestionPreview()
setAutocompleteQuery(value)
onSubmit()
}}
>
@ -217,10 +319,15 @@ export default function BrowserAddressBar({
autoCapitalize="none"
autoCorrect="off"
onChange={(event) => {
const nextValue = event.target.value
// Why: typing creates a new suggestion list, so keyboard selection
// should return to the derived top match instead of a stale row.
// Clearing preview state here also prevents stale hover/selection
// from repopulating the input after Cmd+A → Delete.
prePreviewValueRef.current = null
setSelectedValueOverride(null)
onChange(event.target.value)
setAutocompleteQuery(nextValue)
onChange(nextValue)
}}
role="combobox"
aria-expanded={open}