fix(app): open select dialogs at the top

Searchable pickers start at the first row with search focused. The current selection keeps its checkmark but isn't scrolled to or highlighted.
This commit is contained in:
Dax Raad
2026-10-19 10:32:07 -04:00
Unverified
parent 97168420ed
commit b673e33e32
7 changed files with 52 additions and 62 deletions
@@ -117,6 +117,12 @@ for (const surface of ["popover", "dialog"] as const) {
await view.panel.getByText("figma-desktop", { exact: true }).click()
}
// The dialog opens with its search focused and the first server highlighted.
if (surface === "dialog") {
await expect(view.panel.getByRole("searchbox", { name: "Search", exact: true })).toBeFocused()
await expect(view.panel.getByRole("option")).toHaveAttribute("aria-selected", "true")
}
await reset()
await expect(view.toggle).not.toBeChecked()
await expect(view.toggle).toBeEnabled()
@@ -245,9 +245,11 @@ test("model dialog toggles a favorite from the keyboard and leads with favorite
await expect(sections).toHaveText(["OpenCode"])
await search.press("Escape")
await expect(dialog).toBeHidden()
// The dialog opens on its first row with the search focused, not on the selected model further down.
await model.click()
await search.press("ArrowUp")
await expect(search).toBeFocused()
await expect(dialog.locator('[data-option-key="favorite:opencode:mobile-c"]')).toHaveAttribute("data-active", "")
await expect(dialog.locator('[data-option-key="recent:opencode:mobile-b"]')).not.toHaveAttribute("data-active")
await search.press("ControlOrMeta+f")
await expect(sections).toHaveText(["Recent", "OpenCode"])
await expect(dialog.locator('[data-option-key="opencode:mobile-c"]')).toHaveAttribute("data-active", "")
@@ -99,7 +99,7 @@ export const DialogSelectMcp: Component = () => {
)
}
// A toggle changes the server's status, which makes the list reset its highlight; keep the keyboard user's place.
// A toggle changes the server's status, which makes the list reset its highlight to the first row; keep the user's place.
let kept = ""
const list = useFilteredList({
@@ -112,7 +112,6 @@ export const DialogSelectMcp: Component = () => {
kept = x.name
select(x.name)
},
noInitialSelection: true,
})
createEffect(
@@ -208,10 +207,13 @@ export const DialogSelectMcp: Component = () => {
data-active={list.active() === item.name ? "" : undefined}
class="search-list-row"
onMouseMove={() => {
kept = ""
kept = item.name
list.setActive(item.name)
}}
onMouseLeave={() => list.setActive("")}
onMouseLeave={() => {
kept = ""
list.setActive("")
}}
onMouseDown={(event) => event.preventDefault()}
onClick={() => select(item.name)}
>
@@ -97,37 +97,51 @@ const ModelList: Component<{
collapsed: Record<string, boolean>
}>({
search: "",
active: props.mobile ? (controller.current() ?? "") : "",
active: "",
collapsed: {},
})
createEffect(
on(
() => props.open,
(open) => {
if (!props.mobile || !open) return
setStore({ search: "", active: controller.current() ?? "" })
},
),
)
const models = createMemo(() => controller.models(store.search))
const modelGroups = createMemo(() => controller.groups(models()))
const managed = createMemo(() => consoleModelGroup(controller.all()))
const expanded = (provider: string) => store.search.length > 0 || !store.collapsed[provider]
const managedIDs = createMemo(() => new Set(managed()?.providers.map((provider) => provider.id) ?? []))
const visibleModels = () =>
models().filter(
(item) => expanded(item.provider.id) && (!managedIDs().has(item.provider.id) || expanded(CONSOLE_GROUP_KEY)),
)
// Rows in display order: ProviderModelSections nests every managed provider at the first one's position.
const visibleModels = () => {
const groups = modelGroups()
const first = groups.findIndex((group) => managedIDs().has(group.category))
return groups.flatMap((group, index) => {
if (!managedIDs().has(group.category)) return expanded(group.category) ? group.items : []
if (index !== first || !expanded(CONSOLE_GROUP_KEY)) return []
return groups
.filter((item) => managedIDs().has(item.category) && expanded(item.category))
.flatMap((item) => item.items)
})
}
let scrollRef: HTMLDivElement | undefined
const setSearch = (value: string) => {
const first = controller.models(value).find((item) => value.length > 0 || !store.collapsed[item.provider.id])
setStore({ search: value, active: first ? modelKey(first) : "" })
setStore("search", value)
const first = visibleModels()[0]
setStore("active", first ? modelKey(first) : "")
}
createEffect(
on(
() => props.open,
(open) => {
if (!open) return
setSearch("")
scrollRef?.scrollTo(0, 0)
},
),
)
const moveActive = (delta: number) => {
const keys = visibleModels().map(modelKey)
@@ -613,7 +627,7 @@ export function DialogSelectModel(props: { provider?: string; model?: ModelState
const [store, setStore] = createStore({
search: "",
active: controller.current() ?? "",
active: "",
flyout: "",
keyboard: true,
navigated: false,
@@ -653,7 +667,7 @@ export function DialogSelectModel(props: { provider?: string; model?: ModelState
const flyoutItem = () => (store.flyout ? rowItem(store.flyout) : undefined)
const variantsOf = (item: ModelItem) => Object.keys(item.variants ?? {})
setStore("active", rows().find((row) => modelKey(row.item) === controller.current())?.id ?? "")
setStore("active", rows()[0]?.id ?? "")
const variant = (item: ModelItem) =>
modelKey(item) in staged ? (staged[modelKey(item)] ?? undefined) : controller.variant(item)
@@ -674,8 +688,8 @@ export function DialogSelectModel(props: { provider?: string; model?: ModelState
}
const setSearch = (value: string) => {
const first = controller.models(value)[0]
setStore({ search: value, active: first ? modelKey(first) : "", flyout: "", keyboard: true, navigated: false })
setStore({ search: value, flyout: "", keyboard: true, navigated: false })
setStore("active", rows()[0]?.id ?? "")
}
const triangle = createSafeTriangle({
@@ -99,13 +99,12 @@ export const DialogFork: Component = () => {
key: (x) => x.id,
filterKeys: ["text"],
onSelect: handleSelect,
noInitialSelection: true,
})
let resultsRef: HTMLDivElement | undefined
const onKeyDown = (event: KeyboardEvent) => {
// Nothing is highlighted until the pointer or arrow keys pick a row, so Enter forks from the newest match.
// The pointer leaving the rows clears the highlight, so Enter then forks from the newest match.
if (event.key === "Enter" && !event.isComposing && !list.active()) {
event.preventDefault()
handleSelect(list.flat()[0])
-15
View File
@@ -138,21 +138,6 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
),
)
createEffect(() => {
const scroll = scrollRef()
if (!scroll) return
if (!props.current) return
const key = props.key(props.current)
requestAnimationFrame(() => {
const element = findByKey(scroll, key)
if (!element) return
scrollIntoView(scroll, element, "center")
})
})
createEffect(() => {
const all = flat()
+2 -20
View File
@@ -14,7 +14,6 @@ export interface FilteredListProps<T> {
sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number
skipFilter?: (item: T) => boolean
onSelect?: (value: T | undefined, index: number) => void
noInitialSelection?: boolean
}
export function useFilteredList<T>(props: FilteredListProps<T>) {
@@ -67,31 +66,14 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
)
})
function initialActive() {
if (props.noInitialSelection) return ""
if (props.current) return props.key(props.current)
const items = flat()
if (items.length === 0) return ""
return props.key(items[0])
}
// Lists open on their first row; `current` only marks the selected item.
const list = createList({
items: () => flat().map(props.key),
initialActive: initialActive(),
initialActive: flat()[0] ? props.key(flat()[0]) : "",
loop: true,
})
const reset = () => {
if (props.noInitialSelection) {
list.setActive("")
return
}
const all = flat()
if (all.length === 0) return