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:
@ -1,13 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { SmallDropdown, type SmallDropdownOption } from "@/shared";
|
||||
|
||||
import { cn, zIndex } from "@/shared";
|
||||
|
||||
export interface TicketViewportSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export type TicketViewportSelectOption = SmallDropdownOption;
|
||||
|
||||
export interface TicketViewportSelectProps {
|
||||
ariaLabel?: string;
|
||||
@ -19,18 +12,6 @@ export interface TicketViewportSelectProps {
|
||||
disabled?: 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;
|
||||
|
||||
export function TicketViewportSelect({
|
||||
ariaLabel: ariaLabelProp,
|
||||
"aria-label": ariaLabelAttribute,
|
||||
@ -40,153 +21,14 @@ export function TicketViewportSelect({
|
||||
className,
|
||||
disabled = false,
|
||||
}: TicketViewportSelectProps) {
|
||||
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 selected = options.find((option) => option.value === value) ?? options[0];
|
||||
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 });
|
||||
}, []);
|
||||
|
||||
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;
|
||||
}
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center justify-between gap-2 rounded-md bg-raised px-2 text-xs text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
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}
|
||||
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) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
disabled={option.disabled}
|
||||
onClick={() => {
|
||||
if (option.disabled) return;
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-h-8 w-full items-center px-2 text-left text-xs text-content",
|
||||
"hover:bg-raised focus:bg-raised focus:outline-none",
|
||||
option.value === value && "bg-raised",
|
||||
option.disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
<SmallDropdown
|
||||
aria-label={ariaLabelAttribute ?? ariaLabelProp}
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user