import { bodyLock, bodyLockStatus, bodyUnlock, FLS } from '../files/functions' import { flsModules } from '../files/modules' // Интерфейс для настроек попапа interface PopupOptions { logging?: boolean init?: boolean attributeOpenButton: string attributeCloseButton: string fixElementSelector: string vkAttribute: string vkPlaceAttribute: string setAutoplayVk: boolean classes: { popup: string popupContent: string popupActive: string bodyActive: string } focusCatch: boolean closeEsc: boolean bodyLock: boolean hashSettings: { location: boolean goHash: boolean } on: { beforeOpen: (popup: Popup) => void afterOpen: (popup: Popup) => void beforeClose: (popup: Popup) => void afterClose: (popup: Popup) => void } } // Интерфейс для целевого элемента попапа interface PopupTarget { selector: string | false element: HTMLElement | null } class Popup { private vkCode: string | null private isOpen: boolean private targetOpen: PopupTarget private previousOpen: PopupTarget private lastClosed: PopupTarget private _dataValue: string | null private hash: boolean private _reopen: boolean private _selectorOpen: boolean private lastFocusEl: HTMLElement | null private previousActiveElement: Element | null private _focusEl: string[] private options: PopupOptions private bodyLock: boolean constructor(options: Partial) { const config: PopupOptions = { logging: true, init: true, // Для кнопок attributeOpenButton: 'data-popup', // Атрибут для кнопки, которая вызывает попап attributeCloseButton: 'data-close', // Атрибут для кнопки, которая закрывает попап // Для сторонних объектов fixElementSelector: '[data-lp]', // Атрибут для элементов с левым паддингом (которые fixed) // Для объекта попапа vkAttribute: 'data-popup-vk', // Атрибут для кода vk vkPlaceAttribute: 'data-popup-vk-place', // Атрибут для вставки ролика vk setAutoplayVk: true, // Изменение классов classes: { popup: 'popup', // popupWrapper: 'popup__wrapper', popupContent: 'popup__content', popupActive: 'popup_show', // Добавляется для попапа, когда он открывается bodyActive: 'popup-show', // Добавляется для боди, когда попап открыт }, focusCatch: true, // Фокус внутри попапа зациклен closeEsc: true, // Закрытие по ESC bodyLock: true, // Блокировка скрола hashSettings: { location: true, // Хэш в адресной строке goHash: true, // Переход по наличию в адресной строке }, on: { // События beforeOpen: () => {}, afterOpen: () => {}, beforeClose: () => {}, afterClose: () => {}, }, } this.vkCode = null this.isOpen = false // Текущее окно this.targetOpen = { selector: false, element: null, } // Предыдущее открытое this.previousOpen = { selector: false, element: null, } // Последнее закрытое this.lastClosed = { selector: false, element: null, } this._dataValue = null this.hash = false this._reopen = false this._selectorOpen = false this.lastFocusEl = null this.previousActiveElement = null this._focusEl = [ 'a[href]', 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', 'button:not([disabled]):not([aria-hidden])', 'select:not([disabled]):not([aria-hidden])', 'textarea:not([disabled]):not([aria-hidden])', 'area[href]', 'iframe', 'object', 'embed', '[contenteditable]', '[tabindex]:not([tabindex^="-"])', ] // this.options = Object.assign(config, options); this.options = { ...config, ...options, classes: { ...config.classes, ...options?.classes, }, hashSettings: { ...config.hashSettings, ...options?.hashSettings, }, on: { ...config.on, ...options?.on, }, } this.bodyLock = false if (this.options.init) { this.initPopups() } } initPopups() { this.popupLogging(`Проснулся`) this.eventsPopup() } eventsPopup() { // Клик на всем документе document.addEventListener('click', (e: MouseEvent) => { // Клик по кнопке "открыть" const target = e.target as HTMLElement const buttonOpen = target?.closest(`[${this.options.attributeOpenButton}]`) if (buttonOpen) { e.preventDefault() this._dataValue = buttonOpen.getAttribute(this.options.attributeOpenButton) ? buttonOpen.getAttribute(this.options.attributeOpenButton) : 'error' this.vkCode = buttonOpen.getAttribute(this.options.vkAttribute) ? buttonOpen.getAttribute(this.options.vkAttribute) : null if (this._dataValue !== 'error') { if (!this.isOpen) { this.lastFocusEl = buttonOpen as HTMLElement } this.targetOpen.selector = `${this._dataValue}` this._selectorOpen = true this.open('') return } else { this.popupLogging(`Ой, не заполнен атрибут у ${buttonOpen.classList}`) } return } // Закрытие на пустом месте (popup__wrapper) и кнопки закрытия (popup__close) для закрытия const buttonClose = target?.closest(`[${this.options.attributeCloseButton}]`) if (buttonClose || (!target?.closest(`.${this.options.classes.popupContent}`) && this.isOpen)) { e.preventDefault() this.close('') } }) // Закрытие по ESC document.addEventListener('keydown', (e: KeyboardEvent) => { if (this.options.closeEsc && e.key === 'Escape' && this.isOpen) { e.preventDefault() this.close('') return } if (this.options.focusCatch && e.key === 'Tab' && this.isOpen) { this._focusCatch(e) } }) } open(selectorValue: string): void { if (bodyLockStatus) { // Если перед открытием попапа был режим lock this.bodyLock = !!(document.documentElement.classList.contains('lock') && !this.isOpen) // Если ввести значение селектора (селектор настраивается в options) if (selectorValue && typeof selectorValue === 'string' && selectorValue.trim() !== '') { this.targetOpen.selector = selectorValue this._selectorOpen = true } if (this.isOpen) { this._reopen = true this.close('') } if (!this._selectorOpen) { this.targetOpen.selector = this.lastClosed.selector as string } if (!this._reopen) { this.previousActiveElement = document.activeElement } const element = document.querySelector(this.targetOpen.selector as string) if (element) { this.targetOpen.element = element as HTMLElement // Vk if (this.vkCode) { const codeVideo = this.vkCode const urlVideo = `https://vk.ru/video_ext.php?oid=-${codeVideo}&autoplay=1` const iframe = document.createElement('iframe') iframe.setAttribute('allowfullscreen', '') const autoplay = this.options.setAutoplayVk ? 'autoplay;' : '' iframe.setAttribute('allow', `${autoplay}; encrypted-media`) iframe.setAttribute('src', urlVideo) const vkPlace = this.targetOpen.element.querySelector(`[${this.options.vkPlaceAttribute}]`) if (vkPlace) { vkPlace.appendChild(iframe) } } // До открытия this.options.on.beforeOpen(this) // Создаем свое событие после открытия попапа document.dispatchEvent( new CustomEvent('beforePopupOpen', { detail: { popup: this, }, }) ) this.targetOpen.element.classList.add(this.options.classes.popupActive) document.documentElement.classList.add(this.options.classes.bodyActive) if (!this._reopen) { if (!this.bodyLock) { bodyLock() } } else { this._reopen = false } this.targetOpen.element.setAttribute('aria-hidden', 'false') // Запоминаю это открытое окно. Оно будет последним открытым this.previousOpen.selector = this.targetOpen.selector this.previousOpen.element = this.targetOpen.element this._selectorOpen = false this.isOpen = true setTimeout(() => { this._focusTrap() }, 50) // После открытия this.options.on.afterOpen(this) // Создаем свое событие после открытия попапа document.dispatchEvent( new CustomEvent('afterPopupOpen', { detail: { popup: this, }, }) ) this.popupLogging(`Открыл попап`) } else { this.popupLogging(`Ой ой, такого попапа нет. Проверьте корректность ввода. `) } } } close(selectorValue: string): void { if (selectorValue && typeof selectorValue === 'string' && selectorValue.trim() !== '') { this.previousOpen.selector = selectorValue } if (!this.isOpen || !bodyLockStatus) { return } // До закрытия this.options.on.beforeClose(this) // Создаем свое событие перед закрытием попапа document.dispatchEvent( new CustomEvent('beforePopupClose', { detail: { popup: this, }, }) ) // Vk if (this.vkCode && this.targetOpen.element) { const vkPlace = this.targetOpen.element.querySelector(`[${this.options.vkPlaceAttribute}]`) if (vkPlace) { vkPlace.innerHTML = '' } } if (this.previousOpen.element) { this.previousOpen.element.classList.remove(this.options.classes.popupActive) // aria-hidden this.previousOpen.element.setAttribute('aria-hidden', 'true') } if (!this._reopen) { document.documentElement.classList.remove(this.options.classes.bodyActive) if (!this.bodyLock) { bodyUnlock() } this.isOpen = false } // После закрытия this.options.on.afterClose(this) // Создаем свое событие после закрытия попапа document.dispatchEvent( new CustomEvent('afterPopupClose', { detail: { popup: this, }, }) ) setTimeout(() => { this._focusTrap() }, 50) this.popupLogging(`Закрыл попап`) } _focusCatch(e: KeyboardEvent): void { if (this.targetOpen.element) { const focusable = this.targetOpen.element.querySelectorAll(this._focusEl.join(',')) const focusArray = Array.from(focusable) const focusedIndex = focusArray.indexOf(document.activeElement as HTMLElement) if (e.shiftKey && focusedIndex === 0) { focusArray[focusArray.length - 1].focus() e.preventDefault() } if (!e.shiftKey && focusedIndex === focusArray.length - 1) { focusArray[0].focus() e.preventDefault() } } } _focusTrap(): void { if (this.previousOpen.element) { const focusable = this.previousOpen.element.querySelectorAll(this._focusEl.join(',')) if (!this.isOpen && this.lastFocusEl) { this.lastFocusEl.focus() } else if (focusable.length > 0) { focusable[0].focus() } } } // Функция вывода в консоль popupLogging(message: string): void { if (this.options.logging) { FLS(`[Попап]: ${message}`) } } } // Запускаем и добавляем в объект модулей flsModules.popup = new Popup({})