* 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:
parent
1205457d53
commit
b8a899edb1
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue