diff --git a/docs-site/src/components/pattern-lab-hacks/pattern-lab-hacks.scss b/docs-site/src/components/pattern-lab-hacks/pattern-lab-hacks.scss index efa983ece7..dfcaf7da82 100644 --- a/docs-site/src/components/pattern-lab-hacks/pattern-lab-hacks.scss +++ b/docs-site/src/components/pattern-lab-hacks/pattern-lab-hacks.scss @@ -39,6 +39,10 @@ max-height: 0px; // prevent background from showing up } +.pl-c-pattern { + position: static; +} + .pl-c-category, .pl-c-category__title, .pl-c-pattern[id*=-docs] > .pl-c-pattern__header, diff --git a/docs-site/src/pages/pattern-lab/_patterns/02-components/modal/custom-events/_modal-usage-custom-events.twig b/docs-site/src/pages/pattern-lab/_patterns/02-components/modal/custom-events/_modal-usage-custom-events.twig new file mode 100644 index 0000000000..d21e15fb4a --- /dev/null +++ b/docs-site/src/pages/pattern-lab/_patterns/02-components/modal/custom-events/_modal-usage-custom-events.twig @@ -0,0 +1,132 @@ +{% set javascript %} + +{% endset %} + +Modal Custom Events +Bolt Modal emits the following custom events: modal:show, modal:shown, modal:hide, modal:hidden. + +
+ Placeholder "fixed" element, should not shift when modal shows/hides. +
+ +Demo +
+ {% set modal_content %} + {% include "@bolt-components-video/video.twig" with { + videoId: "3861325118001", + accountId: "1900410236", + playerId: "r1CAdLzTW", + showMeta: true, + showMetaTitle: true, + attributes: { + class: "js-modal-video-123" + } + } only %} + {% endset %} + + {% set trigger %} + {% include "@bolt-components-button/button.twig" with { + text: "Play the video", + size: "small", + width: "full", + attributes: { + "on-click": "show", + "on-click-target": "js-modal-123" + } + } only %} + {% include "@bolt-components-modal/modal.twig" with { + attributes: { + class: "js-modal-123" + }, + content: modal_content, + width: "optimal", + spacing: "none", + theme: "none", + scroll: "overall", + } only %} + {{ javascript }} + {% endset %} + {% set description %} + Set padding on a "fixed" element when modal shows/hides + Use the modal:show and modal:hidden events to set padding on a fixed element to prevent it from shifting. + Note: for this example, you must use modal:hidden not modal:hide event, as modal:hidden fires after the modal animation, and that is key to getting the correct hasScrollbar state. + {% endset %} + {% include "@bolt-components-grid/grid.twig" with { + items: [ + { + column_start: "1 1@small", + column_span: "12 8@small 9@medium", + row_start: "2 1@small", + row_span: "1", + valign: "center", + content: description, + }, + { + column_start: "1 10@small 11@medium", + column_span: "6 3@small 2@medium", + row_start: "1 1@small", + row_span: "1", + valign: "center", + content: trigger, + }, + ] + } only %} +
+ +Custom Javascript +{% spaceless %} + {{ javascript | replace({ + '<': '<', + '>': '>', + }) | trim | raw }} +{% endspaceless %} diff --git a/packages/components/bolt-modal/modal.schema.yml b/packages/components/bolt-modal/modal.schema.yml index 0199bbdd38..f99c801fb0 100644 --- a/packages/components/bolt-modal/modal.schema.yml +++ b/packages/components/bolt-modal/modal.schema.yml @@ -58,7 +58,6 @@ properties: uuid: type: string description: Unique ID for modal, randomly generated if not provided. - # @todo: persistent and hide close button props are not ready. # persistent: # type: boolean diff --git a/packages/components/bolt-modal/src/modal.js b/packages/components/bolt-modal/src/modal.js index a08b91b55f..dc44930851 100644 --- a/packages/components/bolt-modal/src/modal.js +++ b/packages/components/bolt-modal/src/modal.js @@ -5,6 +5,10 @@ import { define, hasNativeShadowDomSupport, getTransitionDuration, + bodyHasScrollbar, + getScrollbarWidth, + setScrollbarPadding, + resetScrollbarPadding, } from '@bolt/core/utils'; import { html, withLitHtml } from '@bolt/core/renderers/renderer-lit-html'; import classNames from 'classnames/bind'; @@ -50,15 +54,21 @@ class BoltModal extends withLitHtml() { self.show = self.show.bind(this); self.hide = self.hide.bind(this); self._handleKeyPresseskeypress = this._handleKeyPresseskeypress.bind(this); + self._noBodyScroll = false; // Internal switch to enable 'no-body-scroll' feature which is not ready for release return self; } + static scrollbarWidth = getScrollbarWidth(); + + static get bodyHasScrollbar() { + return bodyHasScrollbar(); + } + connecting() { super.connecting && super.connecting(); document.addEventListener('keydown', this._handleKeyPresseskeypress); this.setAttribute('ready', ''); - this.scrollbarWidth = this._getScrollbarWidth(); } // Initialise everything needed for the dialog to work properly @@ -83,6 +93,18 @@ class BoltModal extends withLitHtml() { this.dispatchEvent(new CustomEvent('modal:ready')); } + get _toggleEventOptions() { + return this._noBodyScroll + ? { + detail: { + hasScrollbar: BoltModal.bodyHasScrollbar, + scrollbarWidth: BoltModal.scrollbarWidth, + }, + bubbles: true, + } + : {}; + } + /** * Show the dialog element, disable all the targets (siblings), trap the * current focus within it, listen for some specific key presses and fire all @@ -101,7 +123,9 @@ class BoltModal extends withLitHtml() { // triggers re-render this.open = true; - this._setScrollbar(); + this.dispatchEvent(new CustomEvent('modal:show', this._toggleEventOptions)); + + this._noBodyScroll && this._setScrollbar(); // @todo: re-evaluate if the trigger element used needs to have it's tabindex messed with // this.querySelector('[slot="trigger"]').setAttribute('tabindex', '-1'); @@ -113,7 +137,9 @@ class BoltModal extends withLitHtml() { // this.dialog.setAttribute('open', ''); // this.container.removeAttribute('aria-hidden'); - this.dispatchEvent(new CustomEvent('modal:show')); + this.dispatchEvent( + new CustomEvent('modal:shown', this._toggleEventOptions), + ); } /** @@ -133,13 +159,19 @@ class BoltModal extends withLitHtml() { this.focusTrap.active = false; this.open = false; this.ready = false; + + this.dispatchEvent(new CustomEvent('modal:hide', this._toggleEventOptions)); + this.transitionDuration = getTransitionDuration( this.renderRoot.querySelector('.c-bolt-modal'), ); // Wait until after transition or modal will shift setTimeout(() => { - this._resetScrollbar(); + this._noBodyScroll && this._resetScrollbar(); + this.dispatchEvent( + new CustomEvent('modal:hidden', this._toggleEventOptions), + ); }, this.transitionDuration); // @todo: refactor this to be more component / element agnostic @@ -170,13 +202,6 @@ class BoltModal extends withLitHtml() { // target.removeAttribute('aria-hidden'); // }); } - - this.dispatchEvent(new CustomEvent('modal:hide')); - } - - get _bodyHasScrollbar() { - const bodyRect = document.body.getBoundingClientRect(); - return bodyRect.left + bodyRect.right < window.innerWidth; } /** @@ -223,51 +248,18 @@ class BoltModal extends withLitHtml() { } _setScrollbar() { - // Technique inspired by Bootstrap Modal: https://github.com/twbs/bootstrap/blob/master/js/src/modal/modal.js - - if (this._bodyHasScrollbar) { - const originalPadding = document.body.style.paddingRight; - const calculatedPadding = window.getComputedStyle(document.body)[ - 'padding-right' - ]; - - // Save original padding value for later - document.body.setAttribute('data-padding-right', originalPadding); - document.body.style.paddingRight = `${parseFloat(calculatedPadding) + - this.scrollbarWidth}px`; - } + BoltModal.bodyHasScrollbar && + setScrollbarPadding(document.body, BoltModal.scrollbarWidth); document.body.classList.add('u-bolt-overflow-hidden'); } _resetScrollbar() { - const padding = document.body.getAttribute('data-padding-right'); - - document.body.style.paddingRight = ''; - - if (typeof padding === 'undefined') { - document.body.style.paddingRight = ''; - } else { - document.body.removeAttribute('data-padding-right'); - // Restore original padding value - document.body.style.paddingRight = padding; - } + resetScrollbarPadding(document.body); document.body.classList.remove('u-bolt-overflow-hidden'); } - // @todo: refactor into core JS/CSS - _getScrollbarWidth() { - // https://davidwalsh.name/detect-scrollbar-width - const scrollDiv = document.createElement('div'); - scrollDiv.className = 'c-bolt-modal__scrollbar-measure'; - document.body.appendChild(scrollDiv); - const scrollbarWidth = - scrollDiv.getBoundingClientRect().width - scrollDiv.clientWidth; - document.body.removeChild(scrollDiv); - return scrollbarWidth; - } - /** * Set the focus to the first element with `autofocus` or the first focusable * child of the given element diff --git a/packages/components/bolt-modal/src/modal.scss b/packages/components/bolt-modal/src/modal.scss index 15ffecbc13..a97df76529 100644 --- a/packages/components/bolt-modal/src/modal.scss +++ b/packages/components/bolt-modal/src/modal.scss @@ -52,7 +52,7 @@ bolt-modal:not([ready]) { position: fixed; top: 0; left: 0; - width: 100vw; + width: 100%; // Use % instead of vh or modal scrollbar is hidden behind bold scrollbar in IE11 height: 100vh; pointer-events: none; transition: opacity $bolt-modal-transition; @@ -419,13 +419,3 @@ bolt-modal:not([ready]) { .c-bolt-modal__dialog-title { @include bolt-visuallyhidden; } - -// https://davidwalsh.name/detect-scrollbar-width -// @todo: refactor into core JS/CSS -.c-bolt-modal__scrollbar-measure { - position: absolute; - top: -9999px; - width: 100px; - height: 100px; - overflow: scroll; -} diff --git a/packages/core/utils/index.js b/packages/core/utils/index.js index 89a20e4796..3a65056ec9 100644 --- a/packages/core/utils/index.js +++ b/packages/core/utils/index.js @@ -11,6 +11,7 @@ export * from './is-valid-selector'; export * from './rgb2hex'; export * from './rename-key'; export * from './sanitize-classes'; +export * from './scrollbar'; export * from './supports-css-vars'; export * from './supports-passive-event-listener'; export * from './which-transition-event'; diff --git a/packages/core/utils/scrollbar.js b/packages/core/utils/scrollbar.js new file mode 100644 index 0000000000..ce24516204 --- /dev/null +++ b/packages/core/utils/scrollbar.js @@ -0,0 +1,61 @@ +/** + * Determine whether or not the BODY has a visible scrollbar + * @returns {Boolean} - Returns true if element has scrollbar + */ +export const bodyHasScrollbar = () => { + const bodyRect = document.body.getBoundingClientRect(); + return bodyRect.left + bodyRect.right < window.innerWidth; +}; + +// https://davidwalsh.name/detect-scrollbar-width +/** + * Get scrollbar width by temporarily adding element with scrollbar to page then removing it + * @returns {number} - Width of the scrollbar in pixels, without unit, e.g 15 + */ +export const getScrollbarWidth = () => { + const scrollDiv = document.createElement('div'); + scrollDiv.style.cssText = + 'position: absolute; top: -9999px; width: 100px; height: 100px; overflow: scroll;'; + document.body.appendChild(scrollDiv); + const scrollbarWidth = + scrollDiv.getBoundingClientRect().width - scrollDiv.clientWidth; + document.body.removeChild(scrollDiv); + + return scrollbarWidth; +}; + +// Technique inspired by Bootstrap Modal: https://github.com/twbs/bootstrap/blob/master/js/src/modal/modal.js +/** + * Set padding on a given element equal to the browser's scrollbar width, used to prevent content shifting when scrollbars are turned off, e.g. when a modal opens + * @param {HTMLElement} element - The target element + * @param {(string\|number)} scrollbarWidth - The scrollbar width, with or without unit, e.g. 15 or 15px + */ +export const setScrollbarPadding = (element, scrollbarWidth) => { + if (!element) { + return; + } + + const originalPaddingRight = element.style.paddingRight; + const calculatedPadding = window.getComputedStyle(element)['padding-right']; + + // Save original padding value for later + element.originalPaddingRight = originalPaddingRight; + element.style.paddingRight = + parseFloat(calculatedPadding) + scrollbarWidth + 'px'; +}; + +/** + * Reset padding on a given element, will remove current padding style and restore original padding style if necessary, used after modal closes + * @param {HTMLElement} element - The target element + */ +export const resetScrollbarPadding = element => { + if (!element) { + return; + } + + if (typeof element.originalPaddingRight === 'undefined') { + element.style.paddingRight = ''; + } else { + element.style.paddingRight = element.originalPaddingRight; // Restore original padding value + } +};