Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/shell-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
132 changes: 132 additions & 0 deletions src/app/shell/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ToastProvider>
<AppShell
pages={pages}
selected="example.plugin/main"
navigationAttempt=""
onSelect={() => {}}
tone="default"
communities={current.communities}
accountActions={current.accountActions}
>
content
</AppShell>
</ToastProvider>,
);

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(
<ToastProvider>
<AppShell
pages={pages}
selected="example.plugin/secondary"
navigationAttempt=""
onSelect={() => {}}
tone="default"
communities={current.communities}
accountActions={current.accountActions}
>
content
</AppShell>
</ToastProvider>,
);

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) => (
<ToastProvider>
<AppShell
pages={pages}
selected={selected}
navigationAttempt=""
onSelect={() => {}}
tone="default"
communities={current.communities}
accountActions={current.accountActions}
>
content
</AppShell>
</ToastProvider>
);
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;
Expand Down
14 changes: 10 additions & 4 deletions src/app/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
kazemek21 marked this conversation as resolved.
Comment thread
kazemek21 marked this conversation as resolved.
const sidebarLabel = meNavigation
? "Me sidebar"
: channelsNavigation
? "Channel sidebar"
: "sidebar";
const [sidebarOpen, setSidebarOpen] = useState(true);
const [navigationOpen, setNavigationOpen] = useState(false);
const navigationToggle = useRef<HTMLButtonElement>(null);
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/shared/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion tests/browser/navigation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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$/,
);
}

Expand Down
22 changes: 22 additions & 0 deletions tests/browser/navigation.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Comment thread
kazemek21 marked this conversation as resolved.
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,
Expand Down
4 changes: 2 additions & 2 deletions tests/browser/sidenav-polish.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
});
Expand Down
Loading