feat(ui): uniformise les droplist dynamiques sur le style du picker d'agent
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 <noreply@anthropic.com>
This commit is contained in:
@ -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";
|
||||
|
||||
296
frontend/src/shared/ui/SmallDropdown.tsx
Normal file
296
frontend/src/shared/ui/SmallDropdown.tsx
Normal file
@ -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<HTMLButtonElement | null>(null);
|
||||
const popupRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState<PopupPosition | null>(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 (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
id={id}
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? popupId : undefined}
|
||||
aria-activedescendant={activeOptionId}
|
||||
aria-invalid={invalid ? "true" : undefined}
|
||||
disabled={disabled}
|
||||
onClick={() => (open ? closePopup() : openPopup())}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
!open &&
|
||||
(event.key === "ArrowDown" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "Enter" ||
|
||||
event.key === " ")
|
||||
) {
|
||||
event.preventDefault();
|
||||
openPopup();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-between gap-2 rounded-md bg-raised text-content",
|
||||
"border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
invalid ? "border-danger" : "border-border",
|
||||
size === "md" ? "h-9 px-3 text-sm" : "h-8 px-2 text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{selected?.label ?? ""}</span>
|
||||
<span aria-hidden="true" className="text-muted">
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
{open &&
|
||||
position &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popupRef}
|
||||
id={popupId}
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
data-placement={position.placement}
|
||||
className="fixed overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-xl"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: position.width,
|
||||
maxHeight: position.maxHeight,
|
||||
zIndex: zIndex.transientPopup,
|
||||
}}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<button
|
||||
key={`${option.value}-${index}`}
|
||||
id={`${idBase}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
disabled={option.disabled}
|
||||
onMouseEnter={() => {
|
||||
if (!option.disabled) setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => selectOption(option)}
|
||||
className={cn(
|
||||
"flex min-h-8 w-full items-center px-2 text-left text-content",
|
||||
"hover:bg-raised focus:bg-raised focus:outline-none",
|
||||
size === "md" ? "text-sm" : "text-xs",
|
||||
(option.value === value || index === activeIndex) && "bg-raised",
|
||||
option.disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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(
|
||||
<SmallDropdown
|
||||
aria-label="test dropdown"
|
||||
value=""
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SmallDropdown
|
||||
aria-label="test dropdown"
|
||||
value=""
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user