feat(tickets): multi-sélection dans TicketPicker (#41)

Le TicketPicker permet la sélection multiple de tickets ; SprintManager
consomme la sélection multiple. Frontend-pur.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 19:45:39 +02:00
parent 61e5e41f0d
commit daa93525d7
4 changed files with 245 additions and 61 deletions

View File

@ -20,9 +20,9 @@
* (SprintManager, MemoryEditor, …). Only the design-system-level `zIndex.ts`
* went into `shared/ui`.
*
* V1 implements single-select (a row click selects and closes). `"multi"` is in
* the frozen prop contract but not required this sprint; the surrounding
* structure keeps it extensible.
* Single-select: a row click resolves and closes, handing back one result.
* Multi-select (#41): a row click toggles a local selection set (the picker
* stays open); a footer button confirms and hands back the resolved array.
*/
import { useEffect, useRef, useState } from "react";
@ -119,6 +119,10 @@ function TicketPickerBody({
const dialogRef = useRef<HTMLDivElement>(null);
const [selecting, setSelecting] = useState(false);
const [resolveError, setResolveError] = useState<string | null>(null);
// Multi-select (#41): the locally-accumulated selection. Persists across
// filter/search changes so the user can build a batch from several queries.
const [selected, setSelected] = useState<Set<TicketRef>>(() => new Set());
const isMulti = selectionMode === "multi";
// Focus-trap (G5): remember the previously-focused element, move focus into
// the dialog on mount, keep Tab cycling inside it, and restore focus on close.
@ -159,22 +163,50 @@ function TicketPickerBody({
};
}, [onClose]);
function describeErr(e: unknown): string {
return e && typeof e === "object" && "message" in e
? String((e as { message: unknown }).message)
: String(e);
}
/** Multi-select: toggle a row in/out of the local selection (never closes). */
function toggleSelected(ref: TicketRef) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(ref)) next.delete(ref);
else next.add(ref);
return next;
});
}
/** Single-select: resolve the clicked row and hand it back, then close. */
async function handlePick(ref: TicketRef) {
if (selecting) return;
setSelecting(true);
setResolveError(null);
try {
const result = await vm.resolve(ref);
// V1: single-select selects-and-closes. Multi (not required this sprint)
// would accumulate a set and confirm via the footer button instead.
onSelect(selectionMode === "multi" ? [result] : result);
onSelect(result);
onClose();
} catch (e) {
setResolveError(
e && typeof e === "object" && "message" in e
? String((e as { message: unknown }).message)
: String(e),
setResolveError(describeErr(e));
setSelecting(false);
}
}
/** Multi-select: resolve every selected ref and hand back the array. */
async function handleConfirm() {
if (selecting || selected.size === 0) return;
setSelecting(true);
setResolveError(null);
try {
const results = await Promise.all(
Array.from(selected).map((ref) => vm.resolve(ref)),
);
onSelect(results);
onClose();
} catch (e) {
setResolveError(describeErr(e));
setSelecting(false);
}
}
@ -243,30 +275,52 @@ function TicketPickerBody({
<p className="text-sm text-muted">No matching tickets.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{vm.rows.map((t) => (
<li key={t.ref} className="py-1.5 first:pt-0 last:pb-0">
<button
type="button"
aria-label={`select ticket ${t.ref}`}
disabled={selecting}
onClick={() => void handlePick(t.ref)}
className={cn(
"flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
"hover:bg-raised focus:bg-raised focus:outline-none",
"disabled:cursor-not-allowed disabled:opacity-60",
)}
>
<TicketRefBadge ticketRef={t.ref} className="mt-0.5" />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{t.title}
</span>
<span className="flex shrink-0 flex-col items-end gap-1">
<StatusBadge status={t.status} />
<PriorityBadge priority={t.priority} />
</span>
</button>
</li>
))}
{vm.rows.map((t) => {
const checked = selected.has(t.ref);
return (
<li key={t.ref} className="py-1.5 first:pt-0 last:pb-0">
<button
type="button"
aria-label={`select ticket ${t.ref}`}
{...(isMulti
? { role: "checkbox", "aria-checked": checked }
: {})}
disabled={selecting}
onClick={() =>
isMulti ? toggleSelected(t.ref) : void handlePick(t.ref)
}
className={cn(
"flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
"hover:bg-raised focus:bg-raised focus:outline-none",
"disabled:cursor-not-allowed disabled:opacity-60",
isMulti && checked && "bg-raised",
)}
>
{isMulti && (
<span
aria-hidden="true"
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border text-[10px] font-bold",
checked
? "border-primary bg-primary text-on-primary"
: "border-border text-transparent",
)}
>
</span>
)}
<TicketRefBadge ticketRef={t.ref} className="mt-0.5" />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{t.title}
</span>
<span className="flex shrink-0 flex-col items-end gap-1">
<StatusBadge status={t.status} />
<PriorityBadge priority={t.priority} />
</span>
</button>
</li>
);
})}
</ul>
)}
@ -284,15 +338,34 @@ function TicketPickerBody({
)}
</div>
{/* Footer reserved for multi-select confirm (not wired in single V1). */}
{selectionMode === "multi" && (
<footer className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
<Button size="sm" variant="ghost" onClick={onClose}>
Annuler
</Button>
<Button size="sm" disabled>
{confirmLabel}
</Button>
{/* ── Multi-select confirm footer (#41) — count + confirm; disabled
while empty or resolving. Single-select has no footer. ── */}
{isMulti && (
<footer className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-4 py-3">
<span className="text-xs text-muted" aria-live="polite">
{selected.size} sélectionné{selected.size > 1 ? "s" : ""}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel ticket selection"
onClick={onClose}
>
Annuler
</Button>
<Button
size="sm"
aria-label="confirm ticket selection"
loading={selecting}
disabled={selected.size === 0 || selecting}
onClick={() => void handleConfirm()}
>
{selected.size > 0
? `${confirmLabel} (${selected.size})`
: confirmLabel}
</Button>
</div>
</footer>
)}
</div>