From 9b1ced50beaf95e3491d200a290015e548253e7c Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 30 Jul 2026 12:55:57 +0200 Subject: [PATCH] feat(ui): uniformise les droplist dynamiques sur le style du picker d'agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduit SmallDropdown comme composant partagé et le fait adopter par AgentsPanel, ModelServerSelect, OpenCodeModeFields, TemplateEditor et TicketViewportSelect, pour que toutes les listes déroulantes dynamiques (agent, modèle, template, viewport) partagent la même apparence que le sélecteur d'agent de la création de ticket. Refs #114 Co-Authored-By: Claude Sonnet 5 --- frontend/src/features/agents/AgentsPanel.tsx | 120 +++---- frontend/src/features/agents/agents.test.tsx | 47 +-- .../first-run/FirstRunWizard.test.tsx | 30 +- .../features/first-run/OpenCodeModeFields.tsx | 77 ++--- .../first-run/ProfilesSettings.test.tsx | 16 +- .../model-servers/ModelServerSelect.tsx | 32 +- .../model-servers/modelServers.test.tsx | 16 +- frontend/src/features/skills/skills.test.tsx | 9 +- .../src/features/templates/TemplateEditor.tsx | 26 +- .../src/features/templates/templates.test.tsx | 14 +- .../features/tickets/TicketViewportSelect.tsx | 178 +---------- frontend/src/shared/index.ts | 3 + frontend/src/shared/ui/SmallDropdown.tsx | 296 ++++++++++++++++++ frontend/src/shared/ui/ui.test.tsx | 46 +++ 14 files changed, 550 insertions(+), 360 deletions(-) create mode 100644 frontend/src/shared/ui/SmallDropdown.tsx 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"); + }); +});