Repository navigation
fix(agents): focus model catalog search when the picker opens #6477
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
146 changes: 146 additions & 0 deletions
146
apps/web/src/lib/core/component/AI/component/input/ModelCatalogPicker.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| /** | ||
| * @vitest-environment jsdom | ||
| */ | ||
|
|
||
| import { | ||
| cleanup, | ||
| fireEvent, | ||
| render, | ||
| screen, | ||
| waitFor, | ||
| } from '@solidjs/testing-library'; | ||
| import { createSignal, type JSX } from 'solid-js'; | ||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
| import { ModelCatalogPicker } from './ModelCatalogPicker'; | ||
| import type { CatalogModelOption } from './modelCatalog'; | ||
|
|
||
| vi.mock('@ui', () => { | ||
| const cn = (...args: unknown[]) => | ||
| args.flat(Infinity).filter(Boolean).join(' '); | ||
|
|
||
| let onOpenAutoFocus: ((event: Event) => void) | undefined; | ||
|
|
||
| const Dropdown: any = (props: { children?: JSX.Element }) => ( | ||
| <div>{props.children}</div> | ||
| ); | ||
| Dropdown.Trigger = (props: { | ||
| children?: JSX.Element; | ||
| 'aria-label'?: string; | ||
| }) => ( | ||
| <button | ||
| type="button" | ||
| aria-label={props['aria-label']} | ||
| onClick={() => { | ||
| const event = new Event('focusScope.autoFocusOnMount', { | ||
| cancelable: true, | ||
| }); | ||
| onOpenAutoFocus?.(event); | ||
| // Kobalte's selectable list still autofocuses the collection on a | ||
| // deferred timeout after onOpenAutoFocus is prevented. | ||
| setTimeout(() => { | ||
| document.getElementById('model-catalog-menu')?.focus(); | ||
| }, 0); | ||
| }} | ||
| > | ||
| {props.children} | ||
| </button> | ||
| ); | ||
| Dropdown.Content = (props: { | ||
| children?: JSX.Element; | ||
| onOpenAutoFocus?: (event: Event) => void; | ||
| }) => { | ||
| onOpenAutoFocus = props.onOpenAutoFocus; | ||
| return ( | ||
| <div id="model-catalog-menu" role="menu" tabIndex={-1}> | ||
| {props.children} | ||
| </div> | ||
| ); | ||
| }; | ||
| Dropdown.Group = (props: { children?: JSX.Element }) => ( | ||
| <div>{props.children}</div> | ||
| ); | ||
| Dropdown.GroupLabel = (props: { children?: JSX.Element }) => ( | ||
| <div>{props.children}</div> | ||
| ); | ||
| Dropdown.Item = (props: { | ||
| children?: JSX.Element; | ||
| onSelect?: () => void; | ||
| }) => ( | ||
| <div role="menuitem" onClick={() => props.onSelect?.()}> | ||
| {props.children} | ||
| </div> | ||
| ); | ||
| Dropdown.Separator = () => <hr />; | ||
| Dropdown.Sub = (props: { children?: JSX.Element }) => ( | ||
| <div>{props.children}</div> | ||
| ); | ||
| Dropdown.SubTrigger = (props: { children?: JSX.Element }) => ( | ||
| <div>{props.children}</div> | ||
| ); | ||
| Dropdown.SubContent = (props: { children?: JSX.Element }) => ( | ||
| <div>{props.children}</div> | ||
| ); | ||
|
|
||
| return { cn, Dropdown }; | ||
| }); | ||
|
|
||
| const OPTIONS: CatalogModelOption[] = [ | ||
| { id: 'auto', label: 'Auto', group: 'Auto' }, | ||
| { id: 'grok', label: 'Cursor Grok 4.6 High Fast' }, | ||
| { id: 'opus', label: 'Claude Opus 5 High' }, | ||
| { id: 'sonnet', label: 'Claude Sonnet 5 High' }, | ||
| { id: 'sol', label: 'GPT-5.6 Sol High' }, | ||
| { id: 'gemini', label: 'Gemini 3.8 Flash High' }, | ||
| ]; | ||
|
|
||
| afterEach(() => { | ||
| cleanup(); | ||
| }); | ||
|
|
||
| function mountPicker() { | ||
| render(() => { | ||
| const [value, setValue] = createSignal('auto'); | ||
| return ( | ||
| <ModelCatalogPicker | ||
| value={value()} | ||
| options={OPTIONS} | ||
| onSelect={setValue} | ||
| ariaLabel="Agent model" | ||
| /> | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| describe('ModelCatalogPicker search focus', () => { | ||
| it('puts caret in the search field when the menu opens', async () => { | ||
| mountPicker(); | ||
| fireEvent.click(screen.getByRole('button', { name: 'Agent model' })); | ||
| const search = screen.getByRole('textbox', { name: 'Search models' }); | ||
| await waitFor(() => { | ||
| expect(document.activeElement).toBe(search); | ||
| }); | ||
| }); | ||
|
|
||
| it('puts caret in the search field when the menu is opened again', async () => { | ||
| mountPicker(); | ||
| const trigger = screen.getByRole('button', { name: 'Agent model' }); | ||
| fireEvent.click(trigger); | ||
| await waitFor(() => { | ||
| expect(document.activeElement).toBe( | ||
| screen.getByRole('textbox', { name: 'Search models' }) | ||
| ); | ||
| }); | ||
|
|
||
| document.getElementById('model-catalog-menu')?.focus(); | ||
| expect(document.activeElement).not.toBe( | ||
| screen.getByRole('textbox', { name: 'Search models' }) | ||
| ); | ||
|
|
||
| fireEvent.click(trigger); | ||
| await waitFor(() => { | ||
| expect(document.activeElement).toBe( | ||
| screen.getByRole('textbox', { name: 'Search models' }) | ||
| ); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: macro-inc/macro
Length of output: 14470
🏁 Script executed:
Repository: macro-inc/macro
Length of output: 11687
🏁 Script executed:
Repository: macro-inc/macro
Length of output: 3141
🌐 Web query:
Kobalte 0.13.11 source dropdown-menu onCloseAutoFocus Presence forceMount deferAutoFocus💡 Result:
<search_synthesis>
In Kobalte version 0.13.11, the DropdownMenu component provides specific props for managing focus, mounting, and lifecycle events [1][2]. onCloseAutoFocus This event handler is called when focus moves to the trigger element after the menu closes [1]. You can prevent the default focus behavior (which returns focus to the trigger) by calling event.preventDefault within this handler [1]. forceMount This boolean prop allows you to force the mounting of the menu (including the portal and content) [1]. It is primarily used when you need more control over the component&
#39;s lifecycle, such as when managing animations with SolidJS animation libraries [1]. Presence and deferAutoFocus These properties are not standard public props for the DropdownMenu Content component in Kobalte 0.13.x [1]. While Presence is a common pattern in UI libraries (often related to handling exit animations), Kobalte typically manages this through internal primitives or the forceMount prop [1]. Similarly, deferAutoFocus is not a documented prop for the DropdownMenu component, although it may appear in low-level primitives used by Kobalte, such as those for handling focusable collections [3]. Summary Table Prop | Type | Description --- | --- | --- onCloseAutoFocus | (event: Event) => void | Called when focus moves back to the trigger after closing; can be prevented [1]. forceMount | boolean | Forces mounting of the menu; useful for animation control [1].</search_synthesis>
<source_evidence>
Citations:
🏁 Script executed:
Repository: macro-inc/macro
Length of output: 2680
🏁 Script executed:
Repository: macro-inc/macro
Length of output: 9759
🏁 Script executed:
Repository: macro-inc/macro
Length of output: 7825
🏁 Script executed:
Repository: macro-inc/macro
Length of output: 13896
🏁 Script executed:
Repository: macro-inc/macro
Length of output: 14151
Cancel deferred search focus when the menu closes. Kobalte runs close autofocus when its focus scope unmounts, after the presence-managed exit transition. The deferred animation-frame callback can run during that transition and temporarily focus the closing search input. Store both handles and cancel them from
onOpenChange(false)and component cleanup.🤖 Prompt for AI Agents