Commit 3c709c5
Add OlDrawer component for hamburger menu (#12326)
* feat: add OlDrawer Lit component and update header bar navigation
* feat: refine OlDrawer component and integrate with header navigation
Remove lightDismiss prop (always allow backdrop dismiss), tune animation
durations, improve swipe-to-close backdrop opacity calculation, and move
drawer to document.body in demo page. Simplify header_dropdown template
by removing hamburger-specific logic now handled by the drawer. Add
aria-expanded toggling and auto-close on link/button click to nav_head.
Fix drawer menu CSS: use design tokens, scope :hover behind media query,
fix login link underlines via :link/:visited specificity, and add
last-child border removal.
* style: improve readability of hamburgerLinks list formatting
* refactor: hardcode drawer menu links for readability
Replace the loop-based hamburgerLinks rendering with hardcoded markup.
Remove unused loginLinks, contributeLinks, and resourceLinks arrays.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* fix: focus first slotted element on drawer open to fix tab order
Previously, the drawer panel itself received focus on open. Pressing
Tab then hit the start focus-sentinel, which incorrectly wrapped to
the last focusable element. Now the first focusable slotted element
is focused instead, so Tab moves forward naturally.
* Update openlibrary/templates/lib/nav_head.html
Co-authored-by: Copilot <[email protected]>
* Update openlibrary/components/lit/OlDrawer.js
Co-authored-by: Copilot <[email protected]>
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Replace hamburger auto-close with in-place loading feedback
Pressing a menu link no longer closes the drawer. Instead the pressed row
holds full opacity while the rest of the menu dims and a spinner shows at
its trailing edge — mirroring the search modal's result loading treatment —
so the drawer stays open until the new page takes over. Modified/new-tab
clicks are left untreated, and the state is cleared on bfcache restore.
* Add press-scale feedback to drawer menu items
Match the <ol-button> press affordance: menu links and the logout button
scale to 0.97 on :active with an 0.08s transform transition. Hover color
changes stay instant per docs/ai/design.md.
* Guard drawer re-entry when open flips false before enter microtask
A fast toggle could set open=false after _show() scheduled its
updateComplete callback but before it ran. The callback then set
_animState back to 'entering', re-opening the drawer while open was
false — leaving it stuck open and scroll-locked, since Escape and
backdrop dismissal both gate on this.open. Bail out of the open
sequence if open is no longer true.
* Prevent page shift when drawer opens by compensating for scrollbar width
Pinning <body> to lock background scroll removes the document scrollbar;
on platforms with classic (space-consuming) scrollbars, reclaiming that
width shifted the page. Measure the scrollbar width before locking and
hold it as body padding (border-box) so the layout stays put. No-op on
overlay-scrollbar platforms where the width is 0.
* Fix ol-drawer stranding on synchronous open→close
When open was toggled true then false within the same tick (before the
enter transition was armed), _hide() moved the still-parked panel to
'exiting'. That's a closed→closed no-op transition that never fires
transitionend, so cleanup never ran and the drawer was left scroll-locked
with its keydown/touch listeners attached. Finish synchronously from the
'preparing' state, mirroring the reduced-motion path.
* fix: repair drawer after the master merge
- OlDrawer imported getFocusableFromSlot, which master renamed to
getTabbableFromSlot. A focus trap wants tab stops, not every focusable
node, so the rename is also the correct semantics here.
- Port the drawer's design-system docs onto master's rebuilt /developers/design:
a registry row in design.py plus a Jinja partial, replacing the old
hand-written Templetor section.
Claude-Session: https://claude.ai/code/session_016RMfy95Q9ritR7KF727ouR
* refactor(drawer): build ol-drawer on native <dialog>.showModal()
The drawer was a fifth overlay mechanism — position: fixed plus
z-index: var(--z-index-modal) — where docs/ai/web-components.md asks new
overlays to join one of the four that already work. It is a modal by every
test there (focus trap, aria-modal, scrim, Escape, scroll lock), so it
belongs on native <dialog>.
The <dialog> fills the viewport and is transparent; the scrim and the
sliding panel are ordinary children. That costs one element over ::backdrop
and buys direct control of scrim opacity from the swipe gesture, which a
pseudo-element can't take.
What the browser now does for us:
- Top layer. The PR's whole motivation. Verified above a z-index 2147483647
overlay from inside a transform / contain: paint / isolation ancestor —
the containing-block trap that no z-index can escape. The "place it near
the document root" caveat in the old docblock is gone.
- Background inerting, so aria-modal="true" is finally true. Previously the
JS trap caught Tab but a screen reader's virtual cursor still browsed the
page behind. Verified: a header link can't take focus while open.
- Escape, via the cancel event.
Deleted along the way: the hand-rolled backdrop and its opacity
choreography, the z-index, the document-level Escape listener, the
closed/preparing/entering/open/exiting state machine (the dialog is either
open or not), and the swipe path's duplicate close implementation — a swipe
now just sets a reason and flips `open`, so all four dismissals share one
exit.
Two behavioral changes worth naming:
- ol-drawer-hide is now cancelable and fires for every close with a reason
('escape' | 'scrim' | 'swipe' | 'programmatic'), matching ol-dialog's
ol-close. It previously skipped programmatic closes entirely, and the
swipe path fired it after the animation rather than before.
- The dismissal reason 'backdrop' is now 'scrim', matching the element.
Reduced motion is handled by reading the resolved transition-duration
(ol-dialog's trick, applied to transitions) rather than a matchMedia call
in JS, so the media query lives in one place. Initial focus runs
synchronously instead of in a requestAnimationFrame — the dialog is laid
out by the time showModal() returns, and rAF is paused in an occluded tab,
which stranded focus on the dialog itself.
nav_head.html needs no change: it only listens for ol-drawer-show and
ol-drawer-after-hide.
Claude-Session: https://claude.ai/code/session_016RMfy95Q9ritR7KF727ouR
* refactor(nav): share Browse destinations between the drawer and popover
The Browse links were in three places after the drawer landed: browseLinks in
nav_head.html (9 items, and orphaned by this branch — its only consumer was the
old hamburger dropdown), browse_popover.html's own hardcoded list, and the
drawer's hand-written rows. They had already drifted: the popover was missing
Advanced Search.
nav.py holds the destination table — what exists, where it points, its analytics
slug — and nothing else. Presentation stays in the templates: the popover still
owns the icons and blurbs for the rows it features, the drawer still renders one
flat list. Sharing the markup instead would push icons into Python and buy
nothing, since the two surfaces genuinely differ.
Both now derive their featured/simple split and positional analytics ranks from
one ordered list, so adding a destination is a single edit and the popover picks
up Advanced Search at rank 9. The drawer keeps its two-part track values rather
than inheriting the popover's rank — mixing formats within one menu would muddy
the data.
Labels are translated per call rather than at import, so the list is built inside
the function; a module-level _() would freeze one request's locale process-wide.
Claude-Session: https://claude.ai/code/session_016RMfy95Q9ritR7KF727ouR
* fix(header): show Sign Up on phones, not just in the hamburger
.auth-component was display:none below 30em (480px), so on most phones in
portrait — 360-430px covers iPhone SE through Pro Max and the common Android
widths — a logged-out patron saw no account entry point in the header at all.
The hamburger was the only route, and it does nothing until the Lit bundle
executes. This branch lengthens that window (the old menu was a JS-free
<details>), which is what surfaced it.
Only Sign Up appears below 768px; .hide-me still holds Log In back, exactly as
it did between 480 and 767px. That's deliberate: measured at 360px and 390px,
Sign Up alone is ~77px and sits on the header's first row beside the logo,
search and hamburger, leaving header height at 52px. Showing both wraps the
hamburger onto a second row and takes the header to 74px. No horizontal
overflow at 360, 390, 500 or 800px.
Claude-Session: https://claude.ai/code/session_016RMfy95Q9ritR7KF727ouR
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* fix(drawer): stop the end-placement slide from jumping and snapping back
The dialog clipped its off-screen panel with `overflow: hidden`, which still
creates a scroll container — it only hides the scrollbars. A panel parked at
translateX(100%) therefore sat in the dialog's scrollable overflow
(scrollWidth 1652 against a 1352 viewport), and focusing a link inside it
scrolled the dialog by the panel's full width to bring it into view. That
300px offset then fought the transform transition: the drawer appeared
already open, overshot, and snapped back.
Only `end` placement showed it. Overflow to the left of the origin isn't
scrollable in LTR, so `start` never had anything to scroll and always slid
cleanly — which is what pointed at the cause.
`overflow: clip` clips without ever becoming scrollable, with `hidden` left
as the preceding declaration for Safari < 16. For that fallback the dialog's
scrollLeft is also reset after the focus calls, in the same synchronous block
so nothing paints in between.
Verified: scrollLeft now stays 0 for both end-placement demos and the header
drawer, forcing scrollLeft to 999 leaves it at 0, focusing inside the panel
no longer moves it, and the panel carries a real 400ms transform CSSTransition.
Claude-Session: https://claude.ai/code/session_016RMfy95Q9ritR7KF727ouR
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* chore: untrack _testing-prs.json
Local scratch file swept in by a `git add -A`. It stays on disk and is now
listed in .git/info/exclude so it can't be re-added.
Claude-Session: https://claude.ai/code/session_016RMfy95Q9ritR7KF727ouR
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Fix drawer registry row after upstream dropped Component.description
The merge kept the drawer's description string as a third positional arg,
which landed on `group` and collided with the keyword.
* Stop swipe-dismiss from hijacking an in-flight scroll
Both the drawer's horizontal swipe and the popover tray's vertical one
armed on displacement alone, so scrolling the panel with a little drift
in the dismiss direction started a drag the browser had already claimed
for its own scroll — every preventDefault() on those non-cancelable
touchmoves logged an intervention. The drawer now locks to whichever
axis dominates the gesture, both components bail out when the touch is
no longer cancelable, and preventDefault() is guarded either way.
* fix(drawer): let a reopen during the exit transition take
_openDrawer() bailed on an already-open dialog, which is exactly the
state a reopen mid-exit lands in: showModal() is still in effect, so the
early return skipped the _cycle bump and the queued close callback ran
anyway. `open` stayed true against a closed dialog, and it took two more
taps to get the drawer back — an accidental double-tap on the hamburger
reads as a broken menu.
Bump the cycle first so the pending close cancels, and re-run the entry.
showModal() and the _previouslyFocusedElement capture are skipped on a
reopen: the first throws on an open dialog, and the second would record
an element inside the drawer, losing the real restore target.
* fix(drawer): drop the subsection divider that never rendered
--dark-beige is defined nowhere in the repo, so the border-top shorthand
was invalid at computed-value time and the style fell back to none. Rather
than pick a replacement token, remove the rule: the subsections read fine
on spacing alone.
* fix(nav): don't 500 the site on a Browse link with no icon
navIcons[link.track] was a hard subscript on a dict only browse_popover
maintains, while nav.py told you adding a destination is one edit there.
Following that advice raised KeyError in a template nav_head renders on
every page. Fall back to a generic icon, and say so in the docstring.
* fix(nav): keep the merge-request count super-librarian only
The deleted header_dropdown.html gated both merge-count badges on
is_super_librarian_or_higher(); folding the markup into nav_head reused
its is_librarian_or_higher() and quietly showed the counts to every
librarian. The /merges link itself stays librarian-gated as before.
* chore(nav): drop header_dropdown's dead loginClass branch
The hamburger's loginLinks was its only caller, and the drawer rewrite
replaced that with drawer-menu__login-links in nav_head. The .login-links*
rules went with it, so the branch survived only to render unstyled markup
nothing reaches.
* Update openlibrary/components/lit/OlDrawer.js
Co-authored-by: Drini Cami <[email protected]>
* docs(lit): type enum props as unions, not {String}
The API tables on /developers/design print the JSDoc type verbatim, so
{String} rendered a useless "String" while the real values sat in prose
that nothing checks. Converting the closed sets means editors autocomplete
them, tsc --checkJs catches typos, and the table lists them for free.
Follows cdrini's review suggestion on ol-drawer's placement. Popover and
tooltip placement stay {String} — they're a {side}-{align} grammar, not a
short list worth enumerating.
Also writes the rule down in docs/ai/web-components.md so new components
keep doing it.
* fix(drawer): scroll a Tab target into the panel's view
The focus trap preventDefault()s every Tab and moves focus itself, which
also cancels the browser's native scroll-into-view. In a drawer taller
than its own scroller, Tab could land on a control below the fold — focus
invisible to the user, a WCAG 2.4.11 failure. focus({preventScroll:true})
stays, because letting focus() scroll would walk every ancestor and push
the panel itself off-screen; scrollIntoView with block:'nearest' moves
only the panel's scroller, and only when the target isn't already visible.
Consumers with a sticky header or footer can inset the resting position
through the new --ol-drawer-scroll-padding custom property, since the
panel is shadow DOM and scroll-padding is otherwise out of reach.
* refactor(nav): move the hamburger drawer's inline script into a module
The wiring lived as an inline <script> in nav_head.html, so it shipped on
every page render, escaped lint and bundling, and had to stay ES5-flavoured.
It now lives in js/hamburger-drawer.js, dynamically imported from index.js
only when the trigger and drawer are both present.
* fix(nav): stop the hamburger icon rendering blue on iOS Safari
.hamburger-trigger reset the button's background and border but never set
a color, and a <button> doesn't inherit one — the UA's ButtonText wins, and
iOS Safari resolves that to its system blue, which the currentColor sprite
picks up. Pin it to --color-text, the same token the Browse trigger uses.
The magnifier and barcode move to --color-text too, so the three header
icons read as a set. The magnifier had #8a7f71 baked into search-icon.svg
and could not take a token at all, so it becomes a sprite icon; the file
stays for the legacy search box in searchbox.css.
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Drini Cami <[email protected]>1 parent 38f397b commit 3c709c5
28 files changed
Lines changed: 1516 additions & 427 deletions
File tree
- docs/ai
- openlibrary
- components/lit
- utils
- i18n
- plugins/openlibrary
- js
- templates
- design/components
- lib
- static/css
- components
- tests/unit/js
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
102 | | - | |
| 102 | + | |
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
| |||
108 | 108 | | |
109 | 109 | | |
110 | 110 | | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
111 | 133 | | |
112 | 134 | | |
113 | 135 | | |
| |||
538 | 560 | | |
539 | 561 | | |
540 | 562 | | |
541 | | - | |
| 563 | + | |
542 | 564 | | |
543 | 565 | | |
544 | 566 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
12 | | - | |
13 | | - | |
14 | | - | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
| 9 | + | |
9 | 10 | | |
10 | 11 | | |
11 | 12 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
34 | | - | |
35 | | - | |
36 | | - | |
37 | | - | |
38 | | - | |
39 | | - | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
23 | | - | |
24 | | - | |
25 | | - | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
32 | | - | |
| 32 | + | |
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| |||
0 commit comments