feat(ui): refonte menus & fenêtres — menu unique Panneaux et onglets projet (#26)

Sous-menus flyout dans MenuBar ; ProjectsView expose un menu unique
Panneaux et supprime les menus View/Window/File ainsi que le sélecteur
de projet ; ProjectTabs gère les onglets et le bouton +. Tests migrés
en conséquence.

QA vert : tsc --noEmit exit 0, vitest 62 fichiers / 616 tests passés,
invariants préservés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:08:34 +02:00
parent 805d4b70a2
commit 09e7f212b7
7 changed files with 305 additions and 217 deletions

View File

@ -23,6 +23,12 @@ export interface MenuBarItem {
disabled?: boolean;
/** Renders a selected/active marker (e.g. the currently-open panel). */
active?: boolean;
/**
* Nested items shown in a side flyout (#26). When present, the item opens its
* submenu on hover/click instead of running `onSelect`; selecting a leaf item
* of the submenu runs that leaf's `onSelect` and closes the whole menu.
*/
submenu?: MenuBarItem[];
}
export interface MenuBarMenu {
@ -38,16 +44,24 @@ export interface MenuBarProps {
export function MenuBar({ menus, className }: MenuBarProps) {
const [openId, setOpenId] = useState<string | null>(null);
// Which item's submenu flyout is currently open (at most one, #26). Reset
// whenever the top-level menu changes or closes.
const [openSubId, setOpenSubId] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
function closeMenus() {
setOpenId(null);
setOpenSubId(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);
if (!rootRef.current?.contains(e.target as Node)) closeMenus();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpenId(null);
if (e.key === "Escape") closeMenus();
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
@ -73,7 +87,10 @@ export function MenuBar({ menus, className }: MenuBarProps) {
type="button"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpenId((cur) => (cur === menu.id ? null : menu.id))}
onClick={() => {
setOpenSubId(null);
setOpenId((cur) => (cur === menu.id ? null : menu.id));
}}
className={cn(
"rounded px-2.5 py-1 text-sm transition-colors",
open
@ -93,29 +110,89 @@ export function MenuBar({ menus, className }: MenuBarProps) {
"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>
))}
{menu.items.map((item) => {
const hasSub = (item.submenu?.length ?? 0) > 0;
const subOpen = hasSub && openSubId === item.id;
return (
<div
key={item.id}
className="relative"
onMouseEnter={() =>
setOpenSubId(hasSub ? item.id : null)
}
>
<button
type="button"
disabled={item.disabled}
aria-haspopup={hasSub ? "menu" : undefined}
aria-expanded={hasSub ? subOpen : undefined}
onClick={() => {
if (hasSub) {
setOpenSubId((cur) =>
cur === item.id ? null : item.id,
);
return;
}
item.onSelect();
closeMenus();
}}
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>
{hasSub ? (
<span aria-hidden className="text-muted">
{item.active ? "● ▸" : "▸"}
</span>
) : (
item.active && (
<span aria-hidden className="text-primary">
</span>
)
)}
</button>
{subOpen && (
<div
role="menu"
aria-label={item.label}
style={{ zIndex: zIndex.floatingWindowNested }}
className={cn(
"absolute left-full top-0 ml-1 min-w-44 rounded-md border border-border",
"bg-surface py-1 shadow-xl",
)}
>
{item.submenu!.map((sub) => (
<button
key={sub.id}
type="button"
disabled={sub.disabled}
onClick={() => {
sub.onSelect();
closeMenus();
}}
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>{sub.label}</span>
{sub.active && (
<span aria-hidden className="text-primary">
</span>
)}
</button>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>