Skip to content

Commit 4e98cab

Browse files
committed
Refactor modal to use <dialog>
1 parent 89f94f7 commit 4e98cab

File tree

2 files changed

+117
-116
lines changed

2 files changed

+117
-116
lines changed

src/client/lazy-app/Modal/index.tsx

Lines changed: 71 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -31,118 +31,107 @@ export default class Modal extends Component<Props, State> {
3131
shown: false,
3232
};
3333

34-
private modal?: HTMLElement;
35-
private returnFocusElement?: HTMLElement | null;
34+
private modal?: HTMLDialogElement;
35+
36+
componentDidMount() {
37+
// Once a transition ends, check if the modal should be closed (not just hidden)
38+
// dialog.close() instantly hides the modal, so we call it AFTER fading it out i.e. on transition end
39+
this.modal?.addEventListener(
40+
'transitionend',
41+
this._closeOnTransitionEnd.bind(this),
42+
);
43+
this.modal?.setAttribute('inert', 'enabled');
44+
}
3645

46+
private _closeOnTransitionEnd() {
47+
// If modal does not exist
48+
// Or if it's not being closed at the moment
49+
if (!this.modal || !this.modal.classList.contains(style.modalClosing))
50+
return;
51+
52+
this.modal.close();
53+
this.modal.classList.remove(style.modalClosing);
54+
this.modal.setAttribute('inert', 'enabled');
55+
}
56+
57+
/**
58+
* Function to set up the modal and show it
59+
*/
3760
showModal(message: ModalMessage) {
38-
if (this.state.shown) return;
3961
if (!this.modal) return;
4062

41-
// Set element to return focus to after hiding
42-
this.returnFocusElement = document.activeElement as HTMLElement;
43-
44-
this.modal.style.display = '';
4563
this.setState({
4664
message: message,
4765
shown: true,
4866
});
49-
// Wait for the 'display' reset to take place, then focus
50-
setTimeout(() => {
51-
this.modal?.querySelector('button')?.focus();
52-
}, 0);
67+
68+
// Actually show the modal
69+
this.modal.removeAttribute('inert');
70+
this.modal.showModal();
5371
}
5472

73+
/**
74+
* Function to hide the modal with a fade-out transition
75+
* Adds the `modal--closing` class which is removed on transition end
76+
*/
5577
hideModal() {
78+
if (!this.modal || !this.modal.open) return;
79+
80+
// Make the modal fade out
81+
this.modal.classList.add(style.modalClosing);
82+
5683
this.setState({
5784
message: { ...this.state.message },
5885
shown: false,
5986
});
60-
setTimeout(() => {
61-
this.modal && (this.modal.style.display = 'none');
62-
this.returnFocusElement?.focus();
63-
}, 250);
64-
}
65-
66-
private _getCloseButton() {
67-
return this.modal!.querySelector('button')!;
68-
}
69-
70-
private _getLastFocusable() {
71-
const focusables = this.modal!.querySelectorAll('button, a');
72-
return focusables[focusables.length - 1] as HTMLElement;
7387
}
7488

7589
private _onKeyDown(e: KeyboardEvent) {
76-
// If Escape, hide modal
90+
// Default behaviour of <dialog> closes it instantly when you press Esc
91+
// So we hijack it to smoothly hide the modal
7792
if (e.key === 'Escape' || e.keyCode == 27) {
7893
this.hideModal();
7994
e.preventDefault();
8095
e.stopImmediatePropagation();
81-
return;
82-
}
83-
84-
let isTabPressed = e.key === 'Tab' || e.keyCode === 9;
85-
86-
if (!isTabPressed) return;
87-
88-
if (e.shiftKey) {
89-
// If SHIFT + TAB was pressed on the first focusable element
90-
// Move focus to the last focusable element
91-
if (document.activeElement === this._getCloseButton()) {
92-
this._getLastFocusable().focus();
93-
e.preventDefault();
94-
e.stopImmediatePropagation();
95-
}
96-
} else {
97-
// If TAB was pressed on the last focusable element
98-
// Move focus to the first focusable element
99-
if (document.activeElement === this._getLastFocusable()) {
100-
this._getCloseButton().focus();
101-
e.preventDefault();
102-
e.stopImmediatePropagation();
103-
}
10496
}
10597
}
10698

10799
render({}: Props, { message, shown }: State) {
108100
return (
109-
<div
110-
class={`${style.modalOverlay} ${shown && style.modalShown}`}
101+
<dialog
102+
ref={linkRef(this, 'modal')}
111103
onKeyDown={(e) => this._onKeyDown(e)}
112-
tabIndex={shown ? 0 : -1}
113104
>
114-
<div class={style.modal} ref={linkRef(this, 'modal')}>
115-
<header class={style.header}>
116-
<span class={style.modalTypeIcon}>
117-
{message.type === 'info' ? (
118-
<InfoIcon />
119-
) : message.type === 'error' ? (
120-
<ModalErrorIcon />
121-
) : (
122-
<DiamondStarIcon />
123-
)}
124-
</span>
125-
<span class={style.modalTitle}>{message.title}</span>
126-
<button class={style.closeButton} onClick={() => this.hideModal()}>
127-
<svg viewBox="0 0 480 480" fill="currentColor">
128-
<path
129-
d="M119.356 120L361 361M360.644 120L119 361"
130-
stroke="#fff"
131-
stroke-width="37"
132-
stroke-linecap="round"
133-
/>
134-
</svg>
135-
</button>
136-
</header>
137-
<div class={style.contentContainer}>
138-
<article
139-
class={style.content}
140-
dangerouslySetInnerHTML={{ __html: message.content }}
141-
></article>
142-
</div>
143-
<footer class={style.footer}></footer>
105+
<header class={style.header}>
106+
<span class={style.modalTypeIcon}>
107+
{message.type === 'info' ? (
108+
<InfoIcon />
109+
) : message.type === 'error' ? (
110+
<ModalErrorIcon />
111+
) : (
112+
<DiamondStarIcon />
113+
)}
114+
</span>
115+
<span class={style.modalTitle}>{message.title}</span>
116+
<button class={style.closeButton} onClick={() => this.hideModal()}>
117+
<svg viewBox="0 0 480 480" fill="currentColor">
118+
<path
119+
d="M119.356 120L361 361M360.644 120L119 361"
120+
stroke="#fff"
121+
stroke-width="37"
122+
stroke-linecap="round"
123+
/>
124+
</svg>
125+
</button>
126+
</header>
127+
<div class={style.contentContainer}>
128+
<article
129+
class={style.content}
130+
dangerouslySetInnerHTML={{ __html: message.content }}
131+
></article>
144132
</div>
145-
</div>
133+
<footer class={style.footer}></footer>
134+
</dialog>
146135
);
147136
}
148137
}

src/client/lazy-app/Modal/style.css

Lines changed: 46 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,5 @@
1-
.modal-overlay {
2-
position: absolute;
3-
top: 0;
4-
left: 0;
5-
width: 100%;
6-
height: 100%;
7-
8-
display: flex;
9-
justify-content: center;
10-
align-items: center;
11-
12-
backdrop-filter: blur(5px);
13-
-webkit-backdrop-filter: blur(5px);
14-
z-index: 1;
15-
16-
user-select: none;
17-
pointer-events: none;
18-
opacity: 0;
19-
20-
transition: opacity 250ms ease;
21-
}
22-
23-
.modal-shown {
24-
user-select: auto;
25-
pointer-events: auto;
26-
27-
opacity: 1;
28-
}
29-
30-
.modal {
1+
dialog {
2+
padding: 0;
313
width: 70vw;
324
max-width: 60ch;
335
max-height: 70vh;
@@ -38,24 +10,63 @@
3810
grid-template-rows: auto 1fr auto;
3911

4012
font-size: 1.5em;
13+
color: var(--light-gray);
4114

15+
border: none;
4216
border-radius: 10px;
4317
overflow: hidden;
4418

4519
background-color: var(--off-black);
46-
color: var(--light-gray);
20+
21+
opacity: 0;
22+
transform: translateY(50px);
23+
transition: 250ms ease;
24+
25+
/* backdrop can't be transitioned easily, so we must */
26+
27+
&::backdrop {
28+
background-color: rgba(0, 0, 0, 30%);
29+
}
30+
31+
&[open]::backdrop {
32+
animation: backdrop-fade-in 250ms linear;
33+
34+
@keyframes backdrop-fade-in {
35+
from {
36+
opacity: 0;
37+
}
38+
to {
39+
opacity: 1;
40+
}
41+
}
42+
}
43+
44+
&.modal--closing::backdrop {
45+
transition: opacity 250ms linear;
46+
opacity: 0;
47+
}
48+
}
49+
50+
dialog[open] {
51+
opacity: 1;
52+
transform: translateY(0px);
53+
}
54+
55+
dialog.modal--closing {
56+
opacity: 0;
57+
transform: translateY(50px);
4758
}
4859

4960
@media (max-width: 720px) {
50-
.modal {
61+
dialog {
5162
width: 90%;
5263
max-width: none;
5364
font-size: 1.25em;
5465
}
5566
}
5667

5768
@media (max-width: 480px) {
58-
.modal {
69+
dialog {
5970
font-size: 1em;
6071
}
6172
}
@@ -74,9 +85,10 @@
7485
align-items: center;
7586
gap: 0.5em;
7687
border-bottom: 1px solid rgba(0, 0, 0, 8%);
88+
align-content: stretch;
7789

90+
/* Apply font size only to children so that padding is unaffected */
7891
& > * {
79-
height: 100%;
8092
font-size: 1.5em;
8193
}
8294
}

0 commit comments

Comments
 (0)