diff --git a/webui2/package.json b/webui2/package.json index d650512613904cd91426fbf34047dfd274af51ae..4330ac7ac261616451f1bc3591264e20b545e0b4 100644 --- a/webui2/package.json +++ b/webui2/package.json @@ -20,6 +20,7 @@ "dependencies": { "@apollo/client": "^4.1.6", "@base-ui/react": "^1.3.0", + "@floating-ui/react": "^0.27.19", "@fontsource-variable/geist": "^5.2.8", "@shikijs/langs": "^4.0.2", "@shikijs/rehype": "^4.0.2", diff --git a/webui2/pnpm-lock.yaml b/webui2/pnpm-lock.yaml index eedac1d58f6c54bfcb26e5ad62175377c0083f00..17bb14b604b635ee8d107c2dce15527d484824cc 100644 --- a/webui2/pnpm-lock.yaml +++ b/webui2/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@base-ui/react': specifier: ^1.3.0 version: 1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react': + specifier: ^0.27.19 + version: 0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@fontsource-variable/geist': specifier: ^5.2.8 version: 5.2.8 @@ -680,6 +683,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} @@ -5384,6 +5393,14 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@floating-ui/react@0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.11 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tabbable: 6.4.0 + '@floating-ui/utils@0.2.11': {} '@fontsource-variable/geist@5.2.8': {} diff --git a/webui2/src/components/bugs/label-editor.stories.tsx b/webui2/src/components/bugs/label-editor.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d6ad8f61ad08e3ac468db11c1f8c3f0d8c28e317 --- /dev/null +++ b/webui2/src/components/bugs/label-editor.stories.tsx @@ -0,0 +1,156 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; + +import { LabelBadge } from "@/components/shared/label-badge"; +import { SectionHeading } from "@/components/shared/section-heading"; +import * as Listbox from "@/components/ui/listbox"; +import { + useFloating, + useClick, + useDismiss, + useRole, + useListNavigation, + useInteractions, + offset, + flip, + FloatingPortal, + FloatingFocusManager, +} from "@floating-ui/react"; +import { Settings2 } from "lucide-react"; +import { useRef } from "react"; + +// The real LabelEditor depends on GraphQL mutations. For stories, we build a +// self-contained version with the same UI but local state instead of mutations. + +const allLabels = [ + { name: "bug", color: { R: 252, G: 41, B: 41 } }, + { name: "enhancement", color: { R: 0, G: 150, B: 255 } }, + { name: "documentation", color: { R: 0, G: 180, B: 80 } }, + { name: "help wanted", color: { R: 255, G: 152, B: 0 } }, + { name: "good first issue", color: { R: 124, G: 58, B: 237 } }, +]; + +type LabelColor = { R: number; G: number; B: number }; + +function LabelEditorDemo() { + const [current, setCurrent] = useState>([ + allLabels[0]!, + allLabels[2]!, + ]); + + const currentNames = new Set(current.map((l) => l.name)); + + function toggleLabel(label: { name: string; color: LabelColor }) { + if (currentNames.has(label.name)) { + setCurrent((prev) => prev.filter((l) => l.name !== label.name)); + } else { + setCurrent((prev) => [...prev, label]); + } + } + + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + const elementsRef = useRef<(HTMLElement | null)[]>([]); + + const { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange: setOpen, + placement: "bottom-end", + middleware: [offset(4), flip()], + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "listbox" }); + const listNav = useListNavigation(context, { + listRef: elementsRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + click, dismiss, role, listNav, + ]); + + return ( +
+
+ Labels + +
+ + {open && ( + + + +
+ Apply labels +
+ + {allLabels.map((label, i) => { + const active = currentNames.has(label.name); + return ( + { elementsRef.current[i] = el; }} + active={activeIndex === i} + selected={active} + tabIndex={activeIndex === i ? 0 : -1} + {...getItemProps({ onClick: () => toggleLabel(label) })} + > + + + + ); + })} + +
+
+
+ )} + + {current.length === 0 ? ( +

None yet

+ ) : ( +
+ {current.map((label) => ( + + ))} +
+ )} +
+ ); +} + +const meta = { + title: "bugs/LabelEditor", + parameters: { layout: "centered" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; diff --git a/webui2/src/components/bugs/label-editor.tsx b/webui2/src/components/bugs/label-editor.tsx index 83fc814bca67eb552b59b1c3f109950e5986cea4..1bfb5a489e23e01998315a6abdbe228668b49c51 100644 --- a/webui2/src/components/bugs/label-editor.tsx +++ b/webui2/src/components/bugs/label-editor.tsx @@ -1,7 +1,20 @@ +import { + useFloating, + useClick, + useDismiss, + useRole, + useListNavigation, + useInteractions, + offset, + flip, + FloatingPortal, + FloatingFocusManager, +} from "@floating-ui/react"; import { Settings2 } from "lucide-react"; +import { useRef, useState } from "react"; import { useBugChangeLabelsMutation, BugDetailDocument } from "@/__generated__/graphql"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import * as Listbox from "@/components/ui/listbox"; import { SectionHeading } from "@/components/shared/section-heading"; import { useAuth } from "@/lib/auth"; @@ -40,27 +53,79 @@ export function LabelEditor({ bugPrefix, currentLabels, ref_, validLabels }: Lab }); } + // floating-ui state + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + const elementsRef = useRef<(HTMLElement | null)[]>([]); + + const { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange: setOpen, + placement: "bottom-end", + middleware: [offset(4), flip()], + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "listbox" }); + const listNav = useListNavigation(context, { + listRef: elementsRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + click, + dismiss, + role, + listNav, + ]); + return (
Labels {user && validLabels.length > 0 && ( - - - - - -

Apply labels

-
- {validLabels.map((label) => { + + )} +
+ + {open && ( + + + +
+ Apply labels +
+ + {validLabels.map((label, i) => { const active = currentNames.has(label.name); return ( - + ); })} -
- - - )} -
+ + + + + )} {currentLabels.length === 0 ? (

None yet

diff --git a/webui2/src/components/code/__snapshots__/file-viewer.test.tsx.snap b/webui2/src/components/code/__snapshots__/file-viewer.test.tsx.snap index a1121b1d970563cc920fb2502a98ccc7a5129a7e..2ef830d0438dda2f7df1c8804bbd9b18fc06db79 100644 --- a/webui2/src/components/code/__snapshots__/file-viewer.test.tsx.snap +++ b/webui2/src/components/code/__snapshots__/file-viewer.test.tsx.snap @@ -14,7 +14,7 @@ exports[`FileViewer/BinaryFile matches snapshot 1`] = ` 24.0 KB `; @@ -68,11 +47,10 @@ exports[`RefSelector/Default matches snapshot 1`] = `
`; @@ -132,11 +90,10 @@ exports[`RefSelector/OnTag matches snapshot 1`] = `
`; diff --git a/webui2/src/components/code/ref-selector.stories.tsx b/webui2/src/components/code/ref-selector.stories.tsx index d7c20c2475fe83649f3f8262fcc9e1bd34e126b4..39452096ff4e9a6d94104e9fd0f189deccf14d4b 100644 --- a/webui2/src/components/code/ref-selector.stories.tsx +++ b/webui2/src/components/code/ref-selector.stories.tsx @@ -24,7 +24,7 @@ const sampleRefs = [ export const Default: Story = { args: { - refs: sampleRefs, + gitRefs: sampleRefs, currentRef: "main", onSelect: fn(), }, @@ -32,7 +32,7 @@ export const Default: Story = { export const OnTag: Story = { args: { - refs: sampleRefs, + gitRefs: sampleRefs, currentRef: "v1.1.0", onSelect: fn(), }, @@ -40,7 +40,7 @@ export const OnTag: Story = { export const BranchesOnly: Story = { args: { - refs: sampleRefs.filter((r) => r.type === GitRefType.Branch), + gitRefs: sampleRefs.filter((r) => r.type === GitRefType.Branch), currentRef: "develop", onSelect: fn(), }, diff --git a/webui2/src/components/code/ref-selector.tsx b/webui2/src/components/code/ref-selector.tsx index c2df0f31097deb804eaab96ce96a7e238014bbe7..9b4a632b30842bdd36fa7c0e7a8a029d128d27ab 100644 --- a/webui2/src/components/code/ref-selector.tsx +++ b/webui2/src/components/code/ref-selector.tsx @@ -1,104 +1,223 @@ -import { GitBranch, Tag, Check, ChevronsUpDown } from "lucide-react"; -import { useState } from "react"; +import { + useFloating, + useClick, + useDismiss, + useRole, + useListNavigation, + useInteractions, + offset, + flip, + FloatingPortal, + FloatingFocusManager, +} from "@floating-ui/react"; +import { GitBranch, Tag } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { GitRefType, type GitRef } from "@/__generated__/graphql"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import * as Listbox from "@/components/ui/listbox"; import { cn } from "@/lib/utils"; interface RefSelectorProps { - refs: GitRef[]; + gitRefs: GitRef[]; currentRef: string; onSelect: (ref: GitRef) => void; } // Branch / tag selector dropdown for the code browser. Shown in two groups // (branches, tags) with an inline search filter. -export function RefSelector({ refs, currentRef, onSelect }: RefSelectorProps) { +export function RefSelector({ gitRefs, currentRef, onSelect }: RefSelectorProps) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); + const [activeIndex, setActiveIndex] = useState(null); - const filtered = refs.filter((r) => r.shortName.toLowerCase().includes(filter.toLowerCase())); + const elementsRef = useRef<(HTMLElement | null)[]>([]); + const searchRef = useRef(null); + + const { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange(nextOpen) { + setOpen(nextOpen); + if (!nextOpen) setFilter(""); + }, + placement: "bottom-start", + middleware: [offset(4), flip()], + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "listbox" }); + const listNav = useListNavigation(context, { + listRef: elementsRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + virtual: true, + focusItemOnOpen: false, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + click, + dismiss, + role, + listNav, + ]); + + const filtered = gitRefs.filter((r) => + r.shortName.toLowerCase().includes(filter.toLowerCase()), + ); const branches = filtered.filter((r) => r.type === GitRefType.Branch); const tags = filtered.filter((r) => r.type === GitRefType.Tag); + // Build a flat list for indexing (branches first, then tags) + const flatItems = [...branches, ...tags]; + + // Reset active index when filtered list changes + useEffect(() => { + setActiveIndex(flatItems.length > 0 ? 0 : null); + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset on filter change + }, [filter]); + + function handleSearchKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && activeIndex != null) { + e.preventDefault(); + const ref = flatItems[activeIndex]; + if (ref) { + onSelect(ref); + setOpen(false); + setFilter(""); + } + } + } + + let itemIndex = 0; + return ( - - }> + <> + + + {open && ( + + + +
+ Switch branch / tag +
+ setFilter(e.target.value)} + onKeyDown={handleSearchKeyDown} + className="text-xs" + aria-activedescendant={ + activeIndex != null ? `ref-option-${activeIndex}` : undefined + } + /> + + {branches.length > 0 && ( + + Branches + {branches.map((ref) => { + const i = itemIndex++; + return ( + { + onSelect(ref); + setOpen(false); + setFilter(""); + }} + /> + ); + })} + + )} + {tags.length > 0 && ( + + Tags + {tags.map((ref) => { + const i = itemIndex++; + return ( + { + onSelect(ref); + setOpen(false); + setFilter(""); + }} + /> + ); + })} + + )} + {filtered.length === 0 && } + +
+
+
+ )} + ); } function RefItem({ + id, ref_, + index, active, + selected, + elementsRef, + getItemProps, onSelect, }: { + id: string; ref_: GitRef; + index: number; active: boolean; + selected: boolean; + elementsRef: React.MutableRefObject<(HTMLElement | null)[]>; + getItemProps: (props?: Record) => Record; onSelect: () => void; }) { return ( - + ); } diff --git a/webui2/src/components/shared/__snapshots__/query-input.test.tsx.snap b/webui2/src/components/shared/__snapshots__/query-input.test.tsx.snap index abc06a7ec8ddfb6716472f3d31e0c83c9caf6586..87f925948d0356f0c11b0e34cb29556681695503 100644 --- a/webui2/src/components/shared/__snapshots__/query-input.test.tsx.snap +++ b/webui2/src/components/shared/__snapshots__/query-input.test.tsx.snap @@ -36,9 +36,11 @@ exports[`QueryInput/AsyncCompletions matches snapshot 1`] = ` class="text-foreground pointer-events-none absolute inset-0 flex items-center overflow-hidden pr-3 pl-9 font-mono text-sm whitespace-pre" /> ; + +export default meta; +type Story = StoryObj; + +const sampleLabels: LabelItem[] = [ + { name: "bug", color: { R: 252, G: 41, B: 41 } }, + { name: "enhancement", color: { R: 0, G: 150, B: 255 } }, + { name: "documentation", color: { R: 0, G: 180, B: 80 } }, + { name: "help wanted", color: { R: 255, G: 152, B: 0 } }, + { name: "good first issue", color: { R: 124, G: 58, B: 237 } }, + { name: "duplicate", color: { R: 120, G: 120, B: 120 } }, + { name: "wontfix", color: { R: 180, G: 180, B: 180 } }, +]; + +const sampleIdentities: IdentityItem[] = [ + { id: "u1", humanId: "abc1", displayName: "Jane Doe", login: "janedoe", name: "Jane Doe", email: "jane@example.com", avatarUrl: null }, + { id: "u2", humanId: "abc2", displayName: "John Smith", login: "jsmith", name: "John Smith", email: "john@example.com", avatarUrl: null }, + { id: "u3", humanId: "abc3", displayName: "Alice Wonder", login: "alice", name: "Alice Wonder", email: "alice@example.com", avatarUrl: null }, + { id: "u4", humanId: "abc4", displayName: "Bob Builder", login: "bob", name: "Bob Builder", email: "bob@example.com", avatarUrl: null }, + { id: "u5", humanId: "abc5", displayName: "Carol Tester", login: "carol", name: "Carol Tester", email: "carol@example.com", avatarUrl: null }, +]; + +export const Default: Story = { + args: { + labels: sampleLabels, + identities: sampleIdentities, + selectedLabels: [], + onLabelsChange: fn(), + selectedAuthorId: null, + onAuthorChange: fn(), + recentAuthorIds: ["abc1", "abc3"], + sort: "creation-desc", + onSortChange: fn(), + }, +}; + +export const WithSelections: Story = { + args: { + labels: sampleLabels, + identities: sampleIdentities, + selectedLabels: ["bug", "enhancement"], + onLabelsChange: fn(), + selectedAuthorId: "abc2", + onAuthorChange: fn(), + recentAuthorIds: ["abc1", "abc2"], + sort: "edit-desc", + onSortChange: fn(), + }, +}; + +// Interactive story with working state +function Interactive() { + const [selectedLabels, setSelectedLabels] = useState([]); + const [selectedAuthorId, setSelectedAuthorId] = useState(null); + const [sort, setSort] = useState("creation-desc"); + + return ( +
+ setSelectedAuthorId(id)} + recentAuthorIds={["abc1", "abc3"]} + sort={sort} + onSortChange={setSort} + /> +
+ Labels: {selectedLabels.join(", ") || "none"} · Author: {selectedAuthorId ?? "none"} · Sort: {sort} +
+
+ ); +} + +export const InteractiveState: Story = { + render: () => , +}; diff --git a/webui2/src/components/shared/issue-filters.tsx b/webui2/src/components/shared/issue-filters.tsx index 7f5dd7e1ed509e2e6bfcb74f247cfcf86cff1ff0..4674a97837241d9702f1c9696b092e1133f6e70a 100644 --- a/webui2/src/components/shared/issue-filters.tsx +++ b/webui2/src/components/shared/issue-filters.tsx @@ -1,8 +1,21 @@ -import { ArrowUpDown, ChevronDown, Tag, User, X, Search, Check } from "lucide-react"; -import { useMemo, useState } from "react"; +import { + useFloating, + useClick, + useDismiss, + useRole, + useListNavigation, + useTypeahead, + useInteractions, + offset, + flip, + FloatingPortal, + FloatingFocusManager, +} from "@floating-ui/react"; +import { ArrowUpDown, ChevronDown, Tag, User, X } from "lucide-react"; +import { useMemo, useRef, useState, useCallback, useEffect } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import * as Listbox from "@/components/ui/listbox"; import { useAuth } from "@/lib/auth"; import { SORT_OPTIONS, type SortValue } from "@/lib/query-utils"; import { cn } from "@/lib/utils"; @@ -61,17 +74,6 @@ interface IssueFiltersProps { onSortChange: (sort: SortValue) => void; } -// Label and author filter dropdowns shown in the issue list header bar. -// -// The author dropdown has two display modes: -// - Not searching: shows current user first, then recently-seen authors from -// the visible bug list (recentAuthorIds), then alphabetical fill up to -// INITIAL_AUTHOR_LIMIT. This surfaces the most useful choices with no typing. -// - Searching: filters the full identity list reactively as-you-type. -// -// Note: onAuthorChange passes TWO values — humanId (for UI matching, unique) and -// queryValue (login/name for the query string). They're kept separate because -// two identities can share the same display name, but humanId is always unique. export function IssueFilters({ labels, identities, @@ -84,8 +86,6 @@ export function IssueFilters({ onSortChange, }: IssueFiltersProps) { const { user } = useAuth(); - const [labelSearch, setLabelSearch] = useState(""); - const [authorSearch, setAuthorSearch] = useState(""); const validLabels = useMemo( () => labels.toSorted((a, b) => a.name.localeCompare(b.name)), @@ -97,48 +97,387 @@ export function IssueFilters({ [identities], ); - const filteredLabels = labelSearch.trim() - ? validLabels.filter((l) => l.name.toLowerCase().includes(labelSearch.toLowerCase())) - : validLabels; + const selectedAuthorIdentity = allIdentities.find((i) => i.humanId === selectedAuthorId); + + return ( +
+ + + +
+ ); +} + +// ── Sort filter ────────────────────────────────────────────────────────────── + +function SortFilter({ + sort, + onSortChange, +}: { + sort: SortValue; + onSortChange: (sort: SortValue) => void; +}) { + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + const selectedIndex = SORT_OPTIONS.findIndex((o) => o.value === sort); + + const elementsRef = useRef<(HTMLElement | null)[]>([]); + const labelsRef = useRef<(string | null)[]>([]); + + const { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange: setOpen, + placement: "bottom-end", + middleware: [offset(4), flip()], + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "listbox" }); + const listNav = useListNavigation(context, { + listRef: elementsRef, + activeIndex, + selectedIndex, + onNavigate: setActiveIndex, + loop: true, + }); + const typeahead = useTypeahead(context, { + listRef: labelsRef, + activeIndex, + onMatch: setActiveIndex, + }); - // Selected labels float to top, then alphabetical + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + click, + dismiss, + role, + listNav, + typeahead, + ]); + + function handleSelect(index: number) { + const opt = SORT_OPTIONS[index]; + if (opt) { + onSortChange(opt.value); + setOpen(false); + } + } + + return ( + <> + + + {open && ( + + + + + {SORT_OPTIONS.map((opt, i) => { + labelsRef.current[i] = opt.label; + return ( + { + elementsRef.current[i] = el; + }} + active={activeIndex === i} + selected={sort === opt.value} + tabIndex={activeIndex === i ? 0 : -1} + className="whitespace-nowrap" + {...getItemProps({ + onClick: () => handleSelect(i), + })} + > + {opt.label} + + ); + })} + + + + + )} + + ); +} + +// ── Label filter ───────────────────────────────────────────────────────────── + +function LabelFilter({ + validLabels, + selectedLabels, + onLabelsChange, +}: { + validLabels: readonly LabelItem[]; + selectedLabels: string[]; + onLabelsChange: (labels: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [activeIndex, setActiveIndex] = useState(null); + + const elementsRef = useRef<(HTMLElement | null)[]>([]); + const searchRef = useRef(null); + + const { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange(nextOpen) { + setOpen(nextOpen); + if (!nextOpen) setSearch(""); + }, + placement: "bottom-end", + middleware: [offset(4), flip()], + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "listbox" }); + const listNav = useListNavigation(context, { + listRef: elementsRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + virtual: true, + focusItemOnOpen: false, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + click, + dismiss, + role, + listNav, + ]); + + const filteredLabels = search.trim() + ? validLabels.filter((l) => l.name.toLowerCase().includes(search.toLowerCase())) + : [...validLabels]; + + // Selected labels float to top const sortedLabels = [ ...filteredLabels.filter((l) => selectedLabels.includes(l.name)), ...filteredLabels.filter((l) => !selectedLabels.includes(l.name)), ]; - // Build the displayed identity list: - // - When searching: filter full list reactively as-you-type - // - When not searching: show current user first, then recently-seen authors, - // then others up to INITIAL_AUTHOR_LIMIT - const isSearching = authorSearch.trim() !== ""; - - const matchesSearch = (i: (typeof allIdentities)[number]) => { - const q = authorSearch.toLowerCase(); - return ( - i.displayName.toLowerCase().includes(q) || - (i.name ?? "").toLowerCase().includes(q) || - (i.login ?? "").toLowerCase().includes(q) || - (i.email ?? "").toLowerCase().includes(q) - ); - }; - - let visibleIdentities: typeof allIdentities; - if (isSearching) { - visibleIdentities = allIdentities.filter(matchesSearch); - } else { + // Reset active index when filtered list changes + useEffect(() => { + setActiveIndex(sortedLabels.length > 0 ? 0 : null); + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset on search change + }, [search]); + + function toggleLabel(name: string) { + if (selectedLabels.includes(name)) { + onLabelsChange(selectedLabels.filter((l) => l !== name)); + } else { + onLabelsChange([...selectedLabels, name]); + } + } + + function handleSearchKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && activeIndex != null) { + e.preventDefault(); + const label = sortedLabels[activeIndex]; + if (label) toggleLabel(label.name); + } + } + + return ( + <> + + + {open && ( + + + + setSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} + aria-activedescendant={ + activeIndex != null ? `label-option-${activeIndex}` : undefined + } + /> + + {sortedLabels.length === 0 && No labels found} + {sortedLabels.map((label, i) => { + const active = selectedLabels.includes(label.name); + return ( + { + elementsRef.current[i] = el; + }} + active={activeIndex === i} + selected={active} + {...getItemProps({ + onClick: () => toggleLabel(label.name), + })} + > + + + + ); + })} + + {selectedLabels.length > 0 && ( + + onLabelsChange([])}> + + Clear labels + + + )} + + + + )} + + ); +} + +// ── Author filter ──────────────────────────────────────────────────────────── + +function AuthorFilter({ + allIdentities, + selectedAuthorId, + selectedAuthorIdentity, + onAuthorChange, + recentAuthorIds, + currentUserId, +}: { + allIdentities: readonly IdentityItem[]; + selectedAuthorId: string | null; + selectedAuthorIdentity: IdentityItem | undefined; + onAuthorChange: (humanId: string | null, queryValue: string | null) => void; + recentAuthorIds: string[]; + currentUserId: string | null; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [activeIndex, setActiveIndex] = useState(null); + + const elementsRef = useRef<(HTMLElement | null)[]>([]); + const searchRef = useRef(null); + + const { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange(nextOpen) { + setOpen(nextOpen); + if (!nextOpen) setSearch(""); + }, + placement: "bottom-end", + middleware: [offset(4), flip()], + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "listbox" }); + const listNav = useListNavigation(context, { + listRef: elementsRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + virtual: true, + focusItemOnOpen: false, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + click, + dismiss, + role, + listNav, + ]); + + const isSearching = search.trim() !== ""; + + const matchesSearch = useCallback( + (i: IdentityItem) => { + const q = search.toLowerCase(); + return ( + i.displayName.toLowerCase().includes(q) || + (i.name ?? "").toLowerCase().includes(q) || + (i.login ?? "").toLowerCase().includes(q) || + (i.email ?? "").toLowerCase().includes(q) + ); + }, + [search], + ); + + const visibleIdentities = useMemo(() => { + if (isSearching) { + return allIdentities.filter(matchesSearch); + } + const pinned = new Set(); - const result: typeof allIdentities = []; + const result: IdentityItem[] = []; // 1. Current user - if (user) { - const me = allIdentities.find((i) => i.id === user.id); + if (currentUserId) { + const me = allIdentities.find((i) => i.id === currentUserId); if (me) { result.push(me); pinned.add(me.id); } } - // 2. Selected author (if not already added) + // 2. Selected author if (selectedAuthorId) { const sel = allIdentities.find((i) => i.humanId === selectedAuthorId); if (sel && !pinned.has(sel.id)) { @@ -146,7 +485,7 @@ export function IssueFilters({ pinned.add(sel.id); } } - // 3. Recently seen authors (recentAuthorIds are humanIds from bug rows) + // 3. Recently seen for (const humanId of recentAuthorIds) { const match = allIdentities.find((i) => i.humanId === humanId); if (match && !pinned.has(match.id)) { @@ -154,233 +493,156 @@ export function IssueFilters({ pinned.add(match.id); } } - // 4. Fill up to limit with remaining alphabetical + // 4. Fill to limit for (const i of allIdentities) { if (result.length >= INITIAL_AUTHOR_LIMIT) break; if (!pinned.has(i.id)) result.push(i); } - visibleIdentities = result; - } + return result; + }, [allIdentities, isSearching, matchesSearch, currentUserId, selectedAuthorId, recentAuthorIds]); - function toggleLabel(name: string) { - if (selectedLabels.includes(name)) { - onLabelsChange(selectedLabels.filter((l) => l !== name)); - } else { - onLabelsChange([...selectedLabels, name]); + // Reset active index when filtered list changes + useEffect(() => { + setActiveIndex(visibleIdentities.length > 0 ? 0 : null); + }, [visibleIdentities]); + + function handleSearchKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && activeIndex != null) { + e.preventDefault(); + const identity = visibleIdentities[activeIndex]; + if (identity) { + const isActive = selectedAuthorId === identity.humanId; + onAuthorChange( + isActive ? null : identity.humanId, + isActive ? null : authorQueryValue(identity), + ); + setOpen(false); + } } } - const selectedAuthorIdentity = allIdentities.find((i) => i.humanId === selectedAuthorId); - return ( -
- {/* Label filter */} - { - if (!open) setLabelSearch(""); - }} + <> + - ); - })} -
- {selectedLabels.length > 0 && ( -
- -
- )} - -
- - {/* Author filter */} - { - if (!open) setAuthorSearch(""); - }} - > - - {selectedAuthorIdentity ? ( - <> - - - - {selectedAuthorIdentity.displayName.slice(0, 2).toUpperCase()} - - - {selectedAuthorIdentity.displayName} - - ) : ( - <> - - Author - - )} - - - - {/* Search */} -
- - setAuthorSearch(e.target.value)} - className="placeholder:text-muted-foreground w-full bg-transparent text-sm outline-hidden" - /> -
-
- {visibleIdentities.length === 0 && ( -

- No authors found -

- )} - {visibleIdentities.map((identity) => { - const active = selectedAuthorId === identity.humanId; - return ( - + + {open && ( + + + + setSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} + aria-activedescendant={ + activeIndex != null ? `author-option-${activeIndex}` : undefined + } + /> + + {visibleIdentities.length === 0 && ( + No authors found + )} + {visibleIdentities.map((identity, i) => { + const active = selectedAuthorId === identity.humanId; + return ( + { + elementsRef.current[i] = el; + }} + active={activeIndex === i} + selected={active} + {...getItemProps({ + onClick: () => { + onAuthorChange( + active ? null : identity.humanId, + active ? null : authorQueryValue(identity), + ); + setOpen(false); + }, + })} + > + + + + {identity.displayName.slice(0, 2).toUpperCase()} + + +
+
{identity.displayName}
+ {identity.login && identity.login !== identity.displayName && ( +
+ @{identity.login} +
+ )}
- )} +
+ ); + })} + {!isSearching && allIdentities.length > INITIAL_AUTHOR_LIMIT && ( +
+ {allIdentities.length - visibleIdentities.length} more — type to search
- {active && } - - ); - })} - {!isSearching && allIdentities.length > INITIAL_AUTHOR_LIMIT && ( -

- {allIdentities.length - visibleIdentities.length} more — type to search -

- )} -
- {selectedAuthorId && ( -
- -
- )} -
-
- - {/* Sort */} - - - - {SORT_OPTIONS.find((o) => o.value === sort)?.label ?? "Sort"} - - - - {SORT_OPTIONS.map((opt) => ( - - ))} - - - + + + + )} + ); } + +// ── Sort filter ── (extracted above) diff --git a/webui2/src/components/shared/query-input.stories.tsx b/webui2/src/components/shared/query-input.stories.tsx index a77f1e088a4b21aff2c47c294de44749f7956a24..2c179a4347e5fff78118a2922bca71ebe7f8a7f7 100644 --- a/webui2/src/components/shared/query-input.stories.tsx +++ b/webui2/src/components/shared/query-input.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { Search } from "lucide-react"; -import { expect, userEvent, within } from "storybook/test"; +import { expect, screen, userEvent, within } from "storybook/test"; import { useState } from "react"; import type { CompletionProvider } from "./query-input"; @@ -169,12 +169,12 @@ export const AutocompleteInteraction: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const input = canvas.getByRole("textbox"); + const input = canvas.getByRole("combobox"); // Type "label:" to trigger suggestions await userEvent.type(input, "label:"); - // Suggestions dropdown should appear - const bugOption = await canvas.findByText("bug"); + // Suggestions dropdown appears in a portal outside the canvas + const bugOption = await screen.findByText("bug"); await expect(bugOption).toBeVisible(); // First suggestion is already highlighted — press Enter to select diff --git a/webui2/src/components/shared/query-input.tsx b/webui2/src/components/shared/query-input.tsx index 881685145c6aef6623bcd9e580c8841ff779258d..922f376ea96a52856e1395be1749c991c210cfbd 100644 --- a/webui2/src/components/shared/query-input.tsx +++ b/webui2/src/components/shared/query-input.tsx @@ -5,6 +5,16 @@ // 2. The real floats on top with transparent text and bg, so the caret // is visible but the text itself is hidden in favour of the backdrop. +import { + useFloating, + useDismiss, + useListNavigation, + useInteractions, + offset, + flip, + size, + FloatingPortal, +} from "@floating-ui/react"; import { createContext, useContext, @@ -16,9 +26,10 @@ import { type ReactNode, } from "react"; +import * as Listbox from "@/components/ui/listbox"; import { cn } from "@/lib/utils"; -// ── Public types ────────────────────────────────────────────────────────────── +// ── Public types ────────��──────────────────────��────────────────────────────── export interface Suggestion { /** What gets inserted into the input (already quoted if needed). */ @@ -48,7 +59,7 @@ export interface SyntaxRule { highlightClass: string; } -// ── Defaults ────────────────────────────────────────────────────────────────── +// ── Defaults ────���────────────────────────────���──────────────────────────────── const DEFAULT_SYNTAX_RULES: SyntaxRule[] = [ { match: "status:open", highlightClass: "text-green-600 dark:text-green-400" }, @@ -56,7 +67,7 @@ const DEFAULT_SYNTAX_RULES: SyntaxRule[] = [ { match: (t) => t.startsWith("sort:"), highlightClass: "text-orange-600 dark:text-orange-400" }, ]; -// ── Segment parsing ─────────────────────────────────────────────────────────── +// ── Segment parsing ────────���──────────────────────────────��─────────────────── interface Segment { text: string; @@ -189,20 +200,26 @@ function getTokenEnd(value: string, tokenStart: number): number { return value.length; } -// ── Context ─────────────────────────────────────────────────────────────────── +// ── Context ───────��───────────────────────────────────���─────────────────────── interface QueryInputContextValue { value: string; segments: Segment[]; inputRef: React.RefObject; suggestions: Suggestion[]; - activeIndex: number; + activeIndex: number | null; showDropdown: boolean; loading: boolean; handleChange: (e: ChangeEvent) => void; handleKeyDown: (e: React.KeyboardEvent) => void; handleSelect: (e: React.SyntheticEvent) => void; selectSuggestion: (index: number) => void; + // floating-ui + floatingRef: (node: HTMLElement | null) => void; + floatingStyles: React.CSSProperties; + getFloatingProps: () => Record; + getItemProps: (userProps?: Record) => Record; + elementsRef: React.MutableRefObject<(HTMLElement | null)[]>; } const QueryInputContext = createContext(null); @@ -213,7 +230,7 @@ function useQueryInput() { return ctx; } -// ── Components ──────────────────────────────────────────────────────────────── +// ── Components ───────────���─────────────────────────────��────────────────────── interface RootProps { value: string; @@ -236,15 +253,57 @@ export function Root({ }: RootProps) { const inputRef = useRef(null); const [completion, setCompletion] = useState(null); - const [activeIndex, setActiveIndex] = useState(0); + const [activeIndex, setActiveIndex] = useState(null); const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(false); + const elementsRef = useRef<(HTMLElement | null)[]>([]); + + const showDropdown = suggestions.length > 0; + const segments = useMemo( () => parseSegments(value, providers, syntaxRules), [value, providers, syntaxRules], ); + // floating-ui for dropdown positioning + const { refs, floatingStyles, context } = useFloating({ + open: showDropdown || loading, + onOpenChange(nextOpen) { + if (!nextOpen) { + setCompletion(null); + setSuggestions([]); + } + }, + placement: "bottom-start", + middleware: [ + offset(4), + flip(), + size({ + apply({ rects, elements }) { + Object.assign(elements.floating.style, { + minWidth: `${rects.reference.width}px`, + }); + }, + }), + ], + }); + + const dismiss = useDismiss(context, { escapeKey: true, outsidePress: true }); + const listNav = useListNavigation(context, { + listRef: elementsRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + virtual: true, + focusItemOnOpen: false, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + dismiss, + listNav, + ]); + // Fetch suggestions when completion changes useEffect(() => { if (!completion) { @@ -261,11 +320,13 @@ export function Root({ void result.then((items) => { if (!cancelled) { setSuggestions(items); + setActiveIndex(items.length > 0 ? 0 : null); setLoading(false); } }); } else { setSuggestions(result); + setActiveIndex(result.length > 0 ? 0 : null); setLoading(false); } @@ -277,7 +338,7 @@ export function Root({ function updateCompletion(newValue: string, cursor: number) { const info = getCompletionInfo(newValue, cursor, providers); setCompletion(info); - setActiveIndex(0); + setActiveIndex(null); } function handleChange(e: ChangeEvent) { @@ -317,31 +378,23 @@ export function Root({ } function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter" && !completion) { - e.preventDefault(); - onSubmit(); + if (e.key === "Enter") { + if (activeIndex != null && suggestions.length > 0) { + e.preventDefault(); + selectSuggestion(activeIndex); + } else if (!completion) { + e.preventDefault(); + onSubmit(); + } return; } - if (!completion || suggestions.length === 0) return; - - if (e.key === "ArrowDown") { - e.preventDefault(); - setActiveIndex((i) => (i + 1) % suggestions.length); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length); - } else if (e.key === "Enter" || e.key === "Tab") { + if (e.key === "Tab" && activeIndex != null && suggestions.length > 0) { e.preventDefault(); selectSuggestion(activeIndex); - } else if (e.key === "Escape") { - setCompletion(null); - setSuggestions([]); } } - const showDropdown = suggestions.length > 0; - const ctx: QueryInputContextValue = { value, segments, @@ -354,17 +407,24 @@ export function Root({ handleKeyDown, handleSelect, selectSuggestion, + floatingRef: refs.setFloating, + floatingStyles, + getFloatingProps, + getItemProps, + elementsRef, }; return (
inputRef.current?.focus()} + {...getReferenceProps()} > {children}
@@ -390,7 +450,8 @@ interface InputProps { } export function Input({ placeholder, className }: InputProps) { - const { value, segments, inputRef, handleChange, handleKeyDown, handleSelect } = useQueryInput(); + const { value, segments, inputRef, handleChange, handleKeyDown, handleSelect, activeIndex, suggestions } = + useQueryInput(); return ( <> @@ -411,6 +472,11 @@ export function Input({ placeholder, className }: InputProps) { onChange={handleChange} onKeyDown={handleKeyDown} onSelect={handleSelect} + role="combobox" + aria-expanded={suggestions.length > 0} + aria-activedescendant={ + activeIndex != null ? `query-option-${activeIndex}` : undefined + } className={cn( "caret-foreground placeholder:text-muted-foreground relative w-full bg-transparent py-2 pr-3 pl-9 font-mono text-sm text-transparent outline-hidden placeholder:font-sans", className, @@ -423,34 +489,53 @@ export function Input({ placeholder, className }: InputProps) { } export function Completions() { - const { suggestions, activeIndex, showDropdown, loading, selectSuggestion } = useQueryInput(); + const { + suggestions, + activeIndex, + showDropdown, + loading, + selectSuggestion, + floatingRef, + floatingStyles, + getFloatingProps, + getItemProps, + elementsRef, + } = useQueryInput(); if (!showDropdown && !loading) return null; return ( -
- {loading && suggestions.length === 0 && ( -
Loading…
- )} - {suggestions.map((s, i) => ( - - ))} -
+ + + {loading && suggestions.length === 0 && ( +
Loading…
+ )} + {suggestions.map((s, i) => ( + { + elementsRef.current[i] = el; + }} + active={activeIndex === i} + onMouseDown={(e) => { + e.preventDefault(); + selectSuggestion(i); + }} + {...getItemProps()} + > + {s.icon} + {s.label} + {s.description && ( + {s.description} + )} + + ))} +
+
); } diff --git a/webui2/src/components/ui/listbox.stories.tsx b/webui2/src/components/ui/listbox.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..246987d754a6c5dcadbfd9c2f98762851d8e5955 --- /dev/null +++ b/webui2/src/components/ui/listbox.stories.tsx @@ -0,0 +1,400 @@ +import { + useFloating, + useClick, + useDismiss, + useRole, + useListNavigation, + useTypeahead, + useInteractions, + offset, + flip, + FloatingPortal, + FloatingFocusManager, +} from "@floating-ui/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { GitBranch, ChevronDown, Tag } from "lucide-react"; +import { useRef, useState, useEffect } from "react"; + +import { Button } from "./button"; +import * as Listbox from "./listbox"; + +// We can't use `component:` for a namespace import, so we target Content as +// the "primary" component just to give Storybook a title. +const meta = { + title: "ui/Listbox", + parameters: { layout: "centered" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ── Simple single-select ───────────────────────────────────────────────────── + +const fruits = ["Apple", "Banana", "Cherry", "Dragonfruit", "Elderberry", "Fig", "Grape"]; + +function SimpleSelect() { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState("Cherry"); + const [activeIndex, setActiveIndex] = useState(null); + const selectedIndex = fruits.indexOf(selected); + + const elementsRef = useRef<(HTMLElement | null)[]>([]); + const labelsRef = useRef<(string | null)[]>([]); + + const { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange: setOpen, + placement: "bottom-start", + middleware: [offset(4), flip()], + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "listbox" }); + const listNav = useListNavigation(context, { + listRef: elementsRef, + activeIndex, + selectedIndex, + onNavigate: setActiveIndex, + loop: true, + }); + const typeahead = useTypeahead(context, { + listRef: labelsRef, + activeIndex, + onMatch: setActiveIndex, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + click, dismiss, role, listNav, typeahead, + ]); + + return ( + <> + + + {open && ( + + + + + {fruits.map((fruit, i) => { + labelsRef.current[i] = fruit; + return ( + { elementsRef.current[i] = el; }} + active={activeIndex === i} + selected={selected === fruit} + tabIndex={activeIndex === i ? 0 : -1} + {...getItemProps({ + onClick: () => { setSelected(fruit); setOpen(false); }, + })} + > + {fruit} + + ); + })} + + + + + )} + + ); +} + +export const SingleSelect: Story = { + render: () => , +}; + +// ── Multi-select with search ───────────────────────────────────────────────── + +const allTags = [ + { name: "bug", color: "rgb(252, 41, 41)" }, + { name: "enhancement", color: "rgb(0, 150, 255)" }, + { name: "documentation", color: "rgb(0, 180, 80)" }, + { name: "help wanted", color: "rgb(255, 152, 0)" }, + { name: "good first issue", color: "rgb(124, 58, 237)" }, + { name: "duplicate", color: "rgb(120, 120, 120)" }, + { name: "wontfix", color: "rgb(180, 180, 180)" }, +]; + +function MultiSelectWithSearch() { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState(["bug", "enhancement"]); + const [activeIndex, setActiveIndex] = useState(null); + + const elementsRef = useRef<(HTMLElement | null)[]>([]); + const searchRef = useRef(null); + + const { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange(nextOpen) { + setOpen(nextOpen); + if (!nextOpen) setSearch(""); + }, + placement: "bottom-start", + middleware: [offset(4), flip()], + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "listbox" }); + const listNav = useListNavigation(context, { + listRef: elementsRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + virtual: true, + focusItemOnOpen: false, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + click, dismiss, role, listNav, + ]); + + const filtered = search.trim() + ? allTags.filter((t) => t.name.toLowerCase().includes(search.toLowerCase())) + : allTags; + + useEffect(() => { + setActiveIndex(filtered.length > 0 ? 0 : null); + }, [filtered.length]); + + function toggle(name: string) { + setSelected((prev) => + prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name], + ); + } + + return ( + <> + + + {open && ( + + + + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && activeIndex != null) { + e.preventDefault(); + const tag = filtered[activeIndex]; + if (tag) toggle(tag.name); + } + }} + /> + + {filtered.length === 0 && No tags found} + {filtered.map((tag, i) => ( + { elementsRef.current[i] = el; }} + active={activeIndex === i} + selected={selected.includes(tag.name)} + {...getItemProps({ onClick: () => toggle(tag.name) })} + > + + {tag.name} + + ))} + + {selected.length > 0 && ( + + setSelected([])}> + Clear all + + + )} + + + + )} + + ); +} + +export const MultiSelectSearch: Story = { + render: () => , +}; + +// ── Grouped select ─────────────────────────────────────────────────────────── + +const branches = ["main", "develop", "feature/auth"]; +const tags = ["v1.0.0", "v1.1.0", "v2.0.0-rc1"]; + +function GroupedSelect() { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState("main"); + const [filter, setFilter] = useState(""); + const [activeIndex, setActiveIndex] = useState(null); + + const elementsRef = useRef<(HTMLElement | null)[]>([]); + const searchRef = useRef(null); + + const { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange(nextOpen) { + setOpen(nextOpen); + if (!nextOpen) setFilter(""); + }, + placement: "bottom-start", + middleware: [offset(4), flip()], + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "listbox" }); + const listNav = useListNavigation(context, { + listRef: elementsRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + virtual: true, + focusItemOnOpen: false, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + click, dismiss, role, listNav, + ]); + + const q = filter.toLowerCase(); + const filteredBranches = branches.filter((b) => b.toLowerCase().includes(q)); + const filteredTags = tags.filter((t) => t.toLowerCase().includes(q)); + const allFiltered = [...filteredBranches, ...filteredTags]; + + useEffect(() => { + setActiveIndex(allFiltered.length > 0 ? 0 : null); + }, [allFiltered.length]); + + let idx = 0; + + return ( + <> + + + {open && ( + + + + setFilter(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && activeIndex != null) { + e.preventDefault(); + const item = allFiltered[activeIndex]; + if (item) { setSelected(item); setOpen(false); } + } + }} + className="text-xs" + /> + + {filteredBranches.length > 0 && ( + + Branches + {filteredBranches.map((b) => { + const i = idx++; + return ( + { elementsRef.current[i] = el; }} + active={activeIndex === i} + selected={selected === b} + className="font-mono text-xs" + {...getItemProps({ + onClick: () => { setSelected(b); setOpen(false); }, + })} + > + + {b} + + ); + })} + + )} + {filteredTags.length > 0 && ( + + Tags + {filteredTags.map((t) => { + const i = idx++; + return ( + { elementsRef.current[i] = el; }} + active={activeIndex === i} + selected={selected === t} + className="font-mono text-xs" + {...getItemProps({ + onClick: () => { setSelected(t); setOpen(false); }, + })} + > + + {t} + + ); + })} + + )} + {allFiltered.length === 0 && } + + + + + )} + + ); +} + +export const Grouped: Story = { + render: () => , +}; diff --git a/webui2/src/components/ui/listbox.tsx b/webui2/src/components/ui/listbox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b8de1b8fa3bf82ee627aa4be50374e4484af6071 --- /dev/null +++ b/webui2/src/components/ui/listbox.tsx @@ -0,0 +1,144 @@ +// Pure presentational compound components for listbox/dropdown menus. +// No floating-ui logic — consumers wire hooks directly and pass refs/props. +// Each component forwards refs and spreads extra props for getFloatingProps, getItemProps, etc. + +import { Check, Search } from "lucide-react"; +import { forwardRef, type ComponentProps } from "react"; + +import { cn } from "@/lib/utils"; + +// ── Content ────────────────────────────────────────────────────────────────── + +const Content = forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Content.displayName = "Listbox.Content"; + +// ── ScrollArea ─────────────────────────────────────────────────────────────── + +const ScrollArea = forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +ScrollArea.displayName = "Listbox.ScrollArea"; + +// ── Search ─────────────────────────────────────────────────────────────────── + +const SearchInput = forwardRef< + HTMLInputElement, + ComponentProps<"input"> +>(({ className, ...props }, ref) => ( +
+ + +
+)); +SearchInput.displayName = "Listbox.Search"; + +// ── Group ──────────────────────────────────────────────────────────────────── + +function Group({ className, ...props }: ComponentProps<"div">) { + return
; +} + +// ── GroupLabel ──────────────────────────────────────────────────────────────── + +function GroupLabel({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +// ── Item ───────────────────────────────────────────────────────────────────── + +interface ItemProps extends ComponentProps<"button"> { + /** Keyboard-highlighted (arrow key navigation). */ + active?: boolean; + /** Currently selected / checked. */ + selected?: boolean; +} + +const Item = forwardRef( + ({ active, selected, className, children, ...props }, ref) => ( + + ), +); +Item.displayName = "Listbox.Item"; + +// ── Empty ──────────────────────────────────────────────────────────────────── + +function Empty({ className, children = "No results", ...props }: ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +// ── Footer ─────────────────────────────────────────────────────────────────── + +function Footer({ className, ...props }: ComponentProps<"div">) { + return
; +} + +// ── FooterButton ───────────────────────────────────────────────────────────── + +function FooterButton({ className, ...props }: ComponentProps<"button">) { + return ( +