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

@ -34,3 +34,9 @@ export type { SpinnerProps } from "./ui/Spinner";
export { zIndex } from "./ui/zIndex";
export type { ZIndexLevel } from "./ui/zIndex";
export { FloatingWindow } from "./ui/FloatingWindow";
export type { FloatingWindowProps, FloatingWindowSize } from "./ui/FloatingWindow";
export { MenuBar } from "./ui/MenuBar";
export type { MenuBarProps, MenuBarMenu, MenuBarItem } from "./ui/MenuBar";

View File

@ -0,0 +1,170 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { FloatingWindow } from "./FloatingWindow";
import { MenuBar } from "./MenuBar";
import { zIndex } from "./zIndex";
describe("FloatingWindow (#16, G1)", () => {
it("renders nothing when closed", () => {
const { container } = render(
<FloatingWindow open={false} title="Panel" onClose={vi.fn()}>
<button>inside</button>
</FloatingWindow>,
);
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it("renders a titled modal dialog at the floatingWindow z-index", () => {
render(
<FloatingWindow open title="My panel" onClose={vi.fn()}>
<p>body</p>
</FloatingWindow>,
);
const dialog = screen.getByRole("dialog", { name: "My panel" });
expect(dialog.getAttribute("aria-modal")).toBe("true");
// The backdrop carries the frozen z-index token.
const backdrop = dialog.parentElement as HTMLElement;
expect(backdrop.style.zIndex).toBe(String(zIndex.floatingWindow));
});
it("moves focus into the window on open (focus-trap entry)", () => {
render(
<FloatingWindow open title="Panel" onClose={vi.fn()}>
<input aria-label="first field" />
<button>second</button>
</FloatingWindow>,
);
// The first focusable element (the Close button in the header) receives focus.
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "close Panel" }),
);
});
it("wraps Tab from the last focusable back to the first (focus-trap)", () => {
render(
<FloatingWindow open title="Panel" onClose={vi.fn()}>
<button>only</button>
</FloatingWindow>,
);
const closeBtn = screen.getByRole("button", { name: "close Panel" });
const onlyBtn = screen.getByRole("button", { name: "only" });
// Focus the last focusable, press Tab ⇒ wraps to the first (close button).
onlyBtn.focus();
fireEvent.keyDown(document, { key: "Tab" });
expect(document.activeElement).toBe(closeBtn);
// Shift+Tab from the first wraps to the last.
closeBtn.focus();
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(onlyBtn);
});
it("closes on Escape, backdrop click, and the Close button", () => {
const onClose = vi.fn();
render(
<FloatingWindow open title="Panel" onClose={onClose}>
<p>body</p>
</FloatingWindow>,
);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
const dialog = screen.getByRole("dialog");
fireEvent.mouseDown(dialog.parentElement as HTMLElement); // backdrop
expect(onClose).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole("button", { name: "close Panel" }));
expect(onClose).toHaveBeenCalledTimes(3);
});
it("does not close on clicks inside the panel body", () => {
const onClose = vi.fn();
render(
<FloatingWindow open title="Panel" onClose={onClose}>
<button>inside</button>
</FloatingWindow>,
);
fireEvent.mouseDown(screen.getByRole("button", { name: "inside" }));
fireEvent.click(screen.getByRole("button", { name: "inside" }));
expect(onClose).not.toHaveBeenCalled();
});
it("restores focus to the opener when closed", () => {
function Harness() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>opener</button>
<FloatingWindow open={open} title="Panel" onClose={() => setOpen(false)}>
<p>body</p>
</FloatingWindow>
</>
);
}
render(<Harness />);
const opener = screen.getByRole("button", { name: "opener" });
opener.focus();
fireEvent.click(opener);
// Window open, focus moved inside.
expect(document.activeElement).not.toBe(opener);
// Close via Escape ⇒ focus returns to the opener.
fireEvent.keyDown(document, { key: "Escape" });
expect(document.activeElement).toBe(opener);
});
});
describe("MenuBar (#16)", () => {
const menus = [
{
id: "view",
label: "View",
items: [
{ id: "a", label: "Agents", onSelect: vi.fn() },
{ id: "b", label: "Git", onSelect: vi.fn() },
],
},
];
it("opens a dropdown on click and fires the item's onSelect, then closes", () => {
const onSelect = vi.fn();
render(
<MenuBar
menus={[
{
id: "view",
label: "View",
items: [{ id: "a", label: "Agents", onSelect }],
},
]}
/>,
);
// Item hidden until the menu opens.
expect(screen.queryByRole("button", { name: "Agents" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "View" }));
fireEvent.click(screen.getByRole("button", { name: "Agents" }));
expect(onSelect).toHaveBeenCalledTimes(1);
// Menu closes after selection.
expect(screen.queryByRole("button", { name: "Agents" })).toBeNull();
});
it("closes the open menu on Escape", () => {
render(<MenuBar menus={menus} />);
fireEvent.click(screen.getByRole("button", { name: "View" }));
expect(screen.getByRole("button", { name: "Agents" })).toBeTruthy();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("button", { name: "Agents" })).toBeNull();
});
it("puts the dropdown at the menuDropdown z-index", () => {
render(<MenuBar menus={menus} />);
fireEvent.click(screen.getByRole("button", { name: "View" }));
const dropdown = screen.getByRole("menu", { name: "View" });
expect(dropdown.style.zIndex).toBe(String(zIndex.menuDropdown));
});
});

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

View File

@ -0,0 +1,126 @@
/**
* MenuBar — a top-of-window bar of dropdown menus (ticket #16).
*
* Replaces the former left sidebar tab strip with a classic IDE-style menu bar:
* each top-level menu opens a dropdown of items; an item runs its `onSelect`
* (typically opening a {@link FloatingWindow} or toggling a panel) and closes
* the menu. One menu is open at a time; Escape or an outside click closes it.
*
* Dropdowns use the frozen `menuDropdown` z-index token (40). Menu items are
* plain `<button>`s (implicit `role="button"`) so callers/tests can target them
* by accessible name.
*/
import { useEffect, useRef, useState } from "react";
import { cn } from "../lib/cn";
import { zIndex } from "./zIndex";
export interface MenuBarItem {
id: string;
label: string;
onSelect: () => void;
disabled?: boolean;
/** Renders a selected/active marker (e.g. the currently-open panel). */
active?: boolean;
}
export interface MenuBarMenu {
id: string;
label: string;
items: MenuBarItem[];
}
export interface MenuBarProps {
menus: MenuBarMenu[];
className?: string;
}
export function MenuBar({ menus, className }: MenuBarProps) {
const [openId, setOpenId] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
// Close on outside click and on Escape while a menu is open.
useEffect(() => {
if (openId === null) return;
function onPointerDown(e: MouseEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpenId(null);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpenId(null);
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [openId]);
return (
<div
ref={rootRef}
className={cn(
"flex shrink-0 items-center gap-0.5 border-b border-border bg-surface px-2 py-1",
className,
)}
>
{menus.map((menu) => {
const open = openId === menu.id;
return (
<div key={menu.id} className="relative">
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpenId((cur) => (cur === menu.id ? null : menu.id))}
className={cn(
"rounded px-2.5 py-1 text-sm transition-colors",
open
? "bg-raised text-content"
: "text-muted hover:bg-raised hover:text-content",
)}
>
{menu.label}
</button>
{open && (
<div
role="menu"
aria-label={menu.label}
style={{ zIndex: zIndex.menuDropdown }}
className={cn(
"absolute left-0 top-full mt-1 min-w-44 rounded-md border border-border",
"bg-surface py-1 shadow-xl",
)}
>
{menu.items.map((item) => (
<button
key={item.id}
type="button"
disabled={item.disabled}
onClick={() => {
item.onSelect();
setOpenId(null);
}}
className={cn(
"flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-sm transition-colors",
"text-content hover:bg-raised",
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent",
)}
>
<span>{item.label}</span>
{item.active && (
<span aria-hidden className="text-primary">
</span>
)}
</button>
))}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@ -6,11 +6,10 @@
* mounted at the **chrome level** (ProjectsView/App), never inside
* LayoutGrid/LeafView (G5).
*
* ⚠️ PROVISIONAL (lot B / ticket #18): created here because lot A (ticket #16)
* introduces the canonical `shared/ui/zIndex.ts` (+ `FloatingWindow`) in
* parallel. The values below MUST match the frozen scale; at merge, keep a
* single module (drop this one if lot A's is richer). See the sprint scoping
* note `ui-rework-sprint-scoping-contracts`.
* Consumers: `FloatingWindow` (floatingWindow), `MenuBar` dropdowns
* (menuDropdown), `TicketPicker` (floatingWindowNested), background-task toasts
* (toast). Introduced by lot B (#18) and adopted as the canonical scale by lot A
* (#16). See the sprint scoping note `ui-rework-sprint-scoping-contracts`.
*/
export const zIndex = {
/** Dropdown menus / popovers anchored to a control. */