Merge feature/ticket26-ui-rework-menus into develop
Refonte menus & fenêtres (#26) : menu unique Panneaux, onglets projet avec bouton +, sous-menus flyout. QA vert (tsc + 616 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,9 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* `ProjectTabs` — the project tab bar rendered below the app header.
|
* `ProjectTabs` — the project tab bar rendered below the app header.
|
||||||
*
|
*
|
||||||
* Shows one tab per open project with a close control, and a "+" button to
|
* Shows one tab per open project with a close control, and a "+" button to the
|
||||||
* return to the launcher. Purely presentational: all state lives in the
|
* right that opens the **Projects** panel (floating) — the single entry point
|
||||||
* parent (ProjectsView via useProjects).
|
* for creating a project or switching to a known one (#26). Purely
|
||||||
|
* presentational: all state lives in the parent (ProjectsView via useProjects).
|
||||||
|
*
|
||||||
|
* The bar is always present (even with no open project) so the "+" stays
|
||||||
|
* reachable; the empty case shows a muted placeholder next to it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TabItem } from "@/shared";
|
import type { TabItem } from "@/shared";
|
||||||
@ -14,9 +18,10 @@ export interface ProjectTabsProps {
|
|||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onClose: (id: string) => void;
|
onClose: (id: string) => void;
|
||||||
/** Whether the launcher overlay is currently visible (no active tab). */
|
/** Whether the Projects panel is currently open (in any placement). */
|
||||||
showingLauncher: boolean;
|
projectsPanelOpen: boolean;
|
||||||
onShowLauncher: () => void;
|
/** Open the Projects panel (floating) — create/switch project. */
|
||||||
|
onOpenProjectsPanel: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,19 +30,20 @@ export function ProjectTabs({
|
|||||||
activeTabId,
|
activeTabId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
showingLauncher,
|
projectsPanelOpen,
|
||||||
onShowLauncher,
|
onOpenProjectsPanel,
|
||||||
className,
|
className,
|
||||||
}: ProjectTabsProps) {
|
}: ProjectTabsProps) {
|
||||||
if (items.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1 border-b border-border bg-surface px-2 py-1",
|
"flex shrink-0 items-center gap-1 border-b border-border bg-surface px-2 py-1",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="flex-1 px-2 text-sm text-muted">No open tabs.</p>
|
||||||
|
) : (
|
||||||
<Tabs
|
<Tabs
|
||||||
items={items}
|
items={items}
|
||||||
value={activeTabId}
|
value={activeTabId}
|
||||||
@ -45,11 +51,12 @@ export function ProjectTabs({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={showingLauncher ? "secondary" : "ghost"}
|
variant={projectsPanelOpen ? "secondary" : "ghost"}
|
||||||
onClick={onShowLauncher}
|
onClick={onOpenProjectsPanel}
|
||||||
aria-label="open project launcher"
|
aria-label="open projects panel"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
|
|||||||
@ -49,23 +49,27 @@ async function renderWithProject() {
|
|||||||
return { windowGateway, projectId: created.id };
|
return { windowGateway, projectId: created.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMenuItem(menu: string, item: string) {
|
/**
|
||||||
fireEvent.click(screen.getByRole("button", { name: menu }));
|
* Picks a placement for a panel from the single « Panneaux » menu (#26): menu →
|
||||||
fireEvent.click(screen.getByRole("button", { name: item }));
|
* panel entry (opens its placement submenu) → the placement leaf ("Flottant",
|
||||||
|
* "Ancré à gauche", "Fenêtre détachée", …).
|
||||||
|
*/
|
||||||
|
function pickPlacement(panelTitle: string, placementLabel: string) {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: panelTitle }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: placementLabel }));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ProjectsView detach to OS window (#23)", () => {
|
describe("ProjectsView detach to OS window (#23)", () => {
|
||||||
it("Window menu detaches a view: opens the OS window and marks the slot detached", async () => {
|
it("« Fenêtre détachée » detaches a view: opens the OS window and marks the slot detached", async () => {
|
||||||
const { windowGateway, projectId } = await renderWithProject();
|
const { windowGateway, projectId } = await renderWithProject();
|
||||||
|
|
||||||
// A docked view is visible in the main window first…
|
// A docked view is visible in the main window first…
|
||||||
openMenuItem("View", "Git");
|
pickPlacement("Git", "Ancré à gauche");
|
||||||
const dialog = await screen.findByRole("dialog", { name: "Git" });
|
|
||||||
fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" }));
|
|
||||||
await screen.findByRole("complementary", { name: "left dock" });
|
await screen.findByRole("complementary", { name: "left dock" });
|
||||||
|
|
||||||
// …then « Git → nouvelle fenêtre » pops it out.
|
// …then « Panneaux → Git → Fenêtre détachée » pops it out.
|
||||||
openMenuItem("Window", "Git → nouvelle fenêtre");
|
pickPlacement("Git", "Fenêtre détachée");
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
|
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
|
||||||
@ -80,26 +84,29 @@ describe("ProjectsView detach to OS window (#23)", () => {
|
|||||||
it("re-toggles the placement out of detached when the OS window closes", async () => {
|
it("re-toggles the placement out of detached when the OS window closes", async () => {
|
||||||
const { windowGateway, projectId } = await renderWithProject();
|
const { windowGateway, projectId } = await renderWithProject();
|
||||||
|
|
||||||
openMenuItem("Window", "Git → nouvelle fenêtre");
|
pickPlacement("Git", "Fenêtre détachée");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
|
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The « Window » item shows the active marker (●) while detached.
|
// The « Fenêtre détachée » submenu leaf shows the active marker (●) while
|
||||||
const openWindowMenu = () =>
|
// detached. Re-navigate the submenu each time to read it.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Window" }));
|
const openGitSubmenu = () => {
|
||||||
const gitWindowItem = () =>
|
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||||
screen.getByRole("button", { name: "Git → nouvelle fenêtre" });
|
fireEvent.click(screen.getByRole("button", { name: "Git" }));
|
||||||
|
};
|
||||||
|
const detachedLeaf = () =>
|
||||||
|
screen.getByRole("button", { name: "Fenêtre détachée" });
|
||||||
|
|
||||||
openWindowMenu();
|
openGitSubmenu();
|
||||||
await waitFor(() => expect(gitWindowItem().textContent).toContain("●"));
|
await waitFor(() => expect(detachedLeaf().textContent).toContain("●"));
|
||||||
openWindowMenu(); // close the dropdown
|
fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); // close
|
||||||
|
|
||||||
// The backend reports the OS window closed ⇒ the slot leaves "detached".
|
// The backend reports the OS window closed ⇒ the slot leaves "detached".
|
||||||
windowGateway.simulateOsClose("git", projectId);
|
windowGateway.simulateOsClose("git", projectId);
|
||||||
openWindowMenu();
|
openGitSubmenu();
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(gitWindowItem().textContent).not.toContain("●"),
|
expect(detachedLeaf().textContent).not.toContain("●"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -47,10 +47,15 @@ async function renderWithProject() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens a menu-bar dropdown item. */
|
/**
|
||||||
function openMenuItem(menu: string, item: string) {
|
* Opens a panel floating via the single « Panneaux » menu (#26): menu → panel
|
||||||
fireEvent.click(screen.getByRole("button", { name: menu }));
|
* entry (opens its placement submenu) → « Flottant ». `panelTitle` is the
|
||||||
fireEvent.click(screen.getByRole("button", { name: item }));
|
* panel's human title ("Git", "Agents", …).
|
||||||
|
*/
|
||||||
|
function openPanelFloating(panelTitle: string) {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: panelTitle }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Flottant" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ProjectsView view docking (#22)", () => {
|
describe("ProjectsView view docking (#22)", () => {
|
||||||
@ -58,7 +63,7 @@ describe("ProjectsView view docking (#22)", () => {
|
|||||||
await renderWithProject();
|
await renderWithProject();
|
||||||
|
|
||||||
// View → Git opens a modal floating window (historical behaviour).
|
// View → Git opens a modal floating window (historical behaviour).
|
||||||
openMenuItem("View", "Git");
|
openPanelFloating("Git");
|
||||||
const dialog = await screen.findByRole("dialog", { name: "Git" });
|
const dialog = await screen.findByRole("dialog", { name: "Git" });
|
||||||
expect(dialog).toBeTruthy();
|
expect(dialog).toBeTruthy();
|
||||||
|
|
||||||
@ -78,7 +83,7 @@ describe("ProjectsView view docking (#22)", () => {
|
|||||||
it("keeps one slot per view: docking right moves it off the left dock", async () => {
|
it("keeps one slot per view: docking right moves it off the left dock", async () => {
|
||||||
await renderWithProject();
|
await renderWithProject();
|
||||||
|
|
||||||
openMenuItem("View", "Git");
|
openPanelFloating("Git");
|
||||||
const dialog = await screen.findByRole("dialog", { name: "Git" });
|
const dialog = await screen.findByRole("dialog", { name: "Git" });
|
||||||
fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" }));
|
fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" }));
|
||||||
|
|
||||||
@ -100,7 +105,7 @@ describe("ProjectsView view docking (#22)", () => {
|
|||||||
await renderWithProject();
|
await renderWithProject();
|
||||||
|
|
||||||
// Dock Git left.
|
// Dock Git left.
|
||||||
openMenuItem("View", "Git");
|
openPanelFloating("Git");
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(await screen.findByRole("dialog", { name: "Git" })).getByRole(
|
within(await screen.findByRole("dialog", { name: "Git" })).getByRole(
|
||||||
"button",
|
"button",
|
||||||
@ -108,7 +113,7 @@ describe("ProjectsView view docking (#22)", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Dock Agents right.
|
// Dock Agents right.
|
||||||
openMenuItem("View", "Agents");
|
openPanelFloating("Agents");
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
within(await screen.findByRole("dialog", { name: "Agents" })).getByRole(
|
within(await screen.findByRole("dialog", { name: "Agents" })).getByRole(
|
||||||
"button",
|
"button",
|
||||||
|
|||||||
@ -112,10 +112,14 @@ function renderView(project: MockProjectGateway) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens a menu-bar dropdown item (#16 chrome). */
|
/**
|
||||||
function openMenuItem(menu: string, item: string) {
|
* Opens a panel floating via the single « Panneaux » menu (#26): menu → panel
|
||||||
fireEvent.click(screen.getByRole("button", { name: menu }));
|
* entry (opens its placement submenu) → « Flottant ».
|
||||||
fireEvent.click(screen.getByRole("button", { name: item }));
|
*/
|
||||||
|
function openPanel(panelTitle: string) {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: panelTitle }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Flottant" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openProjectAndWorkTab(label: string) {
|
async function openProjectAndWorkTab(label: string) {
|
||||||
@ -127,7 +131,7 @@ async function openProjectAndWorkTab(label: string) {
|
|||||||
fireEvent.click(within(li!).getByRole("button", { name: "Open" }));
|
fireEvent.click(within(li!).getByRole("button", { name: "Open" }));
|
||||||
await screen.findByRole("tab");
|
await screen.findByRole("tab");
|
||||||
// Open the Work panel window from the View menu.
|
// Open the Work panel window from the View menu.
|
||||||
openMenuItem("View", "Work");
|
openPanel("Work state");
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ProjectsView — LS7 conversation viewer integration", () => {
|
describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||||
@ -177,7 +181,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
|||||||
// persists across activation (the inline welcome list is replaced by the
|
// persists across activation (the inline welcome list is replaced by the
|
||||||
// grid once a project is active).
|
// grid once a project is active).
|
||||||
await screen.findByText("/p/a");
|
await screen.findByText("/p/a");
|
||||||
openMenuItem("File", "Projects…");
|
openPanel("Projects");
|
||||||
for (const root of ["/p/a", "/p/b"]) {
|
for (const root of ["/p/a", "/p/b"]) {
|
||||||
const li = screen
|
const li = screen
|
||||||
.getAllByRole("listitem")
|
.getAllByRole("listitem")
|
||||||
@ -188,7 +192,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
|||||||
|
|
||||||
// Make alpha the active tab, then open its conversation viewer.
|
// Make alpha the active tab, then open its conversation viewer.
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "alpha" }));
|
fireEvent.click(screen.getByRole("tab", { name: "alpha" }));
|
||||||
openMenuItem("View", "Work");
|
openPanel("Work state");
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
|
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,20 +4,22 @@
|
|||||||
* IDE layout (full remaining height), reworked in ticket #16:
|
* IDE layout (full remaining height), reworked in ticket #16:
|
||||||
*
|
*
|
||||||
* ┌───────────────────────────────────────────────────────┐
|
* ┌───────────────────────────────────────────────────────┐
|
||||||
* │ PROJECT TABS [ alpha × ][ beta × ] │
|
* │ PROJECT TABS [ alpha × ][ beta × ] [ + ] │
|
||||||
* ├───────────────────────────────────────────────────────┤
|
* ├───────────────────────────────────────────────────────┤
|
||||||
* │ MENU BAR File │ View │
|
* │ MENU BAR Panneaux │ Settings │
|
||||||
* ├───────────────────────────────────────────────────────┤
|
* ├───────────────────────────────────────────────────────┤
|
||||||
* │ MAIN — LayoutGrid (fills the FULL width) │
|
* │ MAIN — LayoutGrid (fills the FULL width) │
|
||||||
* │ — or the projects manager / welcome when no project │
|
* │ — or the projects manager / welcome when no project │
|
||||||
* └───────────────────────────────────────────────────────┘
|
* └───────────────────────────────────────────────────────┘
|
||||||
*
|
*
|
||||||
* The former left sidebar is gone: every panel (context, work, tickets, agents,
|
* The former left sidebar is gone: every panel (context, work, tickets, agents,
|
||||||
* templates, skills, permissions, memory, git) is now reached from the **View**
|
* templates, skills, permissions, memory, git, projects) is reached from the
|
||||||
* menu and shown in a chrome-level {@link FloatingWindow}, freeing the main area
|
* single **Panneaux** menu (#26), whose per-panel submenu picks the placement
|
||||||
* for the terminal grid. Project creation/opening lives under the **File** menu
|
* directly — closed / floating / docked left/right / detached OS window — and
|
||||||
* and, when no project is open, inline in the welcome area (so it is always
|
* shows it in a chrome-level {@link FloatingWindow} or {@link DockRegion}.
|
||||||
* reachable without a menu).
|
* Project creation/switching is the **Projects** panel, opened by the tab-bar
|
||||||
|
* "+" (or `Panneaux → Projects`) and, when no project is open, shown inline in
|
||||||
|
* the welcome area (so it is always reachable).
|
||||||
*
|
*
|
||||||
* Pure presentation: all behaviour comes from {@link useProjects}. Styling via
|
* Pure presentation: all behaviour comes from {@link useProjects}. Styling via
|
||||||
* `@/shared`; no inline styles beyond z-index tokens, no `invoke()`.
|
* `@/shared`; no inline styles beyond z-index tokens, no `invoke()`.
|
||||||
@ -41,13 +43,14 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
MenuBar,
|
MenuBar,
|
||||||
Panel,
|
Panel,
|
||||||
Tabs,
|
|
||||||
zIndex,
|
zIndex,
|
||||||
type FloatingWindowSize,
|
type FloatingWindowSize,
|
||||||
|
type MenuBarItem,
|
||||||
type MenuBarMenu,
|
type MenuBarMenu,
|
||||||
} from "@/shared";
|
} from "@/shared";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useProjects } from "./useProjects";
|
import { useProjects } from "./useProjects";
|
||||||
|
import { ProjectTabs } from "./ProjectTabs";
|
||||||
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
|
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
|
||||||
import {
|
import {
|
||||||
PANEL_TITLE,
|
PANEL_TITLE,
|
||||||
@ -206,14 +209,6 @@ export function ProjectsView() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Menu toggle: open floating when closed, otherwise close (whatever the slot).
|
|
||||||
function toggleFloating(panel: PanelId) {
|
|
||||||
if (placementOf(placements, panel) === "closed") {
|
|
||||||
setPlacement(panel, "floating");
|
|
||||||
} else {
|
|
||||||
closePanel(panel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Dismiss every floating view (docked views stay put). Used when a full-main
|
// Dismiss every floating view (docked views stay put). Used when a full-main
|
||||||
// surface takes over so a modal window doesn't obscure it.
|
// surface takes over so a modal window doesn't obscure it.
|
||||||
function dismissFloating() {
|
function dismissFloating() {
|
||||||
@ -279,88 +274,77 @@ export function ProjectsView() {
|
|||||||
dismissFloating();
|
dismissFloating();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch the active project from the always-visible selector: activate its
|
// ── Menus (#26) ─────────────────────────────────────────────────────────
|
||||||
// open tab if present, otherwise open it (adds a tab). Leaving Settings so the
|
// A single « Panneaux » menu: one entry per panel, each opening a submenu with
|
||||||
// project surface is shown.
|
// the placement actions directly (closed / floating / docked left/right /
|
||||||
function selectProject(projectId: string) {
|
// detached OS window). One place to pick the panel *and* its mode; the current
|
||||||
setShowSettings(false);
|
// placement is marked in the submenu (and the parent entry shows ● when the
|
||||||
if (vm.openTabs.some((t) => t.id === projectId)) {
|
// panel is open somewhere). Order per UX: content panels first, Projects last.
|
||||||
vm.activateTab(projectId);
|
const panelOrder: PanelId[] = [
|
||||||
} else {
|
"context",
|
||||||
void vm.openProject(projectId);
|
"work",
|
||||||
|
"tickets",
|
||||||
|
"agents",
|
||||||
|
"templates",
|
||||||
|
"skills",
|
||||||
|
"permissions",
|
||||||
|
"memory",
|
||||||
|
"git",
|
||||||
|
"projects",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Placement actions for one panel, marking the active slot. `projects` is
|
||||||
|
// main-window-only chrome, so it never offers the detached-window action.
|
||||||
|
function placementSubmenu(panel: PanelId): MenuBarItem[] {
|
||||||
|
const placement = placementOf(placements, panel);
|
||||||
|
const items: MenuBarItem[] = [
|
||||||
|
{
|
||||||
|
id: "closed",
|
||||||
|
label: "Fermé",
|
||||||
|
active: placement === "closed",
|
||||||
|
onSelect: () => closePanel(panel),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "floating",
|
||||||
|
label: "Flottant",
|
||||||
|
active: placement === "floating",
|
||||||
|
onSelect: () => setPlacement(panel, "floating"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dock-left",
|
||||||
|
label: "Ancré à gauche",
|
||||||
|
active: isDockedTo(placement, "left"),
|
||||||
|
onSelect: () => setPlacement(panel, { dock: "left" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dock-right",
|
||||||
|
label: "Ancré à droite",
|
||||||
|
active: isDockedTo(placement, "right"),
|
||||||
|
onSelect: () => setPlacement(panel, { dock: "right" }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (panel !== "projects") {
|
||||||
|
items.push({
|
||||||
|
id: "detached",
|
||||||
|
label: "Fenêtre détachée",
|
||||||
|
active: isDetached(placement),
|
||||||
|
disabled: !active,
|
||||||
|
onSelect: () => detachPanel(panel),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Menus ──────────────────────────────────────────────────────────────
|
|
||||||
const viewItems: { id: PanelId; label: string }[] = [
|
|
||||||
{ id: "context", label: "Context" },
|
|
||||||
{ id: "work", label: "Work" },
|
|
||||||
{ id: "tickets", label: "Tickets" },
|
|
||||||
{ id: "agents", label: "Agents" },
|
|
||||||
{ id: "templates", label: "Templates" },
|
|
||||||
{ id: "skills", label: "Skills" },
|
|
||||||
{ id: "permissions", label: "Perms" },
|
|
||||||
{ id: "memory", label: "Memory" },
|
|
||||||
{ id: "git", label: "Git" },
|
|
||||||
];
|
|
||||||
// Always-visible project selector (left of the menus): shows the active
|
|
||||||
// project and lists every known project so the user can switch without opening
|
|
||||||
// the Projects window (#16 UX pass).
|
|
||||||
const projectMenu: MenuBarMenu = {
|
|
||||||
id: "project",
|
|
||||||
label: `Projet : ${active?.name ?? "—"}`,
|
|
||||||
items:
|
|
||||||
vm.projects.length === 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "none",
|
|
||||||
label: "No projects yet",
|
|
||||||
disabled: true,
|
|
||||||
onSelect: () => {},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: vm.projects.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
label: p.name,
|
|
||||||
active: p.id === active?.id,
|
|
||||||
onSelect: () => selectProject(p.id),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
const menus: MenuBarMenu[] = [
|
const menus: MenuBarMenu[] = [
|
||||||
projectMenu,
|
|
||||||
{
|
{
|
||||||
id: "file",
|
id: "panels",
|
||||||
label: "File",
|
label: "Panneaux",
|
||||||
items: [
|
items: panelOrder.map((panel) => ({
|
||||||
{
|
id: panel,
|
||||||
id: "projects",
|
label: PANEL_TITLE[panel],
|
||||||
label: "Projects…",
|
active: placementOf(placements, panel) !== "closed",
|
||||||
active: placementOf(placements, "projects") !== "closed",
|
onSelect: () => {},
|
||||||
onSelect: () => toggleFloating("projects"),
|
submenu: placementSubmenu(panel),
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "view",
|
|
||||||
label: "View",
|
|
||||||
items: viewItems.map((it) => ({
|
|
||||||
id: it.id,
|
|
||||||
label: it.label,
|
|
||||||
active: placementOf(placements, it.id) !== "closed",
|
|
||||||
onSelect: () => toggleFloating(it.id),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// #23: pop a view out into its own OS window. Disabled without an active
|
|
||||||
// project (the detached window is project-scoped); active ⇒ detached.
|
|
||||||
id: "window",
|
|
||||||
label: "Window",
|
|
||||||
items: viewItems.map((it) => ({
|
|
||||||
id: it.id,
|
|
||||||
label: `${PANEL_TITLE[it.id]} → nouvelle fenêtre`,
|
|
||||||
active: isDetached(placementOf(placements, it.id)),
|
|
||||||
disabled: !active,
|
|
||||||
onSelect: () => detachPanel(it.id),
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -575,20 +559,18 @@ export function ProjectsView() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Project tab bar ── */}
|
{/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */}
|
||||||
<div className="flex shrink-0 items-center gap-1 border-b border-border bg-surface px-2 py-1">
|
<ProjectTabs
|
||||||
{vm.openTabs.length === 0 ? (
|
|
||||||
<p className="px-2 text-sm text-muted">No open tabs.</p>
|
|
||||||
) : (
|
|
||||||
<Tabs
|
|
||||||
items={vm.openTabs.map((t) => ({ id: t.id, label: t.name }))}
|
items={vm.openTabs.map((t) => ({ id: t.id, label: t.name }))}
|
||||||
value={vm.activeTabId}
|
activeTabId={vm.activeTabId}
|
||||||
onSelect={(id) => vm.activateTab(id)}
|
onSelect={(id) => vm.activateTab(id)}
|
||||||
onClose={(id) => void vm.closeTab(id)}
|
onClose={(id) => void vm.closeTab(id)}
|
||||||
className="flex-1"
|
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
|
||||||
|
onOpenProjectsPanel={() => {
|
||||||
|
setShowSettings(false);
|
||||||
|
setPlacement("projects", "floating");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Menu bar (replaces the former left sidebar) ── */}
|
{/* ── Menu bar (replaces the former left sidebar) ── */}
|
||||||
<MenuBar menus={menus} />
|
<MenuBar menus={menus} />
|
||||||
|
|||||||
@ -75,11 +75,19 @@ function tablist() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a menu-bar dropdown item (#16 chrome). `menu` is a top-level menu
|
* Opens a panel floating via the single « Panneaux » menu (#26): click the menu,
|
||||||
* ("File"/"View"), `item` a panel entry ("Work", "Agents", "Projects…", …).
|
* click the panel entry (opens its placement submenu), then pick « Flottant ».
|
||||||
* The panel then shows in its floating window.
|
* `panelTitle` is the panel's human title ("Work state", "Project context",
|
||||||
|
* "Agents", "Git", "Projects", …).
|
||||||
*/
|
*/
|
||||||
function openPanel(menu: string, item: string) {
|
function openPanel(panelTitle: string) {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: panelTitle }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Flottant" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clicks an item in a flat top-level menu (e.g. Settings). */
|
||||||
|
function openMenuItem(menu: string, item: string) {
|
||||||
fireEvent.click(screen.getByRole("button", { name: menu }));
|
fireEvent.click(screen.getByRole("button", { name: menu }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: item }));
|
fireEvent.click(screen.getByRole("button", { name: item }));
|
||||||
}
|
}
|
||||||
@ -97,7 +105,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
expect(tab.getAttribute("aria-selected")).toBe("true");
|
expect(tab.getAttribute("aria-selected")).toBe("true");
|
||||||
expect(within(tab).getByText("alpha")).toBeTruthy();
|
expect(within(tab).getByText("alpha")).toBeTruthy();
|
||||||
// And it is listed in the projects manager (File → Projects…) with its root.
|
// And it is listed in the projects manager (File → Projects…) with its root.
|
||||||
openPanel("File", "Projects…");
|
openPanel("Projects");
|
||||||
expect(
|
expect(
|
||||||
(await screen.findAllByText("/home/me/alpha")).length,
|
(await screen.findAllByText("/home/me/alpha")).length,
|
||||||
).toBeGreaterThan(0);
|
).toBeGreaterThan(0);
|
||||||
@ -116,7 +124,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
|
|
||||||
// Open the projects manager window so the list persists across activation
|
// Open the projects manager window so the list persists across activation
|
||||||
// (the inline welcome list is replaced by the terminal grid once active).
|
// (the inline welcome list is replaced by the terminal grid once active).
|
||||||
openPanel("File", "Projects…");
|
openPanel("Projects");
|
||||||
|
|
||||||
// Open alpha, then beta — re-querying the list after each activation.
|
// Open alpha, then beta — re-querying the list after each activation.
|
||||||
const openInList = (name: string) =>
|
const openInList = (name: string) =>
|
||||||
@ -155,12 +163,12 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||||
await screen.findByRole("tab", { name: "alpha" });
|
await screen.findByRole("tab", { name: "alpha" });
|
||||||
|
|
||||||
openPanel("View", "Work");
|
openPanel("Work state");
|
||||||
|
|
||||||
expect(await screen.findByText("No agent work state.")).toBeTruthy();
|
expect(await screen.findByText("No agent work state.")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches the active project from the always-visible project selector (#16)", async () => {
|
it("switches the active project via the tab-bar + button → Projects panel (#26)", async () => {
|
||||||
const project = new MockProjectGateway();
|
const project = new MockProjectGateway();
|
||||||
await project.createProject("alpha", "/p/a");
|
await project.createProject("alpha", "/p/a");
|
||||||
await project.createProject("beta", "/p/b");
|
await project.createProject("beta", "/p/b");
|
||||||
@ -174,17 +182,15 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
fireEvent.click(within(alphaLi).getByRole("button", { name: "Open" }));
|
fireEvent.click(within(alphaLi).getByRole("button", { name: "Open" }));
|
||||||
await screen.findByRole("tab", { name: "alpha" });
|
await screen.findByRole("tab", { name: "alpha" });
|
||||||
|
|
||||||
// The selector reflects the active project and lists all known projects.
|
// The "+" button opens the Projects panel (floating); pick beta from its
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Projet : alpha" }));
|
// known-projects list — no verbose "Projet : …" selector anymore.
|
||||||
// Pick beta from the selector — it opens/activates without the Projects window.
|
fireEvent.click(screen.getByRole("button", { name: "open projects panel" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "beta" }));
|
const betaLi = await waitFor(() =>
|
||||||
|
screen.getAllByRole("listitem").find((n) => within(n).queryByText("beta"))!,
|
||||||
|
);
|
||||||
|
fireEvent.click(within(betaLi).getByRole("button", { name: "Open" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2));
|
||||||
expect(screen.getByRole("button", { name: "Projet : beta" })).toBeTruthy(),
|
|
||||||
);
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getAllByRole("tab")).toHaveLength(2),
|
|
||||||
);
|
|
||||||
const betaTab = screen
|
const betaTab = screen
|
||||||
.getAllByRole("tab")
|
.getAllByRole("tab")
|
||||||
.find((t) => within(t).queryByText("beta"))!;
|
.find((t) => within(t).queryByText("beta"))!;
|
||||||
@ -200,12 +206,12 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
|
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
|
||||||
|
|
||||||
// Settings → AI Profiles swaps the main area to the profiles settings.
|
// Settings → AI Profiles swaps the main area to the profiles settings.
|
||||||
openPanel("Settings", "AI Profiles");
|
openMenuItem("Settings", "AI Profiles");
|
||||||
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
|
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
|
||||||
expect(screen.queryByLabelText("project name")).toBeNull();
|
expect(screen.queryByLabelText("project name")).toBeNull();
|
||||||
|
|
||||||
// Settings → Close AI Profiles returns to the project surface.
|
// Settings → Close AI Profiles returns to the project surface.
|
||||||
openPanel("Settings", "Close AI Profiles");
|
openMenuItem("Settings", "Close AI Profiles");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
||||||
);
|
);
|
||||||
@ -233,7 +239,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
|
|
||||||
// Reopen the projects manager (the inline form is replaced by the grid once
|
// Reopen the projects manager (the inline form is replaced by the grid once
|
||||||
// a project is active), then create a second project at the same root.
|
// a project is active), then create a second project at the same root.
|
||||||
openPanel("File", "Projects…");
|
openPanel("Projects");
|
||||||
await createProject("beta", "/dup/root");
|
await createProject("beta", "/dup/root");
|
||||||
|
|
||||||
const alert = await screen.findByRole("alert");
|
const alert = await screen.findByRole("alert");
|
||||||
@ -255,40 +261,40 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// File → Projects… opens the projects manager window — form inputs visible.
|
// File → Projects… opens the projects manager window — form inputs visible.
|
||||||
openPanel("File", "Projects…");
|
openPanel("Projects");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// View → Agents — agent form visible; project form window is replaced.
|
// View → Agents — agent form visible; project form window is replaced.
|
||||||
openPanel("View", "Agents");
|
openPanel("Agents");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("agent name")).toBeTruthy(),
|
expect(screen.getByLabelText("agent name")).toBeTruthy(),
|
||||||
);
|
);
|
||||||
expect(screen.queryByLabelText("project name")).toBeNull();
|
expect(screen.queryByLabelText("project name")).toBeNull();
|
||||||
|
|
||||||
// View → Context — shared project context editor visible.
|
// View → Context — shared project context editor visible.
|
||||||
openPanel("View", "Context");
|
openPanel("Project context");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("project context")).toBeTruthy(),
|
expect(screen.getByLabelText("project context")).toBeTruthy(),
|
||||||
);
|
);
|
||||||
expect(screen.queryByLabelText("agent name")).toBeNull();
|
expect(screen.queryByLabelText("agent name")).toBeNull();
|
||||||
|
|
||||||
// View → Templates — the "New template" button visible.
|
// View → Templates — the "New template" button visible.
|
||||||
openPanel("View", "Templates");
|
openPanel("Templates");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(),
|
expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(),
|
||||||
);
|
);
|
||||||
expect(screen.queryByLabelText("agent name")).toBeNull();
|
expect(screen.queryByLabelText("agent name")).toBeNull();
|
||||||
|
|
||||||
// View → Git — commit message input visible.
|
// View → Git — commit message input visible.
|
||||||
openPanel("View", "Git");
|
openPanel("Git");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("commit message")).toBeTruthy(),
|
expect(screen.getByLabelText("commit message")).toBeTruthy(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// File → Projects… again — form accessible once more.
|
// File → Projects… again — form accessible once more.
|
||||||
openPanel("File", "Projects…");
|
openPanel("Projects");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
||||||
);
|
);
|
||||||
@ -302,7 +308,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
|
|
||||||
await screen.findByText("/p/a");
|
await screen.findByText("/p/a");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||||
openPanel("View", "Context");
|
openPanel("Project context");
|
||||||
|
|
||||||
const editor = (await screen.findByLabelText(
|
const editor = (await screen.findByLabelText(
|
||||||
"project context",
|
"project context",
|
||||||
|
|||||||
@ -23,6 +23,12 @@ export interface MenuBarItem {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Renders a selected/active marker (e.g. the currently-open panel). */
|
/** Renders a selected/active marker (e.g. the currently-open panel). */
|
||||||
active?: boolean;
|
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 {
|
export interface MenuBarMenu {
|
||||||
@ -38,16 +44,24 @@ export interface MenuBarProps {
|
|||||||
|
|
||||||
export function MenuBar({ menus, className }: MenuBarProps) {
|
export function MenuBar({ menus, className }: MenuBarProps) {
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
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);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
function closeMenus() {
|
||||||
|
setOpenId(null);
|
||||||
|
setOpenSubId(null);
|
||||||
|
}
|
||||||
|
|
||||||
// Close on outside click and on Escape while a menu is open.
|
// Close on outside click and on Escape while a menu is open.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openId === null) return;
|
if (openId === null) return;
|
||||||
function onPointerDown(e: MouseEvent) {
|
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) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") setOpenId(null);
|
if (e.key === "Escape") closeMenus();
|
||||||
}
|
}
|
||||||
document.addEventListener("mousedown", onPointerDown);
|
document.addEventListener("mousedown", onPointerDown);
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
@ -73,7 +87,10 @@ export function MenuBar({ menus, className }: MenuBarProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
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(
|
className={cn(
|
||||||
"rounded px-2.5 py-1 text-sm transition-colors",
|
"rounded px-2.5 py-1 text-sm transition-colors",
|
||||||
open
|
open
|
||||||
@ -93,14 +110,31 @@ export function MenuBar({ menus, className }: MenuBarProps) {
|
|||||||
"bg-surface py-1 shadow-xl",
|
"bg-surface py-1 shadow-xl",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{menu.items.map((item) => (
|
{menu.items.map((item) => {
|
||||||
<button
|
const hasSub = (item.submenu?.length ?? 0) > 0;
|
||||||
|
const subOpen = hasSub && openSubId === item.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
className="relative"
|
||||||
|
onMouseEnter={() =>
|
||||||
|
setOpenSubId(hasSub ? item.id : null)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
|
aria-haspopup={hasSub ? "menu" : undefined}
|
||||||
|
aria-expanded={hasSub ? subOpen : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (hasSub) {
|
||||||
|
setOpenSubId((cur) =>
|
||||||
|
cur === item.id ? null : item.id,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
item.onSelect();
|
item.onSelect();
|
||||||
setOpenId(null);
|
closeMenus();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-sm transition-colors",
|
"flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-sm transition-colors",
|
||||||
@ -109,7 +143,45 @@ export function MenuBar({ menus, className }: MenuBarProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
{item.active && (
|
{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 aria-hidden className="text-primary">
|
||||||
●
|
●
|
||||||
</span>
|
</span>
|
||||||
@ -122,5 +194,10 @@ export function MenuBar({ menus, className }: MenuBarProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user