From 26e21da621bf294ce0e6a60c70a6f6221583a7a5 Mon Sep 17 00:00:00 2001 From: mariohamann Date: Fri, 8 May 2026 11:25:23 +0200 Subject: [PATCH] refactor: replace custom modal implementation with native element in sd-dialog and sd-drawer Co-authored-by: Copilot --- .changeset/native-dialog-refactor.md | 13 ++ .../src/components/dialog/dialog.test.ts | 24 +-- .../src/components/dialog/dialog.ts | 140 +++++---------- .../src/components/drawer/drawer.test.ts | 24 +-- .../src/components/drawer/drawer.ts | 163 ++++-------------- packages/components/src/internal/modal.ts | 64 ------- .../src/stories/components/dialog.stories.ts | 18 +- .../src/stories/components/drawer.stories.ts | 6 +- .../stories/components/drawer.test.stories.ts | 1 - 9 files changed, 125 insertions(+), 328 deletions(-) create mode 100644 .changeset/native-dialog-refactor.md delete mode 100644 packages/components/src/internal/modal.ts diff --git a/.changeset/native-dialog-refactor.md b/.changeset/native-dialog-refactor.md new file mode 100644 index 0000000000..1a7d9b838f --- /dev/null +++ b/.changeset/native-dialog-refactor.md @@ -0,0 +1,13 @@ +--- +'@solid-design-system/components': minor +--- + +Replaced custom modal/overlay implementation in `sd-dialog` and `sd-drawer` with the browser-native `` element and its `showModal()`/`close()` APIs. + +- Both components now use `` internally, gaining native top-layer stacking, focus trapping, and scroll locking for free +- Removed the internal `Modal` class (`internal/modal.ts`) — no longer needed +- Removed manual body scroll lock and Escape key handling — handled natively by the browser +- `role="dialog"` and `aria-modal` removed from `[part="panel"]` — native `` provides these semantics +- Removed `contained` property from `sd-drawer` (incompatible with native top-layer stacking) +- `sd-drawer` and `sd-dialog`: `part="overlay"` is retained as a real `
` inside the `` shadow DOM; `overlay.show` and `overlay.hide` animation hooks continue to work +- Story improvements: all non-Default stories for `sd-dialog` and `sd-drawer` now render in iframes (`inline: false`) to prevent native top-layer overlays from bleeding into the docs page; Default stories start closed with an "Open" button diff --git a/packages/components/src/components/dialog/dialog.test.ts b/packages/components/src/components/dialog/dialog.test.ts index 6ab07e641f..c44097ad2e 100644 --- a/packages/components/src/components/dialog/dialog.test.ts +++ b/packages/components/src/components/dialog/dialog.test.ts @@ -11,18 +11,18 @@ describe('', () => { Lorem ipsum dolor sit amet, consectetur adipiscing elit. `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; + const base = el.shadowRoot!.querySelector('[part~="base"]')!; - expect(base.hidden).to.be.false; + expect(base.open).to.be.true; }); it('should not be visible without the open attribute', async () => { const el = await fixture(html` Lorem ipsum dolor sit amet, consectetur adipiscing elit. `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; + const base = el.shadowRoot!.querySelector('[part~="base"]')!; - expect(base.hidden).to.be.true; + expect(base.open).to.be.false; }); it('should include a close button by default', async () => { @@ -47,7 +47,7 @@ describe('', () => { const el = await fixture(html` Lorem ipsum dolor sit amet, consectetur adipiscing elit. `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; + const base = el.shadowRoot!.querySelector('[part~="base"]')!; const showHandler = sinon.spy(); const afterShowHandler = sinon.spy(); @@ -60,14 +60,14 @@ describe('', () => { expect(showHandler).to.have.been.calledOnce; expect(afterShowHandler).to.have.been.calledOnce; - expect(base.hidden).to.be.false; + expect(base.open).to.be.true; }); it('should emit sd-hide and sd-after-hide when calling hide()', async () => { const el = await fixture(html` Lorem ipsum dolor sit amet, consectetur adipiscing elit. `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; + const base = el.shadowRoot!.querySelector('[part~="base"]')!; const hideHandler = sinon.spy(); const afterHideHandler = sinon.spy(); @@ -80,14 +80,14 @@ describe('', () => { expect(hideHandler).to.have.been.calledOnce; expect(afterHideHandler).to.have.been.calledOnce; - expect(base.hidden).to.be.true; + expect(base.open).to.be.false; }); it('should emit sd-show and sd-after-show when setting open = true', async () => { const el = await fixture(html` Lorem ipsum dolor sit amet, consectetur adipiscing elit. `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; + const base = el.shadowRoot!.querySelector('[part~="base"]')!; const showHandler = sinon.spy(); const afterShowHandler = sinon.spy(); @@ -100,14 +100,14 @@ describe('', () => { expect(showHandler).to.have.been.calledOnce; expect(afterShowHandler).to.have.been.calledOnce; - expect(base.hidden).to.be.false; + expect(base.open).to.be.true; }); it('should emit sd-hide and sd-after-hide when setting open = false', async () => { const el = await fixture(html` Lorem ipsum dolor sit amet, consectetur adipiscing elit. `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; + const base = el.shadowRoot!.querySelector('[part~="base"]')!; const hideHandler = sinon.spy(); const afterHideHandler = sinon.spy(); @@ -120,7 +120,7 @@ describe('', () => { expect(hideHandler).to.have.been.calledOnce; expect(afterHideHandler).to.have.been.calledOnce; - expect(base.hidden).to.be.true; + expect(base.open).to.be.false; }); it('should not close when sd-request-close is prevented', async () => { diff --git a/packages/components/src/components/dialog/dialog.ts b/packages/components/src/components/dialog/dialog.ts index 290e99c3a5..b17766f18f 100644 --- a/packages/components/src/components/dialog/dialog.ts +++ b/packages/components/src/components/dialog/dialog.ts @@ -4,16 +4,13 @@ import { animateTo, stopAnimations } from '../../internal/animate'; import { css, html } from 'lit'; import { customElement } from '../../internal/register-custom-element'; import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; -import { getDeepActiveElement } from '../../internal/deep-active-element'; import { HasSlotController } from '../../internal/slot'; import { ifDefined } from 'lit/directives/if-defined.js'; import { LocalizeController } from '../../utilities/localize'; -import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll'; import { property, query } from 'lit/decorators.js'; import { waitForEvent } from '../../internal/event'; import { watch } from '../../internal/watch'; import cx from 'classix'; -import Modal from '../../internal/modal'; import SolidElement from '../../internal/solid-element'; /** @@ -64,12 +61,10 @@ import SolidElement from '../../internal/solid-element'; export default class SdDialog extends SolidElement { private readonly hasSlotController = new HasSlotController(this, 'footer'); public localize = new LocalizeController(this); - private modal: Modal; - private originalTrigger: HTMLElement | null; - @query('[part="base"]') dialog: HTMLElement; - @query('[part="panel"]') panel: HTMLElement; + @query('[part="base"]') dialog: HTMLDialogElement; @query('[part="overlay"]') overlay: HTMLElement; + @query('[part="panel"]') panel: HTMLElement; /** * Indicates whether or not the dialog is open. You can toggle this attribute to show and hide the dialog, or you can @@ -87,27 +82,17 @@ export default class SdDialog extends SolidElement { */ @property({ attribute: 'no-close-button', type: Boolean, reflect: true }) noCloseButton = false; - connectedCallback() { - super.connectedCallback(); - this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); - this.modal = new Modal(this); - } - firstUpdated() { - this.dialog.hidden = !this.open; + this.dialog.addEventListener('cancel', (event: Event) => { + event.preventDefault(); + this.requestClose('keyboard'); + }); if (this.open) { - this.addOpenListeners(); - this.modal.activate(); - lockBodyScrolling(this); + this.dialog.showModal(); } } - disconnectedCallback() { - super.disconnectedCallback(); - unlockBodyScrolling(this); - } - private get prefersReducedMotion() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } @@ -127,31 +112,11 @@ export default class SdDialog extends SolidElement { this.hide(); } - private addOpenListeners() { - document.addEventListener('keydown', this.handleDocumentKeyDown); - } - - private removeOpenListeners() { - document.removeEventListener('keydown', this.handleDocumentKeyDown); - } - - private handleDocumentKeyDown(event: KeyboardEvent) { - if (this.open && event.key === 'Escape') { - event.stopPropagation(); - this.requestClose('keyboard'); - } - } - @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { if (this.open) { // Show this.emit('sd-show'); - this.addOpenListeners(); - this.originalTrigger = getDeepActiveElement(); - this.modal.activate(); - - lockBodyScrolling(this); // When the dialog is shown, Safari will attempt to set focus on whatever element has autofocus. This can cause // the dialogs's animation to jitter (if it starts offscreen), so we'll temporarily remove the attribute, call @@ -164,8 +129,11 @@ export default class SdDialog extends SolidElement { autoFocusTarget.removeAttribute('autofocus'); } - await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]); - this.dialog.hidden = false; + await stopAnimations(this.dialog); + this.dialog.showModal(); + + const overlayShowAnimation = getAnimation(this, 'dialog.overlay.show', { dir: this.localize.dir() }); + animateTo(this.overlay, overlayShowAnimation.keyframes, overlayShowAnimation.options); // Set initial focus requestAnimationFrame(() => { @@ -189,57 +157,23 @@ export default class SdDialog extends SolidElement { const panelAnimation = this.prefersReducedMotion ? getAnimation(this, 'dialog.showReducedMotion', { dir: this.localize.dir() }) : getAnimation(this, 'dialog.show', { dir: this.localize.dir() }); - const overlayAnimation = getAnimation(this, 'dialog.overlay.show', { dir: this.localize.dir() }); - await Promise.all([ - animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options), - animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options) - ]); + await animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options); this.emit('sd-after-show'); } else { // Hide this.emit('sd-hide'); - this.removeOpenListeners(); - this.modal.deactivate(); - await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]); + await stopAnimations(this.dialog); + const overlayHideAnimation = getAnimation(this, 'dialog.overlay.hide', { dir: this.localize.dir() }); + animateTo(this.overlay, overlayHideAnimation.keyframes, overlayHideAnimation.options); + const panelAnimation = this.prefersReducedMotion ? getAnimation(this, 'dialog.hideReducedMotion', { dir: this.localize.dir() }) : getAnimation(this, 'dialog.hide', { dir: this.localize.dir() }); - const overlayAnimation = getAnimation(this, 'dialog.overlay.hide', { dir: this.localize.dir() }); - - // Animate the overlay and the panel at the same time. Because animation durations might be different, we need to - // hide each one individually when the animation finishes, otherwise the first one that finishes will reappear - // unexpectedly. We'll unhide them after all animations have completed. - await Promise.all([ - animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options).then(() => { - this.overlay.hidden = true; - }), - animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options).then(() => { - this.panel.hidden = true; - }) - ]); - - this.dialog.hidden = true; - - // Now that the dialog is hidden, restore the overlay and panel for next time - this.overlay.hidden = false; - this.panel.hidden = false; - - unlockBodyScrolling(this); - - // Restore focus to the original trigger - const trigger = this.originalTrigger; - if (typeof trigger?.focus === 'function' && trigger.isConnected) { - setTimeout(() => { - try { - trigger.focus({ preventScroll: true }); - } catch { - trigger.focus(); - } - this.originalTrigger = null; - }); - } + + await animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options); + this.dialog.close(); this.emit('sd-after-hide'); } @@ -268,29 +202,20 @@ export default class SdDialog extends SolidElement { render() { /* eslint-disable lit-a11y/click-events-have-key-events */ return html` -
-
this.requestClose('overlay')} - tabindex="-1" - >
- +
this.requestClose('overlay')}>
-
+
`; /* eslint-enable lit-a11y/click-events-have-key-events */ } @@ -341,6 +266,11 @@ export default class SdDialog extends SolidElement { css` :host { --width: 662px; + @apply contents; + } + + :host(:not([open])) { + display: none; } [part='panel'] { @@ -352,6 +282,14 @@ export default class SdDialog extends SolidElement { -webkit-overflow-scrolling: touch; } + ::backdrop { + display: none; + } + + [part='overlay'] { + background-color: rgb(var(--sd-overlay-color-background, 5 21 48 / 0.9)); + } + @media (max-width: 414px) { :host { --width: 335px; @@ -402,12 +340,12 @@ setDefaultAnimation('dialog.denyClose', { setDefaultAnimation('dialog.overlay.show', { keyframes: [{ opacity: 0 }, { opacity: 1 }], - options: { duration: 'var(--sd-duration-medium, 300)', easing: 'ease-in-out', reducedMotion: 'allow' } + options: { duration: 'var(--sd-duration-medium, 300)', easing: 'linear', reducedMotion: 'allow' } }); setDefaultAnimation('dialog.overlay.hide', { keyframes: [{ opacity: 1 }, { opacity: 0 }], - options: { duration: 'var(--sd-duration-fast, 150)', easing: 'ease-in-out', reducedMotion: 'allow' } + options: { duration: 'var(--sd-duration-fast, 150)', easing: 'linear', reducedMotion: 'allow' } }); declare global { diff --git a/packages/components/src/components/drawer/drawer.test.ts b/packages/components/src/components/drawer/drawer.test.ts index 40bc31fd3a..8b454dc540 100644 --- a/packages/components/src/components/drawer/drawer.test.ts +++ b/packages/components/src/components/drawer/drawer.test.ts @@ -10,25 +10,25 @@ describe('', () => { const el = await fixture(html` Lorem ipsum dolor sit amet, consectetur adipiscing elit. `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; + const base = el.shadowRoot!.querySelector('[part~="base"]')!; - expect(base.hidden).to.be.false; + expect(base.open).to.be.true; }); it('should not be visible without the open attribute', async () => { const el = await fixture(html` Lorem ipsum dolor sit amet, consectetur adipiscing elit. `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; + const base = el.shadowRoot!.querySelector('[part~="base"]')!; - expect(base.hidden).to.be.true; + expect(base.open).to.be.false; }); it('should emit sd-show and sd-after-show when calling show()', async () => { const el = await fixture(html` Lorem ipsum dolor sit amet, consectetur adipiscing elit. `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; + const base = el.shadowRoot!.querySelector('[part~="base"]')!; const showHandler = sinon.spy(); const afterShowHandler = sinon.spy(); @@ -41,14 +41,14 @@ describe('', () => { expect(showHandler).to.have.been.calledOnce; expect(afterShowHandler).to.have.been.calledOnce; - expect(base.hidden).to.be.false; + expect(base.open).to.be.true; }); it('should emit sd-hide and sd-after-hide when calling hide()', async () => { const el = await fixture(html` Lorem ipsum dolor sit amet, consectetur adipiscing elit. `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; + const base = el.shadowRoot!.querySelector('[part~="base"]')!; const hideHandler = sinon.spy(); const afterHideHandler = sinon.spy(); @@ -61,14 +61,14 @@ describe('', () => { expect(hideHandler).to.have.been.calledOnce; expect(afterHideHandler).to.have.been.calledOnce; - expect(base.hidden).to.be.true; + expect(base.open).to.be.false; }); it('should emit sd-show and sd-after-show when setting open = true', async () => { const el = await fixture(html` Lorem ipsum dolor sit amet, consectetur adipiscing elit. `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; + const base = el.shadowRoot!.querySelector('[part~="base"]')!; const showHandler = sinon.spy(); const afterShowHandler = sinon.spy(); @@ -81,14 +81,14 @@ describe('', () => { expect(showHandler).to.have.been.calledOnce; expect(afterShowHandler).to.have.been.calledOnce; - expect(base.hidden).to.be.false; + expect(base.open).to.be.true; }); it('should emit sd-hide and sd-after-hide when setting open = false', async () => { const el = await fixture(html` Lorem ipsum dolor sit amet, consectetur adipiscing elit. `); - const base = el.shadowRoot!.querySelector('[part~="base"]')!; + const base = el.shadowRoot!.querySelector('[part~="base"]')!; const hideHandler = sinon.spy(); const afterHideHandler = sinon.spy(); @@ -101,7 +101,7 @@ describe('', () => { expect(hideHandler).to.have.been.calledOnce; expect(afterHideHandler).to.have.been.calledOnce; - expect(base.hidden).to.be.true; + expect(base.open).to.be.false; }); it('should not close when sd-request-close is prevented', async () => { diff --git a/packages/components/src/components/drawer/drawer.ts b/packages/components/src/components/drawer/drawer.ts index f018672198..65f4b48e52 100644 --- a/packages/components/src/components/drawer/drawer.ts +++ b/packages/components/src/components/drawer/drawer.ts @@ -4,16 +4,13 @@ import { animateTo, stopAnimations } from '../../internal/animate'; import { css, html } from 'lit'; import { customElement } from '../../internal/register-custom-element'; import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; -import { getDeepActiveElement } from '../../internal/deep-active-element'; import { HasSlotController } from '../../internal/slot'; import { LocalizeController } from '../../utilities/localize'; -import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll'; import { property, query } from 'lit/decorators.js'; import { uppercaseFirstLetter } from '../../internal/string'; import { waitForEvent } from '../../internal/event'; import { watch } from '../../internal/watch'; import cx from 'classix'; -import Modal from '../../internal/modal'; import SolidElement from '../../internal/solid-element'; /** @@ -40,7 +37,6 @@ import SolidElement from '../../internal/solid-element'; * destructive behavior such as data loss. * * @csspart base - The component's base wrapper. - * @csspart overlay - The overlay that covers the screen behind the drawer. * @csspart panel - The drawer's panel (where the drawer and its content are rendered). * @csspart header - The drawer's header. This element wraps the title and the close-button. * @csspart title - The drawer's title. @@ -53,6 +49,8 @@ import SolidElement from '../../internal/solid-element'; * @cssproperty --sd-panel-color-border - The border color of the drawer panel. * @cssproperty --sd-overlay-color-background - The background color of the drawer overlay. * + * @csspart overlay - The overlay that covers the screen behind the drawer. + * * @animation drawer.showEnd - The animation to use when showing a drawer with `end` placement. * @animation drawer.showStart - The animation to use when showing a drawer with `start` placement. * @animation drawer.hideEnd - The animation to use when hiding a drawer with `end` placement. @@ -65,12 +63,10 @@ import SolidElement from '../../internal/solid-element'; export default class SdDrawer extends SolidElement { private readonly hasSlotController = new HasSlotController(this, 'footer'); public localize = new LocalizeController(this); - private modal = new Modal(this); - private originalTrigger: HTMLElement | null; - @query('[part=base]') drawer: HTMLElement; - @query('[part=panel]') panel: HTMLElement; + @query('[part=base]') drawer: HTMLDialogElement; @query('[part=overlay]') overlay: HTMLElement; + @query('[part=panel]') panel: HTMLElement; @query('[part=close-button]') closeButton: HTMLElement; /** @@ -88,34 +84,22 @@ export default class SdDrawer extends SolidElement { /** The direction from which the drawer will open. */ @property({ type: String, reflect: true }) placement: 'end' | 'start' = 'end'; - /** - * By default, the drawer slides out of its containing block (the viewport). Contained is a hidden feature used only for testing purposes. Please do not use it in production as it will likely change. - */ - @property({ type: Boolean, reflect: true }) contained = false; - /** * Removes the header. This will also remove the default close button, so please ensure you provide an easy, accessible way for users to dismiss the drawer. */ @property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false; firstUpdated() { - this.drawer.hidden = !this.open; + this.drawer.addEventListener('cancel', (event: Event) => { + event.preventDefault(); + this.requestClose('keyboard'); + }); if (this.open) { - this.addOpenListeners(); - - if (!this.contained) { - this.modal.activate(); - lockBodyScrolling(this); - } + this.drawer.showModal(); } } - disconnectedCallback() { - super.disconnectedCallback(); - unlockBodyScrolling(this); - } - private requestClose(source: 'close-button' | 'keyboard' | 'overlay') { const slRequestClose = this.emit('sd-request-close', { cancelable: true, @@ -131,21 +115,6 @@ export default class SdDrawer extends SolidElement { this.hide(); } - private addOpenListeners() { - document.addEventListener('keydown', this.handleDocumentKeyDown); - } - - private removeOpenListeners() { - document.removeEventListener('keydown', this.handleDocumentKeyDown); - } - - private handleDocumentKeyDown = (event: KeyboardEvent) => { - if (this.open && event.key === 'Escape') { - event.stopPropagation(); - this.requestClose('keyboard'); - } - }; - @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { const closeButtonBase = this.closeButton.shadowRoot?.querySelector('[part="base"]'); @@ -153,16 +122,6 @@ export default class SdDrawer extends SolidElement { if (this.open) { // Show this.emit('sd-show'); - this.addOpenListeners(); - - // Check if the original trigger is inside the drawer - this.originalTrigger = getDeepActiveElement(); - - // Lock body scrolling only if the drawer isn't contained - if (!this.contained) { - this.modal.activate(); - lockBodyScrolling(this); - } // When the drawer is shown, Safari will attempt to set focus on whatever element has autofocus. This causes the // drawer's animation to jitter, so we'll temporarily remove the attribute, call `focus({ preventScroll: true })` @@ -175,18 +134,17 @@ export default class SdDrawer extends SolidElement { autoFocusTarget.removeAttribute('autofocus'); } - await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]); - this.drawer.hidden = false; + await stopAnimations(this.drawer); + this.drawer.showModal(); this.drawer.removeAttribute('inert'); + const overlayShowAnimation = getAnimation(this, 'drawer.overlay.show', { dir: this.localize.dir() }); + animateTo(this.overlay, overlayShowAnimation.keyframes, overlayShowAnimation.options); + const panelAnimation = getAnimation(this, `drawer.show${uppercaseFirstLetter(this.placement)}`, { dir: this.localize.dir() }); - const overlayAnimation = getAnimation(this, 'drawer.overlay.show', { dir: this.localize.dir() }); - await Promise.all([ - animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options), - animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options) - ]); + await animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options); //Update ARIA attributes to close button closeButtonBase?.setAttribute('aria-controls', 'drawer'); @@ -217,43 +175,18 @@ export default class SdDrawer extends SolidElement { } else { // Hide this.emit('sd-hide'); - this.removeOpenListeners(); - if (!this.contained) { - this.modal.deactivate(); - unlockBodyScrolling(this); - } + await stopAnimations(this.drawer); + const overlayHideAnimation = getAnimation(this, 'drawer.overlay.hide', { dir: this.localize.dir() }); + animateTo(this.overlay, overlayHideAnimation.keyframes, overlayHideAnimation.options); - await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]); const panelAnimation = getAnimation(this, `drawer.hide${uppercaseFirstLetter(this.placement)}`, { dir: this.localize.dir() }); - const overlayAnimation = getAnimation(this, 'drawer.overlay.hide', { dir: this.localize.dir() }); - - // Animate the overlay and the panel at the same time. Because animation durations might be different, we need to - // hide each one individually when the animation finishes, otherwise the first one that finishes will reappear - // unexpectedly. We'll unhide them after all animations have completed. - await Promise.all([ - animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options).then(() => { - this.overlay.hidden = true; - }), - animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options).then(() => { - this.panel.hidden = true; - }) - ]); - - this.drawer.hidden = true; - this.drawer.setAttribute('inert', ''); - - // Now that the dialog is hidden, restore the overlay and panel for next time - this.overlay.hidden = false; - this.panel.hidden = false; - // Restore focus to the original trigger - const trigger = this.originalTrigger; - if (typeof trigger?.focus === 'function') { - setTimeout(() => trigger.focus()); - } + await animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options); + this.drawer.close(); + this.drawer.setAttribute('inert', ''); //Add a11y attributes to close button closeButtonBase?.setAttribute('aria-expanded', 'false'); @@ -262,19 +195,6 @@ export default class SdDrawer extends SolidElement { } } - @watch('contained', { waitUntilFirstUpdate: true }) - handleNoModalChange() { - if (this.open && !this.contained) { - this.modal.activate(); - lockBodyScrolling(this); - } - - if (this.open && this.contained) { - this.modal.deactivate(); - unlockBodyScrolling(this); - } - } - /** Shows the drawer. */ async show() { if (this.open) { @@ -298,24 +218,13 @@ export default class SdDrawer extends SolidElement { render() { /* eslint-disable lit-a11y/click-events-have-key-events */ return html` -
-
this.requestClose('overlay')} - >
- +
this.requestClose('overlay')}>
-
+
`; /* eslint-enable lit-a11y/click-events-have-key-events */ } @@ -380,14 +287,6 @@ export default class SdDrawer extends SolidElement { @apply contents; } - :host([contained]) { - z-index: initial; - } - - :host(:not([contained])) { - @apply z-drawer; - } - [part='body'] { -webkit-overflow-scrolling: touch; @apply overflow-y-scroll; @@ -400,6 +299,14 @@ export default class SdDrawer extends SolidElement { :host [part='panel'] { outline: 1px solid rgb(var(--sd-panel-color-border, transparent)); } + + ::backdrop { + display: none; + } + + [part='overlay'] { + background-color: rgb(var(--sd-overlay-color-background, 5 21 48 / 0.9)); + } ` ]; } @@ -463,12 +370,12 @@ setDefaultAnimation('drawer.denyClose', { // Overlay setDefaultAnimation('drawer.overlay.show', { keyframes: [{ opacity: 0 }, { opacity: 1 }], - options: { duration: 'var(--sd-duration-medium, 300)', easing: 'ease-in-out' } + options: { duration: 'var(--sd-duration-medium, 300)', easing: 'linear' } }); setDefaultAnimation('drawer.overlay.hide', { keyframes: [{ opacity: 1 }, { opacity: 0 }], - options: { duration: 'var(--sd-duration-medium, 300)', easing: 'ease-in-out' } + options: { duration: 'var(--sd-duration-fast, 150)', easing: 'linear' } }); declare global { diff --git a/packages/components/src/internal/modal.ts b/packages/components/src/internal/modal.ts deleted file mode 100644 index c003735302..0000000000 --- a/packages/components/src/internal/modal.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { getTabbableBoundary } from './tabbable'; - -let activeModals: HTMLElement[] = []; - -export default class Modal { - element: HTMLElement; - tabDirection: 'forward' | 'backward' = 'forward'; - - constructor(element: HTMLElement) { - this.element = element; - this.handleFocusIn = this.handleFocusIn.bind(this); - this.handleKeyDown = this.handleKeyDown.bind(this); - this.handleKeyUp = this.handleKeyUp.bind(this); - } - - activate() { - activeModals.push(this.element); - document.addEventListener('focusin', this.handleFocusIn); - document.addEventListener('keydown', this.handleKeyDown); - document.addEventListener('keyup', this.handleKeyUp); - } - - deactivate() { - activeModals = activeModals.filter(modal => modal !== this.element); - document.removeEventListener('focusin', this.handleFocusIn); - document.removeEventListener('keydown', this.handleKeyDown); - document.removeEventListener('keyup', this.handleKeyUp); - } - - isActive() { - // The "active" modal is always the most recent one shown - return activeModals[activeModals.length - 1] === this.element; - } - - checkFocus() { - if (this.isActive()) { - if (!this.element.matches(':focus-within')) { - const { start, end } = getTabbableBoundary(this.element); - const target = this.tabDirection === 'forward' ? start : end; - - if (typeof target?.focus === 'function') { - target.focus({ preventScroll: true }); - } - } - } - } - - handleFocusIn() { - this.checkFocus(); - } - - handleKeyDown(event: KeyboardEvent) { - if (event.key === 'Tab' && event.shiftKey) { - this.tabDirection = 'backward'; - - // Ensure focus remains trapped after the key is pressed - requestAnimationFrame(() => this.checkFocus()); - } - } - - handleKeyUp() { - this.tabDirection = 'forward'; - } -} diff --git a/packages/docs/src/stories/components/dialog.stories.ts b/packages/docs/src/stories/components/dialog.stories.ts index 6e2f863f1f..7adefc9ceb 100644 --- a/packages/docs/src/stories/components/dialog.stories.ts +++ b/packages/docs/src/stories/components/dialog.stories.ts @@ -18,11 +18,6 @@ export default { component: 'sd-dialog', tags: ['!dev', 'autodocs'], args: overrideArgs([ - { - type: 'attribute', - name: 'open', - value: true - }, { type: 'slot', name: 'default', @@ -51,11 +46,17 @@ export default { export const Default = { render: (args: any) => { - return html`
+ return html` + Open Dialog ${generateTemplate({ args })} -
`; + + `; } }; @@ -65,6 +66,7 @@ export const Default = { export const Open = { name: 'Open', + parameters: { docs: { story: { inline: false, height: '500px' } } }, render: () => html`
Open Dialog @@ -94,6 +96,7 @@ export const Open = { export const Headline = { name: 'Headline', + parameters: { docs: { story: { inline: false, height: '500px' } } }, render: () => html`
Open Dialog @@ -124,6 +127,7 @@ export const Headline = { export const NoCloseButton = { name: 'No Close Button', + parameters: { docs: { story: { inline: false, height: '500px' } } }, render: () => html`
Open Dialog diff --git a/packages/docs/src/stories/components/drawer.stories.ts b/packages/docs/src/stories/components/drawer.stories.ts index 046535814a..65f782b9ab 100644 --- a/packages/docs/src/stories/components/drawer.stories.ts +++ b/packages/docs/src/stories/components/drawer.stories.ts @@ -25,15 +25,12 @@ export default { name: 'footer', value: `
Footer slot
` }, - { type: 'attribute', name: 'open', value: true }, - { type: 'attribute', name: 'contained', value: true }, { type: 'attribute', name: 'label', value: 'Label' }, { type: 'attribute', name: 'id', value: 'default-drawer' } ]), argTypes, parameters: { ...parameters, - controls: { exclude: ['contained'] }, design: { type: 'figma', url: 'https://www.figma.com/design/YDktJcseQIIQbsuCpoKS4V/Component-Docs?node-id=2223-8225&node-type=section&t=5PpAC3TA3kYF7ufX-0' @@ -74,6 +71,7 @@ export const Default = { */ export const Open = { name: 'Open', + parameters: { docs: { story: { inline: false, height: '600px' } } }, render: () => html` Open Drawer
@@ -105,6 +103,7 @@ export const Open = { */ export const Placement = { name: 'Placement', + parameters: { docs: { story: { inline: false, height: '600px' } } }, render: () => html` Open Drawer
@@ -130,6 +129,7 @@ export const Placement = { */ export const NoHeader = { name: 'No Header', + parameters: { docs: { story: { inline: false, height: '600px' } } }, render: () => html` Open Drawer
diff --git a/packages/docs/src/stories/components/drawer.test.stories.ts b/packages/docs/src/stories/components/drawer.test.stories.ts index f7fa7e4617..7270a37aea 100644 --- a/packages/docs/src/stories/components/drawer.test.stories.ts +++ b/packages/docs/src/stories/components/drawer.test.stories.ts @@ -31,7 +31,6 @@ export default { value: `
Footer slot
` }, { type: 'attribute', name: 'open', value: true }, - { type: 'attribute', name: 'contained', value: true }, { type: 'attribute', name: 'label', value: 'Label' } ]), argTypes,