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:
2026-07-06 20:55:37 +02:00
parent 0218e3271f
commit c55f948d25
9 changed files with 933 additions and 353 deletions

View 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>
);
}