mirror of
https://github.com/locomotivemtl/locomotive-boilerplate.git
synced 2026-01-15 00:55:08 +08:00
Compare commits
1 Commits
c434d0843f
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
344f4bc98c |
@@ -46,6 +46,8 @@ const CSS_CLASS = Object.freeze({
|
|||||||
// Custom js events
|
// Custom js events
|
||||||
const CUSTOM_EVENT = Object.freeze({
|
const CUSTOM_EVENT = Object.freeze({
|
||||||
RESIZE_END: 'loco.resizeEnd',
|
RESIZE_END: 'loco.resizeEnd',
|
||||||
|
VISIT_START: 'visit.start',
|
||||||
|
MODAL_OPEN: 'modal.open',
|
||||||
// ...
|
// ...
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export {default as Example} from './modules/Example';
|
export {default as Example} from './modules/Example';
|
||||||
export {default as Load} from './modules/Load';
|
export {default as Load} from './modules/Load';
|
||||||
|
export {default as Modal} from './modules/Modal';
|
||||||
export {default as Scroll} from './modules/Scroll';
|
export {default as Scroll} from './modules/Scroll';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { module } from 'modujs';
|
import { module } from 'modujs';
|
||||||
import modularLoad from 'modularload';
|
import modularLoad from 'modularload';
|
||||||
|
import { CUSTOM_EVENT } from '../config';
|
||||||
|
|
||||||
export default class extends module {
|
export default class extends module {
|
||||||
constructor(m) {
|
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) => {
|
load.on('loaded', (transition, oldContainer, newContainer) => {
|
||||||
this.call('destroy', oldContainer, 'app');
|
this.call('destroy', oldContainer, 'app');
|
||||||
this.call('update', newContainer, 'app');
|
this.call('update', newContainer, 'app');
|
||||||
|
|||||||
195
assets/scripts/modules/Modal.js
Normal file
195
assets/scripts/modules/Modal.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
assets/styles/components/_modal.scss
Normal file
52
assets/styles/components/_modal.scss
Normal 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);
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
@import "components/text";
|
@import "components/text";
|
||||||
@import "components/button";
|
@import "components/button";
|
||||||
@import "components/form";
|
@import "components/form";
|
||||||
|
@import "components/modal";
|
||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
36
package-lock.json
generated
36
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "@locomotivemtl/boilerplate",
|
"name": "@locomotivemtl/boilerplate",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"focus-trap": "^7.5.4",
|
||||||
"locomotive-scroll": "^5.0.0-beta.13",
|
"locomotive-scroll": "^5.0.0-beta.13",
|
||||||
"modujs": "^1.4.2",
|
"modujs": "^1.4.2",
|
||||||
"modularload": "^1.2.6",
|
"modularload": "^1.2.6",
|
||||||
@@ -1595,6 +1596,14 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.6",
|
"version": "1.15.6",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
|
||||||
@@ -3545,6 +3554,11 @@
|
|||||||
"node": ">=0.8.0"
|
"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": {
|
"node_modules/tiny-glob": {
|
||||||
"version": "0.2.9",
|
"version": "0.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
||||||
@@ -4931,6 +4945,14 @@
|
|||||||
"path-exists": "^4.0.0"
|
"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": {
|
"follow-redirects": {
|
||||||
"version": "1.15.6",
|
"version": "1.15.6",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
|
||||||
@@ -5681,8 +5703,7 @@
|
|||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/postcss-prefix-selector/-/postcss-prefix-selector-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-prefix-selector/-/postcss-prefix-selector-1.16.0.tgz",
|
||||||
"integrity": "sha512-rdVMIi7Q4B0XbXqNUEI+Z4E+pueiu/CS5E6vRCQommzdQ/sgsS4dK42U7GX8oJR+TJOtT+Qv3GkNo6iijUMp3Q==",
|
"integrity": "sha512-rdVMIi7Q4B0XbXqNUEI+Z4E+pueiu/CS5E6vRCQommzdQ/sgsS4dK42U7GX8oJR+TJOtT+Qv3GkNo6iijUMp3Q==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"postcss-selector-parser": {
|
"postcss-selector-parser": {
|
||||||
"version": "6.0.9",
|
"version": "6.0.9",
|
||||||
@@ -5714,8 +5735,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/posthtml-match-helper/-/posthtml-match-helper-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/posthtml-match-helper/-/posthtml-match-helper-1.0.1.tgz",
|
||||||
"integrity": "sha1-RRJT3o5YRKNI6WOtXt13aesSlRM=",
|
"integrity": "sha1-RRJT3o5YRKNI6WOtXt13aesSlRM=",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"posthtml-parser": {
|
"posthtml-parser": {
|
||||||
"version": "0.4.2",
|
"version": "0.4.2",
|
||||||
@@ -6434,6 +6454,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/svg4everybody/-/svg4everybody-2.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/svg4everybody/-/svg4everybody-2.1.9.tgz",
|
||||||
"integrity": "sha1-W9n23vwTOFmgRGRtR0P6vCjbfi0="
|
"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": {
|
"tiny-glob": {
|
||||||
"version": "0.2.9",
|
"version": "0.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
||||||
@@ -6594,8 +6619,7 @@
|
|||||||
"version": "8.17.1",
|
"version": "8.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"requires": {}
|
|
||||||
},
|
},
|
||||||
"xmlhttprequest-ssl": {
|
"xmlhttprequest-ssl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"build": "node --no-warnings build/build.js"
|
"build": "node --no-warnings build/build.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"focus-trap": "^7.5.4",
|
||||||
"locomotive-scroll": "^5.0.0-beta.13",
|
"locomotive-scroll": "^5.0.0-beta.13",
|
||||||
"modujs": "^1.4.2",
|
"modujs": "^1.4.2",
|
||||||
"modularload": "^1.2.6",
|
"modularload": "^1.2.6",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
<main data-module-example>
|
<main data-module-example>
|
||||||
<div class="o-container">
|
<div class="o-container">
|
||||||
<h1 class="c-heading -h1">Hello</h1>
|
<h1 class="c-heading -h1">Hello</h1>
|
||||||
|
<button data-modal-toggler>Open modal</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -66,6 +67,18 @@
|
|||||||
<p>Made with <a href="https://github.com/locomotivemtl/locomotive-boilerplate"
|
<p>Made with <a href="https://github.com/locomotivemtl/locomotive-boilerplate"
|
||||||
title="Locomotive Boilerplate" target="_blank" rel="noopener">🚂</a></p>
|
title="Locomotive Boilerplate" target="_blank" rel="noopener">🚂</a></p>
|
||||||
</footer>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user