1
0
mirror of https://github.com/locomotivemtl/locomotive-boilerplate.git synced 2026-01-15 00:55:08 +08:00

1 Commits

Author SHA1 Message Date
Lucas Bigot
344f4bc98c Add default Modal component 2024-08-02 12:41:22 -04:00
9 changed files with 302 additions and 6 deletions

View File

@@ -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',
// ...
})

View File

@@ -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';

View File

@@ -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');

View File

@@ -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)
}
}

View File

@@ -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);
}

View File

@@ -57,6 +57,7 @@
@import "components/text";
@import "components/button";
@import "components/form";
@import "components/modal";
// Utilities
// ==========================================================================

36
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -59,6 +59,7 @@
<main data-module-example>
<div class="o-container">
<h1 class="c-heading -h1">Hello</h1>
<button data-modal-toggler>Open modal</button>
</div>
</main>
@@ -66,6 +67,18 @@
<p>Made with <a href="https://github.com/locomotivemtl/locomotive-boilerplate"
title="Locomotive Boilerplate" target="_blank" rel="noopener">🚂</a></p>
</footer>
<div class="c-modal" data-module-modal>
<div class="c-modal_inner" data-modal-target>
<button data-modal-toggler>Close</button>
<h2>Modal</h2>
<p>Content</p>
<form action="">
<input type="text">
</form>
</div>
</div>
</div>
</div>