fix(cmdk): keep the first result focused and the top fog stable across re-ranks (#6635)

This commit is contained in:
Justin Blumencranz
2026-08-12 11:47:35 -07:00
committed by GitHub
parent 0e65ca32ed
commit 7c2ba46cbe
4 changed files with 45 additions and 5 deletions
@@ -46,7 +46,7 @@ describe('CommandFadedList', () => {
vi.unstubAllGlobals()
})
it('fades the palette with the short single mask and the shared search surface', () => {
it('fades the palette with the short pixel-anchored mask and the shared search surface', () => {
act(() => {
root.render(
<Command>
@@ -58,7 +58,7 @@ describe('CommandFadedList', () => {
const list = container.querySelector('[cmdk-list]')
const search = container.querySelector('[cmdk-input]')?.parentElement
expect(list?.className).toContain('transparent_8%,black_13%,black_97%')
expect(list?.className).toContain('transparent_36px,black_58px,black_calc(100%_-_13px)')
expect(list?.className).not.toContain('scrollbar-track')
expect(search?.className).toContain('var(--bg)')
})
@@ -40,14 +40,19 @@ const SEARCH_SURFACE_CLASSNAME = {
/**
* The palette hides its scrollbar (`scrollbar-none` at the call site), so it
* fades with one plain mask; its band is kept short — fully masked only under
* the floating input (0–8%), legible by 13%, and a brief 97–100% exit — so
* rows spend less time in the fog than on the canvas surface.
* the floating input (0–36px), legible by 58px, and a brief 13px exit — so
* rows spend less time in the fog than on the canvas surface. The palette's
* stops are anchored in pixels (the 448px max-height look frozen) because the
* list shrinks to its content: percentage stops would move the fog on every
* result-count change, a shimmer the dark selected first row makes obvious.
* The canvas list fills a fixed-height card, so its percentage stops never
* move.
*/
const LIST_FADE_CLASSNAME = {
canvas:
'[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)]',
palette:
'[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_13%,black_97%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_13%,black_97%,transparent_100%)]',
'[-webkit-mask-image:linear-gradient(to_bottom,transparent_0px,transparent_36px,black_58px,black_calc(100%_-_13px),transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0px,transparent_36px,black_58px,black_calc(100%_-_13px),transparent_100%)]',
} as const
/** Borderless search field layered over a fading command-result list. */
@@ -581,6 +581,28 @@ describe('SearchModal', () => {
expect(rows()[1]?.getAttribute('aria-selected')).toBe('false')
})
it('re-anchors selection to the first row after the re-ranked results commit', async () => {
const workflows = [
{ id: 'workflow-1', name: 'Funnel', href: '/workspace/workspace-1/w/workflow-1' },
{ id: 'workflow-2', name: 'Funnel two', href: '/workspace/workspace-1/w/workflow-2' },
{ id: 'workflow-3', name: 'Function alpha', href: '/workspace/workspace-1/w/workflow-3' },
]
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} workflows={workflows} />)
})
const rows = () => Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]'))
await enterSearchQuery('fun')
expect(rows()[0]?.textContent).toContain('Funnel')
expect(rows()[0]?.getAttribute('aria-selected')).toBe('true')
await enterSearchQuery('func')
expect(rows()).toHaveLength(1)
expect(rows()[0]?.textContent).toContain('Function alpha')
expect(rows()[0]?.getAttribute('aria-selected')).toBe('true')
})
it('unmounts while closed and reopens with a blank query', async () => {
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} />)
@@ -634,6 +634,19 @@ function SearchModalContent({
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
/**
* cmdk re-anchors selection on input change against the rows the DOM still
* shows, but ranking runs against the deferred query, so those rows are one
* keystroke stale. When the re-ranked list lands, the stale pick either
* lingers mid-list (it still matches, demoted) or dangles on an unmounted
* row (cmdk only self-heals when the selected row is the last one removed),
* leaving the first visible row unfocused. Re-anchor once the list the
* ranking agrees with has committed.
*/
useEffect(() => {
inputRef.current?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }))
}, [deferredSearch])
const handleBlockSelect = useCallback(
(block: SearchBlockItem, type: 'block' | 'trigger' | 'tool') => {
const enableTriggerMode =