feat(ui): menus déroulants + fenêtres flottantes, barre unique et sélecteur de projet (#16)
Lot A du sprint UI rework : réorganisation de la navigation racine. - shared/ui/MenuBar.tsx : barre de menus unique avec menus déroulants - shared/ui/FloatingWindow.tsx : primitive de fenêtre flottante réutilisable (socle du lot C #17 fenêtre dédiée gestion tickets) - shared/ui/zIndex.ts : échelle z-index étendue pour les fenêtres flottantes - App : intègre la barre unique et le sélecteur de projet permanent - ProjectsView : sélecteur de projet permanent Tests : tsc --noEmit clean, suite complète 527/527, suites impactées 33/33. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
154
frontend/src/shared/ui/FloatingWindow.tsx
Normal file
154
frontend/src/shared/ui/FloatingWindow.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* FloatingWindow — the shared modal-overlay primitive (ticket #16, gate G1).
|
||||
*
|
||||
* Consolidates the `fixed inset-0 role="dialog" aria-modal` pattern that was
|
||||
* hand-rolled in ~7 features. Mounted at the **chrome level** (ProjectsView/App),
|
||||
* never inside LayoutGrid/LeafView (G5). It provides:
|
||||
* - a focus-trap (mandatory: xterm captures keyboard focus, so a window opened
|
||||
* over a terminal must steal *and* trap focus, then restore it on close),
|
||||
* - Escape-to-close and backdrop-click-to-close,
|
||||
* - the frozen `floatingWindow` z-index token (50).
|
||||
*
|
||||
* Purely presentational: the caller owns `open`/`onClose` state and the body.
|
||||
* Rendered as `null` while closed so the body (and its hooks) only run when the
|
||||
* window is actually open.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
import { Button } from "./Button";
|
||||
import { zIndex } from "./zIndex";
|
||||
|
||||
/** Width presets; height is capped at 80vh with an internal scroll region. */
|
||||
export type FloatingWindowSize = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
const SIZE_CLASS: Record<FloatingWindowSize, string> = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-lg",
|
||||
lg: "max-w-2xl",
|
||||
xl: "max-w-4xl",
|
||||
};
|
||||
|
||||
/** Tabbable-element selector 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 interface FloatingWindowProps {
|
||||
/** Whether the window is shown. Rendered as `null` when closed. */
|
||||
open: boolean;
|
||||
/** Accessible dialog title, shown in the header. */
|
||||
title: string;
|
||||
/** Called on Escape, backdrop click, or the Close button. */
|
||||
onClose: () => void;
|
||||
/** Width preset (default "md"). */
|
||||
size?: FloatingWindowSize;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FloatingWindow({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
size = "md",
|
||||
children,
|
||||
}: FloatingWindowProps) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<FloatingWindowBody title={title} onClose={onClose} size={size}>
|
||||
{children}
|
||||
</FloatingWindowBody>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mounted window. Split out so the focus-trap effect and the body only run
|
||||
* while `open` — mounting/unmounting on `open` gives clean focus save/restore.
|
||||
*/
|
||||
function FloatingWindowBody({
|
||||
title,
|
||||
onClose,
|
||||
size,
|
||||
children,
|
||||
}: Omit<FloatingWindowProps, "open">) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const node = dialogRef.current;
|
||||
// Move focus into the window (first focusable, else the dialog itself).
|
||||
const first = node?.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(first ?? node)?.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<HTMLElement>(FOCUSABLE),
|
||||
);
|
||||
if (focusables.length === 0) {
|
||||
// Nothing focusable inside: keep focus on the dialog container.
|
||||
e.preventDefault();
|
||||
node.focus();
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/55 p-4"
|
||||
style={{ zIndex: zIndex.floatingWindow }}
|
||||
onMouseDown={(e) => {
|
||||
// Backdrop click closes; clicks inside the panel don't reach here.
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"flex max-h-[80vh] w-full flex-col overflow-hidden rounded-lg",
|
||||
"border border-border bg-surface shadow-xl outline-none",
|
||||
SIZE_CLASS[size ?? "md"],
|
||||
)}
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||||
<span className="text-sm font-medium text-content">{title}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`close ${title}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user