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:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user