Skip to content
Draft
Show file tree
Hide file tree
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
13 changes: 13 additions & 0 deletions .changeset/native-dialog-refactor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@solid-design-system/components': minor
---

Replaced custom modal/overlay implementation in `sd-dialog` and `sd-drawer` with the browser-native `<dialog>` element and its `showModal()`/`close()` APIs.

- Both components now use `<dialog>` 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 `<dialog>` 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 `<div>` inside the `<dialog>` 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
24 changes: 12 additions & 12 deletions packages/components/src/components/dialog/dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ describe('<sd-dialog>', () => {
<sd-dialog open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sd-dialog>
`);

const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLDialogElement>('[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<SdDialog>(html`
<sd-dialog>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sd-dialog>
`);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLDialogElement>('[part~="base"]')!;

expect(base.hidden).to.be.true;
expect(base.open).to.be.false;
});

it('should include a close button by default', async () => {
Expand All @@ -47,7 +47,7 @@ describe('<sd-dialog>', () => {
const el = await fixture<SdDialog>(html`
<sd-dialog>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sd-dialog>
`);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLDialogElement>('[part~="base"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();

Expand All @@ -60,14 +60,14 @@ describe('<sd-dialog>', () => {

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<SdDialog>(html`
<sd-dialog open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sd-dialog>
`);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLDialogElement>('[part~="base"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();

Expand All @@ -80,14 +80,14 @@ describe('<sd-dialog>', () => {

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<SdDialog>(html`
<sd-dialog>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sd-dialog>
`);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLDialogElement>('[part~="base"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();

Expand All @@ -100,14 +100,14 @@ describe('<sd-dialog>', () => {

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<SdDialog>(html`
<sd-dialog open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sd-dialog>
`);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLDialogElement>('[part~="base"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();

Expand All @@ -120,7 +120,7 @@ describe('<sd-dialog>', () => {

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 () => {
Expand Down
140 changes: 39 additions & 101 deletions packages/components/src/components/dialog/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand All @@ -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
Expand All @@ -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(() => {
Expand All @@ -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');
}
Expand Down Expand Up @@ -268,29 +202,20 @@ export default class SdDialog extends SolidElement {
render() {
/* eslint-disable lit-a11y/click-events-have-key-events */
return html`
<div
<dialog
part="base"
class=${cx(
'flex items-center justify-center fixed inset-0 z-dialog',
'flex items-center justify-center p-0 m-auto bg-transparent border-none overflow-visible',
this.hasSlotController.test('footer') && 'dialog--has-footer'
)}
>
<div
part="overlay"
class="fixed inset-0 overlay-color-background"
@click=${() => this.requestClose('overlay')}
tabindex="-1"
></div>

<div part="overlay" class="fixed inset-0" @click=${() => this.requestClose('overlay')}></div>
<div
part="panel"
class=${cx(
'panel-color-border border flex flex-col z-20 bg-white py-4 sm:py-8 relative gap-6 focus-visible:focus-outline-inverted overflow-y-auto',
this.open && 'flex opacity-100'
)}
role="dialog"
aria-modal="true"
aria-hidden=${this.open ? 'false' : 'true'}
aria-label=${ifDefined(this.headline ? this.headline : undefined)}
aria-labelledby=${ifDefined(!this.headline ? 'title' : undefined)}
tabindex="0"
Expand Down Expand Up @@ -331,7 +256,7 @@ export default class SdDialog extends SolidElement {
<slot name="footer"></slot>
</footer>
</div>
</div>
</dialog>
`;
/* eslint-enable lit-a11y/click-events-have-key-events */
}
Expand All @@ -341,6 +266,11 @@ export default class SdDialog extends SolidElement {
css`
:host {
--width: 662px;
@apply contents;
}

:host(:not([open])) {
display: none;
}

[part='panel'] {
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading