/**
* 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). */}