diff --git a/src/components/Header/SidebarToggle.css b/src/components/Header/SidebarToggle.css index d8bd380d75..1eba927755 100644 --- a/src/components/Header/SidebarToggle.css +++ b/src/components/Header/SidebarToggle.css @@ -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 { @@ -11,7 +14,8 @@ } @media (min-width: 50em) { - #menu-toggle { + #menu-toggle, + #mobile-sidebar-backdrop { display: none; } } diff --git a/src/components/Header/SidebarToggle.tsx b/src/components/Header/SidebarToggle.tsx index fa06186981..238470bcf3 100644 --- a/src/components/Header/SidebarToggle.tsx +++ b/src/components/Header/SidebarToggle.tsx @@ -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('.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 ( ); }; diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 5ae07a8d74..64a59b13ff 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -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; @@ -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; } - /* - 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; } @@ -132,6 +153,7 @@ const canonicalURL = new URL(Astro.url.pathname.replace(/([^/])$/, '$1/'), Astro 'section-workflow': currentPage.startsWith('/workflow') }}>
+