diff --git a/adr/004-modal-dialogs-implementation.md b/adr/004-modal-dialogs-implementation.md new file mode 100644 index 00000000..50ead067 --- /dev/null +++ b/adr/004-modal-dialogs-implementation.md @@ -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. diff --git a/e2e/orejime.spec.ts b/e2e/orejime.spec.ts index 6394ce6d..bc56520d 100644 --- a/e2e/orejime.spec.ts +++ b/e2e/orejime.spec.ts @@ -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 () => { diff --git a/package-lock.json b/package-lock.json index c28099a2..96120fe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "@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", @@ -25,7 +24,6 @@ "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", @@ -80,7 +78,6 @@ "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -732,7 +729,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -756,7 +752,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -854,7 +849,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -1229,7 +1223,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3831,7 +3824,6 @@ "integrity": "sha512-/R3JenF5wJSj3DPxiewyIPGzuZV336XpRORjUAOvbHPK6zea8Eeqcx6RopWM6TMikRYdZOHThKV99tyi4QLsMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@module-federation/runtime-tools": "0.5.1", "@rspack/binding": "1.0.4", @@ -3991,7 +3983,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.12" @@ -4207,7 +4198,6 @@ "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -4474,13 +4464,6 @@ "@types/unist": "*" } }, - "node_modules/@types/micromodal": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@types/micromodal/-/micromodal-0.3.5.tgz", - "integrity": "sha512-xDref7Vyx0nhfJWpeEkVrSb5l1GuHIyxfePxuHSTP3eW587Qe3hzKcBy0V+1Wjuyh21UhJH46eP43czH2ZRpGw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4753,7 +4736,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5441,7 +5423,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -6245,7 +6226,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -11893,16 +11873,6 @@ "node": ">=8.6" } }, - "node_modules/micromodal": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/micromodal/-/micromodal-0.6.1.tgz", - "integrity": "sha512-rw1fOptxQe3XGDm9xil9hBC2ylPb1kKZyYv4FlK54R/7L+KG+D8SQxPL5L8OlEXAhBDHckeoULYxWSYU9rg/RA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -12029,6 +11999,7 @@ } ], "license": "MIT", + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -14812,7 +14783,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -16840,6 +16810,7 @@ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -17457,8 +17428,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/type-detect": { "version": "4.0.8", @@ -17503,7 +17473,6 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index bc101d66..975ebea0 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "@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", @@ -61,7 +60,6 @@ "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", diff --git a/src/ui/components/Dialog.tsx b/src/ui/components/Dialog.tsx index 213b34b1..5e503776 100644 --- a/src/ui/components/Dialog.tsx +++ b/src/ui/components/Dialog.tsx @@ -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; @@ -45,8 +45,42 @@ const Dialog = ({ onRequestClose, children }: DialogProps) => { - const id = useId(); const [scrollPosition, setScrollPosition] = useState(null); + const portal = useRef(); + const activeElement = useRef(); + + 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) { @@ -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 ( -