/** * Reusable ticket-selection popup (ticket #18, G3). * * A modal picker consumed by any feature that needs the user to choose a ticket * (sprint composition #19, ticket linking #17, …). It reuses the shared * {@link TicketFacetsBar} (search + status/priority facets) and the slim * {@link useTicketSearch} hook, so filtering/search behave exactly like the main * listing without dragging in its sprint grouping or event subscriptions. * * Integration rules (sprint scoping note `ui-rework-sprint-scoping-contracts`): * - G5: mount at the **chrome level** (ProjectsView/App), never inside * LayoutGrid/LeafView. A focus-trap is mandatory because xterm captures * keyboard focus in the workspace. * - z-index `floatingWindowNested` (60): the picker is opened from within other * floating windows, so it must sit above them. * * ⚠️ Location note: this lives under `features/tickets` (not `shared/ui`) because * it depends on the ticket domain + DI gateways, whereas `shared/ui` is the pure * design system (Button/Input/…). This matches every other feature overlay * (SprintManager, MemoryEditor, …). Only the design-system-level `zIndex.ts` * went into `shared/ui`. * * 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"; import type { TicketRef } from "@/domain"; import type { TicketListQuery } from "@/ports"; import { Button, Spinner, cn, zIndex } from "@/shared"; import { TicketFacetsBar } from "./TicketFacetsBar"; import { PriorityBadge, StatusBadge, TicketRef as TicketRefBadge } from "./ticketMeta"; import { useTicketSearch, type TicketPickerResult, } from "./useTicketSearch"; export type { TicketPickerResult } from "./useTicketSearch"; export type TicketPickerSelectionMode = "single" | "multi"; export interface TicketPickerProps { /** Whether the popup is shown. Rendered as `null` when closed. */ open: boolean; projectId: string; /** Dialog heading (default "Sélectionner un ticket"). */ title?: string; /** Confirm-button label (multi-select only; unused in single-select V1). */ confirmLabel?: string; /** Selection mode (default "single"). */ selectionMode?: TicketPickerSelectionMode; /** Refs to hide from the results (e.g. the current ticket, already-linked). */ excludeRefs?: TicketRef[]; /** Seed filter/search query. */ initialQuery?: TicketListQuery; /** * Selection callback. In `"single"` mode it receives one * {@link TicketPickerResult}; in `"multi"` it receives the array. */ onSelect: (result: TicketPickerResult | TicketPickerResult[]) => 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 TicketPicker({ open, projectId, title = "Sélectionner un ticket", confirmLabel = "Sélectionner", selectionMode = "single", excludeRefs, initialQuery, onSelect, onClose, }: TicketPickerProps) { if (!open) return null; return ( ); } /** * The mounted picker. Split from {@link TicketPicker} so the hook (and its * fetch) only run while the popup is actually open — mounting/unmounting on * `open` resets state cleanly between openings. */ function TicketPickerBody({ projectId, title, confirmLabel, selectionMode, excludeRefs, initialQuery, onSelect, onClose, }: Required< Pick< TicketPickerProps, "projectId" | "title" | "confirmLabel" | "selectionMode" | "onSelect" | "onClose" > > & Pick) { const vm = useTicketSearch(projectId, { initialQuery, excludeRefs }); const dialogRef = useRef(null); const [selecting, setSelecting] = useState(false); const [resolveError, setResolveError] = useState(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>(() => 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. useEffect(() => { const previouslyFocused = document.activeElement as HTMLElement | null; const node = dialogRef.current; // Focus the first focusable control (the search input) once mounted. 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]); 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); onSelect(result); onClose(); } catch (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); } } return (
{ // Backdrop click closes; clicks inside the panel don't bubble here. if (e.target === e.currentTarget) onClose(); }} >
{/* ── Header ── */}
{title}
{/* ── Filters ── */}
{(vm.error || resolveError) && (

{resolveError ?? vm.error}

)} {/* ── Results ── */}
{vm.busy && vm.rows.length === 0 ? (
Loading tickets…
) : vm.rows.length === 0 ? (

No matching tickets.

) : (
    {vm.rows.map((t) => { const checked = selected.has(t.ref); return (
  • ); })}
)} {vm.hasMore && (
)}
{/* ── Multi-select confirm footer (#41) — count + confirm; disabled while empty or resolving. Single-select has no footer. ── */} {isMulti && ( )}
); }