feat(ui): clarification des contrôles d'en-tête DockControls (#42)

Refinement UX suite #26 : clarifie les contrôles DockControls dans
l'en-tête des panneaux. Frontend-pur.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 19:32:37 +02:00
parent c864d4a692
commit 73859a05e1
2 changed files with 56 additions and 40 deletions

View File

@ -68,7 +68,7 @@ describe("ProjectsView view docking (#22)", () => {
expect(dialog).toBeTruthy(); expect(dialog).toBeTruthy();
// Dock it to the left via the header control. // Dock it to the left via the header control.
fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" })); fireEvent.click(within(dialog).getByRole("button", { name: "Ancré à gauche — Git" }));
// The modal window is gone; the Git view now lives in the left dock column. // The modal window is gone; the Git view now lives in the left dock column.
await waitFor(() => await waitFor(() =>
@ -85,13 +85,13 @@ describe("ProjectsView view docking (#22)", () => {
openPanelFloating("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: "Ancré à gauche — Git" }));
const leftDock = await screen.findByRole("complementary", { name: "left dock" }); const leftDock = await screen.findByRole("complementary", { name: "left dock" });
const leftHeader = within(leftDock).getByLabelText("Git panel"); const leftHeader = within(leftDock).getByLabelText("Git panel");
// Move it to the right dock from the docked header control. // Move it to the right dock from the docked header control.
fireEvent.click( fireEvent.click(
within(leftHeader).getByRole("button", { name: "dock Git right" }), within(leftHeader).getByRole("button", { name: "Ancré à droite — Git" }),
); );
await waitFor(() => await waitFor(() =>
@ -109,7 +109,7 @@ describe("ProjectsView view docking (#22)", () => {
fireEvent.click( fireEvent.click(
within(await screen.findByRole("dialog", { name: "Git" })).getByRole( within(await screen.findByRole("dialog", { name: "Git" })).getByRole(
"button", "button",
{ name: "dock Git left" }, { name: "Ancré à gauche — Git" },
), ),
); );
// Dock Agents right. // Dock Agents right.
@ -117,7 +117,7 @@ describe("ProjectsView view docking (#22)", () => {
fireEvent.click( fireEvent.click(
within(await screen.findByRole("dialog", { name: "Agents" })).getByRole( within(await screen.findByRole("dialog", { name: "Agents" })).getByRole(
"button", "button",
{ name: "dock Agents right" }, { name: "Ancré à droite — Agents" },
), ),
); );

View File

@ -40,9 +40,11 @@ import {
Button, Button,
DockRegion, DockRegion,
FloatingWindow, FloatingWindow,
IconButton,
Input, Input,
MenuBar, MenuBar,
Panel, Panel,
cn,
zIndex, zIndex,
type FloatingWindowSize, type FloatingWindowSize,
type MenuBarItem, type MenuBarItem,
@ -469,58 +471,72 @@ export function ProjectsView() {
const rightPanels = panelsDockedTo(placements, "right"); const rightPanels = panelsDockedTo(placements, "right");
const floatingList = floatingPanels(placements); const floatingList = floatingPanels(placements);
// Placement toggle buttons for a view's header — the "flottant ↔ ancré // Placement controls for a view's header — labelled icon-buttons aligned 1:1
// (gauche/droite)" control from the contract. The button for the current slot // with the « Panneaux » menu wording (#42). Order: ⇤ gauche · ⇥ droite · □
// is disabled so the active placement reads at a glance. // flottant · ↗ fenêtre détachée · × fermé. The *current* placement reads as
// ACTIVE (aria-pressed + ring), never disabled — a disabled control loses its
// tooltip/keyboard focus and conflates "impossible" with "already selected".
// Only genuinely-impossible actions are disabled (detach with no active
// project). All labelling flows through `title` + `aria-label`; no visible
// text. The detach control is hidden for the main-window-only `projects` view.
function DockControls({ panel }: { panel: PanelId }) { function DockControls({ panel }: { panel: PanelId }) {
const placement = placementOf(placements, panel); const placement = placementOf(placements, panel);
const title = PANEL_TITLE[panel]; const title = PANEL_TITLE[panel];
// Active (current-placement) styling — visually distinct, still clickable.
const activeClass = "bg-raised text-content ring-1 ring-border-strong";
return ( return (
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button <IconButton
size="sm" size="sm"
variant="ghost" title="Ancré à gauche"
aria-label={`dock ${title} left`} aria-label={`Ancré à gauche — ${title}`}
disabled={isDockedTo(placement, "left")} aria-pressed={isDockedTo(placement, "left")}
className={cn(isDockedTo(placement, "left") && activeClass)}
onClick={() => setPlacement(panel, { dock: "left" })} onClick={() => setPlacement(panel, { dock: "left" })}
> >
</Button> </IconButton>
<Button <IconButton
size="sm" size="sm"
variant="ghost" title="Ancré à droite"
aria-label={`dock ${title} right`} aria-label={`Ancré à droite — ${title}`}
disabled={isDockedTo(placement, "right")} aria-pressed={isDockedTo(placement, "right")}
className={cn(isDockedTo(placement, "right") && activeClass)}
onClick={() => setPlacement(panel, { dock: "right" })} onClick={() => setPlacement(panel, { dock: "right" })}
> >
</Button> </IconButton>
<Button <IconButton
size="sm" size="sm"
variant="ghost" title="Flottant"
aria-label={`float ${title}`} aria-label={`Flottant — ${title}`}
disabled={placement === "floating"} aria-pressed={placement === "floating"}
className={cn(placement === "floating" && activeClass)}
onClick={() => setPlacement(panel, "floating")} onClick={() => setPlacement(panel, "floating")}
> >
</Button> </IconButton>
<Button {panel !== "projects" && (
<IconButton
size="sm"
title="Fenêtre détachée"
aria-label={`Fenêtre détachée — ${title}`}
aria-pressed={isDetached(placement)}
disabled={!active}
className={cn(isDetached(placement) && activeClass)}
onClick={() => detachPanel(panel)}
>
</IconButton>
)}
<IconButton
size="sm" size="sm"
variant="ghost" title="Fermé"
aria-label={`open ${title} in new window`} aria-label={`Fermé — ${title}`}
disabled={!active || isDetached(placement)}
onClick={() => detachPanel(panel)}
>
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`close ${title}`}
onClick={() => closePanel(panel)} onClick={() => closePanel(panel)}
> >
×
</Button> </IconButton>
</div> </div>
); );
} }