Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/components/Header.astro
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const messages = uiStrings(currentLocale);
<div class="ch-site-header-inner">
<div class="ch-site-header-primary ch-site-header-primary-home">
{!homepage && (
<a href={import.meta.env.BASE_URL} class="group lg:hidden">
<a href={homeHref} class="group lg:hidden">
<span class="font-semibold text-sm text-foreground group-hover:text-primary transition-colors">
{config.title}
</span>
Expand Down
159 changes: 159 additions & 0 deletions src/components/MobileNavigation.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
---
import LocaleSwitcher from "./LocaleSwitcher.astro";
import { ThemeToggle } from "./ui/theme-toggle";
import MobileNavigationSections from "./MobileNavigationSections.astro";
import Icon from "@cloudflare/nimbus-docs/components/Icon.astro";
import { uiStrings } from "@/lib/ui-strings.server";
interface Props { currentLocale?: string; activeLocales?: string[]; }
const messages = uiStrings(Astro.props.currentLocale);
---
<dialog
data-mobile-sidebar
data-state="closed"
class="group fixed inset-0 m-0 h-full w-full max-h-full max-w-full border-0 bg-transparent p-0 [&::backdrop]:bg-black/40"
aria-label={messages.navigation.site}
>
<div data-mobile-sidebar-panel class="h-full w-(--nb-sidebar-width) max-w-[85vw] -translate-x-full overflow-y-auto overscroll-contain border-r border-border bg-card transition-transform duration-250 ease-out group-data-[state=open]:translate-x-0 motion-reduce:transition-none">
<div class="sticky top-0 z-[1] flex items-center justify-between border-b border-border bg-card px-4 py-3">
<span class="font-semibold text-sm text-foreground">{messages.navigation.navigation}</span>
<button data-close-sidebar class="flex items-center justify-center w-8 h-8 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors" aria-label={messages.navigation.close}>
<Icon name="ph:x" class="w-5 h-5" />
</button>
</div>
<nav class="px-4 pb-8 pt-5">
<div class="mb-3 flex items-center justify-between">
<LocaleSwitcher sidebar currentLocale={Astro.props.currentLocale} activeLocales={Astro.props.activeLocales} />
<ThemeToggle currentLocale={Astro.props.currentLocale} />
</div>
<MobileNavigationSections currentLocale={Astro.props.currentLocale} />
<slot><div data-nb-drawer-target /></slot>
</nav>
</div>
</dialog>
<script>
import { mount, lockScroll, unlockScroll } from "@cloudflare/nimbus-docs/client";
import { initSidebar } from "@/components/ui/sidebar/sidebar.client";
import { bindCollapsibles } from "@/components/ui/sidebar/sidebar-lazy.client";

const CLOSE_DURATION_MS = 250;

function fillDrawer(dialog: HTMLDialogElement) {
const target = dialog.querySelector<HTMLElement>("[data-nb-drawer-target]");
const source = document.querySelector<HTMLElement>("#desktop-sidebar nav");
if (!target || !source || target.childElementCount > 0) return;
for (const child of Array.from(source.children)) {
const clone = child.cloneNode(true) as HTMLElement;
clone.removeAttribute("data-nb-sidebar-persist");
target.appendChild(clone);
}
target.querySelectorAll("[data-nb-sidebar-persist]").forEach((element) => element.removeAttribute("data-nb-sidebar-persist"));
// Cloning copies runtime IDs and binding markers, but not listeners.
// Let Nimbus create fresh ARIA targets and bind each copied disclosure.
target.querySelectorAll<HTMLElement>("[data-nb-collapsible]").forEach((element) => {
delete element.dataset.chBound;
});
target.querySelectorAll("[data-nb-collapsible-content]").forEach((element) => element.removeAttribute("id"));
bindCollapsibles(target);
const cleanups = [...target.querySelectorAll<HTMLElement>("[data-nb-sidebar]")].map(initSidebar);
return () => cleanups.forEach((cleanup) => cleanup());
}

// mount() re-binds on every astro:page-load and tears down on
// astro:before-swap, so the hamburger survives client-side navigation
// (a one-shot module script would go dead after the first swap).
mount("[data-mobile-sidebar]", (root) => {
const menuBtn = document.querySelector<HTMLElement>("[data-menu-btn]");
if (!(root instanceof HTMLDialogElement) || !menuBtn) return () => {};
const dialog = root;

const controller = new AbortController();
const { signal } = controller;
let disposeSidebar: (() => void) | undefined;
let closeTimer: ReturnType<typeof setTimeout> | undefined;

const openSidebar = () => {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = undefined;
}
if (dialog.open) return;

disposeSidebar ??= fillDrawer(dialog);
dialog.showModal();
dialog.dataset.state = "closed";
lockScroll();

requestAnimationFrame(() => {
requestAnimationFrame(() => {
dialog.dataset.state = "open";
});
});

dialog.querySelector<HTMLElement>("[data-close-sidebar]")?.focus();
};

const closeSidebar = () => {
if (!dialog.open || dialog.dataset.state === "closing") return;

dialog.dataset.state = "closing";
closeTimer = setTimeout(() => {
dialog.close();
}, CLOSE_DURATION_MS);
};

menuBtn.addEventListener("click", openSidebar, { signal });
const desktop = matchMedia("(min-width: 1024px)");
desktop.addEventListener("change", () => {
if (desktop.matches && dialog.open) dialog.close();
}, { signal });

dialog.addEventListener("cancel", (event) => {
event.preventDefault();
closeSidebar();
}, { signal });

// Fires for Escape, close button, and backdrop click.
dialog.addEventListener("close", () => {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = undefined;
}

dialog.dataset.state = "closed";
unlockScroll();
menuBtn.focus();
}, { signal });

dialog.querySelector("[data-close-sidebar]")
?.addEventListener("click", closeSidebar, { signal });

dialog.addEventListener("click", (e) => {
if (e.target === dialog) closeSidebar();
}, { signal });

return () => {
controller.abort();
disposeSidebar?.();
if (closeTimer) clearTimeout(closeTimer);
// A swap while open never fires `close`; balance the scroll lock.
if (dialog.open) unlockScroll();
};
});
</script>

<style>
[data-mobile-sidebar] :global(.ch-language-selector) {
flex: 1;
min-width: 0;
border-top: 0;
}
[data-mobile-sidebar] :global(.ch-language-selector-trigger) {
padding: 0 8px;
}
[data-mobile-sidebar] :global(.ch-language-selector-menu) {
top: 100%;
bottom: auto;
left: 0;
right: 0;
}
</style>
29 changes: 29 additions & 0 deletions src/components/MobileNavigationSections.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
import Icon from "@cloudflare/nimbus-docs/components/Icon.astro";
import { loadGeneratedNavigation } from "@/lib/sidebar-navigation.server";
import { sectionsFromConfig, mobileSectionsFromConfig } from "@/lib/sidebar-lazy";
import { localeRouteName } from "@/util/locales";
interface Props { currentLocale?: string; }
const locale = localeRouteName(Astro.props.currentLocale ?? "en");
const items = loadGeneratedNavigation(locale).items;
const path = Astro.url.pathname;
const menus = [sectionsFromConfig(items, path), mobileSectionsFromConfig(items, path)];
---
<div class="mb-4 flex flex-col gap-2" data-mobile-section-selectors>
{menus.filter((options) => options.length).map((options) => (
<details class="relative" name="mobile-navigation-section">
<summary class="flex cursor-pointer list-none items-center justify-between rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-accent [&::-webkit-details-marker]:hidden">
<span>{(options.find((item) => item.isActive) ?? options[0]).label}</span>
<Icon name="ph:caret-down" class="size-4" />
</summary>
<div class="mt-1 rounded-md border border-border bg-popover p-1">
{options.map((item) => (
<a href={item.href} aria-current={item.isActive ? "true" : undefined}
class:list={["block rounded-sm px-3 py-2 text-sm no-underline hover:bg-accent hover:text-foreground", item.isActive ? "bg-accent text-foreground" : "text-muted-foreground"]}>
{item.label}
</a>
))}
</div>
</details>
))}
</div>
2 changes: 1 addition & 1 deletion src/components/ui/search/SearchTrigger.astro
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const messages = uiStrings(currentLocale);
<kbd class:list={[
"items-center gap-0.5 text-muted-foreground",
sidebar
? "inline-flex shrink-0 p-0 text-xs font-semibold"
? "hidden lg:inline-flex shrink-0 p-0 text-xs font-semibold"
: "hidden sm:inline-flex rounded border border-border bg-muted px-1.5 py-0.5 text-[0.625rem] font-medium",
]}>
<span data-shortcut-key>Ctrl</span>K
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/sidebar/sidebar.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function groupKey(group: HTMLElement): string {
return labels.join("\u001f");
}

function initSidebar(root: HTMLElement): () => void {
export function initSidebar(root: HTMLElement): () => void {
const teardowns: Array<() => void> = [];
const persist = root.hasAttribute("data-nb-sidebar-persist");

Expand Down
120 changes: 4 additions & 116 deletions src/layouts/DocsLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Named slots for overrides (all optional, defaults render otherwise):
* header, sidebar, toc, page-title, content-footer, pagination
*/
import Icon from "@cloudflare/nimbus-docs/components/Icon.astro";
import MobileNavigation from "@/components/MobileNavigation.astro";
import BaseLayout from "./BaseLayout.astro";
import Header from "@/components/Header.astro";
import AskAiButton from "@/components/AskAiButton.astro";
Expand Down Expand Up @@ -320,28 +320,9 @@ const messages = uiStrings(currentLocale);

{showSidebar && (
/* Mobile sidebar — native <dialog> for free focus trap, escape, backdrop. */
<dialog
data-mobile-sidebar
data-state="closed"
class="group fixed inset-0 m-0 h-full w-full max-h-full max-w-full border-0 bg-transparent p-0 [&::backdrop]:bg-transparent"
aria-label={messages.navigation.site}
>
<div data-mobile-sidebar-panel class="h-full w-(--nb-sidebar-width) max-w-[85vw] -translate-x-full overflow-y-auto overscroll-contain border-r border-border bg-card transition-transform duration-250 ease-out group-data-[state=open]:translate-x-0 motion-reduce:transition-none">
<div class="sticky top-0 z-[1] flex items-center justify-between border-b border-border bg-card px-4 py-3">
<span class="font-semibold text-sm text-foreground">{messages.navigation.navigation}</span>
<button data-close-sidebar class="flex items-center justify-center w-8 h-8 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors" aria-label={messages.navigation.close}>
<Icon name="ph:x" class="w-5 h-5" />
</button>
</div>
<nav class="px-4 pb-8 pt-5">
{hasSidebar ? <slot name="sidebar" /> : (
/* Filled on first open by cloning the desktop rail (see script below),
instead of shipping the whole tree a second time. */
<div data-nb-drawer-target />
)}
</nav>
</div>
</dialog>
<MobileNavigation currentLocale={currentLocale} activeLocales={activeLocales}>
{hasSidebar ? <slot name="sidebar" /> : <div data-nb-drawer-target />}
</MobileNavigation>
)}

<script>
Expand Down Expand Up @@ -376,99 +357,6 @@ const messages = uiStrings(currentLocale);
document.addEventListener("astro:page-load", initializeDynamicToc);
</script>

<script>
import { mount, lockScroll, unlockScroll } from "@cloudflare/nimbus-docs/client";
import { bindCollapsibles } from "@/components/ui/sidebar/sidebar-lazy.client";

const CLOSE_DURATION_MS = 250;

function fillDrawer(dialog: HTMLDialogElement) {
const target = dialog.querySelector<HTMLElement>("[data-nb-drawer-target]");
const source = document.querySelector<HTMLElement>("#desktop-sidebar nav");
if (!target || !source || target.childElementCount > 0) return;
for (const child of Array.from(source.children)) {
const clone = child.cloneNode(true) as HTMLElement;
clone.removeAttribute("data-nb-sidebar-persist");
target.appendChild(clone);
}
bindCollapsibles(target);
}

// mount() re-binds on every astro:page-load and tears down on
// astro:before-swap, so the hamburger survives client-side navigation
// (a one-shot module script would go dead after the first swap).
mount("[data-mobile-sidebar]", (root) => {
const menuBtn = document.querySelector<HTMLElement>("[data-menu-btn]");
if (!(root instanceof HTMLDialogElement) || !menuBtn) return () => {};
const dialog = root;

const controller = new AbortController();
const { signal } = controller;
let closeTimer: ReturnType<typeof setTimeout> | undefined;

const openSidebar = () => {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = undefined;
}
if (dialog.open) return;

fillDrawer(dialog);
dialog.showModal();
dialog.dataset.state = "closed";
lockScroll();

requestAnimationFrame(() => {
requestAnimationFrame(() => {
dialog.dataset.state = "open";
});
});

dialog.querySelector<HTMLElement>("[data-close-sidebar]")?.focus();
};

const closeSidebar = () => {
if (!dialog.open || dialog.dataset.state === "closing") return;

dialog.dataset.state = "closing";
closeTimer = setTimeout(() => {
dialog.close();
}, CLOSE_DURATION_MS);
};

menuBtn.addEventListener("click", openSidebar, { signal });

dialog.addEventListener("cancel", (event) => {
event.preventDefault();
closeSidebar();
}, { signal });

// Fires for Escape, close button, and backdrop click.
dialog.addEventListener("close", () => {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = undefined;
}

dialog.dataset.state = "closed";
unlockScroll();
menuBtn.focus();
}, { signal });

dialog.querySelector("[data-close-sidebar]")
?.addEventListener("click", closeSidebar, { signal });

dialog.addEventListener("click", (e) => {
if (e.target === dialog) closeSidebar();
}, { signal });

return () => {
controller.abort();
if (closeTimer) clearTimeout(closeTimer);
// A swap while open never fires `close`; balance the scroll lock.
if (dialog.open) unlockScroll();
};
});
</script>

</BaseLayout>
22 changes: 22 additions & 0 deletions src/lib/sidebar-lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,25 @@ export function sectionsFromConfig(items: ConfigItem[], currentPath: string): Ar
isActive: tabIndex === activeTab,
}));
}

/** Mobile selectors share the desktop rail's page-to-tab index. */
export function mobileSectionsFromConfig(items: ConfigItem[], currentPath: string) {
const target = normPath(currentPath);
const index = navigationIndex(items);
const location = index.locations.get(target);
if (!location || location.tabIndex === 0) return [];
const tab = index.tabs[location.tabIndex];
const groups = location.tabIndex === 2
? tab.items.flatMap((item) => "items" in item ? item.items : [item])
: tab.items;
const destination = (item: ConfigItem): string | undefined => {
const href = "link" in item ? item.link : item.landing;
if (href) return /^(https?:)?\/\//.test(href) ? href : withBase(href);
return "items" in item ? item.items.map(destination).find(Boolean) : undefined;
};
return groups.map((item) => ({
label: item.label,
href: destination(item),
isActive: internalLinks([item]).includes(target),
})).filter((item) => item.href);
}
Loading
Loading