Skip to content

fix(CommandPalette): only scroll to highlighted item when focused#6579

Merged
benjamincanac merged 3 commits into
v4from
fix/command-palette-async-scroll
Jun 12, 2026
Merged

fix(CommandPalette): only scroll to highlighted item when focused#6579
benjamincanac merged 3 commits into
v4from
fix/command-palette-async-scroll

Conversation

@benjamincanac

Copy link
Copy Markdown
Member

🔗 Linked issue

❓ Type of change

  • 🐞 Bug fix (a non-breaking change that fixes an issue)

📚 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's highlightFirstItem(), which runs changeHighlight() and ends with scrollIntoView({ block: 'nearest' }). When the results land while the palette is still below the fold and was never interacted with, that scrollIntoView pulls 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

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

@github-actions github-actions Bot added the v4 #4488 label Jun 10, 2026
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This 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)
Check name Status Explanation
Description check ✅ Passed The description provides comprehensive context about the bug, root cause, solution approach, and includes references to related PRs and test coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title directly and clearly summarizes the main fix: conditionally scrolling to the highlighted item only when the CommandPalette is focused, which is the primary change addressing the page scroll bug.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/command-palette-async-scroll

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/components/CommandPalette.spec.ts (1)

171-205: ⚡ Quick win

Wrap mock restoration in try-finally to prevent test pollution.

If an assertion fails before line 204, the original scrollIntoView implementation 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

📥 Commits

Reviewing files that changed from the base of the PR and between 76bcd3b and d79944a.

📒 Files selected for processing (2)
  • src/runtime/components/CommandPalette.vue
  • test/components/CommandPalette.spec.ts

Comment thread src/runtime/components/CommandPalette.vue
Avoid the untyped `$el` access (TS2339) and always restore the scrollIntoView mock via try/finally in the regression test.
@pkg-pr-new

pkg-pr-new Bot commented Jun 10, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/@nuxt/ui@6579

commit: fdc8f37

@benjamincanac
benjamincanac marked this pull request as draft June 10, 2026 13:12
@benjamincanac
benjamincanac marked this pull request as ready for review June 11, 2026 17:09
@benjamincanac benjamincanac changed the title fix(CommandPalette): don't scroll the page when results load without focus fix(CommandPalette): only scroll to highlighted item when focused Jun 11, 2026
@benjamincanac
benjamincanac merged commit 02259a6 into v4 Jun 12, 2026
24 checks passed
@benjamincanac
benjamincanac deleted the fix/command-palette-async-scroll branch June 12, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v4 #4488

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant