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:
@ -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";
|
||||
|
||||
170
frontend/src/shared/ui/FloatingWindow.test.tsx
Normal file
170
frontend/src/shared/ui/FloatingWindow.test.tsx
Normal 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));
|
||||
});
|
||||
});
|
||||
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>
|
||||
);
|
||||
}
|
||||
126
frontend/src/shared/ui/MenuBar.tsx
Normal file
126
frontend/src/shared/ui/MenuBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user