/** * Sprint-selection popup (ticket #38) — the sprint analogue of * {@link TicketPicker}. A modal that lists the project's sprints (via * {@link TicketGateway.listSprints}) and lets the caller pick one, or the * explicit **« Sans sprint »** entry to clear the assignment. * * Used by the ticket-creation form to attach a sprint at creation time, but it * is a generic read-only picker: it never mutates: it just hands the chosen * `Sprint` (or `null` for "no sprint") back through `onSelect`. * * Integration rules (mirrors {@link TicketPicker}, note * `ui-rework-sprint-scoping-contracts`): * - G5: mount at the **chrome/panel level**, never inside LayoutGrid/LeafView. A * focus-trap is mandatory because xterm captures keyboard focus. * - z-index `floatingWindowNested` (60): it opens from within other floating * surfaces (the create form), so it must sit above them. */ import { useEffect, useRef, useState } from "react"; import type { GatewayError, Sprint } from "@/domain"; import { Button, Spinner, cn, zIndex } from "@/shared"; import { useGateways } from "@/app/di"; export interface SprintPickerProps { /** Whether the popup is shown. Rendered as `null` when closed. */ open: boolean; projectId: string; /** The currently-selected sprint id (highlighted); `null`/absent ⇒ none. */ selectedSprintId?: string | null; /** Dialog heading (default "Sélectionner un sprint"). */ title?: string; /** Selection callback — a `Sprint`, or `null` for the "Sans sprint" entry. */ onSelect: (sprint: Sprint | null) => void; /** Called on Escape, backdrop click, or the Close button. */ onClose: () => void; } /** Selector for tabbable elements, used by the focus-trap. */ const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; export function SprintPicker({ open, projectId, selectedSprintId, title = "Sélectionner un sprint", onSelect, onClose, }: SprintPickerProps) { if (!open) return null; return ( ); } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } /** * The mounted picker. Split from {@link SprintPicker} so the sprint fetch only * runs while the popup is actually open — mounting/unmounting on `open` resets * state cleanly between openings. */ function SprintPickerBody({ projectId, selectedSprintId, title, onSelect, onClose, }: Required>) { const { ticket } = useGateways(); const dialogRef = useRef(null); const [sprints, setSprints] = useState(null); const [error, setError] = useState(null); // Load the project's sprints once on mount. useEffect(() => { let cancelled = false; setError(null); void ticket .listSprints(projectId) .then((list) => { if (!cancelled) setSprints(list); }) .catch((e) => { if (!cancelled) { setError(describe(e)); setSprints([]); } }); return () => { cancelled = true; }; }, [ticket, projectId]); // Focus-trap (G5): mirror TicketPicker — move focus in on mount, keep Tab // cycling inside the dialog, restore focus on close, and close on Escape. useEffect(() => { const previouslyFocused = document.activeElement as HTMLElement | null; const node = dialogRef.current; const first = node?.querySelector(FOCUSABLE); first?.focus(); function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { e.preventDefault(); onClose(); return; } if (e.key !== "Tab" || !node) return; const focusables = Array.from( node.querySelectorAll(FOCUSABLE), ).filter((el) => el.offsetParent !== null || el === document.activeElement); if (focusables.length === 0) return; const firstEl = focusables[0]; const lastEl = focusables[focusables.length - 1]; const active = document.activeElement; if (e.shiftKey && active === firstEl) { e.preventDefault(); lastEl.focus(); } else if (!e.shiftKey && active === lastEl) { e.preventDefault(); firstEl.focus(); } } document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("keydown", onKeyDown); previouslyFocused?.focus?.(); }; }, [onClose]); const ordered = sprints ? [...sprints].sort((a, b) => a.order - b.order) : []; function pick(sprint: Sprint | null) { onSelect(sprint); onClose(); } return (
{ // Backdrop click closes; clicks inside the panel don't bubble here. if (e.target === e.currentTarget) onClose(); }} >
{/* ── Header ── */}
{title}
{error && (

{error}

)} {/* ── Options ── */}
    {/* "Sans sprint" always available (clears the assignment). */}
  • pick(null)} />
  • {sprints === null ? (
  • Loading sprints…
  • ) : ( ordered.map((s) => (
  • pick(s)} />
  • )) )}
{sprints !== null && ordered.length === 0 && !error && (

No sprints yet.

)}
); } /** One selectable row (checkmark on the active entry), shared shape/styling. */ function SprintOption({ label, ariaLabel, count, selected, onClick, }: { label: string; ariaLabel: string; count?: number; selected: boolean; onClick: () => void; }) { return ( ); }