merge feature/3-tickets-dropdown-viewport-clip dans develop

Ticket #3 vert (995/995 suite complète, tsc propre) : les dropdowns de la
vue Tickets ne sont plus rognées en bas de fenêtre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 18:29:47 +02:00
8 changed files with 416 additions and 157 deletions

View File

@ -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 ──
<div className="flex flex-wrap items-center gap-2">
<select
<TicketViewportSelect
aria-label="profil de l'assistant"
className={selectClass}
value={profileId}
disabled={vm.opening || vm.profiles.length === 0}
onChange={(e) => setProfileId(e.target.value)}
>
<option value="">
{vm.profiles.length === 0
options={[
{
value: "",
label:
vm.profiles.length === 0
? "Aucun profil disponible"
: "Choisir un profil IA…"}
</option>
{vm.profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
: "Choisir un profil IA…",
},
...vm.profiles.map((profile) => ({
value: profile.id,
label: profile.name,
})),
]}
onChange={setProfileId}
/>
<Button
size="sm"
disabled={!profileId || vm.opening}

View File

@ -17,6 +17,7 @@ import { useTicketDetail } from "./useTicketDetail";
import { useProjectAgents } from "./useProjectAgents";
import { TicketAssistantPanel } from "./TicketAssistantPanel";
import { TicketPicker } from "./TicketPicker";
import { TicketViewportSelect } from "./TicketViewportSelect";
import {
PriorityBadge,
StatusBadge,
@ -30,12 +31,6 @@ import {
statusLabel,
} from "./ticketMeta";
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",
);
const textareaClass = cn(
"w-full rounded-md bg-raised p-3 text-sm text-content",
"border border-border outline-none transition-colors",
@ -320,44 +315,38 @@ export function TicketDetail({
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-xs text-muted">
Status
<select
<TicketViewportSelect
aria-label="ticket status"
className={selectClass}
value={t.status}
disabled={vm.busy}
onChange={(e) =>
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) => (
<option key={s} value={s}>
{statusLabel(s)}
</option>
))}
</select>
/>
</label>
<label className="flex items-center gap-2 text-xs text-muted">
Priority
<select
<TicketViewportSelect
aria-label="ticket priority"
className={selectClass}
value={t.priority}
disabled={vm.busy}
onChange={(e) =>
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) => (
<option key={p} value={p}>
{priorityLabel(p)}
</option>
))}
</select>
/>
</label>
</div>
</Section>
@ -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). */}
<div className="mt-1 flex flex-wrap items-center gap-2">
<select
<TicketViewportSelect
aria-label="link kind"
className={selectClass}
value={linkKind}
disabled={vm.busy}
onChange={(e) => setLinkKind(e.target.value as TicketLinkKind)}
>
{TICKET_LINK_KINDS.map((k) => (
<option key={k} value={k}>
{linkKindLabel(k)}
</option>
))}
</select>
options={TICKET_LINK_KINDS.map((kind) => ({
value: kind,
label: linkKindLabel(kind),
}))}
onChange={(next) => setLinkKind(next as TicketLinkKind)}
/>
<Button
size="sm"
aria-label="add link"
@ -479,24 +465,25 @@ export function TicketDetail({
</ul>
)}
<div className="mt-1 flex items-center gap-2">
<select
<TicketViewportSelect
aria-label="assign agent"
className={selectClass}
value={assignPick}
disabled={vm.busy || assignable.length === 0}
onChange={(e) => setAssignPick(e.target.value)}
>
<option value="">
{assignable.length === 0
options={[
{
value: "",
label:
assignable.length === 0
? "No more agents"
: "Select an agent…"}
</option>
{assignable.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
: "Select an agent…",
},
...assignable.map((agent) => ({
value: agent.id,
label: agent.name,
})),
]}
onChange={setAssignPick}
/>
<Button
size="sm"
disabled={!assignPick || vm.busy}

View File

@ -15,7 +15,8 @@
import type { TicketPriority, TicketStatus } from "@/domain";
import type { TicketListSort, TicketListSortField } from "@/ports";
import { Button, Input, cn } from "@/shared";
import { Button, Input } from "@/shared";
import { TicketViewportSelect } from "./TicketViewportSelect";
import {
PriorityBadge,
StatusBadge,
@ -25,13 +26,6 @@ import {
statusLabel,
} from "./ticketMeta";
/** Styling for the sort-field select, matching the panel's other selects. */
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",
);
/** Human labels for the sort fields (also the option text). */
const SORT_FIELD_LABEL: Record<TicketListSortField, string> = {
number: "Numéro",
@ -146,23 +140,22 @@ export function TicketFacetsBar({
>
<label className="flex items-center gap-1.5 text-xs text-muted">
Trier par
<select
<TicketViewportSelect
aria-label="sort tickets by"
className={selectClass}
value={sort?.field ?? ""}
onChange={(e) => {
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" });
}}
>
<option value="">Par défaut</option>
{SORT_FIELDS.map((f) => (
<option key={f} value={f}>
{SORT_FIELD_LABEL[f]}
</option>
))}
</select>
/>
</label>
{sort && (
<Button

View File

@ -145,7 +145,8 @@ describe("TicketPicker (#18)", () => {
// 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",

View File

@ -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(
<TicketViewportSelect
ariaLabel="filter by assignee"
value=""
options={options}
onChange={onChange}
/>,
);
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(
<TicketViewportSelect
ariaLabel="sprint for #1"
value=""
options={options}
onChange={onChange}
/>,
);
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(
<TicketViewportSelect
ariaLabel="sort tickets by"
value=""
options={options}
onChange={vi.fn()}
/>,
);
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();
});
});

View File

@ -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<HTMLButtonElement | null>(null);
const popupRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<PopupPosition | null>(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 (
<>
<button
ref={triggerRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => 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,
)}
>
<span className="min-w-0 truncate">{selected?.label ?? ""}</span>
<span aria-hidden="true" className="text-muted"></span>
</button>
{open &&
position &&
createPortal(
<div
ref={popupRef}
role="listbox"
aria-label={ariaLabel}
data-placement={position.placement}
className="fixed overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-xl"
style={{
top: position.top,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
zIndex: zIndex.menuDropdown,
}}
>
{options.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={option.value === value}
disabled={option.disabled}
onClick={() => {
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",
)}
>
<span className="min-w-0 truncate">{option.label}</span>
</button>
))}
</div>,
document.body,
)}
</>
);
}

View File

@ -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)}
/>
<div className="flex items-center gap-2">
<select
<TicketViewportSelect
aria-label="new ticket priority"
className={selectClass}
value={newPriority}
onChange={(e) => setNewPriority(e.target.value as TicketPriority)}
>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{priorityLabel(p)}
</option>
))}
</select>
options={TICKET_PRIORITIES.map((priority) => ({
value: priority,
label: priorityLabel(priority),
}))}
onChange={(next) => setNewPriority(next as TicketPriority)}
/>
<Button
type="submit"
size="sm"
@ -211,24 +203,23 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
}}
/>
<div className="flex flex-wrap items-center gap-2">
<select
<TicketViewportSelect
aria-label="filter by assignee"
className={selectClass}
value={vm.query.assignedAgentId ?? ""}
onChange={(e) =>
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,
})
}
>
<option value="">All assignees</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
/>
</div>
</div>
@ -363,21 +354,20 @@ function SprintSection({
</span>
</div>
<div className="mt-1 pl-6">
<select
<TicketViewportSelect
aria-label={`sprint for ${t.ref}`}
className={selectClass}
value={t.sprintId ?? ""}
onChange={(e) =>
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)
}
>
<option value=""> No sprint </option>
{sprints.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
/>
</div>
</li>
))}

View File

@ -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.