refactor(web): migrate dropdown menus to floating-ui with keyboard navigation

Quentin Gliech and Claude Opus 4.6 (1M context) created

Replace Base UI Popover in all interactive list menus with @floating-ui/react
hooks (useFloating, useListNavigation, useInteractions, etc.) for proper
keyboard-navigable listboxes with ARIA roles.

- Add @floating-ui/react dependency
- Create pure presentational Listbox.* compound components in ui/listbox.tsx
  (Content, ScrollArea, Search, Item, Group, GroupLabel, Empty, Footer)
- Migrate IssueFilters (sort, labels, authors) with full keyboard nav
- Migrate LabelEditor to floating-ui with roving tabIndex
- Migrate RefSelector with virtual focus and grouped items
- Migrate QueryInput completions to FloatingPortal + useListNavigation
- Add stories for Listbox primitives, IssueFilters, and LabelEditor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

webui2/package.json                                                  |   1 
webui2/pnpm-lock.yaml                                                |  17 
webui2/src/components/bugs/label-editor.stories.tsx                  | 156 
webui2/src/components/bugs/label-editor.tsx                          | 103 
webui2/src/components/code/__snapshots__/file-viewer.test.tsx.snap   |   4 
webui2/src/components/code/__snapshots__/ref-selector.test.tsx.snap  |  81 
webui2/src/components/code/ref-selector.stories.tsx                  |   6 
webui2/src/components/code/ref-selector.tsx                          | 256 
webui2/src/components/shared/__snapshots__/query-input.test.tsx.snap |  10 
webui2/src/components/shared/issue-filters.stories.tsx               |  91 
webui2/src/components/shared/issue-filters.tsx                       | 782 
webui2/src/components/shared/query-input.stories.tsx                 |   8 
webui2/src/components/shared/query-input.tsx                         | 187 
webui2/src/components/ui/listbox.stories.tsx                         | 400 
webui2/src/components/ui/listbox.tsx                                 | 144 
webui2/src/routes/$repo/_code.tsx                                    |   2 
16 files changed, 1,767 insertions(+), 481 deletions(-)

Detailed changes

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",

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': {}

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<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 />,
+};

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<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>

webui2/src/components/code/__snapshots__/file-viewer.test.tsx.snap πŸ”—

@@ -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"

webui2/src/components/code/__snapshots__/ref-selector.test.tsx.snap πŸ”—

@@ -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>
 `;

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(),
   },

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<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>
   );
 }

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"
     />
     <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"

webui2/src/components/shared/issue-filters.stories.tsx πŸ”—

@@ -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 />,
+};

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 (
+    <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)

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

webui2/src/components/shared/query-input.tsx πŸ”—

@@ -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>
   );
 }

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<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 />,
+};

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<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 };

webui2/src/routes/$repo/_code.tsx πŸ”—

@@ -99,7 +99,7 @@ function CodeLayout() {
               History
             </ButtonLink>
           )}
-          <RefSelector refs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
+          <RefSelector gitRefs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
         </div>
       </div>