diff --git a/frontend/src/features/tickets/TicketFacetsBar.tsx b/frontend/src/features/tickets/TicketFacetsBar.tsx
new file mode 100644
index 0000000..f09811e
--- /dev/null
+++ b/frontend/src/features/tickets/TicketFacetsBar.tsx
@@ -0,0 +1,126 @@
+/**
+ * Reusable ticket filter/search bar (UI rework #18): a free-text search input
+ * plus the multi-select status/priority checkbox facets. Extracted from
+ * {@link TicketsPanel} so both the main listing and the {@link TicketPicker}
+ * popup share one implementation and stay in sync.
+ *
+ * Purely controlled/presentational — it holds no state and knows nothing about
+ * the gateway; the owner (a view-model or {@link useTicketSearch}) drives every
+ * value and toggle. Facet semantics (ticket #12): OR within a facet, AND across;
+ * an empty set ⇒ that facet is unconstrained.
+ *
+ * The assignee filter stays with the main listing (it needs the project's agent
+ * roster and is out of the picker's scope), so it is intentionally not here.
+ */
+
+import type { TicketPriority, TicketStatus } from "@/domain";
+import { Button, Input } from "@/shared";
+import {
+ PriorityBadge,
+ StatusBadge,
+ TICKET_PRIORITIES,
+ TICKET_STATUSES,
+ priorityLabel,
+ statusLabel,
+} from "./ticketMeta";
+
+export interface TicketFacetsBarProps {
+ /** Current free-text search value (controlled). */
+ text: string;
+ /** Emitted on every keystroke with the raw input value. */
+ onTextChange: (text: string) => void;
+ /** Currently-selected status facet values. */
+ statuses: TicketStatus[];
+ /** Currently-selected priority facet values. */
+ priorities: TicketPriority[];
+ onToggleStatus: (status: TicketStatus) => void;
+ onTogglePriority: (priority: TicketPriority) => void;
+ /**
+ * Clears both facets. When provided, a « Effacer » button appears while any
+ * facet is active. Omit to hide the affordance (the caller clears elsewhere).
+ */
+ onClearFacets?: () => void;
+ /** Placeholder for the search input. */
+ searchPlaceholder?: string;
+}
+
+export function TicketFacetsBar({
+ text,
+ onTextChange,
+ statuses,
+ priorities,
+ onToggleStatus,
+ onTogglePriority,
+ onClearFacets,
+ searchPlaceholder = "Search title/description…",
+}: TicketFacetsBarProps) {
+ const hasFacets = statuses.length > 0 || priorities.length > 0;
+ return (
+
+ onTextChange(e.target.value)}
+ />
+ {/* Multi-select facets (ticket #12): OR within a facet, AND across; no
+ box ticked ⇒ that facet is unconstrained. */}
+
+
+ {onClearFacets && hasFacets && (
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/features/tickets/TicketPicker.test.tsx b/frontend/src/features/tickets/TicketPicker.test.tsx
new file mode 100644
index 0000000..1b7317f
--- /dev/null
+++ b/frontend/src/features/tickets/TicketPicker.test.tsx
@@ -0,0 +1,201 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+
+import { DIProvider } from "@/app/di";
+import { MockSystemGateway, MockTicketGateway } from "@/adapters/mock";
+import type { Ticket } from "@/domain";
+import type { Gateways } from "@/ports";
+import { TicketPicker, type TicketPickerResult } from "./TicketPicker";
+
+const PROJECT_ID = "project-picker-test";
+
+function renderPicker(
+ ticket: MockTicketGateway,
+ props: Partial> = {},
+) {
+ const onSelect = vi.fn();
+ const onClose = vi.fn();
+ const gateways = { ticket } as unknown as Gateways;
+ const utils = render(
+
+
+ ,
+ );
+ return { ...utils, onSelect, onClose };
+}
+
+/** Seeds three tickets with distinct status/priority for filter assertions. */
+async function seedTrio(ticket: MockTicketGateway): Promise {
+ const a = await ticket.create(PROJECT_ID, {
+ title: "Alpha bug",
+ status: "open",
+ priority: "high",
+ });
+ const b = await ticket.create(PROJECT_ID, {
+ title: "Bravo task",
+ priority: "low",
+ });
+ await ticket.update(PROJECT_ID, b.ref, {
+ status: "closed",
+ expectedVersion: b.version,
+ });
+ const c = await ticket.create(PROJECT_ID, {
+ title: "Charlie chore",
+ status: "QA",
+ priority: "medium",
+ });
+ return [a, b, c];
+}
+
+describe("TicketPicker (#18)", () => {
+ let system: MockSystemGateway;
+ let ticket: MockTicketGateway;
+
+ beforeEach(() => {
+ system = new MockSystemGateway();
+ ticket = new MockTicketGateway(system);
+ });
+
+ it("renders nothing when closed", () => {
+ const gateways = { ticket } as unknown as Gateways;
+ const { container } = render(
+
+
+ ,
+ );
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
+ });
+
+ it("lists tickets and single-select hands back the full result + closes", async () => {
+ const [a] = await seedTrio(ticket);
+ const { onSelect, onClose } = renderPicker(ticket);
+
+ // Rows appear.
+ await screen.findByText("Alpha bug");
+ expect(screen.getByText("Bravo task")).toBeTruthy();
+ expect(screen.getByText("Charlie chore")).toBeTruthy();
+
+ fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
+
+ await waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
+ const result = onSelect.mock.calls[0][0] as TicketPickerResult;
+ expect(result).toMatchObject({
+ ref: a.ref,
+ id: a.id,
+ number: a.number,
+ title: "Alpha bug",
+ status: "open",
+ priority: "high",
+ });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("hides excludeRefs client-side", async () => {
+ const [a, b] = await seedTrio(ticket);
+ renderPicker(ticket, { excludeRefs: [a.ref] });
+
+ await screen.findByText("Bravo task");
+ expect(screen.queryByText("Alpha bug")).toBeNull();
+ // Excluded ref cannot be selected either.
+ expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
+ expect(screen.getByLabelText(`select ticket ${b.ref}`)).toBeTruthy();
+ });
+
+ it("filters by status facet (OR within, AND across)", async () => {
+ await seedTrio(ticket);
+ renderPicker(ticket);
+
+ await screen.findByText("Alpha bug");
+
+ // Tick "open" ⇒ only Alpha (open) remains; Bravo (closed) & Charlie (QA) go.
+ fireEvent.click(screen.getByLabelText("filter status Open"));
+ await waitFor(() => expect(screen.queryByText("Bravo task")).toBeNull());
+ expect(screen.getByText("Alpha bug")).toBeTruthy();
+ expect(screen.queryByText("Charlie chore")).toBeNull();
+
+ // Add priority "low" ⇒ (open) AND (low) ⇒ nothing (Alpha is high).
+ fireEvent.click(screen.getByLabelText("filter priority Low"));
+ await waitFor(() =>
+ expect(screen.getByText("No matching tickets.")).toBeTruthy(),
+ );
+
+ // Clear facets ⇒ all three back.
+ fireEvent.click(screen.getByLabelText("clear filters"));
+ await waitFor(() => expect(screen.getByText("Charlie chore")).toBeTruthy());
+ expect(screen.getByText("Alpha bug")).toBeTruthy();
+ expect(screen.getByText("Bravo task")).toBeTruthy();
+ });
+
+ it("debounced text search narrows the list", async () => {
+ await seedTrio(ticket);
+ renderPicker(ticket);
+
+ await screen.findByText("Alpha bug");
+
+ fireEvent.change(screen.getByLabelText("search tickets"), {
+ target: { value: "charlie" },
+ });
+
+ // Wait on the removal (all three are visible until the debounced refetch).
+ await waitFor(() => expect(screen.queryByText("Alpha bug")).toBeNull());
+ expect(screen.getByText("Charlie chore")).toBeTruthy();
+ expect(screen.queryByText("Bravo task")).toBeNull();
+ });
+
+ it("honors initialQuery as a seed filter", async () => {
+ await seedTrio(ticket);
+ renderPicker(ticket, { initialQuery: { statuses: ["QA"] } });
+
+ await screen.findByText("Charlie chore");
+ expect(screen.queryByText("Alpha bug")).toBeNull();
+ expect(screen.queryByText("Bravo task")).toBeNull();
+ });
+
+ it("closes on Escape and on backdrop click", async () => {
+ await seedTrio(ticket);
+ const { onClose } = renderPicker(ticket);
+ await screen.findByText("Alpha bug");
+
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ // Backdrop (the outermost fixed overlay) closes on mousedown.
+ const dialog = screen.getByRole("dialog");
+ const backdrop = dialog.parentElement as HTMLElement;
+ fireEvent.mouseDown(backdrop);
+ expect(onClose).toHaveBeenCalledTimes(2);
+ });
+
+ it("uses a custom title and surfaces a resolve failure without closing", async () => {
+ const [a] = await seedTrio(ticket);
+ const readSpy = vi
+ .spyOn(ticket, "read")
+ .mockRejectedValueOnce({ code: "NOT_FOUND", message: "gone" });
+ const { onSelect, onClose } = renderPicker(ticket, {
+ title: "Choisir un ticket à lier",
+ });
+
+ await screen.findByText("Alpha bug");
+ expect(
+ screen.getByRole("dialog", { name: "Choisir un ticket à lier" }),
+ ).toBeTruthy();
+
+ fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
+
+ await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("gone"));
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(onClose).not.toHaveBeenCalled();
+ readSpy.mockRestore();
+ });
+});
diff --git a/frontend/src/features/tickets/TicketPicker.tsx b/frontend/src/features/tickets/TicketPicker.tsx
new file mode 100644
index 0000000..97d5909
--- /dev/null
+++ b/frontend/src/features/tickets/TicketPicker.tsx
@@ -0,0 +1,299 @@
+/**
+ * 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`.
+ *
+ * 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.
+ */
+
+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);
+
+ // 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]);
+
+ 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);
+ onClose();
+ } catch (e) {
+ setResolveError(
+ e && typeof e === "object" && "message" in e
+ ? String((e as { message: unknown }).message)
+ : String(e),
+ );
+ setSelecting(false);
+ }
+ }
+
+ return (
+