Skip to content

Commit 7c2ba46

Browse files
authored
fix(cmdk): keep the first result focused and the top fog stable across re-ranks (#6635)
1 parent 0e65ca3 commit 7c2ba46

4 files changed

Lines changed: 45 additions & 5 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ describe('CommandFadedList', () => {
4646
vi.unstubAllGlobals()
4747
})
4848

49-
it('fades the palette with the short single mask and the shared search surface', () => {
49+
it('fades the palette with the short pixel-anchored mask and the shared search surface', () => {
5050
act(() => {
5151
root.render(
5252
<Command>
@@ -58,7 +58,7 @@ describe('CommandFadedList', () => {
5858

5959
const list = container.querySelector('[cmdk-list]')
6060
const search = container.querySelector('[cmdk-input]')?.parentElement
61-
expect(list?.className).toContain('transparent_8%,black_13%,black_97%')
61+
expect(list?.className).toContain('transparent_36px,black_58px,black_calc(100%_-_13px)')
6262
expect(list?.className).not.toContain('scrollbar-track')
6363
expect(search?.className).toContain('var(--bg)')
6464
})

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,19 @@ const SEARCH_SURFACE_CLASSNAME = {
4040
/**
4141
* The palette hides its scrollbar (`scrollbar-none` at the call site), so it
4242
* fades with one plain mask; its band is kept short — fully masked only under
43-
* the floating input (0–8%), legible by 13%, and a brief 97–100% exit — so
44-
* rows spend less time in the fog than on the canvas surface.
43+
* the floating input (0–36px), legible by 58px, and a brief 13px exit — so
44+
* rows spend less time in the fog than on the canvas surface. The palette's
45+
* stops are anchored in pixels (the 448px max-height look frozen) because the
46+
* list shrinks to its content: percentage stops would move the fog on every
47+
* result-count change, a shimmer the dark selected first row makes obvious.
48+
* The canvas list fills a fixed-height card, so its percentage stops never
49+
* move.
4550
*/
4651
const LIST_FADE_CLASSNAME = {
4752
canvas:
4853
'[-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%)]',
4954
palette:
50-
'[-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%)]',
55+
'[-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%)]',
5156
} as const
5257

5358
/** Borderless search field layered over a fading command-result list. */

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,28 @@ describe('SearchModal', () => {
581581
expect(rows()[1]?.getAttribute('aria-selected')).toBe('false')
582582
})
583583

584+
it('re-anchors selection to the first row after the re-ranked results commit', async () => {
585+
const workflows = [
586+
{ id: 'workflow-1', name: 'Funnel', href: '/workspace/workspace-1/w/workflow-1' },
587+
{ id: 'workflow-2', name: 'Funnel two', href: '/workspace/workspace-1/w/workflow-2' },
588+
{ id: 'workflow-3', name: 'Function alpha', href: '/workspace/workspace-1/w/workflow-3' },
589+
]
590+
await act(async () => {
591+
root.render(<SearchModal open onOpenChange={vi.fn()} workflows={workflows} />)
592+
})
593+
594+
const rows = () => Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]'))
595+
596+
await enterSearchQuery('fun')
597+
expect(rows()[0]?.textContent).toContain('Funnel')
598+
expect(rows()[0]?.getAttribute('aria-selected')).toBe('true')
599+
600+
await enterSearchQuery('func')
601+
expect(rows()).toHaveLength(1)
602+
expect(rows()[0]?.textContent).toContain('Function alpha')
603+
expect(rows()[0]?.getAttribute('aria-selected')).toBe('true')
604+
})
605+
584606
it('unmounts while closed and reopens with a blank query', async () => {
585607
await act(async () => {
586608
root.render(<SearchModal open onOpenChange={vi.fn()} />)

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,19 @@ function SearchModalContent({
634634
return () => document.removeEventListener('keydown', handleKeyDown)
635635
}, [])
636636

637+
/**
638+
* cmdk re-anchors selection on input change against the rows the DOM still
639+
* shows, but ranking runs against the deferred query, so those rows are one
640+
* keystroke stale. When the re-ranked list lands, the stale pick either
641+
* lingers mid-list (it still matches, demoted) or dangles on an unmounted
642+
* row (cmdk only self-heals when the selected row is the last one removed),
643+
* leaving the first visible row unfocused. Re-anchor once the list the
644+
* ranking agrees with has committed.
645+
*/
646+
useEffect(() => {
647+
inputRef.current?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }))
648+
}, [deferredSearch])
649+
637650
const handleBlockSelect = useCallback(
638651
(block: SearchBlockItem, type: 'block' | 'trigger' | 'tool') => {
639652
const enableTriggerMode =

0 commit comments

Comments
 (0)