diff --git a/CHANGELOG.md b/CHANGELOG.md index 498cd8cbc..72251e421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and appended to the `sans` stack, so the swap to the self-hosted webfont no longer reflows the page: measured text width moves from about 8% off target to under 0.5% while the woff2 is still loading ([#155](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/155)). +- The contents drawer is rebuilt on the native Popover API. The browser now + owns its open/closed state (`popovertarget` on the toolbar toggle), so the + drawer works on the server-rendered HTML before hydration, a closed drawer is + `display: none` from the UA stylesheet on the very first paint — the + critical-CSS rule that parked it off-screen is gone rather than patched — + and its links leave the tab order and accessibility tree while closed. + **Behaviour changes:** the drawer is now light-dismissed (`popover="auto"`): + Escape closes it, and so does clicking or selecting anywhere outside it, + where before it stayed open until toggled. It also closes itself when the + search dialog opens, since a popover paints above every z-indexed layer + including a modal's backdrop. **Browser support:** browsers without the + Popover API (Firefox < 125, Safari < 17, which includes anything on iOS 16) + get neither the drawer nor its toggle — the in-page outline and site + navigation still reach every page there. A polyfill was considered and + rejected because it runs after first paint, reintroducing the flash this + removes; revisit if that share matters. Drawer widths now live in + `styles/app.css` keyed on `theme(screens.*)`, so they no longer depend on + Tailwind's emission order + ([#130](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/130), + [#144](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/144)). ### Fixed - Inline code no longer renders wrapped in literal backticks. @@ -67,6 +87,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 directory rather than assuming one. A production build now emits **zero** absolute asset URLs and all 78 references resolve ([#150](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/150)). +- The contents toggle icons no longer animate their first-paint correction, + and the close icon no longer paints beside the hamburger on the pre-`app.css` + frame. The icons now swap with `display` off the drawer's `:popover-open` + state instead of cross-fading with `transition-all`, so there is nothing to + animate, and a zero-specificity critical-CSS rule hides the close icon until + the stylesheet lands + ([#127](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/127), + [#144](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/144)). +- The toolbar logo no longer distorts between roughly 770px and 840px, and the + desktop control set no longer overflows the right edge in the 768–856px band. + The logo is `shrink-0` (preflight's `max-width: 100%` was letting it clamp to + a shrinking flex item), and the toolbar's gap, separator and padding widen at + `lg` rather than `md`, returning about 148px to that band + ([#144](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/144)). +- The contents toggle has a visible `:focus-visible` ring, so keyboard users + can see where focus returns after Escape closes the drawer (WCAG 2.4.7) + ([#144](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/144)). ## [2.3.1] - 2026-08-26 diff --git a/app/components/ContentsSidebar.tsx b/app/components/ContentsSidebar.tsx index a22a86815..2366a1ad6 100644 --- a/app/components/ContentsSidebar.tsx +++ b/app/components/ContentsSidebar.tsx @@ -3,16 +3,13 @@ import { getProjectHeadings } from '@myst-theme/common'; import { useBaseurl, useLinkProvider, - useNavOpen, useProjectManifest, useSiteManifest, - useThemeTop, withBaseurl, } from '@myst-theme/providers'; -import { useSidebarHeight } from '@myst-theme/site'; -import classNames from 'classnames'; import { slugToUrl } from 'myst-common'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; +import { TOC_HEADING_ID, TOC_POPOVER_ID } from './contentsDrawer'; type StrictHeading = Omit & { level: number }; type HeadingGroup = StrictHeading[]; @@ -45,25 +42,43 @@ function Section({ group }: { group: HeadingGroup }) { } /** - * True only after the component has mounted on the client. + * Closes the drawer when a modal dialog opens. * - * Both the server render and the first (hydrating) client render return - * `false`, so the markup matches and React does not warn; the effect then - * flips it on the frame after hydration. + * A popover lives in the browser's top layer, which paints above every + * z-indexed element — including the search dialog's backdrop (`z-[1000]`) and + * panel. Light dismiss does not help: it fires on pointerdown, and the search + * hotkey (Cmd/Ctrl+K) is a keydown, so the drawer would stay open above the + * modal, un-dimmed, with its links dead under the dialog's pointer-events lock. + * + * Radix portals the dialog into a container appended directly to , so a + * shallow childList observer on is enough — no subtree walk on every + * mutation. Any `role="dialog"` counts: a modal opening over an open drawer is + * wrong regardless of which dialog it is. */ -function useMounted() { - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); - return mounted; +function useCloseOnDialogOpen(popoverId: string) { + useEffect(() => { + const drawer = document.getElementById(popoverId); + if (!drawer || typeof drawer.hidePopover !== 'function') return; + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (!(node instanceof Element)) continue; + if (node.matches('[role="dialog"]') || node.querySelector('[role="dialog"]')) { + drawer.hidePopover(); // no-op when already closed + return; + } + } + } + }); + observer.observe(document.body, { childList: true }); + return () => observer.disconnect(); + }, [popoverId]); } export function ContentsSidebar() { - const [open] = useNavOpen(); - const mounted = useMounted(); + useCloseOnDialogOpen(TOC_POPOVER_ID); const config = useSiteManifest(); const project = useProjectManifest(); - const top = useThemeTop(); - const { toc } = useSidebarHeight(top); const baseurl = useBaseurl(); const Link = useLinkProvider(); @@ -96,37 +111,19 @@ export function ContentsSidebar() { ); return ( -
-
Contents
-