feat(drawer): rework drawer + use it for mobile docs navigation#3230
feat(drawer): rework drawer + use it for mobile docs navigation#3230jpzwarte wants to merge 5 commits into
Conversation
Rewrites sl-drawer to use a native <dialog> with modern CSS transitions (@starting-style + allow-discrete), adds a show() method for non-modal use, a built-in close button with proper events (sl-close, sl-cancel), a disable-close option, and external styling via ::part(dialog|header|body). Uses the drawer on the v2 docs website to provide a mobile hamburger menu that slides in the sidebar from the left on top of the content, while keeping the in-grid sidebar on desktop.
|
There was a problem hiding this comment.
Pull request overview
Reworks the sl-drawer web component to use a native <dialog> foundation and updates the v2 docs website to use sl-drawer for mobile navigation (hamburger-triggered sidebar).
Changes:
- Reimplemented
sl-drawerwith<dialog>, addedshow()(non-modal), new close button/header layout, andsl-close/sl-cancelevents. - Updated drawer styling to support directional slide-in transitions and exposed theming via
::part(...)+ CSS custom properties. - Added a mobile header + hamburger-driven navigation drawer to
docs/websiteand adjusted the responsive layout/CSS accordingly.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/components/drawer/src/drawer.ts | New <dialog>-based drawer implementation, events, close behavior, and public API updates (show()/showModal()). |
| packages/components/drawer/src/drawer.spec.ts | Updated/added tests for modal vs non-modal open, re-open guard, and emitted close/cancel events. |
| packages/components/drawer/src/drawer.scss | New top-layer/backdrop styling, attachment sizing, and enter/leave transitions; exposes ::part(...) styling hooks. |
| packages/components/drawer/package.json | Adds dependencies needed for new close button/icon and shared event utilities. |
| docs/website/src/js/main.js | Registers drawer and hamburger icon; wires hamburger ↔ drawer open/close and closes on nav link click. |
| docs/website/src/includes/base.njk | Adds mobile-only header and a left-attached sl-drawer containing the sidebar for mobile navigation. |
| docs/website/src/css/main.css | Responsive grid/layout updates plus mobile header + drawer theming via ::part(...). |
| }); | ||
|
|
||
| it('should close the drawer when the cancel event is fired and close isn`t disabled', async () => { | ||
| it('should emit sl-cancel when the cancel event is fired and close isn`t disabled', async () => { |
There was a problem hiding this comment.
Test name contains a typo: use "isn't" instead of "isn`t".
| it('should emit sl-cancel when the cancel event is fired and close isn`t disabled', async () => { | |
| it("should emit sl-cancel when the cancel event is fired and close isn't disabled", async () => { |
| @@ -60,68 +99,121 @@ export class Drawer extends ScopedElementsMixin(LitElement) { | |||
| aria-labelledby="title" | |||
| part="dialog" | |||
| > | |||
| <div> | |||
| <sl-button-bar> | |||
| <sl-button | |||
| sl-dialog-close | |||
| .size=${this.closeButtonSize} | |||
| tab-index="0" | |||
| aria-label="back to page" | |||
| title="close" | |||
| >x</sl-button | |||
| > | |||
| <slot name="actions"></slot> | |||
| </sl-button-bar> | |||
| <slot name="title" id="title"></slot> | |||
| <div part="header"> | |||
| <slot name="header"> | |||
| <slot name="title" id="title"></slot> | |||
| <sl-button-bar> | |||
There was a problem hiding this comment.
aria-labelledby="title" points at the slot#title, but when consumers provide their own slot="header" content the default title slot may be omitted, leaving the internal <dialog> without a valid accessible label. Consider always rendering an element with id="title" outside the overridable header slot (or conditionally switch to aria-label/aria-labelledby based on slotted content), and/or document that custom header content must include an element with id="title" to preserve labeling.
| #onClose(): void { | ||
| // Reenable scrolling after the dialog has closed | ||
| document.documentElement.style.overflow = ''; | ||
|
|
||
| this.inert = true; | ||
|
|
||
| this.closeEvent.emit(); |
There was a problem hiding this comment.
#onClose() always resets document.documentElement.style.overflow to an empty string, but show() (non-modal) never sets it. This can inadvertently clear an app’s pre-existing overflow style when the drawer was opened non-modally. Track whether scrolling was actually locked (or store/restore the previous overflow value) and only restore it when the drawer set it.
| flex-direction: column; | ||
| margin: 0; | ||
| overflow: hidden; | ||
| padding: 0; |
There was a problem hiding this comment.
The new dialog styles rely on inset but no longer set an explicit positioning context (e.g. position: fixed). The old implementation used position: fixed; without it, non-modal show() drawers can end up positioned relative to the initial containing block and scroll with the page depending on UA styles. Explicitly set position: fixed (and any required inset defaults) so drawers stay anchored to the viewport in both modal and non-modal modes.
| flex-direction: column; | |
| margin: 0; | |
| overflow: hidden; | |
| padding: 0; | |
| flex-direction: column; | |
| inset: auto; | |
| margin: 0; | |
| overflow: hidden; | |
| padding: 0; | |
| position: fixed; |
| slot[name='title']::slotted(*), | ||
| slot[name='title'] ::slotted(*) { |
There was a problem hiding this comment.
slot[name='title'] ::slotted(*) (with a space) is not a valid ::slotted() selector form and will be ignored by the browser; the preceding slot[name='title']::slotted(*) already covers the intended styling. Remove the invalid selector to avoid confusion and keep the stylesheet standards-compliant.
| slot[name='title']::slotted(*), | |
| slot[name='title'] ::slotted(*) { | |
| slot[name='title']::slotted(*) { |
| /** Show the drawer as a modal, in the top layer, with a backdrop. */ | ||
| showModal(): void { | ||
| if (this.dialog?.open) { | ||
| return; | ||
| } | ||
|
|
||
| this.inert = false; | ||
| this.dialog?.showModal(); | ||
|
|
||
| // Disable scrolling while the dialog is open | ||
| // Disable scrolling while the drawer is open | ||
| document.documentElement.style.overflow = 'hidden'; | ||
| } |
There was a problem hiding this comment.
showModal() (and similarly show()) can be called before the first render when this.dialog is still undefined. In that case this method still sets inert = false and locks document.documentElement.style.overflow = 'hidden', leaving the page unscrollable even though no dialog opened (and disconnectedCallback() won’t reset it because dialog?.open is false). Consider guarding on !this.dialog (return early) or deferring the open until updateComplete so scroll locking only happens after the <dialog> exists and is actually shown.
| }); | ||
|
|
||
| it('should close the drawer when the cancel event is fired and close isn`t disabled', async () => { | ||
| it('should emit sl-cancel when the cancel event is fired and close isn`t disabled', async () => { |
There was a problem hiding this comment.
Test description uses isn\t` (backtick) instead of the apostrophe in “isn't”, which reads like a typo in the test output. Consider renaming the test to use a proper apostrophe (or avoid contractions).
| it('should emit sl-cancel when the cancel event is fired and close isn`t disabled', async () => { | |
| it('should emit sl-cancel when the cancel event is fired and close is not disabled', async () => { |
| */ | ||
| close(returnValue?: string): void { | ||
| if (this.dialog?.open) { | ||
| this.dialog.close(returnValue); |
There was a problem hiding this comment.
close(returnValue?: string) currently always forwards one argument to HTMLDialogElement.close(). If returnValue is undefined (e.g. drawer.close() or close(button.getAttribute(...) || undefined)), WebIDL will coerce it to the string "undefined" and set dialog.returnValue unexpectedly. Consider only passing an argument when a real string is provided (otherwise call dialog.close() with no args).
| this.dialog.close(returnValue); | |
| if (returnValue === undefined) { | |
| this.dialog.close(); | |
| } else { | |
| this.dialog.close(returnValue); | |
| } |
🕸 Website previewYou can view a preview here (commit |
Summary
Makes the v2 documentation website (
docs/website) work better on mobile by hiding the sidebar behind a hamburger button that opens ansl-drawersliding in from the left. To support that UX properly, thesl-drawercomponent was reworked first.Changes
sl-drawerrework (packages/components/drawer)<dialog>-based implementation with modern CSS transitions using@starting-styleandtransition-behavior: allow-discrete— no JS animation library, no lifecycle juggling.show()method for non-modal use in addition toshowModal()(useful for persistent/docked drawers).showModal()/show()on an already-open drawer is a no-op instead of throwing.sl-icon"xmark"), with a configurableclose-button-size.sl-closefires whenever the drawer has closed.sl-cancelfires when the user presses Escape or clicks the backdrop.disable-closeattribute/property to suppress Escape + backdrop dismissal (useful for mandatory flows).::part(dialog),::part(header),::part(body)so consumers can theme the drawer from the outside without piercing shadow DOM.--sl-drawer-inline-size(left/right) and--sl-drawer-block-size(top/bottom).display: contentson the host so the drawer participates transparently in its parent layout while the<dialog>lives in the top layer.show(), the re-open guard,sl-close/sl-cancelemission, anddisableClosebehaviour on cancel + backdrop click.Mobile navigation on the v2 docs site (
docs/website)640pxand sticky at the top of the page.drawer.showModal()on ansl-drawerattached to the left.doc-sidebar(the samesidebar.njkpartial), with::part()styling to match the docs theme (padding reset, raised-sunken background).'content' / 'toc';≥640pxrestores the original'sidebar content'/'sidebar content toc'layouts.aria-expandedon the hamburger via thesl-closeevent.far-barsicon registered and@sl-design-system/drawer/register.jsimported inmain.js.Test plan
yarn build+yarn testpass for thedrawerpackage.≥640px) the docs site is unchanged: sidebar in-grid, no mobile header, no drawer visible.<640px) the sidebar is hidden, the hamburger header is sticky, and tapping the hamburger slides the sidebar in from the left on top of the content.aria-expandedon the hamburger toggles correctly when the drawer opens and closes.prefers-reduced-motion).disable-closeprevents Escape + backdrop from closing the drawer (covered by unit tests).https://claude.ai/code/session_015uB4f5pofTvdYH4Xkw1r4q