Skip to content

Commit 4f77536

Browse files
committed
Implemented modals without dependencies
1 parent 0ebb3c0 commit 4f77536

6 files changed

Lines changed: 87 additions & 51 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
date: 2026-03-16
3+
status: Accepted
4+
---
5+
6+
# Modal dialogs implementation
7+
8+
## Context
9+
10+
We're using [micromodal](https://micromodal.vercel.app/) to manage modal
11+
dialogs. This adds a dependency, thus increasing to the bundle size.
12+
13+
## Considerations
14+
15+
- we're using about half of the actual code of the module, which is made to
16+
handle multiple modals, discover them on load, etc.
17+
- We have control over the contents of the various modals we're displaying, we
18+
don't need to handle various edge cases.
19+
20+
## Decision
21+
22+
We're removing micromodal and switching to a custom implementation.

e2e/orejime.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ test.describe('Orejime', () => {
126126
await orejimePage.closeDialogByClickingOutside();
127127
await expect(orejimePage.modal).toHaveCount(0);
128128
await expect(orejimePage.banner).toBeVisible();
129+
130+
// @toto test cancellation
129131
});
130132

131133
test('should close the modal via `Escape` key', async () => {

package-lock.json

Lines changed: 3 additions & 34 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,13 @@
5353
"@swc/jest": "^0.2.36",
5454
"@types/jest": "^27.5.0",
5555
"@types/js-cookie": "^3.0.2",
56-
"@types/micromodal": "^0.3.5",
5756
"@types/node": "^22.7.5",
5857
"clean-deep": "^3.4.0",
5958
"css-loader": "^0.28.11",
6059
"husky": "^9.1.7",
6160
"jest": "^28.1.3",
6261
"jest-environment-jsdom": "^28.1.0",
6362
"js-cookie": "^3.0.1",
64-
"micromodal": "^0.6.1",
6563
"postcss-loader": "^8.1.1",
6664
"postcss-preset-env": "^10.1.5",
6765
"preact": "^10.23.2",

src/ui/components/Dialog.tsx

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {useEffect, useId, useLayoutEffect, useState} from 'preact/hooks';
2-
import MicroModal from 'micromodal';
1+
import {useEffect, useLayoutEffect, useRef, useState} from 'preact/hooks';
2+
import {findFirstFocusableChild, findFocusableChildren} from '../utils/dom';
33

44
interface DialogProps {
55
isAlert?: boolean;
@@ -45,8 +45,42 @@ const Dialog = ({
4545
onRequestClose,
4646
children
4747
}: DialogProps) => {
48-
const id = useId();
4948
const [scrollPosition, setScrollPosition] = useState<number | null>(null);
49+
const portal = useRef<HTMLDivElement>();
50+
const activeElement = useRef<Element>();
51+
52+
const handleClick = (event: Event) => {
53+
if (event.target === event.currentTarget) {
54+
onRequestClose();
55+
}
56+
};
57+
58+
const handleFocusOut = (event: FocusEvent) => {
59+
const focusedElement = event.relatedTarget;
60+
61+
if (!(focusedElement instanceof HTMLElement)) {
62+
return;
63+
}
64+
65+
if (portal.current.contains(focusedElement)) {
66+
return;
67+
}
68+
69+
const focusable = findFocusableChildren(portal.current);
70+
const isFocusedElementAfterDialog =
71+
portal.current.compareDocumentPosition(focusedElement)
72+
& Node.DOCUMENT_POSITION_FOLLOWING;
73+
74+
const index = isFocusedElementAfterDialog ? 0 : focusable.length - 1;
75+
76+
focusable.item(index).focus();
77+
};
78+
79+
const handleKeyDown = (event: KeyboardEvent) => {
80+
if (event.key === 'Escape') {
81+
onRequestClose();
82+
}
83+
};
5084

5185
useLayoutEffect(() => {
5286
if (scrollPosition === null) {
@@ -68,29 +102,36 @@ const Dialog = ({
68102
});
69103

70104
useEffect(() => {
105+
activeElement.current = document.activeElement;
106+
71107
if (htmlClassName) {
72108
document.documentElement.classList.add(htmlClassName);
73109
}
74110

75-
MicroModal.show(id, {
76-
onClose: onRequestClose
77-
});
111+
findFirstFocusableChild(portal.current)?.focus();
78112

79113
return () => {
80-
MicroModal.close(id);
81-
82114
if (htmlClassName) {
83115
document.documentElement.classList.remove(htmlClassName);
84116
}
117+
118+
setTimeout(() => {
119+
if (activeElement.current instanceof HTMLElement) {
120+
activeElement.current.focus();
121+
}
122+
}, 0);
85123
};
86124
}, []);
87125

88126
return (
89-
<div className={portalClassName} id={id} aria-hidden="true">
127+
<div ref={portal} className={portalClassName}>
90128
<div
91129
className={overlayClassName}
92130
tabIndex={-1}
93-
data-micromodal-close={isAlert ? null : true}
131+
onMouseUp={isAlert ? null : handleClick}
132+
onTouchEnd={isAlert ? null : handleClick}
133+
onFocusOut={handleFocusOut}
134+
onKeyDown={handleKeyDown}
94135
>
95136
<div
96137
className={className}

src/ui/utils/dom.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type {ElementReference} from '../types';
22

3+
// A very minimal selector tailored for the kind of elements
4+
// used within the app.
5+
const FOCUSABLE_ELEMENTS_SELECTOR =
6+
'a[href], button:not([disabled]):not([aria-hidden]), [tabindex]:not([tabindex^="-"])';
7+
38
export const getElement = (
49
reference: ElementReference,
510
defaultElement?: HTMLElement
@@ -47,12 +52,11 @@ export const softFocus = (element?: HTMLElement) => {
4752
}
4853
};
4954

50-
// A very minimal implementation tailored for the kind of
51-
// elements used within the app.
55+
export const findFocusableChildren = (element: HTMLElement) =>
56+
element.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS_SELECTOR);
57+
5258
export const findFirstFocusableChild = (element: HTMLElement) =>
53-
element.querySelector<HTMLElement>(
54-
'a[href], button:not([disabled]):not([aria-hidden]), [tabindex]:not([tabindex^="-"])'
55-
);
59+
element.querySelector<HTMLElement | null>(FOCUSABLE_ELEMENTS_SELECTOR);
5660

5761
// Translates an element vertically by a given offset,
5862
// relatively to its current translation.

0 commit comments

Comments
 (0)