✨ 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",
|
"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 }}
|
||||||
|
|||||||
Reference in new issue
Block a user