diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index c9c78d8..7a2b245 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -16,7 +16,7 @@ import { useEffect, useState } from "react"; -import { Button, Input, Panel, Spinner, cn } from "@/shared"; +import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared"; import { TerminalView } from "@/features/terminals/TerminalView"; import { AnnouncementsPreview } from "@/features/announcements"; import { useDrift } from "@/features/templates/useDrift"; @@ -287,25 +287,19 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { > Template - + size="md" + options={[ + { value: "", label: "(none / from scratch)" }, + ...templates.map((t) => ({ value: t.id, label: t.name })), + ]} + /> {/* Profile selector — hidden when a template is chosen */} @@ -321,24 +315,21 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { )} {vm.profiles.length > 0 ? ( - + onChange={setNewProfileId} + className="w-full" + size="md" + options={[ + { value: "", label: "— select profile —" }, + ...vm.profiles.map((p) => ({ + value: p.id, + label: profileLabel(p), + })), + ]} + /> ) : ( 0 && ( - + options={[ + ...(vm.profiles.every((p) => p.id !== a.profileId) + ? [{ value: a.profileId, label: a.profileId }] + : []), + ...vm.profiles.map((p) => ({ + value: p.id, + label: profileLabel(p), + })), + ]} + /> )} {agentDrift && ( - {open && - position && - createPortal( -
- {options.map((option) => ( - - ))} -
, - document.body, - )} - + ); } diff --git a/frontend/src/shared/index.ts b/frontend/src/shared/index.ts index 3e95840..25e3788 100644 --- a/frontend/src/shared/index.ts +++ b/frontend/src/shared/index.ts @@ -43,3 +43,6 @@ export type { MenuBarProps, MenuBarMenu, MenuBarItem } from "./ui/MenuBar"; export { DockRegion } from "./ui/DockRegion"; export type { DockRegionProps, DockSide } from "./ui/DockRegion"; + +export { SmallDropdown } from "./ui/SmallDropdown"; +export type { SmallDropdownProps, SmallDropdownOption } from "./ui/SmallDropdown"; diff --git a/frontend/src/shared/ui/SmallDropdown.tsx b/frontend/src/shared/ui/SmallDropdown.tsx new file mode 100644 index 0000000..25a2943 --- /dev/null +++ b/frontend/src/shared/ui/SmallDropdown.tsx @@ -0,0 +1,296 @@ +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +import { cn } from "@/shared/lib/cn"; +import { zIndex } from "./zIndex"; + +export interface SmallDropdownOption { + value: string; + label: string; + disabled?: boolean; +} + +export interface SmallDropdownProps { + ariaLabel?: string; + "aria-label"?: string; + value: string; + options: SmallDropdownOption[]; + onChange: (value: string) => void; + id?: string; + className?: string; + disabled?: boolean; + size?: "sm" | "md"; + invalid?: boolean; +} + +interface PopupPosition { + top: number; + left: number; + width: number; + maxHeight: number; + placement: "top" | "bottom"; +} + +const VIEWPORT_MARGIN = 8; +const MIN_POPUP_HEIGHT = 96; +const MAX_POPUP_HEIGHT = 280; + +function firstEnabledIndex(options: SmallDropdownOption[]): number { + return options.findIndex((option) => !option.disabled); +} + +function nextEnabledIndex( + options: SmallDropdownOption[], + from: number, + direction: 1 | -1, +): number { + if (options.length === 0) return -1; + for (let offset = 1; offset <= options.length; offset += 1) { + const index = (from + offset * direction + options.length) % options.length; + if (!options[index]?.disabled) return index; + } + return -1; +} + +export function SmallDropdown({ + ariaLabel: ariaLabelProp, + "aria-label": ariaLabelAttribute, + value, + options, + onChange, + id, + className, + disabled = false, + size = "sm", + invalid = false, +}: SmallDropdownProps) { + const idBase = useId(); + const triggerRef = useRef(null); + const popupRef = useRef(null); + const [open, setOpen] = useState(false); + const [position, setPosition] = useState(null); + const selectedIndex = options.findIndex((option) => option.value === value); + const selected = selectedIndex >= 0 ? options[selectedIndex] : options[0]; + const [activeIndex, setActiveIndex] = useState( + selectedIndex >= 0 ? selectedIndex : firstEnabledIndex(options), + ); + const ariaLabel = ariaLabelAttribute ?? ariaLabelProp ?? "select option"; + + const updatePosition = useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) return; + + const rect = trigger.getBoundingClientRect(); + const viewportWidth = window.innerWidth || document.documentElement.clientWidth; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const popupWidth = Math.max(rect.width, 160); + const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN; + const spaceAbove = rect.top - VIEWPORT_MARGIN; + const placement = + spaceBelow < MIN_POPUP_HEIGHT && spaceAbove > spaceBelow ? "top" : "bottom"; + const available = Math.max( + MIN_POPUP_HEIGHT, + placement === "top" ? spaceAbove : spaceBelow, + ); + const maxHeight = Math.min(MAX_POPUP_HEIGHT, available); + const left = Math.min( + Math.max(VIEWPORT_MARGIN, rect.left), + Math.max(VIEWPORT_MARGIN, viewportWidth - popupWidth - VIEWPORT_MARGIN), + ); + const top = + placement === "top" + ? Math.max(VIEWPORT_MARGIN, rect.top - maxHeight - 4) + : Math.min(viewportHeight - VIEWPORT_MARGIN, rect.bottom + 4); + + setPosition({ top, left, width: popupWidth, maxHeight, placement }); + }, []); + + const openPopup = useCallback(() => { + if (disabled) return; + const nextActive = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex(options); + setActiveIndex(nextActive); + setOpen(true); + }, [disabled, options, selectedIndex]); + + const closePopup = useCallback(() => { + setOpen(false); + }, []); + + const selectOption = useCallback( + (option: SmallDropdownOption | undefined) => { + if (!option || option.disabled) return; + onChange(option.value); + closePopup(); + triggerRef.current?.focus(); + }, + [closePopup, onChange], + ); + + useLayoutEffect(() => { + if (!open) return; + updatePosition(); + }, [open, updatePosition]); + + useEffect(() => { + if (!open) return; + + function onPointerDown(event: PointerEvent) { + const target = event.target as Node | null; + if ( + target && + (triggerRef.current?.contains(target) || popupRef.current?.contains(target)) + ) { + return; + } + closePopup(); + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + closePopup(); + triggerRef.current?.focus(); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((current) => + nextEnabledIndex(options, current, event.key === "ArrowDown" ? 1 : -1), + ); + return; + } + if (event.key === "Home") { + event.preventDefault(); + setActiveIndex(firstEnabledIndex(options)); + return; + } + if (event.key === "End") { + event.preventDefault(); + setActiveIndex(nextEnabledIndex(options, 0, -1)); + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectOption(options[activeIndex]); + } + } + + const onViewportChange = () => { + updatePosition(); + }; + + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + window.addEventListener("resize", onViewportChange); + window.addEventListener("scroll", onViewportChange, true); + window.visualViewport?.addEventListener("resize", onViewportChange); + window.visualViewport?.addEventListener("scroll", onViewportChange); + + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + window.removeEventListener("resize", onViewportChange); + window.removeEventListener("scroll", onViewportChange, true); + window.visualViewport?.removeEventListener("resize", onViewportChange); + window.visualViewport?.removeEventListener("scroll", onViewportChange); + }; + }, [activeIndex, closePopup, open, options, selectOption, updatePosition]); + + const popupId = `${idBase}-listbox`; + const activeOptionId = + open && activeIndex >= 0 ? `${idBase}-option-${activeIndex}` : undefined; + + return ( + <> + + {open && + position && + createPortal( +
+ {options.map((option, index) => ( + + ))} +
, + document.body, + )} + + ); +} diff --git a/frontend/src/shared/ui/ui.test.tsx b/frontend/src/shared/ui/ui.test.tsx index bc1472b..03735fb 100644 --- a/frontend/src/shared/ui/ui.test.tsx +++ b/frontend/src/shared/ui/ui.test.tsx @@ -11,6 +11,7 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { Button } from "./Button"; import { Input } from "./Input"; import { Field } from "./Field"; +import { SmallDropdown } from "./SmallDropdown"; import { Tabs } from "./Tabs"; describe("Button", () => { @@ -115,3 +116,48 @@ describe("Tabs", () => { expect(onClose).toHaveBeenCalledWith("b"); }); }); + +describe("SmallDropdown", () => { + const options = [ + { value: "", label: "None" }, + { value: "a", label: "Alpha" }, + { value: "b", label: "Beta", disabled: true }, + ]; + + it("opens a listbox and reports the selected value", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByLabelText("test dropdown")); + fireEvent.click(screen.getByRole("option", { name: "Alpha" })); + + expect(onChange).toHaveBeenCalledWith("a"); + expect(screen.queryByRole("listbox")).toBeNull(); + }); + + it("supports keyboard selection and ignores disabled options", () => { + const onChange = vi.fn(); + render( + , + ); + + const trigger = screen.getByLabelText("test dropdown"); + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + fireEvent.keyDown(document, { key: "ArrowDown" }); + fireEvent.keyDown(document, { key: "Enter" }); + + expect(onChange).toHaveBeenCalledWith("a"); + }); +});