Skip to content
Open
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
22 changes: 22 additions & 0 deletions adr/004-modal-dialogs-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
date: 2026-03-16
status: Accepted
---

# Modal dialogs implementation

## Context

We're using [micromodal](https://micromodal.vercel.app/) to manage modal
dialogs. This adds a dependency, thus increasing to the bundle size.

## Considerations

- we're using about half of the actual code of the module, which is made to
handle multiple modals, discover them on load, etc.
- We have control over the contents of the various modals we're displaying, we
don't need to handle various edge cases.

## Decision

We're removing micromodal and switching to a custom implementation.
2 changes: 2 additions & 0 deletions e2e/orejime.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ test.describe('Orejime', () => {
await orejimePage.closeDialogByClickingOutside();
await expect(orejimePage.modal).toHaveCount(0);
await expect(orejimePage.banner).toBeVisible();

// @toto test cancellation
});

test('should close the modal via `Escape` key', async () => {
Expand Down
37 changes: 3 additions & 34 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,13 @@
"@swc/jest": "^0.2.36",
"@types/jest": "^27.5.0",
"@types/js-cookie": "^3.0.2",
"@types/micromodal": "^0.3.5",
"@types/node": "^22.7.5",
"clean-deep": "^3.4.0",
"css-loader": "^0.28.11",
"husky": "^9.1.7",
"jest": "^28.1.3",
"jest-environment-jsdom": "^28.1.0",
"js-cookie": "^3.0.1",
"micromodal": "^0.6.1",
"postcss-loader": "^8.1.1",
"postcss-preset-env": "^10.1.5",
"preact": "^10.23.2",
Expand Down
61 changes: 51 additions & 10 deletions src/ui/components/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {useEffect, useId, useLayoutEffect, useState} from 'preact/hooks';
import MicroModal from 'micromodal';
import {useEffect, useLayoutEffect, useRef, useState} from 'preact/hooks';
import {findFirstFocusableChild, findFocusableChildren} from '../utils/dom';

interface DialogProps {
isAlert?: boolean;
Expand Down Expand Up @@ -45,8 +45,42 @@ const Dialog = ({
onRequestClose,
children
}: DialogProps) => {
const id = useId();
const [scrollPosition, setScrollPosition] = useState<number | null>(null);
const portal = useRef<HTMLDivElement>();
const activeElement = useRef<Element>();

const handleClick = (event: Event) => {
if (event.target === event.currentTarget) {
onRequestClose();
}
};

const handleFocusOut = (event: FocusEvent) => {
const focusedElement = event.relatedTarget;

if (!(focusedElement instanceof HTMLElement)) {
return;
}

if (portal.current.contains(focusedElement)) {
return;
}

const focusable = findFocusableChildren(portal.current);
const isFocusedElementAfterDialog =
portal.current.compareDocumentPosition(focusedElement)
& Node.DOCUMENT_POSITION_FOLLOWING;

const index = isFocusedElementAfterDialog ? 0 : focusable.length - 1;

focusable.item(index).focus();
};

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onRequestClose();
}
};

useLayoutEffect(() => {
if (scrollPosition === null) {
Expand All @@ -68,29 +102,36 @@ const Dialog = ({
});

useEffect(() => {
activeElement.current = document.activeElement;

if (htmlClassName) {
document.documentElement.classList.add(htmlClassName);
}

MicroModal.show(id, {
onClose: onRequestClose
});
findFirstFocusableChild(portal.current)?.focus();

return () => {
MicroModal.close(id);

if (htmlClassName) {
document.documentElement.classList.remove(htmlClassName);
}

setTimeout(() => {
if (activeElement.current instanceof HTMLElement) {
activeElement.current.focus();
}
}, 0);
};
}, []);

return (
<div className={portalClassName} id={id} aria-hidden="true">
<div ref={portal} className={portalClassName}>
<div
className={overlayClassName}
tabIndex={-1}
data-micromodal-close={isAlert ? null : true}
onMouseUp={isAlert ? null : handleClick}
onTouchEnd={isAlert ? null : handleClick}
onFocusOut={handleFocusOut}
onKeyDown={handleKeyDown}
>
<div
className={className}
Expand Down
14 changes: 9 additions & 5 deletions src/ui/utils/dom.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type {ElementReference} from '../types';

// A very minimal selector tailored for the kind of elements
// used within the app.
const FOCUSABLE_ELEMENTS_SELECTOR =
'a[href], button:not([disabled]):not([aria-hidden]), [tabindex]:not([tabindex^="-"])';

export const getElement = (
reference: ElementReference,
defaultElement?: HTMLElement
Expand Down Expand Up @@ -47,12 +52,11 @@ export const softFocus = (element?: HTMLElement) => {
}
};

// A very minimal implementation tailored for the kind of
// elements used within the app.
export const findFocusableChildren = (element: HTMLElement) =>
element.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS_SELECTOR);

export const findFirstFocusableChild = (element: HTMLElement) =>
element.querySelector<HTMLElement>(
'a[href], button:not([disabled]):not([aria-hidden]), [tabindex]:not([tabindex^="-"])'
);
element.querySelector<HTMLElement | null>(FOCUSABLE_ELEMENTS_SELECTOR);

// Translates an element vertically by a given offset,
// relatively to its current translation.
Expand Down
Loading