diff --git a/vscode/package.json b/vscode/package.json index 39affb9..6fe12e8 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -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", diff --git a/vscode/webview-ui/src/components/ui/select-menu.tsx b/vscode/webview-ui/src/components/ui/select-menu.tsx index 8d981f3..83ce708 100644 --- a/vscode/webview-ui/src/components/ui/select-menu.tsx +++ b/vscode/webview-ui/src/components/ui/select-menu.tsx @@ -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(null) const menuRef = useRef(null) const optionRefs = useRef>([]) + const searchRef = useRef(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, 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): 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 && ( +
+ 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)]" + /> +
+ )} + {filtered.length === 0 ? (
- 无选项 + {query ? '无匹配项' : '无选项'}
) - : options.map((opt, i) => ( + : filtered.map((opt, i) => (