Files
IdeA/frontend/src/features/tickets/SprintPicker.tsx
Blomios 0039958b82 feat(tickets): attribution d'un sprint à la création via popup SprintPicker (#38)
Nouveau composant SprintPicker permettant de choisir un sprint lors de
la création d'un ticket depuis TicketsPanel ; export ajouté à l'index
des features tickets. Frontend-pur.

QA vert : tsc --noEmit exit 0, vitest 63 fichiers / 634 tests passés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:03:41 +02:00

281 lines
8.6 KiB
TypeScript

/**
* 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 (
<SprintPickerBody
projectId={projectId}
selectedSprintId={selectedSprintId ?? null}
title={title}
onSelect={onSelect}
onClose={onClose}
/>
);
}
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<Omit<SprintPickerProps, "open">>) {
const { ticket } = useGateways();
const dialogRef = useRef<HTMLDivElement>(null);
const [sprints, setSprints] = useState<Sprint[] | null>(null);
const [error, setError] = useState<string | null>(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<HTMLElement>(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<HTMLElement>(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 (
<div
className="fixed inset-0 flex items-center justify-center bg-black/55 p-4"
style={{ zIndex: zIndex.floatingWindowNested }}
onMouseDown={(e) => {
// Backdrop click closes; clicks inside the panel don't bubble here.
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title}
className="flex max-h-[80vh] w-full max-w-sm flex-col overflow-hidden rounded-lg border border-border bg-surface shadow-xl"
>
{/* ── Header ── */}
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
<span className="text-sm font-medium text-content">{title}</span>
<Button
size="sm"
variant="ghost"
aria-label="close sprint picker"
onClick={onClose}
>
Close
</Button>
</header>
{error && (
<p
role="alert"
className="shrink-0 border-b border-danger/40 bg-danger/10 px-4 py-2 text-sm text-danger"
>
{error}
</p>
)}
{/* ── Options ── */}
<div className="min-h-0 flex-1 overflow-auto px-4 py-3">
<ul className="flex flex-col divide-y divide-border">
{/* "Sans sprint" always available (clears the assignment). */}
<li className="py-1.5 first:pt-0">
<SprintOption
label="Sans sprint"
ariaLabel="select no sprint"
selected={selectedSprintId === null}
onClick={() => pick(null)}
/>
</li>
{sprints === null ? (
<li className="flex items-center gap-2 py-2 text-sm text-muted">
<Spinner size={14} />
<span>Loading sprints…</span>
</li>
) : (
ordered.map((s) => (
<li key={s.id} className="py-1.5 last:pb-0">
<SprintOption
label={s.name}
ariaLabel={`select sprint ${s.name}`}
count={s.ticketCount}
selected={selectedSprintId === s.id}
onClick={() => pick(s)}
/>
</li>
))
)}
</ul>
{sprints !== null && ordered.length === 0 && !error && (
<p className="mt-2 text-sm text-muted">No sprints yet.</p>
)}
</div>
</div>
</div>
);
}
/** 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 (
<button
type="button"
role="option"
aria-selected={selected}
aria-label={ariaLabel}
onClick={onClick}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
"hover:bg-raised focus:bg-raised focus:outline-none",
selected && "bg-raised",
)}
>
<span
aria-hidden="true"
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center text-[10px] font-bold",
selected ? "text-primary" : "text-transparent",
)}
>
✓
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{label}
</span>
{count !== undefined && (
<span className="shrink-0 rounded-full bg-raised px-1.5 text-[10px] font-medium text-muted">
{count}
</span>
)}
</button>
);
}