feat(tickets): fenêtre dédiée de gestion des tickets — liens via popup + assistant IA flottant (#17)

Lot C du sprint UI rework : fenêtre dédiée de gestion des tickets,
appuyée sur la primitive FloatingWindow (#16) et la popup TicketPicker (#18).

- TicketDetail : gestion des liens de ticket via la popup TicketPicker,
  assistant IA en fenêtre flottante
- FloatingWindow : ajustements pour l'usage fenêtre dédiée

Tests : tsc --noEmit clean, suite complète 530/530, suites tickets +
FloatingWindow 50/50.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 06:35:20 +02:00
parent cb8c9cd634
commit 7caced1b99
4 changed files with 178 additions and 55 deletions

View File

@ -7,6 +7,41 @@ import { MenuBar } from "./MenuBar";
import { zIndex } from "./zIndex";
describe("FloatingWindow (#16, G1)", () => {
it("Escape closes only the topmost window when stacked (#17)", () => {
const onCloseOuter = vi.fn();
const onCloseInner = vi.fn();
const { rerender } = render(
<>
<FloatingWindow open title="Outer" onClose={onCloseOuter}>
<p>outer body</p>
</FloatingWindow>
<FloatingWindow open title="Inner" onClose={onCloseInner}>
<p>inner body</p>
</FloatingWindow>
</>,
);
// The inner (last-mounted) window is topmost: Escape closes only it.
fireEvent.keyDown(document, { key: "Escape" });
expect(onCloseInner).toHaveBeenCalledTimes(1);
expect(onCloseOuter).not.toHaveBeenCalled();
// Unmount the inner window; the outer becomes topmost and now handles Escape.
rerender(
<>
<FloatingWindow open title="Outer" onClose={onCloseOuter}>
<p>outer body</p>
</FloatingWindow>
<FloatingWindow open={false} title="Inner" onClose={onCloseInner}>
<p>inner body</p>
</FloatingWindow>
</>,
);
fireEvent.keyDown(document, { key: "Escape" });
expect(onCloseOuter).toHaveBeenCalledTimes(1);
expect(onCloseInner).toHaveBeenCalledTimes(1);
});
it("renders nothing when closed", () => {
const { container } = render(
<FloatingWindow open={false} title="Panel" onClose={vi.fn()}>

View File

@ -34,6 +34,13 @@ const SIZE_CLASS: Record<FloatingWindowSize, string> = {
const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
/**
* Stack of currently-mounted windows (most-recent last). Only the topmost window
* reacts to Escape and traps Tab, so a window opened over another (e.g. a ticket
* detail over the tickets list) doesn't close/steal from the one beneath it.
*/
const windowStack: object[] = [];
export interface FloatingWindowProps {
/** Whether the window is shown. Rendered as `null` when closed. */
open: boolean;
@ -76,11 +83,17 @@ function FloatingWindowBody({
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
const node = dialogRef.current;
// Register on the window stack so only the topmost handles keys.
const token = {};
windowStack.push(token);
const isTopmost = () => windowStack[windowStack.length - 1] === token;
// Move focus into the window (first focusable, else the dialog itself).
const first = node?.querySelector<HTMLElement>(FOCUSABLE);
(first ?? node)?.focus();
function onKeyDown(e: KeyboardEvent) {
// Ignore keys while a later window is stacked on top of this one.
if (!isTopmost()) return;
if (e.key === "Escape") {
e.preventDefault();
onClose();
@ -111,6 +124,8 @@ function FloatingWindowBody({
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
const i = windowStack.indexOf(token);
if (i >= 0) windowStack.splice(i, 1);
previouslyFocused?.focus?.();
};
}, [onClose]);