diff --git a/docs/components/src/sidebar/sidebar.css b/docs/components/src/sidebar/sidebar.css
index 597a15ee37..f9b61fb14e 100644
--- a/docs/components/src/sidebar/sidebar.css
+++ b/docs/components/src/sidebar/sidebar.css
@@ -17,6 +17,12 @@
display: block;
}
+@media (width < 640px) {
+ header {
+ display: none;
+ }
+}
+
header {
padding: var(--sl-size-300);
padding-block-end: var(--sl-size-200);
diff --git a/docs/website/src/css/main.css b/docs/website/src/css/main.css
index 7adce8736b..3c58f3b7d8 100644
--- a/docs/website/src/css/main.css
+++ b/docs/website/src/css/main.css
@@ -16,7 +16,6 @@ body {
.site-layout {
display: grid;
grid-template-areas:
- 'sidebar'
'content'
'toc';
min-block-size: 100dvh;
@@ -32,10 +31,123 @@ body {
}
}
-doc-sidebar {
+.site-sidebar {
grid-area: sidebar;
}
+/* Mobile: hide the in-grid sidebar; show the hamburger header and drawer. */
+@media (width < 640px) {
+ .site-sidebar {
+ display: none;
+ }
+
+ .content {
+ padding-block-start: var(--sl-size-200);
+ }
+}
+
+.mobile-header {
+ align-items: center;
+ background: var(--sl-elevation-surface-raised-sunken);
+ border-block-end: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain);
+ display: flex;
+ gap: var(--sl-size-200);
+ padding: var(--sl-size-150) var(--sl-size-200);
+ position: sticky;
+ inset-block-start: 0;
+ z-index: 2;
+}
+
+.mobile-header__hamburger {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ border-radius: var(--sl-size-borderRadius-sm);
+ color: var(--sl-color-foreground-plain);
+ cursor: pointer;
+ display: inline-flex;
+ font-size: var(--sl-size-300);
+ justify-content: center;
+ padding: var(--sl-size-100);
+
+ &:hover {
+ background: var(--sl-color-background-neutral-subtle);
+ }
+
+ &:focus-visible {
+ outline: var(--sl-color-border-focused) solid var(--sl-size-borderWidth-focusRing);
+ outline-offset: var(--sl-size-outlineOffset-default);
+ }
+
+ sl-icon {
+ inline-size: var(--sl-size-300);
+ }
+}
+
+.mobile-header__title {
+ flex: 1;
+ font-family: the-message, sans-serif;
+ font-weight: 700;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.mobile-drawer {
+ --sl-drawer-inline-size: 320px;
+}
+
+.mobile-drawer::part(dialog) {
+ background: var(--sl-elevation-surface-raised-sunken);
+}
+
+.mobile-drawer::part(body) {
+ padding: 0;
+}
+
+.mobile-drawer::part(header) {
+ align-items: center;
+ padding: var(--sl-size-150) var(--sl-size-300) var(--sl-size-200);
+}
+
+.mobile-drawer doc-sidebar {
+ block-size: 100%;
+ border-inline-end: 0;
+ position: static;
+}
+
+.mobile-drawer__logo {
+ align-items: center;
+ display: inline-flex;
+ text-decoration: none;
+
+ img {
+ block-size: 2rem;
+ display: block;
+ inline-size: auto;
+ }
+
+ .logo-dark {
+ display: none;
+ }
+}
+
+[data-color-scheme='dark'] .mobile-drawer__logo .logo-light {
+ display: none;
+}
+
+[data-color-scheme='dark'] .mobile-drawer__logo .logo-dark {
+ display: block;
+}
+
+/* Desktop: hide the mobile-only header and drawer. */
+@media (width >= 640px) {
+ .mobile-header,
+ .mobile-drawer {
+ display: none;
+ }
+}
+
.content {
box-sizing: border-box;
grid-area: content;
diff --git a/docs/website/src/includes/base.njk b/docs/website/src/includes/base.njk
index 2e499d5691..5d584415d5 100644
--- a/docs/website/src/includes/base.njk
+++ b/docs/website/src/includes/base.njk
@@ -21,11 +21,37 @@
+ {% if hasSidebar %}
+
+ {% endif %}
+
{% if hasSidebar %}
-
+
+
+
+
+
+
+
+ {% include "sidebar.njk" %}
+
+
{% endif %}
diff --git a/docs/website/src/js/main.js b/docs/website/src/js/main.js
index 84e9346dce..499b8080b4 100644
--- a/docs/website/src/js/main.js
+++ b/docs/website/src/js/main.js
@@ -1,12 +1,13 @@
import '@webcomponents/scoped-custom-element-registry/scoped-custom-element-registry.min.js';
import './theme.js';
import './icons.js';
-import { faBug, faCircleExclamation, faCode, faFileLines } from '@fortawesome/pro-regular-svg-icons';
+import { faBars, faBug, faCircleExclamation, faCode, faFileLines } from '@fortawesome/pro-regular-svg-icons';
import '@sl-design-system/button/register.js';
import '@sl-design-system/button-bar/register.js';
import '@sl-design-system/badge/register.js';
import '@sl-design-system/callout/register.js';
import '@sl-design-system/dialog/register.js';
+import '@sl-design-system/drawer/register.js';
import '@sl-design-system/icon/register.js';
import { Icon } from '@sl-design-system/icon';
import '@sl-design-system/tooltip/register.js';
@@ -22,7 +23,7 @@ import { NavGroup } from '@sl-design-system/doc-components/site-nav/nav-group.js
import { NavItem } from '@sl-design-system/doc-components/site-nav/nav-item.js';
import { SiteNav } from '@sl-design-system/doc-components/site-nav/site-nav.js';
-Icon.register(faBug, faCircleExclamation, faCode, faFileLines);
+Icon.register(faBars, faBug, faCircleExclamation, faCode, faFileLines);
customElements.define('doc-code', Code);
customElements.define('doc-code-example', CodeExample);
@@ -35,3 +36,24 @@ customElements.define('doc-open-issue-count', OpenIssueCount);
customElements.define('doc-page-toc', PageToc);
customElements.define('doc-sidebar', Sidebar);
customElements.define('doc-site-nav', SiteNav);
+
+const hamburger = document.querySelector('.mobile-header__hamburger');
+const drawer = document.querySelector('#mobile-drawer');
+
+if (hamburger && drawer) {
+ hamburger.addEventListener('click', () => {
+ drawer.showModal();
+ hamburger.setAttribute('aria-expanded', 'true');
+ });
+
+ drawer.addEventListener('sl-close', () => {
+ hamburger.setAttribute('aria-expanded', 'false');
+ });
+
+ drawer.addEventListener('click', event => {
+ const link = event.target.closest('a');
+ if (link && drawer.contains(link)) {
+ drawer.close();
+ }
+ });
+}
diff --git a/packages/components/drawer/package.json b/packages/components/drawer/package.json
index 850a1dd255..d83688e241 100644
--- a/packages/components/drawer/package.json
+++ b/packages/components/drawer/package.json
@@ -40,7 +40,9 @@
},
"dependencies": {
"@sl-design-system/button": "^2.0.0",
- "@sl-design-system/button-bar": "^1.5.0"
+ "@sl-design-system/button-bar": "^1.5.0",
+ "@sl-design-system/icon": "^1.4.2",
+ "@sl-design-system/shared": "^0.12.0"
},
"devDependencies": {
"@open-wc/scoped-elements": "^3.0.6",
diff --git a/packages/components/drawer/src/drawer.scss b/packages/components/drawer/src/drawer.scss
index e96c4e5fa0..6a524e1d84 100644
--- a/packages/components/drawer/src/drawer.scss
+++ b/packages/components/drawer/src/drawer.scss
@@ -1,93 +1,162 @@
:host {
- --_background: var(--sl-color-elevation-surface-raised);
- --_border: 0;
- --_border-radius: 3px;
- --_box-shadow: 0 6px 10px -6px rgb(0 0 0 / 70%);
- --_gap: 0.5rem;
- --_spacing: 0.5rem;
- --_max-inline-size: var(--sl-drawer-max-inline-size, 500px);
+ display: contents;
+}
+
+dialog {
+ background: var(--sl-color-elevation-surface-raised);
+ border: 0;
+ box-shadow: 0 6px 24px -6px rgb(0 0 0 / 35%);
+ box-sizing: border-box;
+ color: var(--sl-color-foreground-plain);
+ display: flex;
+ flex-direction: column;
+ margin: 0;
+ overflow: hidden;
+ padding: 0;
+
+ &::backdrop {
+ background: rgb(0 0 0 / 40%);
+ opacity: 0;
+ }
- display: block;
+ &[open] {
+ &::backdrop {
+ opacity: 1;
+
+ @starting-style {
+ opacity: 0;
+ }
+ }
+ }
+
+ &:focus-visible {
+ outline: none;
+ }
}
-:host([attachment='right']) dialog {
- --_fade-start-x: 100%;
+:host([attachment='right']) dialog,
+:host([attachment='left']) dialog {
+ block-size: 100dvh;
+ inline-size: min(90dvw, var(--sl-drawer-inline-size, 28rem));
+ max-block-size: 100dvh;
+}
- block-size: 100vh;
- inset-block-start: 0;
- inset-inline: unset 0;
+:host([attachment='right']) dialog {
+ inset: 0 0 0 auto;
}
:host([attachment='left']) dialog {
- --_fade-start-x: -100%;
+ inset: 0 auto 0 0;
+}
- block-size: 100vh;
- inset-block-start: 0;
- inset-inline: 0 unset;
+:host([attachment='top']) dialog,
+:host([attachment='bottom']) dialog {
+ block-size: min(90dvh, var(--sl-drawer-block-size, 20rem));
+ inline-size: 100dvw;
+ max-inline-size: 100dvw;
}
:host([attachment='top']) dialog {
- --_fade-start-y: -100%;
-
- inline-size: 100vw;
- inset-block: 0 unset;
- inset-inline-start: 0;
- max-inline-size: 100vw;
+ inset: 0 0 auto 0;
}
:host([attachment='bottom']) dialog {
- --_fade-start-y: 100%;
+ inset: auto 0 0 0;
+}
- inline-size: 100vw;
- inset-block: unset 0;
- inset-inline-start: 0;
- max-inline-size: 100vw;
+// Enter/leave animations using @starting-style + allow-discrete
+:host([attachment='right']) dialog {
+ translate: 100% 0;
}
-dialog {
- background: var(--_background);
- border: var(--_border);
- border-radius: var(--_border-radius);
- box-shadow: var(--_box-shadow);
- display: flex;
- flex-direction: column;
- gap: var(--_gap);
- margin: 0;
- max-block-size: min(100vh, 100%);
- max-block-size: min(100dvb, 100%);
- max-inline-size: min(90vw, var(--_max-inline-size));
- padding: 1rem;
- position: fixed;
- transform: translate(var(--_fade-start-x, 0), var(--_fade-start-y, 0))
- scale(var(--_fade-start-sx, 1), var(--_fade-start-sx, 1));
+:host([attachment='left']) dialog {
+ translate: -100% 0;
+}
- &::backdrop {
- -webkit-backdrop-filter: blur(3px);
- backdrop-filter: blur(3px);
- transition: backdrop-filter 0.5s ease;
+:host([attachment='top']) dialog {
+ translate: 0 -100%;
+}
+
+:host([attachment='bottom']) dialog {
+ translate: 0 100%;
+}
+
+:host([attachment='right']) dialog[open],
+:host([attachment='left']) dialog[open],
+:host([attachment='top']) dialog[open],
+:host([attachment='bottom']) dialog[open] {
+ translate: 0 0;
+}
+
+:host([attachment='right']) dialog[open] {
+ @starting-style {
+ translate: 100% 0;
}
+}
- @media (prefers-reduced-motion: no-preference) {
- transition: all 0.5s cubic-bezier(0.25, 0, 0.3, 1);
+:host([attachment='left']) dialog[open] {
+ @starting-style {
+ translate: -100% 0;
}
}
-dialog[open] {
- transform: translate(0, 0) scale(1, 1);
+:host([attachment='top']) dialog[open] {
+ @starting-style {
+ translate: 0 -100%;
+ }
}
-div {
- align-items: center;
- display: grid;
- gap: var(--_gap);
- grid-auto-flow: column;
- grid-template-columns: 1fr auto;
+:host([attachment='bottom']) dialog[open] {
+ @starting-style {
+ translate: 0 100%;
+ }
+}
- sl-button-bar {
- grid-column-start: -1;
+@media (prefers-reduced-motion: no-preference) {
+ dialog,
+ dialog::backdrop {
+ transition: 0.3s cubic-bezier(0.25, 0, 0.3, 1);
+ transition-behavior: allow-discrete;
+ transition-property: opacity, overlay, translate;
}
- [sl-dialog-close] {
- order: 1;
+ @supports (overlay: auto) {
+ dialog,
+ dialog::backdrop {
+ transition-property: display, opacity, overlay, translate;
+ }
}
}
+
+[part='header'] {
+ align-items: start;
+ box-sizing: border-box;
+ display: flex;
+ gap: var(--sl-size-200);
+ justify-content: space-between;
+ padding: var(--sl-size-200) var(--sl-size-200) var(--sl-size-100);
+}
+
+slot[name='title']::slotted(*),
+slot[name='title'] ::slotted(*) {
+ color: var(--sl-color-foreground-bold);
+ font-size: calc((20 / 14) * 1em);
+ font-weight: var(--sl-text-new-typeset-fontWeight-semiBold);
+ line-height: 1.2;
+ margin: 0;
+}
+
+sl-button-bar {
+ flex-shrink: 0;
+}
+
+sl-button.sl-close {
+ flex-shrink: 0;
+}
+
+[part='body'] {
+ flex: 1 1 auto;
+ overflow: auto;
+ padding: var(--sl-size-100) var(--sl-size-200) var(--sl-size-200);
+ scrollbar-width: thin;
+}
diff --git a/packages/components/drawer/src/drawer.spec.ts b/packages/components/drawer/src/drawer.spec.ts
index f26257b111..cc2002db1c 100644
--- a/packages/components/drawer/src/drawer.spec.ts
+++ b/packages/components/drawer/src/drawer.spec.ts
@@ -32,7 +32,7 @@ describe('sl-drawer', () => {
expect(el.renderRoot.querySelector('dialog')).not.to.have.attribute('open');
});
- it('should open and close the drawer', async () => {
+ it('should open and close the drawer as a modal', async () => {
const dialog = el.renderRoot.querySelector('dialog');
el.showModal();
await el.updateComplete;
@@ -48,9 +48,45 @@ describe('sl-drawer', () => {
expect(document.documentElement.style.overflow).to.equal('');
});
+ it('should open and close the drawer non-modally using show()', async () => {
+ const dialog = el.renderRoot.querySelector('dialog');
+ el.show();
+ await el.updateComplete;
+
+ expect(dialog).to.have.attribute('open');
+ // Non-modal open should not lock page scrolling.
+ expect(document.documentElement.style.overflow).to.equal('');
+
+ el.close();
+ expect(dialog).not.to.have.attribute('open');
+ });
+
+ it('should not open the drawer twice', async () => {
+ const dialog = el.renderRoot.querySelector('dialog');
+ const showModalSpy = spy(dialog!, 'showModal');
+
+ el.showModal();
+ el.showModal();
+
+ expect(showModalSpy).to.have.been.calledOnce;
+ });
+
+ it('should emit sl-close when the drawer closes', async () => {
+ const onClose = spy();
+ el.addEventListener('sl-close', onClose);
+
+ el.showModal();
+ await el.updateComplete;
+
+ el.close();
+ el.renderRoot.querySelector('dialog')?.dispatchEvent(new Event('close'));
+
+ expect(onClose).to.have.been.called;
+ });
+
it('should not close the drawer when the cancel event is fired but close is disabled', async () => {
const dialog = el.renderRoot.querySelector('dialog');
- const cancelEvent = new Event('cancel');
+ const cancelEvent = new Event('cancel', { cancelable: true });
const cancelEventSpy = spy(cancelEvent, 'preventDefault');
el.disableClose = true;
@@ -64,10 +100,12 @@ describe('sl-drawer', () => {
expect(cancelEventSpy).to.have.been.called;
});
- 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 () => {
+ const onCancel = spy();
+ el.addEventListener('sl-cancel', onCancel);
+
const dialog = el.renderRoot.querySelector('dialog');
- const cancelEvent = new Event('cancel');
- const cancelEventSpy = spy(cancelEvent, 'preventDefault');
+ const cancelEvent = new Event('cancel', { cancelable: true });
el.disableClose = false;
el.showModal();
@@ -77,7 +115,7 @@ describe('sl-drawer', () => {
dialog?.dispatchEvent(cancelEvent);
- expect(cancelEventSpy).not.to.have.been.called;
+ expect(onCancel).to.have.been.called;
});
describe('click event', () => {
@@ -88,21 +126,12 @@ describe('sl-drawer', () => {
beforeEach(() => {
el.showModal();
dialog = el.renderRoot.querySelector('dialog');
- event = new PointerEvent('click');
+ event = new PointerEvent('click', { bubbles: true, composed: true });
if (dialog) {
dialogCloseSpy = spy(dialog, 'close');
}
});
- it('should close the drawer when the close button is clicked', () => {
- const closeButton = el.renderRoot.querySelector('sl-button[sl-dialog-close]');
- stub(event, 'target').value(closeButton as HTMLElement);
-
- dialog?.dispatchEvent(event);
-
- expect(dialogCloseSpy).to.have.been.called;
- });
-
it('should close the drawer when the backdrop is clicked', () => {
if (dialog) {
stub(dialog, 'getBoundingClientRect').returns({
@@ -136,6 +165,26 @@ describe('sl-drawer', () => {
expect(dialogCloseSpy).not.to.have.been.called;
}
});
+
+ it('should not close the drawer when disableClose is true and the backdrop is clicked', async () => {
+ el.disableClose = true;
+ await el.updateComplete;
+
+ if (dialog) {
+ stub(dialog, 'getBoundingClientRect').returns({
+ top: 0,
+ right: 900,
+ bottom: 600,
+ left: 500
+ } as DOMRect);
+ stub(event, 'clientX').value(100);
+ stub(event, 'clientY').value(100);
+
+ dialog.dispatchEvent(event);
+
+ expect(dialogCloseSpy).not.to.have.been.called;
+ }
+ });
});
});
});
diff --git a/packages/components/drawer/src/drawer.ts b/packages/components/drawer/src/drawer.ts
index de36153fbe..010741d7fe 100644
--- a/packages/components/drawer/src/drawer.ts
+++ b/packages/components/drawer/src/drawer.ts
@@ -1,56 +1,95 @@
import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
import { Button, type ButtonSize } from '@sl-design-system/button';
import { ButtonBar } from '@sl-design-system/button-bar';
+import { Icon } from '@sl-design-system/icon';
+import { type EventEmitter, event } from '@sl-design-system/shared';
+import { type SlCancelEvent } from '@sl-design-system/shared/events.js';
import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit';
import { property, query } from 'lit/decorators.js';
import styles from './drawer.scss.js';
declare global {
+ interface GlobalEventHandlersEventMap {
+ 'sl-close': SlCloseEvent;
+ }
+
interface HTMLElementTagNameMap {
'sl-drawer': Drawer;
}
}
+export type SlCloseEvent = CustomEvent;
+
export type DrawerAttachment = 'right' | 'left' | 'top' | 'bottom';
/**
* A drawer component for displaying UI at the side of the screen.
*
* @customElement sl-drawer
- * @cssprop --sl-drawer-max-inline-size - The maximum inline size of the drawer
+ *
+ * @csspart dialog - The dialog element
+ * @csspart header - The drawer header
+ * @csspart body - The body of the drawer
+ * @cssprop --sl-drawer-inline-size - The inline size of the drawer when attached to the left or right
+ * @cssprop --sl-drawer-block-size - The block size of the drawer when attached to the top or bottom
* @slot default - Body content for the drawer
- * @slot header - Header content for the drawer
+ * @slot header - Header content; overrides the default header layout
* @slot title - The title of the drawer
+ * @slot actions - Additional actions rendered next to the close button
*/
export class Drawer extends ScopedElementsMixin(LitElement) {
/** @internal */
static get scopedElements(): ScopedElementsMap {
return {
'sl-button': Button,
- 'sl-button-bar': ButtonBar
+ 'sl-button-bar': ButtonBar,
+ 'sl-icon': Icon
};
}
/** @internal */
static override styles: CSSResultGroup = styles;
- @query('dialog') dialog?: HTMLDialogElement;
-
- /** Disables the ability to close the dialog using the Escape key. */
- @property({ type: Boolean, attribute: 'disable-close' }) disableClose = false;
- /** The side of the screen where the drawer is attached */
+ /** The side of the screen the drawer is attached to. */
@property({ reflect: true }) attachment: DrawerAttachment = 'right';
- /** The size of the button */
+ /**
+ * @internal Emits when the drawer has been cancelled. This happens when the
+ * user presses Escape or clicks on the backdrop.
+ */
+ @event({ name: 'sl-cancel' }) cancelEvent!: EventEmitter;
+
+ /** @internal Emits when the drawer has been closed. */
+ @event({ name: 'sl-close' }) closeEvent!: EventEmitter;
+
+ /** The size of the close button. */
@property({ attribute: 'close-button-size' }) closeButtonSize: ButtonSize = 'sm';
+ /** @internal */
+ @query('dialog') dialog?: HTMLDialogElement;
+
+ /**
+ * Disables the ability to cancel the drawer by pressing the Escape key or
+ * clicking on the backdrop.
+ * @default false
+ */
+ @property({ type: Boolean, attribute: 'disable-close' }) disableClose = false;
+
override connectedCallback(): void {
super.connectedCallback();
this.inert = true;
}
+ override disconnectedCallback(): void {
+ if (this.dialog?.open) {
+ document.documentElement.style.overflow = '';
+ }
+
+ super.disconnectedCallback();
+ }
+
override render(): TemplateResult {
return html`