Skip to content
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

Use native dialog #38

Merged
merged 8 commits into from
Feb 6, 2024
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 5.0.0

- Use native `dialog`

## 4.5.4

- Convert to `.gjs`
Expand Down
29 changes: 7 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,30 +32,27 @@ https://zestia.github.io/ember-modal-dialog

## Features

- Uses native `dialog` ✔︎
- Focus trap ✔︎
- Body scroll lock ✔︎
- Loading state handling ✔︎
- Optionally escapable ✔︎
- Exceeds viewport detection ✔︎
- Animatable (includes test waiters) ✔︎
- Animatable (remains in the DOM until animated out) ✔︎
- Simple API ✔︎

## Notes

- This addon intentionally does not come with any styles.

- It is configured with [ember-test-waiters](https://github.com/emberjs/ember-test-waiters) so `await`ing in your test suite will just work.
- Does not use native `dialog` _yet_, because:
- Can't animate `::backdrop`
- Can't use `::backdrop` with CSS variables
- Does not provide a focus trap
- Does not provide a scroll lock

## Example
- Animating a modal dialog out is [not possible](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#dialog_keyframe_animations#css_3). Although you can achieve it with transitions. (see demo)

The modal dialog component isn't designed to be used on its own, but rather used to compose a new modal dialog component... in this example it's called "my-modal"
- Native body scroll lock only works on the body element (see demo)

## Example

```handlebars
{{! my-modal.hbs }}
<ModalDialog @onClose={{@onClose}} as |modal|>
Content

Expand Down Expand Up @@ -96,10 +93,6 @@ Optional. Fired when the request to load data fails. Receives the error as a par

Required. This action fires when `close` has been called, _and_ any animations have run to hide the modal dialog.

#### `@onEscape`

Optional. Fired when escape is pressed or the user clicks outside the dialog box. You can use the API to call `close` for example.

### API

#### `close`
Expand All @@ -109,11 +102,3 @@ Call this when you want to close the modal. It will first wait for any animation
#### `isLoading`

Whether the data required for the modal dialog to display is loading.

#### `element`

The DOM element of the modal dialog component.

#### `boxElement`

The inner DOM element of the modal dialog component, that contains the content.
276 changes: 52 additions & 224 deletions addon/components/modal-dialog.gjs
Original file line number Diff line number Diff line change
@@ -1,218 +1,47 @@
import Component from '@glimmer/component';
import { modifier } from 'ember-modifier';
import { tracked } from '@glimmer/tracking';
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import { tracked } from '@glimmer/tracking';
import { waitFor } from '@ember/test-waiters';
import { waitForAnimation } from '@zestia/animation-utils';
import { on } from '@ember/modifier';
import { modifier } from 'ember-modifier';

export default class ModalDialogComponent extends Component {
@tracked isInViewport;
@tracked isLoading = this.shouldLoad;
@tracked isShowing = true;
@tracked element;
@tracked isLoading = true;
@tracked isWarning;

element;
boxElement;
lastMouseDownElement;
modal = modifier(
(element) => {
this.element = element;
this.element.showModal();
},
{ eager: false }
);

constructor() {
super(...arguments);
this.args.onReady?.(this.api);

if (this.shouldLoad) {
this._load();
}
}

get shouldLoad() {
return typeof this.args.onLoad === 'function';
}

get containsModal() {
return !!this.boxElement.querySelector('.modal-dialog');
this._load();
}

@action
async close() {
this.isShowing = false;

await this._waitForAnimation();
handleKeyDown(event) {
if (event.key !== 'Escape') {
return;
}

this.args.onClose?.();
this._handleEscape(event);
}

@action
handleMouseDown(event) {
this.lastMouseDownElement = event.target;
}

@action
handleMouseUp(event) {
if (this.lastMouseDownElement === this.element) {
this._escape(event);
}
}

registerElement = modifier(
(element) => {
this.element = element;
},
{ eager: false }
);

registerBoxElement = modifier(
(element) => {
this.boxElement = element;
},
{ eager: false }
);

bodyScrollLock = modifier(
(element) => {
disableBodyScroll(element, {
reserveScrollBarGap: true,
allowTouchMove: (element) => {
while (this.boxElement.contains(element)) {
if (element.scrollHeight > element.clientHeight) {
return true;
}

element = element.parentElement;
}
}
});

return () => enableBodyScroll(element);
},
{ eager: false }
);

inViewport = modifier(
(element) => {
const handler = () => {
const rect = element.getBoundingClientRect();

this.isInViewport =
rect.top > 0 &&
rect.left > 0 &&
rect.bottom < window.innerHeight &&
rect.right < window.innerWidth;
};

const observer = new MutationObserver(handler);

observer.observe(element, {
childList: true,
subtree: true
});

window.addEventListener('resize', handler);

handler();

return () => {
observer.disconnect();
window.removeEventListener('resize', handler);
};
},
{ eager: false }
);

trapFocus = modifier(
(element) => {
const handler = (event) => {
if (this.containsModal || event.key !== 'Tab') {
return;
}

const focusable = element.querySelectorAll(`
a[href],
button:not(:disabled),
textarea:not(:disabled),
input:not(:disabled),
select:not(:disabled),
[tabindex="0"],
[contenteditable="true"]
`);

const first = focusable.item(0);
const last = focusable.item(focusable.length - 1);
const focused = document.activeElement;

if (event.shiftKey && focused === first) {
last.focus();
event.preventDefault();
} else if (!event.shiftKey && focused === last) {
first.focus();
event.preventDefault();
}
};

element.addEventListener('keydown', handler);

return () => element.removeEventListener('keydown', handler);
},
{ eager: false }
);

internalFocus = modifier(
() => {
let last;

const focused = () => last?.focus();
const blurred = () => (last = document.activeElement);

window.addEventListener('focus', focused);
window.addEventListener('blur', blurred);

return () => {
window.removeEventListener('focus', focused);
window.removeEventListener('blur', blurred);
};
},
{ eager: false }
);

externalFocus = modifier(
() => {
const last = document.activeElement;

return () => {
try {
last.focus();
} catch (error) {
// Squelch
}
};
},
{ eager: false }
);

escapable = modifier(
() => {
const handler = (event) => {
if (this.containsModal || event.key !== 'Escape') {
return;
}

this._escape(event);
};

window.addEventListener('keydown', handler);

return () => window.removeEventListener('keydown', handler);
},
{ eager: false }
);

_escape(event) {
this.args.onEscape?.(this.api, event);
close() {
return this._close();
}

async _load() {
try {
const data = await this.args.onLoad();
const data = await this.args.onLoad?.();
this.args.onLoaded?.(data);
} catch (error) {
this.args.onLoadError?.(error);
Expand All @@ -221,20 +50,34 @@ export default class ModalDialogComponent extends Component {
}
}

_handleEscape(event) {
event.preventDefault();

if (this.args.escapable) {
this._close();
} else {
this._warn();
}
}

@waitFor
async _close() {
this.element.close();
await waitForAnimation(this.element, { maybe: true });
this.args.onClose();
}

@waitFor
_waitForAnimation() {
return Promise.all([
waitForAnimation(this.element, { maybe: true }),
waitForAnimation(this.boxElement, { maybe: true })
]);
async _warn() {
this.isWarning = true;
await waitForAnimation(this.element);
this.isWarning = false;
}

get _api() {
return {
close: this.close,
isLoading: this.isLoading,
element: this.element,
boxElement: this.boxElement
isLoading: this.isLoading
};
}

Expand All @@ -246,31 +89,16 @@ export default class ModalDialogComponent extends Component {
});

<template>
{{! template-lint-disable no-pointer-down-event-binding no-invalid-interactive }}
<div
{{! template-lint-disable no-invalid-interactive }}
<dialog
class="modal-dialog"
data-showing="{{this.isShowing}}"
{{on "mousedown" this.handleMouseDown}}
{{on "mouseup" this.handleMouseUp}}
{{this.registerElement}}
aria-busy="{{this.isLoading}}"
data-warning={{this.isWarning}}
{{this.modal}}
{{on "keydown" this.handleKeyDown}}
...attributes
>
<div
class="modal-dialog__box"
role="dialog"
aria-modal="true"
aria-busy="{{this.isLoading}}"
data-in-viewport="{{this.isInViewport}}"
{{this.registerBoxElement}}
{{this.bodyScrollLock}}
{{this.inViewport}}
{{this.trapFocus}}
{{this.externalFocus}}
{{this.internalFocus}}
{{this.escapable}}
>
{{yield this.api}}
</div>
</div>
{{yield this.api}}
</dialog>
</template>
}
Loading
Loading