diff --git a/frontend/src/features/tickets/TicketAssistantPanel.tsx b/frontend/src/features/tickets/TicketAssistantPanel.tsx
index a6d9016..e9f5735 100644
--- a/frontend/src/features/tickets/TicketAssistantPanel.tsx
+++ b/frontend/src/features/tickets/TicketAssistantPanel.tsx
@@ -11,14 +11,9 @@
import { useEffect, useRef, useState } from "react";
import { Button, Spinner, cn } from "@/shared";
+import { TicketViewportSelect } from "./TicketViewportSelect";
import { useTicketAssistant } from "./useTicketAssistant";
-const selectClass = cn(
- "h-9 rounded-md bg-raised px-3 text-sm text-content",
- "border border-border outline-none transition-colors",
- "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
-);
-
export interface TicketAssistantPanelProps {
projectId: string;
ticketRef: string;
@@ -53,24 +48,25 @@ export function TicketAssistantPanel({
{!open ? (
// ── Session opener ──
- setProfileId(e.target.value)}
- >
-
- {vm.profiles.length === 0
- ? "Aucun profil disponible"
- : "Choisir un profil IA…"}
-
- {vm.profiles.map((p) => (
-
- {p.name}
-
- ))}
-
+ options={[
+ {
+ value: "",
+ label:
+ vm.profiles.length === 0
+ ? "Aucun profil disponible"
+ : "Choisir un profil IA…",
+ },
+ ...vm.profiles.map((profile) => ({
+ value: profile.id,
+ label: profile.name,
+ })),
+ ]}
+ onChange={setProfileId}
+ />
Status
-
+ options={TICKET_STATUSES.map((status) => ({
+ value: status,
+ label: statusLabel(status),
+ }))}
+ onChange={(next) =>
void vm.updateFields({
- status: e.target.value as (typeof TICKET_STATUSES)[number],
+ status: next as (typeof TICKET_STATUSES)[number],
})
}
- >
- {TICKET_STATUSES.map((s) => (
-
- {statusLabel(s)}
-
- ))}
-
+ />
Priority
-
+ options={TICKET_PRIORITIES.map((priority) => ({
+ value: priority,
+ label: priorityLabel(priority),
+ }))}
+ onChange={(next) =>
void vm.updateFields({
priority:
- e.target.value as (typeof TICKET_PRIORITIES)[number],
+ next as (typeof TICKET_PRIORITIES)[number],
})
}
- >
- {TICKET_PRIORITIES.map((p) => (
-
- {priorityLabel(p)}
-
- ))}
-
+ />
@@ -426,19 +415,16 @@ export function TicketDetail({
{/* Add a link via the TicketPicker popup (#17): the kind is chosen
here, the target ticket is selected in the popup (no manual #id). */}
-
setLinkKind(e.target.value as TicketLinkKind)}
- >
- {TICKET_LINK_KINDS.map((k) => (
-
- {linkKindLabel(k)}
-
- ))}
-
+ options={TICKET_LINK_KINDS.map((kind) => ({
+ value: kind,
+ label: linkKindLabel(kind),
+ }))}
+ onChange={(next) => setLinkKind(next as TicketLinkKind)}
+ />
)}
-
setAssignPick(e.target.value)}
- >
-
- {assignable.length === 0
- ? "No more agents"
- : "Select an agent…"}
-
- {assignable.map((a) => (
-
- {a.name}
-
- ))}
-
+ options={[
+ {
+ value: "",
+ label:
+ assignable.length === 0
+ ? "No more agents"
+ : "Select an agent…",
+ },
+ ...assignable.map((agent) => ({
+ value: agent.id,
+ label: agent.name,
+ })),
+ ]}
+ onChange={setAssignPick}
+ />
= {
number: "Numéro",
@@ -146,23 +140,22 @@ export function TicketFacetsBar({
>
Trier par
- {
- const field = e.target.value as TicketListSortField | "";
+ options={[
+ { value: "", label: "Par défaut" },
+ ...SORT_FIELDS.map((field) => ({
+ value: field,
+ label: SORT_FIELD_LABEL[field],
+ })),
+ ]}
+ onChange={(next) => {
+ const field = next as TicketListSortField | "";
if (field === "") onSortChange(undefined);
else onSortChange({ field, direction: sort?.direction ?? "asc" });
}}
- >
- Par défaut
- {SORT_FIELDS.map((f) => (
-
- {SORT_FIELD_LABEL[f]}
-
- ))}
-
+ />
{sort && (
{
// The sort control is present in the picker too (shared TicketFacetsBar).
const sortBy = screen.getByLabelText("sort tickets by");
- fireEvent.change(sortBy, { target: { value: "title" } });
+ fireEvent.click(sortBy);
+ fireEvent.click(await screen.findByRole("option", { name: "Titre" }));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
field: "title",
diff --git a/frontend/src/features/tickets/TicketViewportSelect.test.tsx b/frontend/src/features/tickets/TicketViewportSelect.test.tsx
new file mode 100644
index 0000000..0b07c2a
--- /dev/null
+++ b/frontend/src/features/tickets/TicketViewportSelect.test.tsx
@@ -0,0 +1,105 @@
+import { describe, expect, it, vi } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import { zIndex } from "@/shared";
+import { TicketViewportSelect } from "./TicketViewportSelect";
+
+const options = [
+ { value: "", label: "All" },
+ { value: "alpha", label: "Alpha" },
+ { value: "beta", label: "Beta" },
+];
+
+function setViewport(width: number, height: number) {
+ Object.defineProperty(window, "innerWidth", {
+ value: width,
+ configurable: true,
+ });
+ Object.defineProperty(window, "innerHeight", {
+ value: height,
+ configurable: true,
+ });
+}
+
+describe("TicketViewportSelect", () => {
+ it("renders the listbox in a portal and selects an option", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ const trigger = screen.getByLabelText("filter by assignee");
+ fireEvent.click(trigger);
+
+ const listbox = await screen.findByRole("listbox", {
+ name: "filter by assignee",
+ });
+ expect(listbox.parentElement).toBe(document.body);
+ expect(listbox.style.zIndex).toBe(String(zIndex.menuDropdown));
+
+ fireEvent.click(screen.getByRole("option", { name: "Alpha" }));
+ expect(onChange).toHaveBeenCalledWith("alpha");
+ expect(screen.queryByRole("listbox")).toBeNull();
+ });
+
+ it("flips above the trigger and bounds height near the viewport bottom", async () => {
+ setViewport(500, 220);
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ const trigger = screen.getByLabelText("sprint for #1");
+ trigger.getBoundingClientRect = () =>
+ ({
+ x: 20,
+ y: 190,
+ top: 190,
+ left: 20,
+ right: 140,
+ bottom: 218,
+ width: 120,
+ height: 28,
+ toJSON: () => {},
+ }) as DOMRect;
+
+ fireEvent.click(trigger);
+
+ const listbox = await screen.findByRole("listbox", { name: "sprint for #1" });
+ expect(listbox.getAttribute("data-placement")).toBe("top");
+ expect(Number.parseFloat(listbox.style.maxHeight)).toBeLessThanOrEqual(182);
+ expect(listbox.className).toContain("overflow-y-auto");
+ });
+
+ it("closes on Escape and outside pointer down", async () => {
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByLabelText("sort tickets by"));
+ expect(await screen.findByRole("listbox")).toBeTruthy();
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(screen.queryByRole("listbox")).toBeNull();
+
+ fireEvent.click(screen.getByLabelText("sort tickets by"));
+ expect(await screen.findByRole("listbox")).toBeTruthy();
+ fireEvent.pointerDown(document.body);
+ expect(screen.queryByRole("listbox")).toBeNull();
+ });
+});
+
diff --git a/frontend/src/features/tickets/TicketViewportSelect.tsx b/frontend/src/features/tickets/TicketViewportSelect.tsx
new file mode 100644
index 0000000..b67b0a9
--- /dev/null
+++ b/frontend/src/features/tickets/TicketViewportSelect.tsx
@@ -0,0 +1,192 @@
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+
+import { cn, zIndex } from "@/shared";
+
+export interface TicketViewportSelectOption {
+ value: string;
+ label: string;
+ disabled?: boolean;
+}
+
+export interface TicketViewportSelectProps {
+ ariaLabel?: string;
+ "aria-label"?: string;
+ value: string;
+ options: TicketViewportSelectOption[];
+ onChange: (value: string) => void;
+ className?: string;
+ disabled?: boolean;
+}
+
+interface PopupPosition {
+ top: number;
+ left: number;
+ width: number;
+ maxHeight: number;
+ placement: "top" | "bottom";
+}
+
+const VIEWPORT_MARGIN = 8;
+const MIN_POPUP_HEIGHT = 96;
+const MAX_POPUP_HEIGHT = 280;
+
+export function TicketViewportSelect({
+ ariaLabel: ariaLabelProp,
+ "aria-label": ariaLabelAttribute,
+ value,
+ options,
+ onChange,
+ className,
+ disabled = false,
+}: TicketViewportSelectProps) {
+ const triggerRef = useRef(null);
+ const popupRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [position, setPosition] = useState(null);
+ const selected = options.find((option) => option.value === value) ?? options[0];
+ const ariaLabel = ariaLabelAttribute ?? ariaLabelProp ?? "select option";
+
+ const updatePosition = useCallback(() => {
+ const trigger = triggerRef.current;
+ if (!trigger) return;
+
+ const rect = trigger.getBoundingClientRect();
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
+ const popupWidth = Math.max(rect.width, 160);
+ const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN;
+ const spaceAbove = rect.top - VIEWPORT_MARGIN;
+ const placement =
+ spaceBelow < MIN_POPUP_HEIGHT && spaceAbove > spaceBelow ? "top" : "bottom";
+ const available = Math.max(
+ MIN_POPUP_HEIGHT,
+ placement === "top" ? spaceAbove : spaceBelow,
+ );
+ const maxHeight = Math.min(MAX_POPUP_HEIGHT, available);
+ const left = Math.min(
+ Math.max(VIEWPORT_MARGIN, rect.left),
+ Math.max(VIEWPORT_MARGIN, viewportWidth - popupWidth - VIEWPORT_MARGIN),
+ );
+ const top =
+ placement === "top"
+ ? Math.max(VIEWPORT_MARGIN, rect.top - maxHeight - 4)
+ : Math.min(viewportHeight - VIEWPORT_MARGIN, rect.bottom + 4);
+
+ setPosition({ top, left, width: popupWidth, maxHeight, placement });
+ }, []);
+
+ useLayoutEffect(() => {
+ if (!open) return;
+ updatePosition();
+ }, [open, updatePosition]);
+
+ useEffect(() => {
+ if (!open) return;
+
+ function onPointerDown(event: PointerEvent) {
+ const target = event.target as Node | null;
+ if (
+ target &&
+ (triggerRef.current?.contains(target) || popupRef.current?.contains(target))
+ ) {
+ return;
+ }
+ setOpen(false);
+ }
+
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setOpen(false);
+ triggerRef.current?.focus();
+ }
+ }
+
+ const onViewportChange = () => {
+ updatePosition();
+ };
+
+ document.addEventListener("pointerdown", onPointerDown);
+ document.addEventListener("keydown", onKeyDown);
+ window.addEventListener("resize", onViewportChange);
+ window.addEventListener("scroll", onViewportChange, true);
+ window.visualViewport?.addEventListener("resize", onViewportChange);
+ window.visualViewport?.addEventListener("scroll", onViewportChange);
+
+ return () => {
+ document.removeEventListener("pointerdown", onPointerDown);
+ document.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("resize", onViewportChange);
+ window.removeEventListener("scroll", onViewportChange, true);
+ window.visualViewport?.removeEventListener("resize", onViewportChange);
+ window.visualViewport?.removeEventListener("scroll", onViewportChange);
+ };
+ }, [open, updatePosition]);
+
+ return (
+ <>
+ setOpen((current) => !current)}
+ className={cn(
+ "inline-flex h-8 items-center justify-between gap-2 rounded-md bg-raised px-2 text-xs text-content",
+ "border border-border outline-none transition-colors",
+ "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ >
+ {selected?.label ?? ""}
+ ▾
+
+ {open &&
+ position &&
+ createPortal(
+
+ {options.map((option) => (
+ {
+ if (option.disabled) return;
+ onChange(option.value);
+ setOpen(false);
+ triggerRef.current?.focus();
+ }}
+ className={cn(
+ "flex min-h-8 w-full items-center px-2 text-left text-xs text-content",
+ "hover:bg-raised focus:bg-raised focus:outline-none",
+ option.value === value && "bg-raised",
+ option.disabled && "cursor-not-allowed opacity-50",
+ )}
+ >
+ {option.label}
+
+ ))}
+
,
+ document.body,
+ )}
+ >
+ );
+}
diff --git a/frontend/src/features/tickets/TicketsPanel.tsx b/frontend/src/features/tickets/TicketsPanel.tsx
index b733b0d..cca7727 100644
--- a/frontend/src/features/tickets/TicketsPanel.tsx
+++ b/frontend/src/features/tickets/TicketsPanel.tsx
@@ -10,12 +10,13 @@
import { useEffect, useState } from "react";
import type { Sprint, TicketPriority, TicketSummary } from "@/domain";
-import { Button, Input, Panel, Spinner, cn } from "@/shared";
+import { Button, Input, Panel, Spinner } from "@/shared";
import { useTickets } from "./useTickets";
import { useProjectAgents } from "./useProjectAgents";
import { SprintManager } from "./SprintManager";
import { SprintPicker } from "./SprintPicker";
import { TicketFacetsBar } from "./TicketFacetsBar";
+import { TicketViewportSelect } from "./TicketViewportSelect";
import {
PriorityBadge,
StatusBadge,
@@ -24,12 +25,6 @@ import {
priorityLabel,
} from "./ticketMeta";
-const selectClass = cn(
- "h-8 rounded-md bg-raised px-2 text-xs text-content",
- "border border-border outline-none transition-colors",
- "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
-);
-
export interface TicketsPanelProps {
projectId: string;
/** Opens the detail overlay for the given `#ref` (F7). */
@@ -151,18 +146,15 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onChange={(e) => setNewTitle(e.target.value)}
/>
-
setNewPriority(e.target.value as TicketPriority)}
- >
- {TICKET_PRIORITIES.map((p) => (
-
- {priorityLabel(p)}
-
- ))}
-
+ options={TICKET_PRIORITIES.map((priority) => ({
+ value: priority,
+ label: priorityLabel(priority),
+ }))}
+ onChange={(next) => setNewPriority(next as TicketPriority)}
+ />
-
+ options={[
+ { value: "", label: "All assignees" },
+ ...agents.map((agent) => ({
+ value: agent.id,
+ label: agent.name,
+ })),
+ ]}
+ onChange={(next) =>
vm.setQuery({
...vm.query,
- assignedAgentId: e.target.value || undefined,
+ assignedAgentId: next || undefined,
})
}
- >
- All assignees
- {agents.map((a) => (
-
- {a.name}
-
- ))}
-
+ />
@@ -363,21 +354,20 @@ function SprintSection({
-
- onAssignSprint(t.ref, e.target.value || null)
+ options={[
+ { value: "", label: "— No sprint —" },
+ ...sprints.map((sprint) => ({
+ value: sprint.id,
+ label: sprint.name,
+ })),
+ ]}
+ onChange={(next) =>
+ onAssignSprint(t.ref, next || null)
}
- >
- — No sprint —
- {sprints.map((s) => (
-
- {s.name}
-
- ))}
-
+ />
))}
diff --git a/frontend/src/features/tickets/tickets.test.tsx b/frontend/src/features/tickets/tickets.test.tsx
index cd4190f..ffad360 100644
--- a/frontend/src/features/tickets/tickets.test.tsx
+++ b/frontend/src/features/tickets/tickets.test.tsx
@@ -353,9 +353,8 @@ describe("TicketsView", () => {
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined();
// Choose a field ⇒ ascending by default, relayed in the query.
- fireEvent.change(screen.getByLabelText("sort tickets by"), {
- target: { value: "title" },
- });
+ fireEvent.click(screen.getByLabelText("sort tickets by"));
+ fireEvent.click(await screen.findByRole("option", { name: "Titre" }));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
field: "title",
@@ -375,9 +374,8 @@ describe("TicketsView", () => {
);
// Back to « Par défaut » ⇒ `sort` dropped from the query again.
- fireEvent.change(screen.getByLabelText("sort tickets by"), {
- target: { value: "" },
- });
+ fireEvent.click(screen.getByLabelText("sort tickets by"));
+ fireEvent.click(await screen.findByRole("option", { name: "Par défaut" }));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined(),
);
@@ -390,8 +388,8 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Editable"));
const dialog = await screen.findByRole("dialog");
- const statusSelect = within(dialog).getByLabelText("ticket status");
- fireEvent.change(statusSelect, { target: { value: "inProgress" } });
+ fireEvent.click(within(dialog).getByLabelText("ticket status"));
+ fireEvent.click(await screen.findByRole("option", { name: "In progress" }));
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
@@ -426,9 +424,8 @@ describe("TicketsView", () => {
fireEvent.click(await screen.findByText("Assignable"));
const dialog = await screen.findByRole("dialog");
- fireEvent.change(within(dialog).getByLabelText("assign agent"), {
- target: { value: known.id },
- });
+ fireEvent.click(within(dialog).getByLabelText("assign agent"));
+ fireEvent.click(await screen.findByRole("option", { name: "Backend" }));
fireEvent.click(within(dialog).getByText("Assign"));
await waitFor(async () => {
@@ -495,9 +492,8 @@ describe("TicketsView", () => {
// Change the priority — an immediate-apply mutation that re-fetches the ticket
// and bumps its version. The unsaved draft must survive.
- fireEvent.change(within(dialog).getByLabelText("ticket priority"), {
- target: { value: "high" },
- });
+ fireEvent.click(within(dialog).getByLabelText("ticket priority"));
+ fireEvent.click(await screen.findByRole("option", { name: "High" }));
// The backend applied the priority bump…
await waitFor(async () => {
@@ -643,9 +639,8 @@ describe("TicketsView", () => {
expect(within(bucket).getByText("Movable")).toBeTruthy();
// Pick the sprint in the row selector → assign it.
- fireEvent.change(screen.getByLabelText(`sprint for ${t.ref}`), {
- target: { value: "s1" },
- });
+ fireEvent.click(screen.getByLabelText(`sprint for ${t.ref}`));
+ fireEvent.click(await screen.findByRole("option", { name: "Sprint One" }));
// The gateway recorded the membership…
await waitFor(async () => {
@@ -777,7 +772,8 @@ describe("TicketsView", () => {
);
expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy();
- fireEvent.change(profileSelect, { target: { value: "qa-assistant" } });
+ fireEvent.click(profileSelect);
+ fireEvent.click(await screen.findByRole("option", { name: "QA Assistant" }));
fireEvent.click(within(assistant).getByText("Ouvrir la conversation"));
expect(
@@ -837,9 +833,8 @@ describe("TicketsView", () => {
expect(within(detail).queryByLabelText("link target ref")).toBeNull();
// Choose the link kind, then open the picker and select the target ticket.
- fireEvent.change(within(detail).getByLabelText("link kind"), {
- target: { value: "blocks" },
- });
+ fireEvent.click(within(detail).getByLabelText("link kind"));
+ fireEvent.click(await screen.findByRole("option", { name: "blocks" }));
fireEvent.click(within(detail).getByLabelText("add link"));
// The picker excludes the ticket itself; the target is offered.