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:
@ -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()}>
|
||||
|
||||
@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user