-
Notifications
You must be signed in to change notification settings - Fork 69
Add default Modal component #181
base: master
Are you sure you want to change the base?
Conversation
|
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
|
Since my proposal for a modal in 2021, browser support for
|
| /** | ||
| * 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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| /** | |
| * 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) { | |
| static CLASS = { | |
| EL: 'is-open', | |
| HTML: 'has-modal-open', | |
| } | |
| /** | |
| * Creates a new Modal. | |
| * | |
| * @param {object} options - The module options. | |
| * @param {string} options.dataName - The module data attribute name. | |
| */ | |
| constructor(options) { |
Misplaced block comment and no related error to the @throws.
| static CLASS = { | ||
| EL: 'is-open', | ||
| HTML: 'has-modal-open', | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| static CLASS = { | |
| EL: 'is-open', | |
| HTML: 'has-modal-open', | |
| } | |
| static OPEN_CLASS = { | |
| EL: 'is-open', | |
| HTML: 'has-modal-open', | |
| } |
CLASS is too generic and meaningless given its very specific use case.
I recommend OPEN_CLASS or something to that effect.
|
|
||
| // Data | ||
| this.moduleName = options.name | ||
| this.dataName = this.getData('name') || options.dataName |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| this.dataName = this.getData('name') || options.dataName | |
| this.dataName = options.dataName || this.getData('name') |
I think the constructor's option.dataName parameter should have precedence over the options.el's attribute given the class' purpose is to decorate the base element.
| // 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 | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| // 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 | |
| ) | |
| // UI | |
| this.$togglers = document.querySelectorAll(`[data-${this.dataName}-toggler]`) | |
| this.$focusTrapTargets = Array.from(this.el.querySelectorAll(`[data-${this.dataName}-target]`)) | |
| this.isOpen = false | |
| } | |
| getFocusTrapOptions() { | |
| return { | |
| /** | |
| * 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, | |
| } | |
| } | |
| ///////////////// | |
| // Lifecycle | |
| ///////////////// | |
| init() { | |
| this.onBeforeInit?.() | |
| this.focusTrap = createFocusTrap( | |
| this.$focusTrapTargets.length > 0 ? this.$focusTrapTargets : [this.el], | |
| this.getFocusTrapOptions() | |
| ) |
this.focusTrapOptions should be moved to a separate method, something like this.getFocusTrapOptions which would be easier to override/extend in a sub-class.
| } | ||
|
|
||
| open(args) { | ||
| if (this.isOpen) return |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| if (this.isOpen) return | |
| if (this.isOpen) { | |
| return | |
| } |
The compiler is going to minify this anyway, might as well maximize readability.
| } | ||
|
|
||
| close(args) { | ||
| if (!this.isOpen) return |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| if (!this.isOpen) return | |
| if (!this.isOpen) { | |
| return | |
| } |
The compiler is going to minify this anyway, might as well maximize readability.
| /** | ||
| * Generic component to display a modal. | ||
| * | ||
| */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please provide an example(s) of the HTML needed to use this module as well as information on how to sub-class this base class, including which hooks are available (such as this.onBeforeInit).
| const CUSTOM_EVENT = Object.freeze({ | ||
| RESIZE_END: 'loco.resizeEnd', | ||
| VISIT_START: 'visit.start', | ||
| MODAL_OPEN: 'modal.open', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There should be an equivalent MODAL_CLOSE custom event.
|
|
||
| load.on('loading', (transition, oldContainer) => { | ||
| const args = { transition, oldContainer }; | ||
| // Dispatch custom event |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| // Dispatch custom event |
Useless comment.
| // Custom js events | ||
| const CUSTOM_EVENT = Object.freeze({ | ||
| RESIZE_END: 'loco.resizeEnd', | ||
| VISIT_START: 'visit.start', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume VISIT_START is named this way based on SWUP's visit:start hook?
Visit started: transition to a new page begins.
A default Modal component to create generic modal or to extend for specific purposes.
Javascript
It requires focus-trap dependency to be able to isolate the focus within the modal. This benefits from all the advantages of this library, as the
clickOutsideDeactivatesoption or the many callbacks.Basic modal
You can create a new basic modal using the
data-module-modalattribute on the modal itself. This makes the default modal easier to create. If you have multiple basic modals, you'll need to name these in order to use the togglers correctly. To do so, use thedata-modal-nameattribute.Specific modal
You can also create a specific modal with custom code by extending the Modal Class. To do so, you'll have to match the data-attribute with your extended Class.
Example: If you create a
VideoModal.jswith custom code to handle the video player, the HTML attribute must be:data-module-video-modal. Nothing new here, just using Modular.js.Togglers
No need to create a dedicated Class for togglers, as they are just stored using the data-attribute
data-[module-name]-toggleron HTML elements.data-modal-toggleron every toggler you want, including the close button inside the modal.VideoModalexample, it would bedata-video-modal-togglerFocus Trap Targets
The
targetsworks exactly like thetogglers. You can also set multiple HTML elements, or className (string), or whatever FocusTrap supports (see documentation). If not specified, it will use the modal itself.CSS
Very basic css, the minimum to be able to test the modal without adding anything.