Skip to content

Drawer improvements #1692

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 8 commits into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 98 additions & 64 deletions packages/components/drawer/src/drawer.scss
Original file line number Diff line number Diff line change
@@ -1,93 +1,127 @@
: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: block;
--_duration: 0.2s;

display: contents;
}

:host([attachment='right']) dialog {
--_fade-start-x: 100%;
[part='popover'] {
background: transparent;
border: 0;
inset: 0 0 0 auto;
margin: 0;
padding: 0;
transition-behavior: allow-discrete;
transition-duration: var(--_duration);
transition-property: display;

@media (width <= 1024px) {
inset: auto 0 0;
margin-inline: auto;
}

block-size: 100vh;
inset-block-start: 0;
inset-inline: unset 0;
}
@media (width <= 600px) {
inline-size: 100dvw;
margin-inline: 0;
}

:host([attachment='left']) dialog {
--_fade-start-x: -100%;
&.left {
inset: 0 auto 0 0;
}

block-size: 100vh;
inset-block-start: 0;
inset-inline: 0 unset;
}
&::backdrop {
background: color-mix(
in srgb,
var(--sl-color-palette-primary-base) calc(var(--sl-opacity-700) * 100%),
transparent
);
opacity: calc(var(--_opened) - (var(--_opened) * var(--_closed)));
transition-behavior: allow-discrete;
transition-duration: var(--_duration);
transition-property: display, --_opened, --_closed, overlay;
transition-timing-function: ease;
}

:host([attachment='top']) dialog {
--_fade-start-y: -100%;
&:popover-open {
overscroll-behavior: none;

inline-size: 100vw;
inset-block: 0 unset;
inset-inline-start: 0;
max-inline-size: 100vw;
}
&::backdrop {
--_opened: 1;

@starting-style {
--_opened: 0;
}
}

:host([attachment='bottom']) dialog {
--_fade-start-y: 100%;
[part='content'] {
translate: 0 0;

inline-size: 100vw;
inset-block: unset 0;
inset-inline-start: 0;
max-inline-size: 100vw;
@starting-style {
translate: 100% 0;
}

@media (width <= 1024px) {
translate: 0 0;

@starting-style {
translate: 0 100%;
}
}
}

&.left [part='content'] {
@starting-style {
translate: -100% 0;
}
}
}
}

dialog {
background: var(--_background);
border: var(--_border);
border-radius: var(--_border-radius);
box-shadow: var(--_box-shadow);
[part='content'] {
background: var(--sl-elevation-surface-raised-default-idle);
block-size: 100dvh;
border-inline-start: var(--sl-color-border-plain) solid var(--sl-size-borderWidth-subtle);
box-shadow: var(--sl-elevation-shadow-overlay);
box-sizing: border-box;
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));

&::backdrop {
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
transition: backdrop-filter 0.5s ease;
gap: var(--sl-space-100);
inline-size: min-content;
padding: var(--sl-space-100) var(--sl-space-200);
transition-duration: var(--_duration);
transition-property: translate;
transition-timing-function: ease;
translate: 100% 0;

.left & {
border-inline-end: var(--sl-color-border-plain) solid var(--sl-size-borderWidth-subtle);
border-inline-start: 0;
translate: -100% 0;
}

@media (prefers-reduced-motion: no-preference) {
transition: all 0.5s cubic-bezier(0.25, 0, 0.3, 1);
@media (width <= 1024px) {
block-size: fit-content;
border: var(--sl-color-border-plain) solid var(--sl-size-borderWidth-subtle);
border-block-end: 0;
border-start-end-radius: var(--sl-size-borderRadius-default);
border-start-start-radius: var(--sl-size-borderRadius-default);
inset: auto 0 0;
translate: 0 100%;
}
}

dialog[open] {
transform: translate(0, 0) scale(1, 1);
@media (width <= 600px) {
border-inline: 0;
inline-size: 100dvw;
}
}

div {
[part='header'] {
align-items: center;
display: grid;
gap: var(--_gap);
gap: var(--sl-space-100);
grid-auto-flow: column;
grid-template-columns: 1fr auto;

sl-button-bar {
grid-column-start: -1;
}

[sl-dialog-close] {
order: 1;
}
}
227 changes: 111 additions & 116 deletions packages/components/drawer/src/drawer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { expect, fixture } from '@open-wc/testing';
import { html } from 'lit';
import { SinonSpy, spy, stub } from 'sinon';
import '../register.js';
import { type Drawer, type DrawerAttachment } from './drawer.js';

@@ -11,10 +10,6 @@ describe('sl-drawer', () => {
el = await fixture(html`<sl-drawer></sl-drawer>`);
});

it('should render correctly', () => {
expect(el).shadowDom.to.equalSnapshot();
});

describe('positioning', () => {
it('should attach the drawer to the right by default', () => {
expect(el).to.have.attribute('attachment', 'right');
@@ -30,115 +25,115 @@ describe('sl-drawer', () => {
});
});

describe('opening and closing', () => {
it('should not show the dialog by default', () => {
expect(el.renderRoot.querySelector('dialog')).not.to.have.attribute('open');
});

it('should open and close the drawer', async () => {
const dialog = el.renderRoot.querySelector('dialog');
el.showModal();
await el.updateComplete;

expect(dialog).to.have.attribute('open');
expect(document.documentElement.style.overflow).to.equal('hidden');

el.close();
expect(dialog).not.to.have.attribute('open');

// dispatch the event ourselves, because waiting for it to come from the actual dialog is too unreliable
dialog?.dispatchEvent(new Event('close'));
expect(document.documentElement.style.overflow).to.equal('');
});

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 cancelEventSpy = spy(cancelEvent, 'preventDefault');

el.disableClose = true;
el.showModal();
await el.updateComplete;

expect(dialog).to.have.attribute('open');

dialog?.dispatchEvent(cancelEvent);

expect(cancelEventSpy).to.have.been.called;
});

it('should close the drawer when the cancel event is fired and close isn`t disabled', async () => {
const dialog = el.renderRoot.querySelector('dialog');
const cancelEvent = new Event('cancel');
const cancelEventSpy = spy(cancelEvent, 'preventDefault');

el.disableClose = false;
el.showModal();
await el.updateComplete;

expect(dialog).to.have.attribute('open');

dialog?.dispatchEvent(cancelEvent);

expect(cancelEventSpy).not.to.have.been.called;
});

describe('click event', () => {
let dialog: HTMLDialogElement | null;
let event: PointerEvent;
let dialogCloseSpy: SinonSpy;

beforeEach(() => {
el.showModal();
dialog = el.renderRoot.querySelector('dialog');
event = new PointerEvent('click');
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({
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).to.have.been.called;
}
});

it("should not close the drawer when there's a click in the drawer itself", () => {
if (dialog) {
stub(dialog, 'getBoundingClientRect').returns({
top: 0,
right: 900,
bottom: 600,
left: 500
} as DOMRect);
stub(event, 'clientX').value(600);
stub(event, 'clientY').value(100);

dialog.dispatchEvent(event);

expect(dialogCloseSpy).not.to.have.been.called;
}
});
});
});
// describe('opening and closing', () => {
// it('should not show the dialog by default', () => {
// expect(el.renderRoot.querySelector('dialog')).not.to.have.attribute('open');
// });

// it('should open and close the drawer', async () => {
// const dialog = el.renderRoot.querySelector('dialog');
// el.showModal();
// await el.updateComplete;

// expect(dialog).to.have.attribute('open');
// expect(document.documentElement.style.overflow).to.equal('hidden');

// el.close();
// expect(dialog).not.to.have.attribute('open');

// // dispatch the event ourselves, because waiting for it to come from the actual dialog is too unreliable
// dialog?.dispatchEvent(new Event('close'));
// expect(document.documentElement.style.overflow).to.equal('');
// });

// 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 cancelEventSpy = spy(cancelEvent, 'preventDefault');

// el.disableClose = true;
// el.showModal();
// await el.updateComplete;

// expect(dialog).to.have.attribute('open');

// dialog?.dispatchEvent(cancelEvent);

// expect(cancelEventSpy).to.have.been.called;
// });

// it('should close the drawer when the cancel event is fired and close isn`t disabled', async () => {
// const dialog = el.renderRoot.querySelector('dialog');
// const cancelEvent = new Event('cancel');
// const cancelEventSpy = spy(cancelEvent, 'preventDefault');

// el.disableClose = false;
// el.showModal();
// await el.updateComplete;

// expect(dialog).to.have.attribute('open');

// dialog?.dispatchEvent(cancelEvent);

// expect(cancelEventSpy).not.to.have.been.called;
// });

// describe('click event', () => {
// let dialog: HTMLDialogElement | null;
// let event: PointerEvent;
// let dialogCloseSpy: SinonSpy;

// beforeEach(() => {
// el.showModal();
// dialog = el.renderRoot.querySelector('dialog');
// event = new PointerEvent('click');
// 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({
// 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).to.have.been.called;
// }
// });

// it("should not close the drawer when there's a click in the drawer itself", () => {
// if (dialog) {
// stub(dialog, 'getBoundingClientRect').returns({
// top: 0,
// right: 900,
// bottom: 600,
// left: 500
// } as DOMRect);
// stub(event, 'clientX').value(600);
// stub(event, 'clientY').value(100);

// dialog.dispatchEvent(event);

// expect(dialogCloseSpy).not.to.have.been.called;
// }
// });
// });
// });
});
177 changes: 118 additions & 59 deletions packages/components/drawer/src/drawer.stories.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,139 @@
import '@sl-design-system/button/register.js';
import { type StoryObj } from '@storybook/web-components';
import { type Meta, type StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import '../register.js';
import { type Drawer } from './drawer.js';

type Props = Pick<Drawer, 'attachment'> & { styles?: string };
type Story = StoryObj<Props>;

export default {
title: 'Overlay/Drawer',
tags: ['draft'],
args: {
attachment: 'right',
buttonSize: 'sm'
parameters: {
layout: 'fullscreen',
viewport: {
defaultViewport: 'reset'
}
},
argTypes: {
attachment: {
control: 'radio',
control: 'inline-radio',
options: ['right', 'left', 'top', 'bottom']
},
buttonSize: {
control: 'radio',
options: ['sm', 'md', 'lg']
styles: {
table: { disable: true }
}
},
parameters: {
// Disables Chromatic's snapshotting on a story level
chromatic: { disableSnapshot: true }
}
};
render: ({ attachment, styles }) => {
const onClick = (event: Event & { target: HTMLElement }) => {
(event.target.nextElementSibling as Drawer).toggle();
};

const onClick = (event: Event & { target: HTMLElement }): void => {
(event.target.nextElementSibling as Drawer).showModal();
};
return html`
<style>
@property --_opened {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
export const API: StoryObj = {
render: ({ attachment, buttonSize }) => html`
<sl-button @click=${onClick}>Show Drawer</sl-button>
<sl-drawer .attachment=${attachment} .closeButtonSize=${buttonSize}>
<h2 slot="title">In this sidepanel you can find a lot of info</h2>
<p>
Apple pie chocolate jelly-o carrot cake gummi bears halvah cake cheesecake. Sesame snaps macaroon shortbread
cheesecake muffin soufflé. Powder croissant sugar plum candy canes cupcake chupa chups cake marzipan. Chocolate
bar pie jujubes chocolate powder jelly. Marshmallow biscuit bear claw cookie topping. Tart sugar plum toffee
gingerbread macaroon danish brownie. Candy canes dragée sesame snaps lollipop ice cream.
</p>
</sl-drawer>
`
};
/* don't transition this one */
@property --_closed {
syntax: '<number>';
inherits: true;
initial-value: 1;
}
:root {
--_duration: 0.2s;
timeline-scope: --drawer;
}
:root.sl-drawer-open,
:root.sl-drawer-open main {
overflow: hidden;
overscroll-behavior: none;
}
:root.sl-drawer-open main {
overflow: hidden;
--_opened: 1;
}
export const DisableClose: StoryObj = {
render: () => html`
<sl-button @click=${onClick}>Show Drawer</sl-button>
<sl-drawer disable-close>
<span slot="title" id="title">Drawer title</span>
<p>
Jelly beans macaroon bonbon chocolate cake jelly beans chocolate lollipop cake. Wafer bonbon powder toffee pie.
Shortbread sweet dessert tiramisu danish jelly-o wafer. Brownie lemon drops cake lollipop tart candy cookie
gummies chocolate. Cupcake gummies sesame snaps topping gummi bears. Croissant danish marshmallow macaroon
fruitcake.
</p>
</sl-drawer>
`
:root.sl-drawer-open {
--_closed: 0;
}
@supports (animation-timeline: scroll()) {
:root.sl-drawer-open {
--_closed: 1;
animation: open both linear reverse;
animation-range: entry;
animation-timeline: --drawer;
}
@keyframes open {
0% {
--_closed: 0;
}
}
}
main {
block-size: 100dvh;
display: grid;
inline-size: 100vw;
place-items: center;
transition-duration: var(--_duration);
transform-origin: 50% 0%;
transition-property: --_opened, --_closed;
transition-timing-function: ease;
}
${styles ??
`
sl-drawer::part(content) {
@media (width > 600px) {
inline-size: 60dvw;
}
@media (width > 1024px) {
inline-size: 300px;
}
}
`}
</style>
<main>
<sl-button @click=${onClick}>Show Drawer</sl-button>
<sl-drawer attachment=${ifDefined(attachment)}>
<h2 slot="title">In this side panel you can find a lot of info</h2>
<p>
Apple pie chocolate jelly-o carrot cake gummi bears halvah cake cheesecake. Sesame snaps macaroon shortbread
cheesecake muffin soufflé. Powder croissant sugar plum candy canes cupcake chupa chups cake marzipan.
Chocolate bar pie jujubes chocolate powder jelly. Marshmallow biscuit bear claw cookie topping. Tart sugar
plum toffee gingerbread macaroon danish brownie. Candy canes dragée sesame snaps lollipop ice cream.
</p>
</sl-drawer>
</main>
`;
}
} satisfies Meta<Props>;

export const Basic: Story = {};

export const Mobile: Story = {
parameters: {
viewport: {
defaultViewport: 'iphone6'
}
}
};

export const CompleteHeader: StoryObj = {
render: ({ attachment, buttonSize }) => html`
<sl-button @click=${onClick}>Show Drawer</sl-button>
<sl-drawer .attachment=${attachment} .closeButtonSize=${buttonSize}>
<h1 slot="title">Test title</h1>
<sl-button slot="actions">download</sl-button>
<p>
Macaroon caramels tootsie roll cookie liquorice cake gingerbread cookie. Toffee fruitcake macaroon cheesecake
muffin gingerbread apple pie. Donut powder lollipop macaroon jelly-o. Powder powder tiramisu brownie jelly
macaroon jelly ice cream. Cake macaroon pudding cookie cookie powder macaroon. Sesame snaps cheesecake jujubes
tootsie roll macaroon oat cake jujubes cotton candy. Chocolate chocolate cake tart fruitcake sugar plum. Lemon
drops dessert pastry jujubes bonbon fruitcake muffin. Candy canes wafer brownie chocolate cake macaroon
cheesecake.
</p>
</sl-drawer>
`
export const Tablet: Story = {
parameters: {
viewport: {
defaultViewport: 'ipad',
defaultOrientation: 'landscape'
}
}
};
108 changes: 43 additions & 65 deletions packages/components/drawer/src/drawer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { localized, msg } from '@lit/localize';
import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
import { Button, type ButtonSize } from '@sl-design-system/button';
import { Button } from '@sl-design-system/button';
import { ButtonBar } from '@sl-design-system/button-bar';
import { Icon } from '@sl-design-system/icon';
import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import styles from './drawer.scss.js';

declare global {
@@ -21,28 +24,25 @@ export type DrawerAttachment = 'right' | 'left' | 'top' | 'bottom';
* @slot header - Header content for the drawer
* @slot title - The title of the drawer
*/
@localized()
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;
/** @internal The popover element */
@query('[popover]') popoverElement!: HTMLElement;

/** The side of the screen where the drawer is attached */
@property({ reflect: true }) attachment: DrawerAttachment = 'right';

/** The size of the button */
@property({ attribute: 'close-button-size' }) closeButtonSize: ButtonSize = 'sm';
@property() attachment?: DrawerAttachment;

override connectedCallback(): void {
super.connectedCallback();
@@ -52,75 +52,53 @@ export class Drawer extends ScopedElementsMixin(LitElement) {

override render(): TemplateResult {
return html`
<dialog
@cancel=${this.#onCancel}
@click=${this.#onClick}
@close=${this.#onClose}
<div
@beforetoggle=${this.#onBeforeToggle}
@toggle=${this.#onToggle}
aria-labelledby="title"
part="dialog"
class=${ifDefined(this.attachment)}
part="popover"
popover
>
<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="content">
<div part="header">
<sl-button-bar reverse>
<sl-button @click=${this.#onClick} aria-label=${msg('Close the drawer')} fill="ghost">
<sl-icon name="xmark"></sl-icon>
</sl-button>
<slot name="actions"></slot>
</sl-button-bar>
<slot name="title" id="title"></slot>
</div>
<slot></slot>
</div>
<slot></slot>
</dialog>
</div>
`;
}

showModal(): void {
this.inert = false;
this.dialog?.showModal();

// Disable scrolling while the dialog is open
document.documentElement.style.overflow = 'hidden';
hide(): void {
this.popoverElement?.hidePopover();
}

close(): void {
if (this.dialog?.open) {
this.dialog?.close();
}
show(): void {
this.popoverElement?.showPopover();
}

#onCancel(event: Event): void {
if (this.disableClose) {
event.preventDefault();
}
toggle(): void {
this.popoverElement?.togglePopover();
}

#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();
}
}
#onBeforeToggle(event: ToggleEvent): void {
this.inert = event.newState === 'closed';

document.documentElement.classList.toggle('sl-drawer-open', event.newState === 'open');
}

#onClose(): void {
// Reenable scrolling after the dialog has closed
document.documentElement.style.overflow = '';
#onClick(): void {
this.hide();
}

this.inert = true;
#onToggle(event: ToggleEvent): void {
console.log('toggle', event);
}
}