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>
193 lines
6.2 KiB
TypeScript
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,
|
|
)}
|
|
</>
|
|
);
|
|
}
|