From 344f4bc98ca7543b4a16a97ca267885ee3e24b1b Mon Sep 17 00:00:00 2001 From: Lucas Bigot Date: Fri, 2 Aug 2024 12:41:22 -0400 Subject: [PATCH] Add default Modal component --- assets/scripts/config.js | 2 + assets/scripts/modules.js | 1 + assets/scripts/modules/Load.js | 7 + assets/scripts/modules/Modal.js | 195 +++++++++++++++++++++++++++ assets/styles/components/_modal.scss | 52 +++++++ assets/styles/main.scss | 1 + package-lock.json | 36 ++++- package.json | 1 + www/index.html | 13 ++ 9 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 assets/scripts/modules/Modal.js create mode 100644 assets/styles/components/_modal.scss diff --git a/assets/scripts/config.js b/assets/scripts/config.js index 9e1f911a..07c7d3c7 100644 --- a/assets/scripts/config.js +++ b/assets/scripts/config.js @@ -46,6 +46,8 @@ const CSS_CLASS = Object.freeze({ // Custom js events const CUSTOM_EVENT = Object.freeze({ RESIZE_END: 'loco.resizeEnd', + VISIT_START: 'visit.start', + MODAL_OPEN: 'modal.open', // ... }) diff --git a/assets/scripts/modules.js b/assets/scripts/modules.js index c3d43730..6d2ba398 100644 --- a/assets/scripts/modules.js +++ b/assets/scripts/modules.js @@ -1,3 +1,4 @@ export {default as Example} from './modules/Example'; export {default as Load} from './modules/Load'; +export {default as Modal} from './modules/Modal'; export {default as Scroll} from './modules/Scroll'; diff --git a/assets/scripts/modules/Load.js b/assets/scripts/modules/Load.js index 8ff708ef..55f10c66 100644 --- a/assets/scripts/modules/Load.js +++ b/assets/scripts/modules/Load.js @@ -1,5 +1,6 @@ import { module } from 'modujs'; import modularLoad from 'modularload'; +import { CUSTOM_EVENT } from '../config'; export default class extends module { constructor(m) { @@ -14,6 +15,12 @@ export default class extends module { } }); + load.on('loading', (transition, oldContainer) => { + const args = { transition, oldContainer }; + // Dispatch custom event + window.dispatchEvent(new CustomEvent(CUSTOM_EVENT.VISIT_START, { detail: args })) + }); + load.on('loaded', (transition, oldContainer, newContainer) => { this.call('destroy', oldContainer, 'app'); this.call('update', newContainer, 'app'); diff --git a/assets/scripts/modules/Modal.js b/assets/scripts/modules/Modal.js new file mode 100644 index 00000000..f45281b1 --- /dev/null +++ b/assets/scripts/modules/Modal.js @@ -0,0 +1,195 @@ +import { createFocusTrap } from 'focus-trap' +import { module as Module } from 'modujs' +import { $html } from '../utils/dom' +import { CUSTOM_EVENT } from '../config' + +/** + * Generic component to display a modal. + * + */ +export default class Modal extends Module { + /** + * Creates a new Modal. + * + * @param {object} options - The module options. + * @param {string} options.dataName - The module data attribute name. + * @throws {TypeError} If the class does not have an active CSS class defined. + */ + + static CLASS = { + EL: 'is-open', + HTML: 'has-modal-open', + } + + constructor(options) { + super(options) + + // Data + this.moduleName = options.name + this.dataName = this.getData('name') || options.dataName + + // Bindings + this.toggle = this.toggle.bind(this) + this.onModalOpen = this.onModalOpen.bind(this) + this.onVisitStart = this.onVisitStart.bind(this) + + // UI + this.$togglers = document.querySelectorAll(`[data-${this.dataName}-toggler]`) + this.$focusTrapTargets = Array.from(this.el.querySelectorAll(`[data-${this.dataName}-target]`)) + + // Focus trap options + this.focusTrapOptions = { + /** + * There is a delay between when the class is applied + * and when the element is focusable + */ + checkCanFocusTrap: (trapContainers) => { + const results = trapContainers.map((trapContainer) => { + return new Promise((resolve) => { + const interval = setInterval(() => { + if ( + getComputedStyle(trapContainer).visibility !== + 'hidden' + ) { + resolve() + clearInterval(interval) + } + }, 5) + }) + }) + + // Return a promise that resolves when all the trap containers are able to receive focus + return Promise.all(results) + }, + + onActivate: () => { + this.el.classList.add(Modal.CLASS.EL) + $html.classList.add(Modal.CLASS.HTML) + $html.classList.add('has-'+this.dataName+'-open') + this.el.setAttribute('aria-hidden', false) + this.isOpen = true + + this.onActivate?.(); + }, + + onPostActivate: () => { + this.$togglers.forEach(($toggler) => { + $toggler.setAttribute('aria-expanded', true) + }) + }, + + onDeactivate: () => { + this.el.classList.remove(Modal.CLASS.EL) + $html.classList.remove(Modal.CLASS.HTML) + $html.classList.remove('has-'+this.dataName+'-open') + this.el.setAttribute('aria-hidden', true) + this.isOpen = false + + this.onDeactivate?.(); + }, + + onPostDeactivate: () => { + this.$togglers.forEach(($toggler) => { + $toggler.setAttribute('aria-expanded', false) + }) + }, + + clickOutsideDeactivates: true, + } + + this.isOpen = false + } + + ///////////////// + // Lifecycle + ///////////////// + init() { + this.onBeforeInit?.() + + this.focusTrap = createFocusTrap( + this.$focusTrapTargets.length > 0 ? this.$focusTrapTargets : [this.el], + this.focusTrapOptions + ) + + this.bindEvents() + + this.onInit?.() + } + + destroy() { + this.focusTrap?.deactivate?.({ + returnFocus: false, + }) + + this.unbindEvents() + + this.onDestroy?.() + + super.destroy() + } + + ///////////////// + // Events + ///////////////// + bindEvents() { + window.addEventListener(CUSTOM_EVENT.VISIT_START, this.onVisitStart) + window.addEventListener(CUSTOM_EVENT.MODAL_OPEN, this.onModalOpen) + + this.$togglers.forEach(($toggler) => { + $toggler.addEventListener('click', this.toggle) + }) + } + + unbindEvents() { + window.removeEventListener(CUSTOM_EVENT.VISIT_START, this.onVisitStart) + window.removeEventListener(CUSTOM_EVENT.MODAL_OPEN, this.onModalOpen) + + this.$togglers.forEach(($toggler) => { + $toggler.removeEventListener('click', this.toggle) + }) + } + + ///////////////// + // Callbacks + ///////////////// + onVisitStart() { + // Close the modal on page change + this.close() + } + + onModalOpen(event) { + // Close the modal if another one is opened + if (event.detail !== this.el) { + this.close() + } + } + + ///////////////// + // Methods + ///////////////// + toggle(event) { + if (this.el.classList.contains(Modal.CLASS.EL)) { + this.close(event) + } else { + this.open(event) + } + } + + open(args) { + if (this.isOpen) return + + this.focusTrap?.activate?.() + + this.onOpen?.(args) + + window.dispatchEvent(new CustomEvent(CUSTOM_EVENT.MODAL_OPEN, { detail: this.el })) + } + + close(args) { + if (!this.isOpen) return + + this.focusTrap?.deactivate?.() + + this.onClose?.(args) + } +} diff --git a/assets/styles/components/_modal.scss b/assets/styles/components/_modal.scss new file mode 100644 index 00000000..5aaf426b --- /dev/null +++ b/assets/styles/components/_modal.scss @@ -0,0 +1,52 @@ +// ========================================================================== +// Components / Modal +// ========================================================================== + +.c-modal { + position: fixed; + top: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100dvh; + max-width: inherit; + max-height: 100lvh; + margin: 0; + padding: 0 var(--grid-margin); + background: transparent; + border: none; + visibility: hidden; + pointer-events: none; + overflow: hidden; + + // Backdrop + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: color(darkest, 0.5); + z-index: -1; + } + + html.is-first-loaded & { + transition: visibility speed(normal); + } + + &.is-open { + pointer-events: auto; + visibility: visible; + transition-duration: 0s; + } +} + +.c-modal_inner { + width: 100%; + max-width: rem(500px); + padding: $unit-small; + background-color: color(lightest); +} diff --git a/assets/styles/main.scss b/assets/styles/main.scss index 55dea497..0c33d0aa 100644 --- a/assets/styles/main.scss +++ b/assets/styles/main.scss @@ -57,6 +57,7 @@ @import "components/text"; @import "components/button"; @import "components/form"; +@import "components/modal"; // Utilities // ========================================================================== diff --git a/package-lock.json b/package-lock.json index 2e54c807..6136f181 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "@locomotivemtl/boilerplate", "version": "1.0.0", "dependencies": { + "focus-trap": "^7.5.4", "locomotive-scroll": "^5.0.0-beta.13", "modujs": "^1.4.2", "modularload": "^1.2.6", @@ -1595,6 +1596,14 @@ "node": ">=8" } }, + "node_modules/focus-trap": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz", + "integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==", + "dependencies": { + "tabbable": "^6.2.0" + } + }, "node_modules/follow-redirects": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", @@ -3545,6 +3554,11 @@ "node": ">=0.8.0" } }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "node_modules/tiny-glob": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", @@ -4931,6 +4945,14 @@ "path-exists": "^4.0.0" } }, + "focus-trap": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz", + "integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==", + "requires": { + "tabbable": "^6.2.0" + } + }, "follow-redirects": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", @@ -5681,8 +5703,7 @@ "version": "1.16.0", "resolved": "https://registry.npmjs.org/postcss-prefix-selector/-/postcss-prefix-selector-1.16.0.tgz", "integrity": "sha512-rdVMIi7Q4B0XbXqNUEI+Z4E+pueiu/CS5E6vRCQommzdQ/sgsS4dK42U7GX8oJR+TJOtT+Qv3GkNo6iijUMp3Q==", - "dev": true, - "requires": {} + "dev": true }, "postcss-selector-parser": { "version": "6.0.9", @@ -5714,8 +5735,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/posthtml-match-helper/-/posthtml-match-helper-1.0.1.tgz", "integrity": "sha1-RRJT3o5YRKNI6WOtXt13aesSlRM=", - "dev": true, - "requires": {} + "dev": true }, "posthtml-parser": { "version": "0.4.2", @@ -6434,6 +6454,11 @@ "resolved": "https://registry.npmjs.org/svg4everybody/-/svg4everybody-2.1.9.tgz", "integrity": "sha1-W9n23vwTOFmgRGRtR0P6vCjbfi0=" }, + "tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "tiny-glob": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", @@ -6594,8 +6619,7 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "dev": true, - "requires": {} + "dev": true }, "xmlhttprequest-ssl": { "version": "2.0.0", diff --git a/package.json b/package.json index 5ba7b870..62cae050 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build": "node --no-warnings build/build.js" }, "dependencies": { + "focus-trap": "^7.5.4", "locomotive-scroll": "^5.0.0-beta.13", "modujs": "^1.4.2", "modularload": "^1.2.6", diff --git a/www/index.html b/www/index.html index 411edd9e..b4fd9ddf 100644 --- a/www/index.html +++ b/www/index.html @@ -59,6 +59,7 @@

Locomotive Boilerplate

Hello

+
@@ -66,6 +67,18 @@

Hello

Made with 🚂

+ +
+
+ +

Modal

+

Content

+
+ +
+
+
+