From cd25791160ee774e0ccb6406a0c7499ac41f76c8 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 3 Nov 2026 13:25:16 +0700 Subject: [PATCH] feat(select): long lists become a combobox; the branch switcher uses it (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select opens with a focused search box when it has more than 8 options (or searchable): typing narrows by label/value, ↑/↓ move, Enter picks, Esc closes; role=combobox drives the listbox via aria-activedescendant, and options may carry a group heading. The git bar's native branch select (and the separate search box added for #88) is replaced by it, remote branches under their own heading. --- client/src/components/Select.tsx | 181 +++++++++++++----- .../src/components/__tests__/Select.test.tsx | 56 +++++- client/src/components/lanes/LaneGitBar.tsx | 102 +++------- .../lanes/__tests__/LaneChangesPanel.test.tsx | 39 ++-- .../lanes/__tests__/LaneGitBar.test.tsx | 92 ++++----- client/src/i18n/locales/en/common.json | 4 + client/src/i18n/locales/en/lanes.json | 2 - client/src/i18n/locales/vi/common.json | 4 + client/src/i18n/locales/vi/lanes.json | 2 - docs/LANES.md | 2 +- 10 files changed, 287 insertions(+), 197 deletions(-) diff --git a/client/src/components/Select.tsx b/client/src/components/Select.tsx index f95417e4..e54cd757 100644 --- a/client/src/components/Select.tsx +++ b/client/src/components/Select.tsx @@ -15,6 +15,14 @@ * the next option whose label starts with it. Focus returns to the trigger on * every close except Tab. * + * ## Search — a combobox once the list is long + * With more than `SEARCH_FROM` options (or `searchable`), the popup opens with + * a search box on top, focused: typing narrows the list to options whose + * label or value contains the text (case-insensitive), ↑/↓ move, Enter picks, + * Escape closes. The box is `role="combobox"` controlling the listbox through + * `aria-activedescendant`. Options may carry a `group`: a heading is drawn + * where the group changes. + * * ## Consumers * Used on the Run Claude page and webhook settings form wherever a compact * enum picker is needed. @@ -73,7 +81,11 @@ * ----------------------------------------------------------------------------- */ import { useEffect, useId, useRef, useState, type ReactNode } from "react"; -import { ChevronDown, Check } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, Check, Search } from "lucide-react"; + +/** A list longer than this gets a search box (unless `searchable` says otherwise). */ +export const SEARCH_FROM = 8; /** Room above and below `r` (the trigger's rect) for a menu it opens: inside * the nearest `data-menu-bounds` box — a split pane, whose neighbour paints @@ -95,6 +107,8 @@ export interface SelectOption { icon?: ReactNode; /** Draw a rule above this option: it starts a group of its own. */ divider?: boolean; + /** The group this option belongs to: a heading is drawn where it changes. */ + group?: string; } /** Props for {@link Select}. */ @@ -111,6 +125,10 @@ export interface SelectProps { variant?: "field" | "ghost"; /** Accessible name and tooltip of the trigger (a ghost one has no label). */ label?: string; + /** Show the search box: default only for lists longer than `SEARCH_FROM`. */ + searchable?: boolean; + /** The search box's name and placeholder (default: "Search…"). */ + searchLabel?: string; } /** @@ -124,15 +142,26 @@ export function Select({ disabled, variant = "field", label, + searchable, + searchLabel, }: SelectProps) { + const { t } = useTranslation("common"); const ghost = variant === "ghost"; + const canSearch = searchable ?? options.length > SEARCH_FROM; + const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const [openUp, setOpenUp] = useState(false); const containerRef = useRef(null); const buttonRef = useRef(null); const listRef = useRef(null); + const searchRef = useRef(null); const typed = useRef({ text: "", at: 0 }); + // The options shown: all of them, or those the search text matches. + const q = canSearch ? query.trim().toLowerCase() : ""; + const shown = q + ? options.filter((o) => `${o.label} ${o.value}`.toLowerCase().includes(q)) + : options; const id = useId(); const optionId = (idx: number) => `${id}-opt-${idx}`; @@ -150,6 +179,7 @@ export function Select({ // above the trigger (288 px = `max-h-72`), and move focus into the list. useEffect(() => { if (!open) return; + setQuery(""); const idx = options.findIndex((o) => o.value === value); setActive(idx >= 0 ? idx : 0); if (buttonRef.current) { @@ -159,14 +189,14 @@ export function Select({ ); setOpenUp(below < 288 && above > below); } - listRef.current?.focus({ preventScroll: true }); + (canSearch ? searchRef.current : listRef.current)?.focus({ preventScroll: true }); // eslint-disable-next-line react-hooks/exhaustive-deps -- only on open }, [open]); useEffect(() => { if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" }); // eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable - }, [open, active]); + }, [open, active, q]); const close = (refocus = true) => { setOpen(false); @@ -185,8 +215,12 @@ export function Select({ } }; - const onListKey = (e: React.KeyboardEvent) => { - const last = options.length - 1; + const onListKey = (e: React.KeyboardEvent) => { + const last = shown.length - 1; + // In the search box, keys that edit text stay the box's own. + const inSearch = e.currentTarget === searchRef.current; + if (inSearch && (e.key === " " || e.key === "Home" || e.key === "End")) return; + if (inSearch && e.key === "Enter" && e.nativeEvent.isComposing) return; const move: Record number> = { ArrowDown: (a) => Math.min(last, a + 1), ArrowUp: (a) => Math.max(0, a - 1), @@ -200,7 +234,7 @@ export function Select({ setActive(move[e.key]!); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - const opt = options[active]; + const opt = shown[active]; if (opt) choose(opt); } else if (e.key === "Escape") { // Stop here: a Select inside a dialog must not close the dialog too. @@ -209,7 +243,7 @@ export function Select({ close(); } else if (e.key === "Tab") { close(false); - } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + } else if (!inSearch && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { // Type-ahead: keys typed within 500 ms build one prefix; a repeated single // character cycles through the options that start with it. const now = Date.now(); @@ -258,57 +292,100 @@ export function Select({ {open && (
{ - if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false); - }} - className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${ + className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} flex max-h-72 flex-col overflow-hidden rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 ${ openUp ? "bottom-full mb-1" : "top-full mt-1" }`} > - {options.map((opt, idx) => { - const isSelected = opt.value === value; - const isActive = idx === active; - return ( -
e.preventDefault()} - onClick={() => choose(opt)} - onMouseEnter={() => setActive(idx)} - className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${ - opt.divider ? "mt-1 border-t border-border pt-2" : "" - } ${ - isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3" - }`} - > -
- {opt.icon} - + + { + setQuery(e.target.value); + setActive(0); + }} + onKeyDown={onListKey} + onBlur={(e) => { + if (!containerRef.current?.contains(e.relatedTarget as Node | null)) + setOpen(false); + }} + className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none" + /> +
+ )} +
{ + if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false); + }} + className="min-h-0 flex-1 overflow-auto py-1 outline-none" + > + {canSearch && shown.length === 0 && ( +
{t("select.noMatch")}
+ )} + {shown.map((opt, idx) => { + const isSelected = opt.value === value; + const isActive = idx === active; + const heading = opt.group && opt.group !== shown[idx - 1]?.group ? opt.group : null; + return ( +
+ {heading && ( +
+ {heading} +
+ )} +
e.preventDefault()} + onClick={() => choose(opt)} + onMouseEnter={() => setActive(idx)} + className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${ + opt.divider ? "mt-1 border-t border-border pt-2" : "" + } ${ + isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3" }`} > - {opt.label} - - {isSelected && ( - - )} +
+ {opt.icon} + + {opt.label} + + {isSelected && ( + + )} +
+ {opt.hint && ( +
{opt.hint}
+ )} +
- {opt.hint && ( -
{opt.hint}
- )} -
- ); - })} + ); + })} +
)} diff --git a/client/src/components/__tests__/Select.test.tsx b/client/src/components/__tests__/Select.test.tsx index 6a44cc9a..cf131c58 100644 --- a/client/src/components/__tests__/Select.test.tsx +++ b/client/src/components/__tests__/Select.test.tsx @@ -105,7 +105,61 @@ describe("Select (listbox button)", () => { vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380)); vi.stubGlobal("innerHeight", 2000); fireEvent.click(button); - expect(screen.getByRole("listbox").className).toContain("bottom-full"); + expect(screen.getByRole("listbox").parentElement!.className).toContain("bottom-full"); vi.unstubAllGlobals(); }); + + it("turns a long list into a combobox: type to narrow, Enter picks, groups keep headings", async () => { + const onChange = vi.fn(); + const options = [ + { value: "", label: "Switch branch…" }, + ...["develop", "main", "feat/campaign-pause", "fix/campaign-step", "chore/a", "chore/b"].map( + (v) => ({ value: v, label: v }) + ), + ...["origin/feat/campaign-statistic", "origin/x", "origin/y"].map((v) => ({ + value: v, + label: v, + group: "On the remote", + })), + ]; + render( + ); + fireEvent.click(screen.getByRole("button", { name: "Pick" })); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(screen.getByRole("listbox")).toHaveFocus(); + }); }); diff --git a/client/src/components/lanes/LaneGitBar.tsx b/client/src/components/lanes/LaneGitBar.tsx index b1df11d5..ab7cf317 100644 --- a/client/src/components/lanes/LaneGitBar.tsx +++ b/client/src/components/lanes/LaneGitBar.tsx @@ -57,6 +57,7 @@ import { isSubmitEnter } from "../../lib/keys"; import type { LaneGitFacts, LaneWorktree } from "../../lib/types"; import { restoreShelf } from "./LaneShelves"; import { BranchesButton } from "./git/BranchesButton"; +import { Select } from "../Select"; type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish"; @@ -132,8 +133,6 @@ export function LaneGitBar({ const [remoteBranches, setRemoteBranches] = useState([]); const [worktrees, setWorktrees] = useState([]); const [picked, setPicked] = useState(""); - // Issue #88: typing narrows the switcher to the branches whose name holds it. - const [branchQuery, setBranchQuery] = useState(""); const [newBranch, setNewBranch] = useState(""); // Set when git needed a login for `action`; the form resends that action. const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null); @@ -162,7 +161,6 @@ export function LaneGitBar({ setRemoteBranches([]); setWorktrees([]); setPicked(""); - setBranchQuery(""); setNewBranch(""); setLogin(null); setPassword(""); @@ -179,26 +177,6 @@ export function LaneGitBar({ // A remote branch: switching creates its local tracking branch; it cannot // be deleted here or given a sibling worktree before that. const pickedRemote = remoteBranches.includes(picked); - // The switcher's lists, narrowed by the search box (case-insensitive, any - // part of the name). The picked branch always stays in its list, so the - // select never shows a value it has no option for. - const query = branchQuery.trim().toLowerCase(); - const matches = (b: string) => !query || b.toLowerCase().includes(query) || b === picked; - const shownBranches = (branches || []).filter(matches); - const shownRemote = remoteBranches.filter(matches); - const matchCount = query - ? [...(branches || []), ...remoteBranches].filter((b) => b.toLowerCase().includes(query)) - .length - : null; - const searchBranches = (text: string) => { - setBranchQuery(text); - const q = text.trim().toLowerCase(); - if (!q) return; - const hits = [...(branches || []), ...remoteBranches].filter((b) => - b.toLowerCase().includes(q) - ); - if (hits.length === 1) setPicked(hits[0]!); - }; const merging = !!git.merging; const run = async (action: Action, body?: RunBody) => { @@ -549,58 +527,32 @@ export function LaneGitBar({ {switching && (
- searchBranches(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape" && branchQuery) { - e.stopPropagation(); - setBranchQuery(""); - } else if (isSubmitEnter(e)) { - e.preventDefault(); - const first = [...shownBranches, ...shownRemote].find( - (b) => b.toLowerCase().includes(query) && b !== git.branch - ); - if (first) setPicked(first); - } - }} - className="w-36 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" - /> - + {/* A combobox once the list is long (Select's search box, #88): + type to narrow the local and remote branches. */} +
+