diff --git a/docs/shell-design.md b/docs/shell-design.md index 782e42a46..6914bc328 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -60,9 +60,11 @@ semantic tokens, UI authoring rules and the local component reference. 220px disclosure overlays content, supports Escape, and keeps sidebar state mounted. A navigation selection closes the phone drawer and hands focus to the main content; this includes conversation and Settings-section selections. - Me, Messages, Agents, and desktop Settings share an animated header toggle; hiding - the sidebar preserves its mounted state and saved width. Reduced motion disables - the transition. Other desktop pages retain the visible sidebar. + Me, Messages, Agents, desktop Settings, and any selected primary page share an + animated header toggle; hiding the sidebar preserves its mounted state and saved + width. Pages without a Channel or Me-specific label use generic "Hide sidebar" / + "Show sidebar" labels. Reduced motion disables the transition. Non-primary + desktop destinations retain the visible sidebar. The same header keeps history and account/search actions, plus a centered navigation strip and full-page toolbar icons. Real control widths, native insets and text scale determine whether header pages fit without overlap. Otherwise diff --git a/src/app/shell/AppShell.test.tsx b/src/app/shell/AppShell.test.tsx index 2ac323069..4f5f5304c 100644 --- a/src/app/shell/AppShell.test.tsx +++ b/src/app/shell/AppShell.test.tsx @@ -112,6 +112,138 @@ it("hides and shows the channel sidebar without re-rendering its content", async expect(row()).not.toHaveAttribute("aria-current"); }); +it("lets a plugin page hide and show the shell sidebar", async () => { + const current = createServices(); + services = current; + const pages: RegisteredPage[] = [ + { + id: "main", + key: "example.plugin/main", + pluginId: "example.plugin", + revision: "1", + title: "Beacon", + component: () => null, + primary: true, + }, + ]; + render( + + {}} + tone="default" + communities={current.communities} + accountActions={current.accountActions} + > + content + + , + ); + + const navigation = document.getElementById("shell-navigation"); + expect( + within(screen.getByRole("navigation", { name: "Pages" })).getByRole( + "button", + { name: "Beacon" }, + ), + ).toBeInTheDocument(); + expect(navigation).toHaveAttribute("data-sidebar-collapsible"); + await userEvent.click(screen.getByRole("button", { name: "Hide sidebar" })); + expect(navigation).toHaveAttribute("aria-hidden", "true"); + expect(navigation).toHaveAttribute("inert"); + expect(screen.getByRole("button", { name: "Show sidebar" })).toHaveAttribute( + "aria-expanded", + "false", + ); + expect( + screen.queryByRole("navigation", { name: "Pages" }), + ).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Show sidebar" })); + expect(navigation).toHaveAttribute("aria-hidden", "false"); + expect(navigation).not.toHaveAttribute("inert"); + expect(screen.getByRole("button", { name: "Hide sidebar" })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect( + within(screen.getByRole("navigation", { name: "Pages" })).getByRole( + "button", + { name: "Beacon" }, + ), + ).toBeInTheDocument(); +}); + +it("does not make a non-primary plugin page's sidebar collapsible", () => { + const current = createServices(); + services = current; + const pages: RegisteredPage[] = [ + { + id: "secondary", + key: "example.plugin/secondary", + pluginId: "example.plugin", + revision: "1", + title: "Secondary", + component: () => null, + primary: false, + }, + ]; + render( + + {}} + tone="default" + communities={current.communities} + accountActions={current.accountActions} + > + content + + , + ); + + expect(document.getElementById("shell-navigation")).not.toHaveAttribute( + "data-sidebar-collapsible", + ); + expect( + screen.queryByRole("button", { name: "Hide sidebar" }), + ).not.toBeInTheDocument(); +}); + +it("keeps built-in sidebar controls available before page contributions load", () => { + const current = createServices(); + services = current; + const pages: RegisteredPage[] = []; + const shell = (selected: string) => ( + + {}} + tone="default" + communities={current.communities} + accountActions={current.accountActions} + > + content + + + ); + const { rerender } = render(shell("buzz.channels/channels")); + expect( + screen.getByRole("button", { name: "Hide Channel sidebar" }), + ).toHaveAttribute("aria-expanded", "true"); + + rerender(shell("buzz.me/me")); + expect( + screen.getByRole("button", { name: "Hide Me sidebar" }), + ).toHaveAttribute("aria-expanded", "true"); +}); + it("shows a primary plugin page's declared icon beside its nav label", () => { const current = createServices(); services = current; diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index d7112a4ed..6007fdb0f 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -96,7 +96,15 @@ export function AppShell({ }, []); const meNavigation = selected === "buzz.me/me"; const collapsibleSidebar = - channelsNavigation || meNavigation || selected === "settings"; + channelsNavigation || + meNavigation || + selected === "settings" || + pages.some((page) => page.key === selected && page.primary); + const sidebarLabel = meNavigation + ? "Me sidebar" + : channelsNavigation + ? "Channel sidebar" + : "sidebar"; const [sidebarOpen, setSidebarOpen] = useState(true); const [navigationOpen, setNavigationOpen] = useState(false); const navigationToggle = useRef(null); @@ -105,9 +113,7 @@ export function AppShell({ ? navigationOpen ? "Hide navigation" : "Show navigation" - : sidebarOpen - ? `Hide ${meNavigation ? "Me" : "Channel"} sidebar` - : `Show ${meNavigation ? "Me" : "Channel"} sidebar`; + : `${sidebarOpen ? "Hide" : "Show"} ${sidebarLabel}`; // biome-ignore lint/correctness/useExhaustiveDependencies: Only a navigation attempt closes the drawer. useEffect(() => { if (navigationOpen && navigationToggle.current?.getClientRects().length) { diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index bcf88546f..9555db587 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -367,6 +367,9 @@ .shell-navigation { display: none; } + .shell-navigation[data-sidebar-collapsible] { + display: none; + } .shell-navigation[data-expanded="true"] { @apply absolute inset-y-0 left-0 z-20 flex; background: var(--surface-panel); diff --git a/tests/browser/navigation.mjs b/tests/browser/navigation.mjs index f0d17b0e7..4e5413a1e 100644 --- a/tests/browser/navigation.mjs +++ b/tests/browser/navigation.mjs @@ -65,7 +65,7 @@ export async function settleShellToggle(page) { ).toHaveAccessibleName( page.viewportSize().width <= 650 ? /^(Show|Hide) navigation$/ - : /^(Show|Hide) Channel sidebar$/, + : /^(Show|Hide) (?:Channel |Me )?sidebar$/, ); } diff --git a/tests/browser/navigation.spec.mjs b/tests/browser/navigation.spec.mjs index 8797bef78..cb3ca2a07 100644 --- a/tests/browser/navigation.spec.mjs +++ b/tests/browser/navigation.spec.mjs @@ -9,6 +9,28 @@ const composer = (page, name) => const entry = (page) => page.evaluate(() => history.state.buzzNavigationV1.entry); +test("a collapsible sidebar yields to narrow drawer styles during resize", async ({ + page, + app, +}) => { + await open(page, app); + const navigation = page.locator("#shell-navigation"); + await expect(navigation).toHaveAttribute("data-sidebar-collapsible", "true"); + + await page.setViewportSize({ width: 390, height: 900 }); + // Model the desktop marker remaining for a frame until React observes the + // media-query change; narrow drawer CSS must still win during that interval. + await navigation.evaluate((element) => { + element.setAttribute("data-sidebar-collapsible", "true"); + }); + await expect(navigation).toHaveCSS("display", "none"); + + await button(page, "Show navigation").click(); + await expect(navigation).toHaveAttribute("data-expanded", "true"); + await expect(navigation).toHaveCSS("display", "flex"); + await expect(navigation).toBeVisible(); +}); + test("a retired contributed Settings destination fails promptly on Back", async ({ page, app, diff --git a/tests/browser/sidenav-polish.spec.mjs b/tests/browser/sidenav-polish.spec.mjs index 4880973af..495360b0b 100644 --- a/tests/browser/sidenav-polish.spec.mjs +++ b/tests/browser/sidenav-polish.spec.mjs @@ -391,7 +391,7 @@ test("Settings retains the sidebar toggle across desktop and narrow layouts", as }); const toggle = page.locator("[data-shell-sidebar-toggle]"); await expect(toggle).toHaveCount(1); - await expect(toggle).toHaveAccessibleName("Show Channel sidebar"); + await expect(toggle).toHaveAccessibleName("Show sidebar"); await expect(sidebar).not.toBeVisible(); await toggle.click(); await expect(sidebar).toBeVisible(); @@ -411,7 +411,7 @@ test("Settings retains the sidebar toggle across desktop and narrow layouts", as await expect(sidebar).not.toBeVisible(); await expect(toggle).toBeFocused(); await page.setViewportSize({ width: 1440, height: 950 }); - await expect(toggle).toHaveAccessibleName("Show Channel sidebar"); + await expect(toggle).toHaveAccessibleName("Show sidebar"); await toggle.click(); await expect(sidebar).toBeVisible(); });