diff --git a/pages/src/localdev.ts b/pages/src/localdev.ts index 587420c019..bd760b283a 100644 --- a/pages/src/localdev.ts +++ b/pages/src/localdev.ts @@ -34,6 +34,7 @@ import { ESLAnimate, ESLAnimateMixin, ESLShare, + ESLModal, ESLRelatedTarget } from '../../src/modules/all'; @@ -104,6 +105,8 @@ ESLTooltip.register(); ESLAnimate.register(); ESLAnimateMixin.register(); +ESLModal.register(); + ESLShare.config(() => fetch('/assets/share/config.json').then((response) => response.json())); ESLShare.register(); diff --git a/src/modules/all.less b/src/modules/all.less index 05fab970e1..09d2e82d69 100644 --- a/src/modules/all.less +++ b/src/modules/all.less @@ -25,3 +25,5 @@ @import "./esl-animate/core.less"; @import "./esl-share/core.less"; + +@import "./esl-modal/core.less"; diff --git a/src/modules/all.ts b/src/modules/all.ts index a6dde9383a..309a029019 100644 --- a/src/modules/all.ts +++ b/src/modules/all.ts @@ -40,6 +40,9 @@ export * from './esl-tooltip/core'; // Animate export * from './esl-animate/core'; +// Modal +export * from './esl-modal/core'; + // Related Target Mixin export * from './esl-related-target/core'; diff --git a/src/modules/esl-modal/README.md b/src/modules/esl-modal/README.md new file mode 100644 index 0000000000..d57c95d106 --- /dev/null +++ b/src/modules/esl-modal/README.md @@ -0,0 +1,30 @@ +# [ESL](../../../) Modal + +Version: *1.0.0-beta*. + +Authors: *Anastasiya Lesun*. + + + +**ESLModal** - a custom element based on `ESLToggleable` instance. + +`ESLModal` opens in overlay on top of the main content when `esl:show` DOM event is dispatched on the appropriate modal item. +By default, modal window before its opening is moved to the `document.body` (determined by `body-inject` attribute) and blocks all other workflows on the main page until modal is closed (supports 'none' | 'native' | 'pseudo' locks using `scroll-lock-strategy` attribute). +Modal opening can be taken up together with backdrop appearance (depends on `no-backdrop` attribute). + +### ESLModal Attributes | Properties: +- `no-backdrop` (boolean) - disable modal backdrop +- `body-inject` (boolean) - provide element movement to body before its opening +- `scroll-lock-strategy` ('none' | 'native' | 'pseudo') - define scroll lock type + +### Example +```html + +
+ ... + + + ... +
+ +``` diff --git a/src/modules/esl-modal/core.less b/src/modules/esl-modal/core.less new file mode 100644 index 0000000000..913090db65 --- /dev/null +++ b/src/modules/esl-modal/core.less @@ -0,0 +1 @@ +@import "./core/esl-modal.less"; diff --git a/src/modules/esl-modal/core.ts b/src/modules/esl-modal/core.ts new file mode 100644 index 0000000000..28207faf11 --- /dev/null +++ b/src/modules/esl-modal/core.ts @@ -0,0 +1,3 @@ +export type {ESLModalTagShape} from './core/esl-modal.shape'; + +export * from './core/esl-modal'; diff --git a/src/modules/esl-modal/core/esl-modal.less b/src/modules/esl-modal/core/esl-modal.less new file mode 100644 index 0000000000..57aeb53dd8 --- /dev/null +++ b/src/modules/esl-modal/core/esl-modal.less @@ -0,0 +1,64 @@ +esl-modal { + display: none; +} + +.esl-modal { + position: fixed; + z-index: 1000000; + left: 0; + top: 0; + width: 100vw; + height: var(--100vh, 100vh); + display: flex; + flex-direction: column; + align-items: center; + overflow-y: scroll; + + &:not(.open) { + display: none; + } + + &::before, &::after { + content: ''; + flex: 1 0 30px; + } + + .esl-modal-container { + flex: 0 0 auto; + position: relative; + padding: 50px; + max-width: 100%; + width: 80%; + background-color: #fff; + overflow: auto; + } + + .close-btn { + position: absolute; + right: 15px; + top: 15px; + width: 20px; + height: 20px; + font-size: 19px; + line-height: 19px; + + &::before { + content: '\2715'; + } + } +} + +.esl-modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + transition: all .3s ease-in; + + &.active { + background: rgba(0, 0, 0, .4); + z-index: 999999; + } +} diff --git a/src/modules/esl-modal/core/esl-modal.shape.ts b/src/modules/esl-modal/core/esl-modal.shape.ts new file mode 100644 index 0000000000..b385889dae --- /dev/null +++ b/src/modules/esl-modal/core/esl-modal.shape.ts @@ -0,0 +1,24 @@ +import type {ESLToggleableTagShape} from '../../esl-toggleable/core/esl-toggleable.shape'; +import type {ESLModal, ScrollLockStrategies} from './esl-modal'; + +/** + * Tag declaration interface of {@link ESLModal} element + * Used for TSX declaration + */ +export interface ESLModalTagShape extends ESLToggleableTagShape { + /** Disable modal backdrop */ + 'no-backdrop'?: boolean; + /** Provides modal movement to body before its opening */ + 'body-inject'?: boolean; + /** Defines a scroll lock strategy when the modal is open (default 'pseudo') */ + 'scroll-lock-strategy'?: ScrollLockStrategies; +} + +declare global { + namespace JSX { + export interface IntrinsicElements { + /** {@link ESLModal} custom tag */ + 'esl-modal': ESLModalTagShape; + } + } +} diff --git a/src/modules/esl-modal/core/esl-modal.ts b/src/modules/esl-modal/core/esl-modal.ts new file mode 100644 index 0000000000..23c13d36a9 --- /dev/null +++ b/src/modules/esl-modal/core/esl-modal.ts @@ -0,0 +1,116 @@ +import {ExportNs} from '../../esl-utils/environment/export-ns'; +import {ESLToggleable} from '../../esl-toggleable/core/esl-toggleable'; +import {prop, boolAttr, attr, memoize, listen} from '../../esl-utils/decorators'; +import {hasAttr, setAttr} from '../../esl-utils/dom/attr'; +import {getKeyboardFocusableElements, handleFocusChain} from '../../esl-utils/dom/focus'; +import {lockScroll, unlockScroll} from '../../esl-utils/dom/scroll/utils'; +import {TAB} from '../../esl-utils/dom/keys'; + +import type {ScrollLockOptions} from '../../esl-utils/dom/scroll/utils'; +import type {ESLToggleableActionParams} from '../../esl-toggleable/core/esl-toggleable'; + +export type ScrollLockStrategies = ScrollLockOptions['strategy']; + +export interface ModalActionParams extends ESLToggleableActionParams { } + +@ExportNs('Modal') +export class ESLModal extends ESLToggleable { + public static override is = 'esl-modal'; + + @attr({defaultValue: '[data-modal-close]'}) + public override closeTrigger: string; + + /** Define option to lock scroll {@see ScrollLockOptions} */ + @attr({defaultValue: 'pseudo'}) + public scrollLockStrategy: ScrollLockStrategies; + + @boolAttr() public noBackdrop: boolean; + @boolAttr() public bodyInject: boolean; + + @prop(true) public override closeOnEsc: boolean; + @prop(true) public override closeOnOutsideAction: boolean; + + @memoize() + protected static get $backdrop(): any { + const $backdrop = document.createElement('esl-modal-backdrop'); + $backdrop.classList.add('esl-modal-backdrop'); + return $backdrop; + } + + public override connectedCallback(): void { + super.connectedCallback(); + if (!hasAttr(this, 'role')) setAttr(this, 'role', 'dialog'); + if (!hasAttr(this, 'tabindex')) setAttr(this, 'tabIndex', '-1'); + } + + public override onShow(params: ModalActionParams): void { + this.bodyInject && this.inject(); + this.activator = params.activator; + this.showBackdrop(); + super.onShow(params); + this.focus(); + lockScroll(document.documentElement, this.lockOptions); + } + + public override onHide(params: ModalActionParams): void { + unlockScroll(document.documentElement, this.lockOptions); + super.onHide(params); + this.hideBackdrop(); + this.activator?.focus(); + this.bodyInject && this.extract(); + } + + protected inject(): void { + if (this.parentNode === document.body) return; + document.body.appendChild(this); + } + + protected extract(): void { + if (this.parentNode !== document.body) return; + document.body.removeChild(this); + } + + + protected showBackdrop(): void { + if (this.noBackdrop) return; + if (!document.body.contains(ESLModal.$backdrop)) document.body.appendChild(ESLModal.$backdrop); + ESLModal.$backdrop.classList.add('active'); + } + + protected hideBackdrop(): void { + if (this.noBackdrop) return; + ESLModal.$backdrop.classList.remove('active'); + } + + public get lockOptions(): ScrollLockOptions { + return { + strategy: this.scrollLockStrategy, + initiator: this + }; + } + + public get $boundaryFocusable(): {first: HTMLElement, last: HTMLElement} { + const $focusableEls = getKeyboardFocusableElements(this) as HTMLElement[]; + return {first: $focusableEls[0], last: $focusableEls[$focusableEls.length - 1]}; + } + + @listen({inherit: true}) + protected override _onKeyboardEvent(e: KeyboardEvent): void { + super._onKeyboardEvent(e); + if (e.key === TAB) this._onTabKey(e); + } + + protected _onTabKey(e: KeyboardEvent): boolean | undefined { + const {first, last} = this.$boundaryFocusable; + return handleFocusChain(e, first, last); + } +} + +declare global { + export interface ESLLibrary { + Modal: typeof ESLModal; + } + export interface HTMLElementTagNameMap { + 'esl-modal': ESLModal; + } +}