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
6 changes: 5 additions & 1 deletion src/components/Header/SidebarToggle.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
--border-color-default: transparent;
margin-inline-end: -0.25rem;
transition-property: border-color, background-color, color;
/* Stack above the backdrop so it stays tappable while the drawer is open. */
position: relative;
z-index: 21;
}

.mobile-sidebar-toggle #menu-toggle {
Expand All @@ -11,7 +14,8 @@
}

@media (min-width: 50em) {
#menu-toggle {
#menu-toggle,
#mobile-sidebar-backdrop {
display: none;
}
}
94 changes: 76 additions & 18 deletions src/components/Header/SidebarToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,75 @@
import { useEffect, useState } from 'react';
import './SidebarToggle.css';
import './HeaderButton.css';
import './SidebarToggle.css';

const MenuToggle = () => {
const [sidebarShown, setSidebarShown] = useState(false);
const [open, setOpen] = useState(false);
// Some layouts (e.g. 404.astro) render the Header without #left-sidebar.
// Only set aria-controls when the controlled element actually exists.
const [hasSidebar, setHasSidebar] = useState(false);

useEffect(() => {
setHasSidebar(!!document.getElementById('left-sidebar'));
}, []);

useEffect(() => {
if (!hasSidebar) return;
document.body.classList.toggle('mobile-sidebar-toggle', open);
}, [open, hasSidebar]);

// ESC + backdrop click both close the drawer.
useEffect(() => {
if (!open) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
const backdrop = document.getElementById('mobile-sidebar-backdrop');
const handleBackdrop = () => setOpen(false);
window.addEventListener('keydown', handleKey);
backdrop?.addEventListener('click', handleBackdrop);
return () => {
window.removeEventListener('keydown', handleKey);
backdrop?.removeEventListener('click', handleBackdrop);
};
}, [open]);

// Keep tab order + a11y tree in sync with the drawer state. On mobile,
// the closed drawer hides the sidebar from interaction; the open drawer
// is modal, so the rest of the page (main + footer) becomes inert.
useEffect(() => {
const body = document.getElementsByTagName('body')[0];
if (sidebarShown) {
body.classList.add('mobile-sidebar-toggle');
} else {
body.classList.remove('mobile-sidebar-toggle');
}
}, [sidebarShown]);
if (!hasSidebar) return;
const mq = window.matchMedia('(max-width: 49.999em)');
const sidebar = document.getElementById('left-sidebar');
const mainContent = document.getElementById('main-content');
const footer = document.querySelector<HTMLElement>('.main-column-footer');

const updateInert = () => {
const isMobile = mq.matches;
sidebar?.toggleAttribute('inert', isMobile && !open);
mainContent?.toggleAttribute('inert', isMobile && open);
footer?.toggleAttribute('inert', isMobile && open);
};

updateInert();
mq.addEventListener('change', updateInert);
return () => {
mq.removeEventListener('change', updateInert);
sidebar?.removeAttribute('inert');
mainContent?.removeAttribute('inert');
footer?.removeAttribute('inert');
};
}, [open, hasSidebar]);

return (
<button
id="menu-toggle"
className="header-button"
type="button"
aria-pressed={sidebarShown ? 'true' : 'false'}
onClick={() => setSidebarShown(!sidebarShown)}
aria-pressed={open ? 'true' : 'false'}
aria-expanded={open ? 'true' : 'false'}
aria-controls={hasSidebar ? 'left-sidebar' : undefined}
aria-label={open ? 'Close sidebar' : 'Open sidebar'}
onClick={() => setOpen(!open)}
Comment thread
jd marked this conversation as resolved.
>
<svg
xmlns="http://www.w3.org/2000/svg"
Expand All @@ -29,15 +78,24 @@ const MenuToggle = () => {
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
{open ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 6l12 12M6 18L18 6"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
<span className="sr-only">Toggle sidebar</span>
</button>
);
};
Expand Down
54 changes: 38 additions & 16 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const canonicalURL = new URL(Astro.url.pathname.replace(/([^/])$/, '$1/'), Astro
display: none;
z-index: 10;
inset-inline-start: 0;
background: var(--theme-bg-content);
}
#right-sidebar {
display: none;
Expand All @@ -65,29 +66,49 @@ const canonicalURL = new URL(Astro.url.pathname.replace(/([^/])$/, '$1/'), Astro
min-height: calc(100vh - var(--theme-navbar-height));
}

/* Allow showing left sidebar as an overlay, but only while viewport stays narrow */
/* Backdrop lives at the layout level (sibling of the sidebar) so
it isn't trapped inside the Header's stacking context. */
#mobile-sidebar-backdrop {
display: none;
position: fixed;
top: var(--theme-navbar-height);
inset-inline: 0;
bottom: 0;
z-index: 9;
background: var(--theme-backdrop-overlay);
opacity: 0;
transition: opacity 0.2s ease;
}

/* Mobile drawer: a partial-width panel slides in from the left,
sitting above a tappable backdrop. The drawer is always in the
DOM and just translates off-screen when closed, so the transition
stays smooth. */
@media not screen and (min-width: 50em) {
/* Make the left sidebar visible and fill the entire viewport below the navbar */
:global(.mobile-sidebar-toggle #left-sidebar) {
#left-sidebar {
display: block;
top: var(--theme-navbar-height);
inset-inline-end: 0;
width: min(85vw, 20rem);
box-shadow: 4px 0 16px rgba(0, 0, 0, 0.08);
transform: translateX(-100%);
transition: transform 0.22s ease;
}
Comment thread
jd marked this conversation as resolved.
/*
Try to prevent the rest of the page from scrolling,
and the main content from being visible below the overlay.

Unfortunately, iOS Safari doesn't currently play well with this
and will sometimes still scroll the page even though it shouldn't.

Once overscroll-behavior is properly supported, this should be fixed.
*/
:global(.mobile-sidebar-toggle #left-sidebar) {
transform: translateX(0);
}
#mobile-sidebar-backdrop {
display: block;
pointer-events: none;
}
:global(.mobile-sidebar-toggle #mobile-sidebar-backdrop) {
opacity: 1;
pointer-events: auto;
cursor: pointer;
}
/* Lock body scroll so the drawer can scroll independently. */
:global(.mobile-sidebar-toggle) {
overflow: hidden;
}
:global(.mobile-sidebar-toggle .main-column) {
visibility: hidden;
}
:global(.mobile-sidebar-toggle #left-sidebar ul) {
overscroll-behavior: contain;
}
Comment thread
jd marked this conversation as resolved.
Expand Down Expand Up @@ -132,6 +153,7 @@ const canonicalURL = new URL(Astro.url.pathname.replace(/([^/])$/, '$1/'), Astro
'section-workflow': currentPage.startsWith('/workflow')
}}>
<Header currentPage={currentPage} transition:animate="none" />
<div id="mobile-sidebar-backdrop" aria-hidden="true"></div>
<main class="layout">
<aside id="left-sidebar" class="sidebar" transition:animate="none">
<slot name="primary-sidebar">
Expand Down
Loading