✨ feat(vscode): 下拉选项超过一屏时顶部出现搜索框
profile 列表长到要滚动后,找一个模型得上下翻。 SelectMenu 在选项数超过弹层一屏能放下的行数时,在顶部固定一个搜索框, 按 label 和 hint 过滤;打开弹层即聚焦搜索框,方向键/回车/Esc 仍可用, 空格在搜索框里当普通字符输入。选项不多时不渲染搜索框,行为不变。 Claude-Session: https://claude.ai/code/session_01REdhpbaNyqWASF2HWsnTFv
This commit is contained in:
1 parent
958c59e090
commit
9cd5ab7e9d
2 files changed
+97
-35
No files matched your search
+1
-1
@@ -2,7 +2,7 @@
|
||||
"publisher": "clurdra",
|
||||
"name": "superpowers-vscode-clurdra",
|
||||
"displayName": "Superpowers-clurdra",
|
||||
"version": "0.2.128",
|
||||
"version": "0.2.129",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"description": "Superpowers specs and plans Kanban explorer",
|
||||
"author": "clurdra",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KeyboardEvent, MouseEvent, ReactElement } from '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 { cn } from '@/lib/utils'
|
||||
|
||||
@@ -22,6 +22,13 @@ export interface SelectMenuProps {
|
||||
}
|
||||
|
||||
const MAX_MENU_HEIGHT = 240
|
||||
/** 搜索框行高(含上下边框),算弹层高度时要带上。 */
|
||||
const SEARCH_ROW_PX = 30
|
||||
/**
|
||||
* 选项多到弹层要滚动才显示搜索框——阈值就取「一屏能放下的行数」,
|
||||
* 不用再单独维护一个魔法数。
|
||||
*/
|
||||
const SEARCH_THRESHOLD = Math.floor(MAX_MENU_HEIGHT / 32)
|
||||
/** 勾选图标 + gap + 水平 padding + 竖滚动条槽。少算会把最后几个字挤成省略号。 */
|
||||
const MENU_CHROME_PX = 14 + 6 + 16 + 12
|
||||
|
||||
@@ -59,13 +66,25 @@ export function SelectMenu({
|
||||
className,
|
||||
}: SelectMenuProps): ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [highlight, setHighlight] = useState(-1)
|
||||
const [pos, setPos] = useState<{ top: number, left: number, width: number } | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement | null>(null)
|
||||
const optionRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const searchRef = useRef<HTMLInputElement | null>(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 el = triggerRef.current
|
||||
@@ -73,6 +92,7 @@ export function SelectMenu({
|
||||
return
|
||||
const rect = el.getBoundingClientRect()
|
||||
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 spaceAbove = rect.top
|
||||
const openUp = spaceBelow < menuHeight && spaceAbove > spaceBelow
|
||||
@@ -94,31 +114,46 @@ export function SelectMenu({
|
||||
|
||||
const openMenu = useCallback((): void => {
|
||||
computePos()
|
||||
setQuery('')
|
||||
setOpen(true)
|
||||
setHighlight(options.findIndex(o => o.value === value))
|
||||
}, [computePos, options, value])
|
||||
|
||||
const closeMenu = useCallback((): void => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setHighlight(-1)
|
||||
}, [])
|
||||
|
||||
const selectOption = useCallback((index: number): void => {
|
||||
const opt = options[index]
|
||||
const opt = filtered[index]
|
||||
if (opt)
|
||||
onChange(opt.value)
|
||||
closeMenu()
|
||||
}, [options, onChange, closeMenu])
|
||||
}, [filtered, onChange, closeMenu])
|
||||
|
||||
const moveHighlight = useCallback((dir: 1 | -1): void => {
|
||||
if (options.length === 0)
|
||||
if (filtered.length === 0)
|
||||
return
|
||||
setHighlight((prev) => {
|
||||
if (prev < 0)
|
||||
return dir === 1 ? 0 : options.length - 1
|
||||
return (prev + dir + options.length) % options.length
|
||||
return dir === 1 ? 0 : filtered.length - 1
|
||||
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(() => {
|
||||
@@ -170,6 +205,41 @@ export function SelectMenu({
|
||||
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 {
|
||||
if (disabled)
|
||||
return
|
||||
@@ -181,30 +251,7 @@ export function SelectMenu({
|
||||
}
|
||||
return
|
||||
}
|
||||
switch (key) {
|
||||
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
|
||||
}
|
||||
onOpenKeyDown(e, true)
|
||||
}
|
||||
|
||||
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"
|
||||
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)]">
|
||||
无选项
|
||||
{query ? '无匹配项' : '无选项'}
|
||||
</div>
|
||||
)
|
||||
: options.map((opt, i) => (
|
||||
: filtered.map((opt, i) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
ref={(el) => { optionRefs.current[i] = el }}
|
||||
|
||||
Reference in new issue
Block a user