Files
IdeA/frontend/src/features/tickets/TicketViewportSelect.tsx
Blomios 05402c7544 fix(tickets): dropdowns ne sortent plus du viewport en bas d'écran (#3)
Remplace les <select> natifs de la vue Tickets (statut, priorité, tri,
lien, agent assigné/assistant, sprint) par TicketViewportSelect, qui
repositionne intelligemment son overlay selon l'espace disponible dans
la fenêtre au lieu de toujours ouvrir vers le bas.

QA vert : 80/80 (frontend/src/features/tickets), tsc --noEmit propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 18:29:17 +02:00

193 lines
6.2 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn, zIndex } from "@/shared";
export interface TicketViewportSelectOption {
value: string;
label: string;
disabled?: boolean;
}
export interface TicketViewportSelectProps {
ariaLabel?: string;
"aria-label"?: string;
value: string;
options: TicketViewportSelectOption[];
onChange: (value: string) => void;
className?: string;
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,
value,
options,
onChange,
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.menuDropdown,
}}
>
{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,
)}
</>
);
}