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 %} +
+ + {% if hasGeneratedTitle %} + {{ title }} + {% endif %} +
+ {% endif %} +
{% if hasSidebar %} - + {% include "sidebar.njk" %} + + + + {% 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` -
- - x - - - +
+ + + + + + + + + +
+
+
-
`; } + /** 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'; } - close(): void { + /** Show the drawer without a backdrop (non-modal). */ + show(): void { if (this.dialog?.open) { - this.dialog?.close(); + return; + } + + this.inert = false; + this.dialog?.show(); + } + + /** + * Close the drawer. + * @param returnValue - Optional value to set as the dialog's return value. + */ + close(returnValue?: string): void { + if (this.dialog?.open) { + this.dialog.close(returnValue); } } #onCancel(event: Event): void { if (this.disableClose) { event.preventDefault(); + return; } + + this.cancelEvent.emit(); } - #onClick(event: PointerEvent & { target: HTMLElement }): void { - if (event.target.matches('sl-button[sl-dialog-close]')) { - this.dialog?.close(event.target.getAttribute('sl-dialog-close') || ''); - } else if (!this.disableClose && this.dialog) { - const rect = this.dialog.getBoundingClientRect(); - - // Check if the user clicked on the backdrop - if ( - event.clientY < rect.top || - event.clientY > rect.bottom || - event.clientX < rect.left || - event.clientX > rect.right - ) { - // If so, close the dialog - this.dialog.close(); - } + #onClick(event: PointerEvent): void { + const button = event.composedPath().find((el): el is Button => el instanceof Button); + + if (button?.hasAttribute('sl-dialog-close')) { + this.close(button.getAttribute('sl-dialog-close') || undefined); + return; + } + + if (this.disableClose || !this.dialog) { + return; + } + + // Only react to clicks that originate from the dialog itself (the backdrop). + if (event.composedPath()[0] !== this.dialog) { + return; + } + + const rect = this.dialog.getBoundingClientRect(); + + // Check if the user clicked on the backdrop + if ( + event.clientY < rect.top || + event.clientY > rect.bottom || + event.clientX < rect.left || + event.clientX > rect.right + ) { + event.preventDefault(); + event.stopPropagation(); + + this.cancelEvent.emit(); + this.close(); } } + #onCloseClick(event: PointerEvent): void { + event.preventDefault(); + event.stopPropagation(); + + this.close(); + } + #onClose(): void { // Reenable scrolling after the dialog has closed document.documentElement.style.overflow = ''; this.inert = true; + + this.closeEvent.emit(); } + } diff --git a/yarn.lock b/yarn.lock index 38ac649c49..27499ea540 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5829,6 +5829,8 @@ __metadata: "@open-wc/scoped-elements": "npm:^3.0.6" "@sl-design-system/button": "npm:^2.0.0" "@sl-design-system/button-bar": "npm:^1.5.0" + "@sl-design-system/icon": "npm:^1.4.2" + "@sl-design-system/shared": "npm:^0.12.0" lit: "npm:^3.3.2" peerDependencies: "@open-wc/scoped-elements": ^3.0.6