Detailed changes
@@ -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",
@@ -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': {}
@@ -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<Array<{ name: string; color: LabelColor }>>([
+ 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<number | null>(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 (
+ <div className="w-64">
+ <div className="mb-2 flex items-center justify-between">
+ <SectionHeading className="mb-0">Labels</SectionHeading>
+ <button
+ ref={refs.setReference}
+ className="text-muted-foreground hover:text-foreground"
+ {...getReferenceProps()}
+ >
+ <Settings2 className="size-3.5" />
+ </button>
+ </div>
+
+ {open && (
+ <FloatingPortal>
+ <FloatingFocusManager context={context} modal={false}>
+ <Listbox.Content
+ ref={refs.setFloating}
+ style={floatingStyles}
+ className="w-56"
+ {...getFloatingProps()}
+ >
+ <div className="text-muted-foreground mb-1 px-3 pt-2 text-xs font-medium">
+ Apply labels
+ </div>
+ <Listbox.ScrollArea>
+ {allLabels.map((label, i) => {
+ const active = currentNames.has(label.name);
+ return (
+ <Listbox.Item
+ key={label.name}
+ ref={(el) => { elementsRef.current[i] = el; }}
+ active={activeIndex === i}
+ selected={active}
+ tabIndex={activeIndex === i ? 0 : -1}
+ {...getItemProps({ onClick: () => toggleLabel(label) })}
+ >
+ <span
+ className={`size-2 rounded-full border-2 transition-colors ${
+ active ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
+ }`}
+ style={
+ active
+ ? { backgroundColor: `rgb(${label.color.R},${label.color.G},${label.color.B})` }
+ : {}
+ }
+ />
+ <LabelBadge name={label.name} color={label.color} />
+ </Listbox.Item>
+ );
+ })}
+ </Listbox.ScrollArea>
+ </Listbox.Content>
+ </FloatingFocusManager>
+ </FloatingPortal>
+ )}
+
+ {current.length === 0 ? (
+ <p className="text-muted-foreground text-sm">None yet</p>
+ ) : (
+ <div className="flex flex-wrap gap-1">
+ {current.map((label) => (
+ <LabelBadge key={label.name} name={label.name} color={label.color} />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
+const meta = {
+ title: "bugs/LabelEditor",
+ parameters: { layout: "centered" },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+ render: () => <LabelEditorDemo />,
+};
@@ -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<number | null>(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 (
<div>
<div className="mb-2 flex items-center justify-between">
<SectionHeading className="mb-0">Labels</SectionHeading>
{user && validLabels.length > 0 && (
- <Popover>
- <PopoverTrigger className="text-muted-foreground hover:text-foreground">
- <Settings2 className="size-3.5" />
- </PopoverTrigger>
- <PopoverContent align="end" className="w-56 p-2">
- <p className="text-muted-foreground mb-2 px-2 text-xs font-medium">Apply labels</p>
- <div className="space-y-1">
- {validLabels.map((label) => {
+ <button
+ ref={refs.setReference}
+ className="text-muted-foreground hover:text-foreground"
+ {...getReferenceProps()}
+ >
+ <Settings2 className="size-3.5" />
+ </button>
+ )}
+ </div>
+
+ {open && (
+ <FloatingPortal>
+ <FloatingFocusManager context={context} modal={false}>
+ <Listbox.Content
+ ref={refs.setFloating}
+ style={floatingStyles}
+ className="w-56"
+ {...getFloatingProps()}
+ >
+ <div className="text-muted-foreground mb-1 px-3 pt-2 text-xs font-medium">
+ Apply labels
+ </div>
+ <Listbox.ScrollArea>
+ {validLabels.map((label, i) => {
const active = currentNames.has(label.name);
return (
- <button
+ <Listbox.Item
key={label.name}
- onClick={() => {
- void toggleLabel(label.name);
+ ref={(el) => {
+ elementsRef.current[i] = el;
}}
- className="hover:bg-muted flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
+ active={activeIndex === i}
+ selected={active}
+ tabIndex={activeIndex === i ? 0 : -1}
+ {...getItemProps({
+ onClick: () => {
+ void toggleLabel(label.name);
+ },
+ })}
>
<span
className={`size-2 rounded-full border-2 transition-colors ${
@@ -77,14 +142,14 @@ export function LabelEditor({ bugPrefix, currentLabels, ref_, validLabels }: Lab
}
/>
<LabelBadge name={label.name} color={label.color} />
- </button>
+ </Listbox.Item>
);
})}
- </div>
- </PopoverContent>
- </Popover>
- )}
- </div>
+ </Listbox.ScrollArea>
+ </Listbox.Content>
+ </FloatingFocusManager>
+ </FloatingPortal>
+ )}
{currentLabels.length === 0 ? (
<p className="text-muted-foreground text-sm">None yet</p>
@@ -14,7 +14,7 @@ exports[`FileViewer/BinaryFile matches snapshot 1`] = `
24.0 KB
</span>
<button
- class="group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 size-7"
+ class="group/button inline-flex shrink-0 items-center justify-center border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3"
data-slot="button"
tabindex="0"
title="Copy"
@@ -97,7 +97,7 @@ exports[`FileViewer/TruncatedFile matches snapshot 1`] = `
Β· truncated
</span>
<button
- class="group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 size-7"
+ class="group/button inline-flex shrink-0 items-center justify-center border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3"
data-slot="button"
tabindex="0"
title="Copy"
@@ -4,11 +4,10 @@ exports[`RefSelector/BranchesOnly matches snapshot 1`] = `
<div>
<button
aria-expanded="false"
- aria-haspopup="dialog"
+ aria-haspopup="listbox"
class="group/button inline-flex shrink-0 items-center justify-center border bg-clip-padding font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 gap-2 font-mono text-xs"
- data-base-ui-click-trigger=""
- data-slot="popover-trigger"
- id="base-ui-_r_8_"
+ data-slot="button"
+ role="combobox"
tabindex="0"
type="button"
>
@@ -40,26 +39,6 @@ exports[`RefSelector/BranchesOnly matches snapshot 1`] = `
/>
</svg>
develop
- <svg
- aria-hidden="true"
- class="lucide lucide-chevrons-up-down text-muted-foreground size-3"
- fill="none"
- height="24"
- stroke="currentColor"
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- viewBox="0 0 24 24"
- width="24"
- xmlns="http://www.w3.org/2000/svg"
- >
- <path
- d="m7 15 5 5 5-5"
- />
- <path
- d="m7 9 5-5 5 5"
- />
- </svg>
</button>
</div>
`;
@@ -68,11 +47,10 @@ exports[`RefSelector/Default matches snapshot 1`] = `
<div>
<button
aria-expanded="false"
- aria-haspopup="dialog"
+ aria-haspopup="listbox"
class="group/button inline-flex shrink-0 items-center justify-center border bg-clip-padding font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 gap-2 font-mono text-xs"
- data-base-ui-click-trigger=""
- data-slot="popover-trigger"
- id="base-ui-_r_2_"
+ data-slot="button"
+ role="combobox"
tabindex="0"
type="button"
>
@@ -104,26 +82,6 @@ exports[`RefSelector/Default matches snapshot 1`] = `
/>
</svg>
main
- <svg
- aria-hidden="true"
- class="lucide lucide-chevrons-up-down text-muted-foreground size-3"
- fill="none"
- height="24"
- stroke="currentColor"
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- viewBox="0 0 24 24"
- width="24"
- xmlns="http://www.w3.org/2000/svg"
- >
- <path
- d="m7 15 5 5 5-5"
- />
- <path
- d="m7 9 5-5 5 5"
- />
- </svg>
</button>
</div>
`;
@@ -132,11 +90,10 @@ exports[`RefSelector/OnTag matches snapshot 1`] = `
<div>
<button
aria-expanded="false"
- aria-haspopup="dialog"
+ aria-haspopup="listbox"
class="group/button inline-flex shrink-0 items-center justify-center border bg-clip-padding font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 gap-2 font-mono text-xs"
- data-base-ui-click-trigger=""
- data-slot="popover-trigger"
- id="base-ui-_r_5_"
+ data-slot="button"
+ role="combobox"
tabindex="0"
type="button"
>
@@ -168,26 +125,6 @@ exports[`RefSelector/OnTag matches snapshot 1`] = `
/>
</svg>
v1.1.0
- <svg
- aria-hidden="true"
- class="lucide lucide-chevrons-up-down text-muted-foreground size-3"
- fill="none"
- height="24"
- stroke="currentColor"
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- viewBox="0 0 24 24"
- width="24"
- xmlns="http://www.w3.org/2000/svg"
- >
- <path
- d="m7 15 5 5 5-5"
- />
- <path
- d="m7 9 5-5 5 5"
- />
- </svg>
</button>
</div>
`;
@@ -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(),
},
@@ -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<number | null>(null);
- const filtered = refs.filter((r) => r.shortName.toLowerCase().includes(filter.toLowerCase()));
+ const elementsRef = useRef<(HTMLElement | null)[]>([]);
+ const searchRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
+ if (e.key === "Enter" && activeIndex != null) {
+ e.preventDefault();
+ const ref = flatItems[activeIndex];
+ if (ref) {
+ onSelect(ref);
+ setOpen(false);
+ setFilter("");
+ }
+ }
+ }
+
+ let itemIndex = 0;
+
return (
- <Popover open={open} onOpenChange={setOpen}>
- <PopoverTrigger render={<Button variant="outline" size="sm" className="gap-2 font-mono text-xs" />}>
+ <>
+ <Button
+ ref={refs.setReference}
+ variant="outline"
+ size="sm"
+ className="gap-2 font-mono text-xs"
+ {...getReferenceProps()}
+ >
<GitBranch className="size-3.5" />
{currentRef}
- <ChevronsUpDown className="text-muted-foreground size-3" />
- </PopoverTrigger>
- <PopoverContent align="start" className="w-64 p-2">
- <p className="text-muted-foreground mb-2 px-1 text-xs font-semibold">Switch branch / tag</p>
- <Input
- placeholder="Filterβ¦"
- className="mb-2 h-7 text-xs"
- value={filter}
- onChange={(e) => setFilter(e.target.value)}
- autoFocus
- />
- <div className="max-h-64 overflow-y-auto">
- {branches.length > 0 && (
- <div className="mb-1">
- <p className="text-muted-foreground px-2 py-1 text-xs">Branches</p>
- {branches.map((ref) => (
- <RefItem
- key={ref.name}
- ref_={ref}
- active={ref.shortName === currentRef}
- onSelect={() => {
- onSelect(ref);
- setOpen(false);
- setFilter("");
- }}
- />
- ))}
- </div>
- )}
- {tags.length > 0 && (
- <div>
- <p className="text-muted-foreground px-2 py-1 text-xs">Tags</p>
- {tags.map((ref) => (
- <RefItem
- key={ref.name}
- ref_={ref}
- active={ref.shortName === currentRef}
- onSelect={() => {
- onSelect(ref);
- setOpen(false);
- setFilter("");
- }}
- />
- ))}
- </div>
- )}
- {filtered.length === 0 && (
- <p className="text-muted-foreground px-2 py-2 text-xs">No results</p>
- )}
- </div>
- </PopoverContent>
- </Popover>
+ </Button>
+
+ {open && (
+ <FloatingPortal>
+ <FloatingFocusManager context={context} modal={false} initialFocus={searchRef}>
+ <Listbox.Content
+ ref={refs.setFloating}
+ style={floatingStyles}
+ className="w-64"
+ {...getFloatingProps()}
+ >
+ <div className="text-muted-foreground px-3 pt-2 pb-1 text-xs font-semibold">
+ Switch branch / tag
+ </div>
+ <Listbox.Search
+ ref={searchRef}
+ placeholder="Filterβ¦"
+ value={filter}
+ onChange={(e) => setFilter(e.target.value)}
+ onKeyDown={handleSearchKeyDown}
+ className="text-xs"
+ aria-activedescendant={
+ activeIndex != null ? `ref-option-${activeIndex}` : undefined
+ }
+ />
+ <Listbox.ScrollArea>
+ {branches.length > 0 && (
+ <Listbox.Group>
+ <Listbox.GroupLabel>Branches</Listbox.GroupLabel>
+ {branches.map((ref) => {
+ const i = itemIndex++;
+ return (
+ <RefItem
+ key={ref.name}
+ id={`ref-option-${i}`}
+ ref_={ref}
+ index={i}
+ active={activeIndex === i}
+ selected={ref.shortName === currentRef}
+ elementsRef={elementsRef}
+ getItemProps={getItemProps}
+ onSelect={() => {
+ onSelect(ref);
+ setOpen(false);
+ setFilter("");
+ }}
+ />
+ );
+ })}
+ </Listbox.Group>
+ )}
+ {tags.length > 0 && (
+ <Listbox.Group>
+ <Listbox.GroupLabel>Tags</Listbox.GroupLabel>
+ {tags.map((ref) => {
+ const i = itemIndex++;
+ return (
+ <RefItem
+ key={ref.name}
+ id={`ref-option-${i}`}
+ ref_={ref}
+ index={i}
+ active={activeIndex === i}
+ selected={ref.shortName === currentRef}
+ elementsRef={elementsRef}
+ getItemProps={getItemProps}
+ onSelect={() => {
+ onSelect(ref);
+ setOpen(false);
+ setFilter("");
+ }}
+ />
+ );
+ })}
+ </Listbox.Group>
+ )}
+ {filtered.length === 0 && <Listbox.Empty />}
+ </Listbox.ScrollArea>
+ </Listbox.Content>
+ </FloatingFocusManager>
+ </FloatingPortal>
+ )}
+ </>
);
}
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<string, unknown>) => Record<string, unknown>;
onSelect: () => void;
}) {
return (
- <button
- onClick={onSelect}
- className={cn(
- "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-muted",
- active && "font-medium",
- )}
+ <Listbox.Item
+ id={id}
+ ref={(el) => {
+ elementsRef.current[index] = el;
+ }}
+ active={active}
+ selected={selected}
+ className={cn("text-xs", selected && "font-medium")}
+ {...getItemProps({ onClick: onSelect })}
>
{ref_.type === GitRefType.Branch ? (
<GitBranch className="text-muted-foreground size-3 shrink-0" />
@@ -106,7 +225,6 @@ function RefItem({
<Tag className="text-muted-foreground size-3 shrink-0" />
)}
<span className="flex-1 truncate font-mono">{ref_.shortName}</span>
- {active && <Check className="text-muted-foreground size-3" />}
- </button>
+ </Listbox.Item>
);
}
@@ -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"
/>
<input
+ aria-expanded="false"
autocomplete="off"
class="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"
placeholder="Type label: to see async loadingβ¦"
+ role="combobox"
spellcheck="false"
type="text"
value=""
@@ -97,9 +99,11 @@ exports[`QueryInput/AutocompleteInteraction matches snapshot 1`] = `
</span>
</div>
<input
+ aria-expanded="false"
autocomplete="off"
class="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"
placeholder="Type label: to test autocompleteβ¦"
+ role="combobox"
spellcheck="false"
type="text"
value="label:bug "
@@ -155,9 +159,11 @@ exports[`QueryInput/Default matches snapshot 1`] = `
</span>
</div>
<input
+ aria-expanded="false"
autocomplete="off"
class="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"
placeholder="status:open author:β¦ label:β¦"
+ role="combobox"
spellcheck="false"
type="text"
value="status:open"
@@ -219,9 +225,11 @@ exports[`QueryInput/SyntaxOnly matches snapshot 1`] = `
</span>
</div>
<input
+ aria-expanded="false"
autocomplete="off"
class="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"
placeholder="Searchβ¦"
+ role="combobox"
spellcheck="false"
type="text"
value="status:open label:bug"
@@ -315,9 +323,11 @@ exports[`QueryInput/WithFilters matches snapshot 1`] = `
</span>
</div>
<input
+ aria-expanded="false"
autocomplete="off"
class="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"
placeholder="status:open author:β¦ label:β¦"
+ role="combobox"
spellcheck="false"
type="text"
value="status:open label:bug author:janedoe fix login"
@@ -0,0 +1,91 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn } from "storybook/test";
+import { useState } from "react";
+
+import type { SortValue } from "@/lib/query-utils";
+
+import { IssueFilters, type LabelItem, type IdentityItem } from "./issue-filters";
+
+const meta = {
+ component: IssueFilters,
+ parameters: { layout: "centered" },
+} satisfies Meta<typeof IssueFilters>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+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<string[]>([]);
+ const [selectedAuthorId, setSelectedAuthorId] = useState<string | null>(null);
+ const [sort, setSort] = useState<SortValue>("creation-desc");
+
+ return (
+ <div className="flex flex-col items-start gap-4">
+ <IssueFilters
+ labels={sampleLabels}
+ identities={sampleIdentities}
+ selectedLabels={selectedLabels}
+ onLabelsChange={setSelectedLabels}
+ selectedAuthorId={selectedAuthorId}
+ onAuthorChange={(id) => setSelectedAuthorId(id)}
+ recentAuthorIds={["abc1", "abc3"]}
+ sort={sort}
+ onSortChange={setSort}
+ />
+ <div className="text-muted-foreground text-xs">
+ Labels: {selectedLabels.join(", ") || "none"} Β· Author: {selectedAuthorId ?? "none"} Β· Sort: {sort}
+ </div>
+ </div>
+ );
+}
+
+export const InteractiveState: Story = {
+ render: () => <Interactive />,
+};
@@ -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 (
+ <div className="flex shrink-0 items-center gap-1">
+ <LabelFilter
+ validLabels={validLabels}
+ selectedLabels={selectedLabels}
+ onLabelsChange={onLabelsChange}
+ />
+ <AuthorFilter
+ allIdentities={allIdentities}
+ selectedAuthorId={selectedAuthorId}
+ selectedAuthorIdentity={selectedAuthorIdentity}
+ onAuthorChange={onAuthorChange}
+ recentAuthorIds={recentAuthorIds}
+ currentUserId={user?.id ?? null}
+ />
+ <SortFilter sort={sort} onSortChange={onSortChange} />
+ </div>
+ );
+}
+
+// ββ Sort filter ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function SortFilter({
+ sort,
+ onSortChange,
+}: {
+ sort: SortValue;
+ onSortChange: (sort: SortValue) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [activeIndex, setActiveIndex] = useState<number | null>(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 (
+ <>
+ <button
+ ref={refs.setReference}
+ className={cn(
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
+ sort !== "creation-desc"
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
+ )}
+ {...getReferenceProps()}
+ >
+ <ArrowUpDown className="size-3.5" />
+ {SORT_OPTIONS.find((o) => o.value === sort)?.label ?? "Sort"}
+ <ChevronDown className="size-3" />
+ </button>
+
+ {open && (
+ <FloatingPortal>
+ <FloatingFocusManager context={context} modal={false}>
+ <Listbox.Content
+ ref={refs.setFloating}
+ style={floatingStyles}
+ {...getFloatingProps()}
+ >
+ <Listbox.ScrollArea>
+ {SORT_OPTIONS.map((opt, i) => {
+ labelsRef.current[i] = opt.label;
+ return (
+ <Listbox.Item
+ key={opt.value}
+ ref={(el) => {
+ 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}
+ </Listbox.Item>
+ );
+ })}
+ </Listbox.ScrollArea>
+ </Listbox.Content>
+ </FloatingFocusManager>
+ </FloatingPortal>
+ )}
+ </>
+ );
+}
+
+// ββ 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<number | null>(null);
+
+ const elementsRef = useRef<(HTMLElement | null)[]>([]);
+ const searchRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
+ if (e.key === "Enter" && activeIndex != null) {
+ e.preventDefault();
+ const label = sortedLabels[activeIndex];
+ if (label) toggleLabel(label.name);
+ }
+ }
+
+ return (
+ <>
+ <button
+ ref={refs.setReference}
+ className={cn(
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+ selectedLabels.length > 0
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
+ )}
+ {...getReferenceProps()}
+ >
+ <Tag className="size-3.5" />
+ Labels
+ {selectedLabels.length > 0 && (
+ <span className="bg-muted rounded-full px-1.5 py-0.5 text-xs leading-none">
+ {selectedLabels.length}
+ </span>
+ )}
+ <ChevronDown className="size-3" />
+ </button>
+
+ {open && (
+ <FloatingPortal>
+ <FloatingFocusManager context={context} modal={false} initialFocus={searchRef}>
+ <Listbox.Content
+ ref={refs.setFloating}
+ style={floatingStyles}
+ {...getFloatingProps()}
+ >
+ <Listbox.Search
+ ref={searchRef}
+ placeholder="Search labelsβ¦"
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ onKeyDown={handleSearchKeyDown}
+ aria-activedescendant={
+ activeIndex != null ? `label-option-${activeIndex}` : undefined
+ }
+ />
+ <Listbox.ScrollArea>
+ {sortedLabels.length === 0 && <Listbox.Empty>No labels found</Listbox.Empty>}
+ {sortedLabels.map((label, i) => {
+ const active = selectedLabels.includes(label.name);
+ return (
+ <Listbox.Item
+ key={label.name}
+ id={`label-option-${i}`}
+ ref={(el) => {
+ elementsRef.current[i] = el;
+ }}
+ active={activeIndex === i}
+ selected={active}
+ {...getItemProps({
+ onClick: () => toggleLabel(label.name),
+ })}
+ >
+ <span
+ className="size-2 shrink-0 rounded-full"
+ style={{
+ backgroundColor: `rgb(${label.color.R},${label.color.G},${label.color.B})`,
+ opacity: active ? 1 : 0.35,
+ }}
+ />
+ <LabelBadge name={label.name} color={label.color} />
+ </Listbox.Item>
+ );
+ })}
+ </Listbox.ScrollArea>
+ {selectedLabels.length > 0 && (
+ <Listbox.Footer>
+ <Listbox.FooterButton onClick={() => onLabelsChange([])}>
+ <X className="size-3" />
+ Clear labels
+ </Listbox.FooterButton>
+ </Listbox.Footer>
+ )}
+ </Listbox.Content>
+ </FloatingFocusManager>
+ </FloatingPortal>
+ )}
+ </>
+ );
+}
+
+// ββ 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<number | null>(null);
+
+ const elementsRef = useRef<(HTMLElement | null)[]>([]);
+ const searchRef = useRef<HTMLInputElement>(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<string>();
- 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<HTMLInputElement>) {
+ 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 (
- <div className="flex shrink-0 items-center gap-1">
- {/* Label filter */}
- <Popover
- onOpenChange={(open) => {
- if (!open) setLabelSearch("");
- }}
+ <>
+ <button
+ ref={refs.setReference}
+ className={cn(
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+ selectedAuthorId
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
+ )}
+ {...getReferenceProps()}
>
- <PopoverTrigger
- className={cn(
- "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
- selectedLabels.length > 0
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
- )}
- >
- <Tag className="size-3.5" />
- Labels
- {selectedLabels.length > 0 && (
- <span className="bg-muted rounded-full px-1.5 py-0.5 text-xs leading-none">
- {selectedLabels.length}
- </span>
- )}
- <ChevronDown className="size-3" />
- </PopoverTrigger>
- <PopoverContent align="end" className="bg-popover w-56 p-0 shadow-lg">
- {/* Search */}
- <div className="border-border flex items-center gap-2 border-b px-3 py-2">
- <Search className="text-muted-foreground size-3.5 shrink-0" />
- <input
- autoFocus
- placeholder="Search labelsβ¦"
- value={labelSearch}
- onChange={(e) => setLabelSearch(e.target.value)}
- className="placeholder:text-muted-foreground w-full bg-transparent text-sm outline-hidden"
- />
- </div>
- <div className="max-h-64 overflow-y-auto p-1">
- {sortedLabels.length === 0 && (
- <p className="text-muted-foreground px-2 py-3 text-center text-xs">No labels found</p>
- )}
- {sortedLabels.map((label) => {
- const active = selectedLabels.includes(label.name);
- return (
- <button
- key={label.name}
- onClick={() => toggleLabel(label.name)}
- className="hover:bg-muted flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
- >
- <span
- className="size-2 shrink-0 rounded-full"
- style={{
- backgroundColor: `rgb(${label.color.R},${label.color.G},${label.color.B})`,
- opacity: active ? 1 : 0.35,
- }}
- />
- <LabelBadge name={label.name} color={label.color} />
- {active && <Check className="text-foreground ml-auto size-3.5 shrink-0" />}
- </button>
- );
- })}
- </div>
- {selectedLabels.length > 0 && (
- <div className="border-border border-t p-1">
- <button
- onClick={() => onLabelsChange([])}
- className="text-muted-foreground hover:bg-muted flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-xs"
- >
- <X className="size-3" />
- Clear labels
- </button>
- </div>
- )}
- </PopoverContent>
- </Popover>
-
- {/* Author filter */}
- <Popover
- onOpenChange={(open) => {
- if (!open) setAuthorSearch("");
- }}
- >
- <PopoverTrigger
- className={cn(
- "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
- selectedAuthorId
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
- )}
- >
- {selectedAuthorIdentity ? (
- <>
- <Avatar className="size-4">
- <AvatarImage
- src={selectedAuthorIdentity.avatarUrl ?? undefined}
- alt={selectedAuthorIdentity.displayName}
- />
- <AvatarFallback className="text-[8px]">
- {selectedAuthorIdentity.displayName.slice(0, 2).toUpperCase()}
- </AvatarFallback>
- </Avatar>
- {selectedAuthorIdentity.displayName}
- </>
- ) : (
- <>
- <User className="size-3.5" />
- Author
- </>
- )}
- <ChevronDown className="size-3" />
- </PopoverTrigger>
- <PopoverContent align="end" className="bg-popover w-56 p-0 shadow-lg">
- {/* Search */}
- <div className="border-border flex items-center gap-2 border-b px-3 py-2">
- <Search className="text-muted-foreground size-3.5 shrink-0" />
- <input
- autoFocus
- placeholder="Search authorsβ¦"
- value={authorSearch}
- onChange={(e) => setAuthorSearch(e.target.value)}
- className="placeholder:text-muted-foreground w-full bg-transparent text-sm outline-hidden"
- />
- </div>
- <div className="max-h-64 overflow-y-auto p-1">
- {visibleIdentities.length === 0 && (
- <p className="text-muted-foreground px-2 py-3 text-center text-xs">
- No authors found
- </p>
- )}
- {visibleIdentities.map((identity) => {
- const active = selectedAuthorId === identity.humanId;
- return (
- <button
- key={identity.id}
- onClick={() =>
- onAuthorChange(
- active ? null : identity.humanId,
- active ? null : authorQueryValue(identity),
- )
- }
- className="hover:bg-muted flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
- >
- <Avatar className="size-5 shrink-0">
- <AvatarImage src={identity.avatarUrl ?? undefined} alt={identity.displayName} />
- <AvatarFallback className="text-[8px]">
- {identity.displayName.slice(0, 2).toUpperCase()}
- </AvatarFallback>
- </Avatar>
- <div className="min-w-0 flex-1 text-left">
- <div className="truncate">{identity.displayName}</div>
- {identity.login && identity.login !== identity.displayName && (
- <div className="text-muted-foreground truncate text-xs">
- @{identity.login}
+ {selectedAuthorIdentity ? (
+ <>
+ <Avatar className="size-4">
+ <AvatarImage
+ src={selectedAuthorIdentity.avatarUrl ?? undefined}
+ alt={selectedAuthorIdentity.displayName}
+ />
+ <AvatarFallback className="text-[8px]">
+ {selectedAuthorIdentity.displayName.slice(0, 2).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ {selectedAuthorIdentity.displayName}
+ </>
+ ) : (
+ <>
+ <User className="size-3.5" />
+ Author
+ </>
+ )}
+ <ChevronDown className="size-3" />
+ </button>
+
+ {open && (
+ <FloatingPortal>
+ <FloatingFocusManager context={context} modal={false} initialFocus={searchRef}>
+ <Listbox.Content
+ ref={refs.setFloating}
+ style={floatingStyles}
+ {...getFloatingProps()}
+ >
+ <Listbox.Search
+ ref={searchRef}
+ placeholder="Search authorsβ¦"
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ onKeyDown={handleSearchKeyDown}
+ aria-activedescendant={
+ activeIndex != null ? `author-option-${activeIndex}` : undefined
+ }
+ />
+ <Listbox.ScrollArea>
+ {visibleIdentities.length === 0 && (
+ <Listbox.Empty>No authors found</Listbox.Empty>
+ )}
+ {visibleIdentities.map((identity, i) => {
+ const active = selectedAuthorId === identity.humanId;
+ return (
+ <Listbox.Item
+ key={identity.id}
+ id={`author-option-${i}`}
+ ref={(el) => {
+ elementsRef.current[i] = el;
+ }}
+ active={activeIndex === i}
+ selected={active}
+ {...getItemProps({
+ onClick: () => {
+ onAuthorChange(
+ active ? null : identity.humanId,
+ active ? null : authorQueryValue(identity),
+ );
+ setOpen(false);
+ },
+ })}
+ >
+ <Avatar className="size-5 shrink-0">
+ <AvatarImage
+ src={identity.avatarUrl ?? undefined}
+ alt={identity.displayName}
+ />
+ <AvatarFallback className="text-[8px]">
+ {identity.displayName.slice(0, 2).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ <div className="min-w-0 flex-1 text-left">
+ <div className="truncate">{identity.displayName}</div>
+ {identity.login && identity.login !== identity.displayName && (
+ <div className="text-muted-foreground truncate text-xs">
+ @{identity.login}
+ </div>
+ )}
</div>
- )}
+ </Listbox.Item>
+ );
+ })}
+ {!isSearching && allIdentities.length > INITIAL_AUTHOR_LIMIT && (
+ <div className="text-muted-foreground px-2 py-1.5 text-center text-xs">
+ {allIdentities.length - visibleIdentities.length} more β type to search
</div>
- {active && <Check className="text-foreground size-3.5 shrink-0" />}
- </button>
- );
- })}
- {!isSearching && allIdentities.length > INITIAL_AUTHOR_LIMIT && (
- <p className="text-muted-foreground px-2 py-1.5 text-center text-xs">
- {allIdentities.length - visibleIdentities.length} more β type to search
- </p>
- )}
- </div>
- {selectedAuthorId && (
- <div className="border-border border-t p-1">
- <button
- onClick={() => onAuthorChange(null, null)}
- className="text-muted-foreground hover:bg-muted flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-xs"
- >
- <X className="size-3" />
- Clear author
- </button>
- </div>
- )}
- </PopoverContent>
- </Popover>
-
- {/* Sort */}
- <Popover>
- <PopoverTrigger
- className={cn(
- "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors whitespace-nowrap",
- sort !== "creation-desc"
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
- )}
- >
- <ArrowUpDown className="size-3.5" />
- {SORT_OPTIONS.find((o) => o.value === sort)?.label ?? "Sort"}
- <ChevronDown className="size-3" />
- </PopoverTrigger>
- <PopoverContent align="end" className="bg-popover w-56 p-1 shadow-lg">
- {SORT_OPTIONS.map((opt) => (
- <button
- key={opt.value}
- onClick={() => onSortChange(opt.value)}
- className="hover:bg-muted flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm whitespace-nowrap"
- >
- {opt.label}
- {sort === opt.value && (
- <Check className="text-foreground ml-auto size-3.5 shrink-0" />
+ )}
+ </Listbox.ScrollArea>
+ {selectedAuthorId && (
+ <Listbox.Footer>
+ <Listbox.FooterButton
+ onClick={() => {
+ onAuthorChange(null, null);
+ setOpen(false);
+ }}
+ >
+ <X className="size-3" />
+ Clear author
+ </Listbox.FooterButton>
+ </Listbox.Footer>
)}
- </button>
- ))}
- </PopoverContent>
- </Popover>
- </div>
+ </Listbox.Content>
+ </FloatingFocusManager>
+ </FloatingPortal>
+ )}
+ </>
);
}
+
+// ββ Sort filter ββ (extracted above)
@@ -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
@@ -5,6 +5,16 @@
// 2. The real <input> 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<HTMLInputElement | null>;
suggestions: Suggestion[];
- activeIndex: number;
+ activeIndex: number | null;
showDropdown: boolean;
loading: boolean;
handleChange: (e: ChangeEvent<HTMLInputElement>) => void;
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
handleSelect: (e: React.SyntheticEvent<HTMLInputElement>) => void;
selectSuggestion: (index: number) => void;
+ // floating-ui
+ floatingRef: (node: HTMLElement | null) => void;
+ floatingStyles: React.CSSProperties;
+ getFloatingProps: () => Record<string, unknown>;
+ getItemProps: (userProps?: Record<string, unknown>) => Record<string, unknown>;
+ elementsRef: React.MutableRefObject<(HTMLElement | null)[]>;
}
const QueryInputContext = createContext<QueryInputContextValue | null>(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<HTMLInputElement>(null);
const [completion, setCompletion] = useState<CompletionInfo | null>(null);
- const [activeIndex, setActiveIndex] = useState(0);
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
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<HTMLInputElement>) {
@@ -317,31 +378,23 @@ export function Root({
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
- 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 (
<QueryInputContext value={ctx}>
<div
+ ref={refs.setReference}
className={cn(
"relative flex flex-1 items-center rounded-md border border-input bg-background",
"ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
className,
)}
onClick={() => inputRef.current?.focus()}
+ {...getReferenceProps()}
>
{children}
</div>
@@ -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 (
- <div className="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-md border shadow-md">
- {loading && suggestions.length === 0 && (
- <div className="text-muted-foreground px-3 py-2 text-sm">Loadingβ¦</div>
- )}
- {suggestions.map((s, i) => (
- <button
- key={`${s.value}-${s.label}`}
- onMouseDown={(e) => {
- e.preventDefault();
- selectSuggestion(i);
- }}
- className={cn(
- "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
- i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted",
- )}
- >
- {s.icon}
- <span className="font-mono">{s.label}</span>
- {s.description && (
- <span className="text-muted-foreground ml-auto text-xs">{s.description}</span>
- )}
- </button>
- ))}
- </div>
+ <FloatingPortal>
+ <Listbox.Content
+ ref={floatingRef}
+ style={floatingStyles}
+ {...getFloatingProps()}
+ >
+ {loading && suggestions.length === 0 && (
+ <div className="text-muted-foreground px-3 py-2 text-sm">Loadingβ¦</div>
+ )}
+ {suggestions.map((s, i) => (
+ <Listbox.Item
+ key={`${s.value}-${s.label}`}
+ id={`query-option-${i}`}
+ ref={(el) => {
+ elementsRef.current[i] = el;
+ }}
+ active={activeIndex === i}
+ onMouseDown={(e) => {
+ e.preventDefault();
+ selectSuggestion(i);
+ }}
+ {...getItemProps()}
+ >
+ {s.icon}
+ <span className="font-mono">{s.label}</span>
+ {s.description && (
+ <span className="text-muted-foreground ml-auto text-xs">{s.description}</span>
+ )}
+ </Listbox.Item>
+ ))}
+ </Listbox.Content>
+ </FloatingPortal>
);
}
@@ -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<typeof meta>;
+
+// ββ 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<number | null>(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 (
+ <>
+ <Button
+ ref={refs.setReference}
+ variant="outline"
+ className="min-w-[160px] justify-between"
+ {...getReferenceProps()}
+ >
+ {selected}
+ <ChevronDown className="text-muted-foreground size-3.5" />
+ </Button>
+
+ {open && (
+ <FloatingPortal>
+ <FloatingFocusManager context={context} modal={false}>
+ <Listbox.Content
+ ref={refs.setFloating}
+ style={floatingStyles}
+ {...getFloatingProps()}
+ >
+ <Listbox.ScrollArea>
+ {fruits.map((fruit, i) => {
+ labelsRef.current[i] = fruit;
+ return (
+ <Listbox.Item
+ key={fruit}
+ ref={(el) => { elementsRef.current[i] = el; }}
+ active={activeIndex === i}
+ selected={selected === fruit}
+ tabIndex={activeIndex === i ? 0 : -1}
+ {...getItemProps({
+ onClick: () => { setSelected(fruit); setOpen(false); },
+ })}
+ >
+ {fruit}
+ </Listbox.Item>
+ );
+ })}
+ </Listbox.ScrollArea>
+ </Listbox.Content>
+ </FloatingFocusManager>
+ </FloatingPortal>
+ )}
+ </>
+ );
+}
+
+export const SingleSelect: Story = {
+ render: () => <SimpleSelect />,
+};
+
+// ββ 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<string[]>(["bug", "enhancement"]);
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
+
+ const elementsRef = useRef<(HTMLElement | null)[]>([]);
+ const searchRef = useRef<HTMLInputElement>(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 (
+ <>
+ <Button
+ ref={refs.setReference}
+ variant="outline"
+ className="min-w-[160px] justify-between"
+ {...getReferenceProps()}
+ >
+ <Tag className="size-3.5" />
+ Labels ({selected.length})
+ <ChevronDown className="text-muted-foreground size-3.5" />
+ </Button>
+
+ {open && (
+ <FloatingPortal>
+ <FloatingFocusManager context={context} modal={false} initialFocus={searchRef}>
+ <Listbox.Content
+ ref={refs.setFloating}
+ style={floatingStyles}
+ {...getFloatingProps()}
+ >
+ <Listbox.Search
+ ref={searchRef}
+ placeholder="Search tagsβ¦"
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && activeIndex != null) {
+ e.preventDefault();
+ const tag = filtered[activeIndex];
+ if (tag) toggle(tag.name);
+ }
+ }}
+ />
+ <Listbox.ScrollArea>
+ {filtered.length === 0 && <Listbox.Empty>No tags found</Listbox.Empty>}
+ {filtered.map((tag, i) => (
+ <Listbox.Item
+ key={tag.name}
+ ref={(el) => { elementsRef.current[i] = el; }}
+ active={activeIndex === i}
+ selected={selected.includes(tag.name)}
+ {...getItemProps({ onClick: () => toggle(tag.name) })}
+ >
+ <span
+ className="size-2.5 shrink-0 rounded-full"
+ style={{ backgroundColor: tag.color }}
+ />
+ {tag.name}
+ </Listbox.Item>
+ ))}
+ </Listbox.ScrollArea>
+ {selected.length > 0 && (
+ <Listbox.Footer>
+ <Listbox.FooterButton onClick={() => setSelected([])}>
+ Clear all
+ </Listbox.FooterButton>
+ </Listbox.Footer>
+ )}
+ </Listbox.Content>
+ </FloatingFocusManager>
+ </FloatingPortal>
+ )}
+ </>
+ );
+}
+
+export const MultiSelectSearch: Story = {
+ render: () => <MultiSelectWithSearch />,
+};
+
+// ββ 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<number | null>(null);
+
+ const elementsRef = useRef<(HTMLElement | null)[]>([]);
+ const searchRef = useRef<HTMLInputElement>(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 (
+ <>
+ <Button
+ ref={refs.setReference}
+ variant="outline"
+ size="sm"
+ className="gap-2 font-mono text-xs"
+ {...getReferenceProps()}
+ >
+ <GitBranch className="size-3.5" />
+ {selected}
+ </Button>
+
+ {open && (
+ <FloatingPortal>
+ <FloatingFocusManager context={context} modal={false} initialFocus={searchRef}>
+ <Listbox.Content
+ ref={refs.setFloating}
+ style={floatingStyles}
+ className="w-64"
+ {...getFloatingProps()}
+ >
+ <Listbox.Search
+ ref={searchRef}
+ placeholder="Filterβ¦"
+ value={filter}
+ onChange={(e) => 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"
+ />
+ <Listbox.ScrollArea>
+ {filteredBranches.length > 0 && (
+ <Listbox.Group>
+ <Listbox.GroupLabel>Branches</Listbox.GroupLabel>
+ {filteredBranches.map((b) => {
+ const i = idx++;
+ return (
+ <Listbox.Item
+ key={b}
+ ref={(el) => { elementsRef.current[i] = el; }}
+ active={activeIndex === i}
+ selected={selected === b}
+ className="font-mono text-xs"
+ {...getItemProps({
+ onClick: () => { setSelected(b); setOpen(false); },
+ })}
+ >
+ <GitBranch className="text-muted-foreground size-3 shrink-0" />
+ {b}
+ </Listbox.Item>
+ );
+ })}
+ </Listbox.Group>
+ )}
+ {filteredTags.length > 0 && (
+ <Listbox.Group>
+ <Listbox.GroupLabel>Tags</Listbox.GroupLabel>
+ {filteredTags.map((t) => {
+ const i = idx++;
+ return (
+ <Listbox.Item
+ key={t}
+ ref={(el) => { elementsRef.current[i] = el; }}
+ active={activeIndex === i}
+ selected={selected === t}
+ className="font-mono text-xs"
+ {...getItemProps({
+ onClick: () => { setSelected(t); setOpen(false); },
+ })}
+ >
+ <Tag className="text-muted-foreground size-3 shrink-0" />
+ {t}
+ </Listbox.Item>
+ );
+ })}
+ </Listbox.Group>
+ )}
+ {allFiltered.length === 0 && <Listbox.Empty />}
+ </Listbox.ScrollArea>
+ </Listbox.Content>
+ </FloatingFocusManager>
+ </FloatingPortal>
+ )}
+ </>
+ );
+}
+
+export const Grouped: Story = {
+ render: () => <GroupedSelect />,
+};
@@ -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<HTMLDivElement, ComponentProps<"div">>(
+ ({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn(
+ "z-50 flex w-56 flex-col overflow-hidden rounded-md bg-popover text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden",
+ className,
+ )}
+ {...props}
+ />
+ ),
+);
+Content.displayName = "Listbox.Content";
+
+// ββ ScrollArea βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const ScrollArea = forwardRef<HTMLDivElement, ComponentProps<"div">>(
+ ({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("max-h-64 overflow-y-auto p-1", className)}
+ {...props}
+ />
+ ),
+);
+ScrollArea.displayName = "Listbox.ScrollArea";
+
+// ββ Search βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const SearchInput = forwardRef<
+ HTMLInputElement,
+ ComponentProps<"input">
+>(({ className, ...props }, ref) => (
+ <div className="border-border flex items-center gap-2 border-b px-3 py-2">
+ <Search className="text-muted-foreground size-3.5 shrink-0" />
+ <input
+ ref={ref}
+ type="text"
+ autoFocus
+ className={cn(
+ "placeholder:text-muted-foreground w-full bg-transparent text-sm outline-hidden",
+ className,
+ )}
+ {...props}
+ />
+ </div>
+));
+SearchInput.displayName = "Listbox.Search";
+
+// ββ Group ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function Group({ className, ...props }: ComponentProps<"div">) {
+ return <div className={cn("mb-1", className)} {...props} />;
+}
+
+// ββ GroupLabel ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function GroupLabel({ className, ...props }: ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("text-muted-foreground px-2 py-1 text-xs", className)}
+ {...props}
+ />
+ );
+}
+
+// ββ Item βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface ItemProps extends ComponentProps<"button"> {
+ /** Keyboard-highlighted (arrow key navigation). */
+ active?: boolean;
+ /** Currently selected / checked. */
+ selected?: boolean;
+}
+
+const Item = forwardRef<HTMLButtonElement, ItemProps>(
+ ({ active, selected, className, children, ...props }, ref) => (
+ <button
+ ref={ref}
+ type="button"
+ role="option"
+ aria-selected={selected}
+ data-active={active || undefined}
+ className={cn(
+ "flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
+ active ? "bg-accent text-accent-foreground" : "hover:bg-muted",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ {selected && <Check className="text-foreground ml-auto size-3.5 shrink-0" />}
+ </button>
+ ),
+);
+Item.displayName = "Listbox.Item";
+
+// ββ Empty ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function Empty({ className, children = "No results", ...props }: ComponentProps<"div">) {
+ return (
+ <div
+ className={cn("text-muted-foreground px-2 py-3 text-center text-xs", className)}
+ {...props}
+ >
+ {children}
+ </div>
+ );
+}
+
+// ββ Footer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function Footer({ className, ...props }: ComponentProps<"div">) {
+ return <div className={cn("border-border border-t p-1", className)} {...props} />;
+}
+
+// ββ FooterButton βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function FooterButton({ className, ...props }: ComponentProps<"button">) {
+ return (
+ <button
+ className={cn(
+ "text-muted-foreground hover:bg-muted flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-xs",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+// ββ Export ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export { Content, ScrollArea, SearchInput as Search, Group, GroupLabel, Item, Empty, Footer, FooterButton };
@@ -99,7 +99,7 @@ function CodeLayout() {
History
</ButtonLink>
)}
- <RefSelector refs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
+ <RefSelector gitRefs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
</div>
</div>