✨ feat(vscode): 下拉选项超过一屏时顶部出现搜索框

profile 列表长到要滚动后,找一个模型得上下翻。

SelectMenu 在选项数超过弹层一屏能放下的行数时,在顶部固定一个搜索框,
按 label 和 hint 过滤;打开弹层即聚焦搜索框,方向键/回车/Esc 仍可用,
空格在搜索框里当普通字符输入。选项不多时不渲染搜索框,行为不变。

Claude-Session: https://claude.ai/code/session_01REdhpbaNyqWASF2HWsnTFv
This commit is contained in:
cruldra committed 2026-09-13 09:14:35 +08:00
1 parent 958c59e090
commit 9cd5ab7e9d
2 files changed
+97 -35

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"publisher": "clurdra", "publisher": "clurdra",
"name": "superpowers-vscode-clurdra", "name": "superpowers-vscode-clurdra",
"displayName": "Superpowers-clurdra", "displayName": "Superpowers-clurdra",
"version": "0.2.128", "version": "0.2.129",
"packageManager": "pnpm@10.27.0", "packageManager": "pnpm@10.27.0",
"description": "Superpowers specs and plans Kanban explorer", "description": "Superpowers specs and plans Kanban explorer",
"author": "clurdra", "author": "clurdra",
@@ -1,6 +1,6 @@
import type { KeyboardEvent, MouseEvent, ReactElement } from 'react' import type { KeyboardEvent, MouseEvent, ReactElement } from 'react'
import { Check, ChevronDown } from 'lucide-react' import { Check, ChevronDown } from 'lucide-react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -22,6 +22,13 @@ export interface SelectMenuProps {
} }
const MAX_MENU_HEIGHT = 240 const MAX_MENU_HEIGHT = 240
/** 搜索框行高(含上下边框),算弹层高度时要带上。 */
const SEARCH_ROW_PX = 30
/**
* 选项多到弹层要滚动才显示搜索框——阈值就取「一屏能放下的行数」,
* 不用再单独维护一个魔法数。
*/
const SEARCH_THRESHOLD = Math.floor(MAX_MENU_HEIGHT / 32)
/** 勾选图标 + gap + 水平 padding + 竖滚动条槽。少算会把最后几个字挤成省略号。 */ /** 勾选图标 + gap + 水平 padding + 竖滚动条槽。少算会把最后几个字挤成省略号。 */
const MENU_CHROME_PX = 14 + 6 + 16 + 12 const MENU_CHROME_PX = 14 + 6 + 16 + 12
@@ -59,13 +66,25 @@ export function SelectMenu({
className, className,
}: SelectMenuProps): ReactElement { }: SelectMenuProps): ReactElement {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [highlight, setHighlight] = useState(-1) const [highlight, setHighlight] = useState(-1)
const [pos, setPos] = useState<{ top: number, left: number, width: number } | null>(null) const [pos, setPos] = useState<{ top: number, left: number, width: number } | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null) const triggerRef = useRef<HTMLButtonElement | null>(null)
const menuRef = useRef<HTMLDivElement | null>(null) const menuRef = useRef<HTMLDivElement | null>(null)
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]) const optionRefs = useRef<Array<HTMLButtonElement | null>>([])
const searchRef = useRef<HTMLInputElement | null>(null)
const selected = options.find(o => o.value === value) ?? null const selected = options.find(o => o.value === value) ?? null
const showSearch = options.length > SEARCH_THRESHOLD
// 搜索只影响弹层里列出哪些项;选中值、宽度测量仍按完整 options 走。
const filtered = useMemo((): SelectMenuOption[] => {
const q = query.trim().toLowerCase()
if (!q)
return options
return options.filter(o =>
o.label.toLowerCase().includes(q) || (o.hint?.toLowerCase().includes(q) ?? false),
)
}, [options, query])
const computePos = useCallback((): void => { const computePos = useCallback((): void => {
const el = triggerRef.current const el = triggerRef.current
@@ -73,6 +92,7 @@ export function SelectMenu({
return return
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
const menuHeight = Math.min(options.length * 32, MAX_MENU_HEIGHT) const menuHeight = Math.min(options.length * 32, MAX_MENU_HEIGHT)
+ (options.length > SEARCH_THRESHOLD ? SEARCH_ROW_PX : 0)
const spaceBelow = window.innerHeight - rect.bottom const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top const spaceAbove = rect.top
const openUp = spaceBelow < menuHeight && spaceAbove > spaceBelow const openUp = spaceBelow < menuHeight && spaceAbove > spaceBelow
@@ -94,31 +114,46 @@ export function SelectMenu({
const openMenu = useCallback((): void => { const openMenu = useCallback((): void => {
computePos() computePos()
setQuery('')
setOpen(true) setOpen(true)
setHighlight(options.findIndex(o => o.value === value)) setHighlight(options.findIndex(o => o.value === value))
}, [computePos, options, value]) }, [computePos, options, value])
const closeMenu = useCallback((): void => { const closeMenu = useCallback((): void => {
setOpen(false) setOpen(false)
setQuery('')
setHighlight(-1) setHighlight(-1)
}, []) }, [])
const selectOption = useCallback((index: number): void => { const selectOption = useCallback((index: number): void => {
const opt = options[index] const opt = filtered[index]
if (opt) if (opt)
onChange(opt.value) onChange(opt.value)
closeMenu() closeMenu()
}, [options, onChange, closeMenu]) }, [filtered, onChange, closeMenu])
const moveHighlight = useCallback((dir: 1 | -1): void => { const moveHighlight = useCallback((dir: 1 | -1): void => {
if (options.length === 0) if (filtered.length === 0)
return return
setHighlight((prev) => { setHighlight((prev) => {
if (prev < 0) if (prev < 0)
return dir === 1 ? 0 : options.length - 1 return dir === 1 ? 0 : filtered.length - 1
return (prev + dir + options.length) % options.length return (prev + dir + filtered.length) % filtered.length
}) })
}, [options.length]) }, [filtered.length])
// 有搜索框就把焦点交给它:打开即可直接打字过滤,方向键/回车也从这里走。
useEffect(() => {
if (open && showSearch)
searchRef.current?.focus()
}, [open, showSearch])
// 过滤词一变,旧的高亮下标就对不上新列表了,重新落到第一项。
useEffect(() => {
if (!open || !query)
return
setHighlight(filtered.length > 0 ? 0 : -1)
}, [open, query, filtered.length])
// 弹层打开时把高亮项滚进视野(初始定位 + 键盘移动都靠它)。 // 弹层打开时把高亮项滚进视野(初始定位 + 键盘移动都靠它)。
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -170,6 +205,41 @@ export function SelectMenu({
e.stopPropagation() e.stopPropagation()
} }
/**
* 弹层打开后的键盘导航,触发器和搜索框共用。
* 空格只在触发器上当「确认」用;搜索框里空格是正常输入字符。
*/
function onOpenKeyDown(e: KeyboardEvent<HTMLElement>, confirmOnSpace: boolean): void {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
moveHighlight(1)
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
moveHighlight(-1)
break
case ' ':
if (!confirmOnSpace)
return
// fallthrough
case 'Enter':
e.preventDefault()
e.stopPropagation()
if (highlight >= 0)
selectOption(highlight)
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
closeMenu()
triggerRef.current?.focus()
break
}
}
function onTriggerKeyDown(e: KeyboardEvent<HTMLButtonElement>): void { function onTriggerKeyDown(e: KeyboardEvent<HTMLButtonElement>): void {
if (disabled) if (disabled)
return return
@@ -181,30 +251,7 @@ export function SelectMenu({
} }
return return
} }
switch (key) { onOpenKeyDown(e, true)
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
moveHighlight(1)
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
moveHighlight(-1)
break
case 'Enter':
case ' ':
e.preventDefault()
e.stopPropagation()
if (highlight >= 0)
selectOption(highlight)
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
closeMenu()
break
}
} }
const triggerClasses const triggerClasses
@@ -244,13 +291,28 @@ export function SelectMenu({
className="fixed z-50 overflow-auto rounded border border-[var(--vscode-panel-border)] bg-[var(--vscode-editorWidget-background,var(--vscode-editor-background))] shadow-md" className="fixed z-50 overflow-auto rounded border border-[var(--vscode-panel-border)] bg-[var(--vscode-editorWidget-background,var(--vscode-editor-background))] shadow-md"
style={{ top: pos.top, left: pos.left, width: pos.width, maxHeight: MAX_MENU_HEIGHT }} style={{ top: pos.top, left: pos.left, width: pos.width, maxHeight: MAX_MENU_HEIGHT }}
> >
{options.length === 0 {showSearch && (
<div className="sticky top-0 z-10 border-b border-[var(--vscode-panel-border)] bg-[var(--vscode-editorWidget-background,var(--vscode-editor-background))] p-1">
<input
ref={searchRef}
type="text"
value={query}
placeholder="搜索"
aria-label="搜索选项"
onChange={e => setQuery(e.target.value)}
onKeyDown={e => onOpenKeyDown(e, false)}
onClick={e => e.stopPropagation()}
className="w-full rounded border border-[var(--vscode-input-border,var(--vscode-panel-border))] bg-[var(--vscode-input-background)] px-2 py-0.5 text-xs text-[var(--vscode-input-foreground)] outline-none focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]"
/>
</div>
)}
{filtered.length === 0
? ( ? (
<div className="px-2 py-3 text-center text-xs text-[var(--vscode-descriptionForeground)]"> <div className="px-2 py-3 text-center text-xs text-[var(--vscode-descriptionForeground)]">
无选项 {query ? '无匹配项' : '无选项'}
</div> </div>
) )
: options.map((opt, i) => ( : filtered.map((opt, i) => (
<button <button
key={opt.value} key={opt.value}
ref={(el) => { optionRefs.current[i] = el }} ref={(el) => { optionRefs.current[i] = el }}