mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(cmdk): keep the first result focused and the top fog stable across re-ranks (#6635)
This commit is contained in:
+2
-2
@@ -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)')
|
||||
})
|
||||
|
||||
+8
-3
@@ -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. */
|
||||
|
||||
+22
@@ -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()} />)
|
||||
|
||||
+13
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user