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>
281 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|