fix(CommandPalette): only scroll to highlighted item when focused#6579
Conversation
📝 WalkthroughWalkthroughThis PR modifies CommandPalette to make result highlighting focus-aware. When filtered results update, the component checks whether its root contains document.activeElement; if focused it calls highlightFirstItem() (which may scroll), otherwise it calls highlightSelected(undefined, false) to update selection without scrolling. A test was added that stubs scrollIntoView to verify no scroll occurs when results arrive unfocused and that scrolling happens after focusing. Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
test/components/CommandPalette.spec.ts (1)
171-205: ⚡ Quick winWrap mock restoration in try-finally to prevent test pollution.
If an assertion fails before line 204, the original
scrollIntoViewimplementation won't be restored, potentially affecting subsequent tests.🛡️ Recommended fix to ensure cleanup
it('re-highlights without scrolling the page when results arrive without focus', async () => { const scrollSpy = vi.fn() const original = window.HTMLElement.prototype.scrollIntoView window.HTMLElement.prototype.scrollIntoView = scrollSpy + try { + const wrapper = await mountSuspended(CommandPalette, { + props: { groups: [{ id: 'users', items: [] }], autofocus: false } as any, + attachTo: document.body + }) + await new Promise(resolve => setTimeout(resolve, 60)) - const wrapper = await mountSuspended(CommandPalette, { - props: { groups: [{ id: 'users', items: [] }], autofocus: false } as any, - attachTo: document.body - }) - await new Promise(resolve => setTimeout(resolve, 60)) - - // Items arrive asynchronously while the palette is not focused - await wrapper.setProps({ - groups: [{ id: 'users', items: Array.from({ length: 10 }, (_, i) => ({ label: `User ${i}` })) }] - } as any) - await new Promise(resolve => setTimeout(resolve, 60)) - - // First item is highlighted for keyboard entry, but the page was not scrolled to it. - expect(wrapper.find('[data-highlighted]').exists()).toBe(true) - expect(scrollSpy).not.toHaveBeenCalled() - - // Once the palette has focus, a results change scrolls the highlight into view. - ;(wrapper.find('input').element as HTMLInputElement).focus() - await wrapper.setProps({ - groups: [{ id: 'users', items: Array.from({ length: 8 }, (_, i) => ({ label: `Person ${i}` })) }] - } as any) - await new Promise(resolve => setTimeout(resolve, 60)) - - expect(scrollSpy).toHaveBeenCalled() - - window.HTMLElement.prototype.scrollIntoView = original + // Items arrive asynchronously while the palette is not focused + await wrapper.setProps({ + groups: [{ id: 'users', items: Array.from({ length: 10 }, (_, i) => ({ label: `User ${i}` })) }] + } as any) + await new Promise(resolve => setTimeout(resolve, 60)) + + // First item is highlighted for keyboard entry, but the page was not scrolled to it. + expect(wrapper.find('[data-highlighted]').exists()).toBe(true) + expect(scrollSpy).not.toHaveBeenCalled() + + // Once the palette has focus, a results change scrolls the highlight into view. + ;(wrapper.find('input').element as HTMLInputElement).focus() + await wrapper.setProps({ + groups: [{ id: 'users', items: Array.from({ length: 8 }, (_, i) => ({ label: `Person ${i}` })) }] + } as any) + await new Promise(resolve => setTimeout(resolve, 60)) + + expect(scrollSpy).toHaveBeenCalled() + } finally { + window.HTMLElement.prototype.scrollIntoView = original + } })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/components/CommandPalette.spec.ts` around lines 171 - 205, The test replaces window.HTMLElement.prototype.scrollIntoView with scrollSpy but only restores it at the end, so a failing assertion can leak the mock; wrap the test body that manipulates scrollIntoView (the original/scrollSpy assignment, the mountSuspended call, wrapper.setProps and the focus/assertions) in a try-finally and move window.HTMLElement.prototype.scrollIntoView = original into the finally block so the original implementation is always restored even if assertions fail; reference the existing scrollSpy variable, the original variable, and the wrapper/find('input') focus sequence when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/components/CommandPalette.vue`:
- Around line 448-461: The watch handler calls ListboxRoot.highlightSelected
with arguments that don't match Reka UI's zero-argument API; update the call in
the filteredGroups watcher to use root?.highlightSelected() (no params) instead
of root.highlightSelected(undefined, false), keeping the existing guard that
checks root?.$el?.contains(document.activeElement); ensure you still call
root.highlightFirstItem() when focused and fallback to the zero-arg
root?.highlightSelected() when not.
---
Nitpick comments:
In `@test/components/CommandPalette.spec.ts`:
- Around line 171-205: The test replaces
window.HTMLElement.prototype.scrollIntoView with scrollSpy but only restores it
at the end, so a failing assertion can leak the mock; wrap the test body that
manipulates scrollIntoView (the original/scrollSpy assignment, the
mountSuspended call, wrapper.setProps and the focus/assertions) in a try-finally
and move window.HTMLElement.prototype.scrollIntoView = original into the finally
block so the original implementation is always restored even if assertions fail;
reference the existing scrollSpy variable, the original variable, and the
wrapper/find('input') focus sequence when making the change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b0e0f2b4-24d1-4259-a8fc-31cc90ef2fa9
📒 Files selected for processing (2)
src/runtime/components/CommandPalette.vuetest/components/CommandPalette.spec.ts
Avoid the untyped `$el` access (TS2339) and always restore the scrollIntoView mock via try/finally in the regression test.
commit: |
🔗 Linked issue
❓ Type of change
📚 Description
Opening the CommandPalette docs page (or any page with a CommandPalette below the fold) scrolled the whole page to the component on load. The same thing happened in apps whenever a palette's items arrived asynchronously, for example with
useLazyFetch({ server: false }).The cause is the
watch(filteredGroups)added in efd7b8e to re-highlight the first item after debounced or async results render. It calls reka-ui'shighlightFirstItem(), which runschangeHighlight()and ends withscrollIntoView({ block: 'nearest' }). When the results land while the palette is still below the fold and was never interacted with, thatscrollIntoViewpulls th page down to it. It does not show on initial mount because static groups don't change after the first render, it only surfaces when items change later (async fetch).The fix keeps the re-highlight behaviour but only scrolls when the palette already has focus. When results change without focus it highlights the first item without scrolling, reusing the no scroll path that reka-ui already exposes via
highlightSelected(undefined, false). This mirrors the approach in #6538 for InputMenu and SelectMenu, where the equivalent re-highlight is gated on the menu being open (and therefore visible), adapted here for an always open inline component by gating on focus instead.Added a regression test covering both sides: results arriving without focus highlight the first item but do not scroll the page, and once the palette is focused a results change does scroll the highlight into view.
📝 Checklist