import type { FancyboxInstance, CarouselSlide, FancyboxOptions } from "@fancyapps/ui"; import { Fancybox } from "@fancyapps/ui/dist/fancybox/index.js"; import { isSelectorValid } from "@web3r/flowerkit/css"; import { isNode } from "@web3r/flowerkit/dom"; import { bubble } from "@web3r/flowerkit/evt"; import { wait } from "@web3r/flowerkit/fn"; import { getJSONFromStr } from "@web3r/flowerkit/json"; import { getMergedObj, isObjHasOwnProp } from "@web3r/flowerkit/obj"; import { getId } from "@web3r/flowerkit/str"; import { createElement } from "react"; import type { PropsWithChildren } from "react"; import { getDocument, getWindow } from "ssr-window"; import { locales } from "./config/locales"; import type { TLocales } from "./config/locales"; import type { TCallbackArg, TControls, TFbOptions, TModalCfg, TPresets, TResult, TModalId, TLayout, TAsset, TActions, } from "./config/types"; import { getAsyncModalLayout, getTemplateLayout, getCfgProp, getLocaleCluster, isAssetsValid, } from "./utils/index"; import type { IBtn } from "../../components/btn"; import { Btn } from "../../components/btn"; import { Icon } from "../../components/icon"; import { Locale } from "../../components/locale"; import { Modal } from "../../components/modal"; import type { IModal } from "../../components/modal/config/types"; import { getPresetPartials } from "../../components/modal/lib"; import { constants } from "../../constants"; import type { IComponentBaseProps } from "../../types"; import { dispatchContentLoaded } from "../../utils/dispatchContentLoaded"; import { getAttr } from "../../utils/getAttr"; import { getClassString } from "../../utils/getClassString"; import { getSanitizedHTML } from "../../utils/getSanitizedHTML"; import { getStaticMarkupForClient } from "../../utils/react/getStaticMarkup.client"; import { Localisation } from "../localisation"; /** * Singleton. Плагин модальных окон. * Асинхронно загружает внутренние ресурсы и предоставляет методы для работы с модальными окнами. */ export class Modals { /** * Статусы операций во время показа модального окна */ static reasons = { open: "MODAL_IS_OPEN", close: "MODAL_IS_CLOSE", error: "MODAL_IS_ERROR", }; /** * Всплывающие события */ static bubbles = { // открытие модального окна после анимации open: "modals::open", // модальное окно готово к закрытию beforeClose: "modals::beforeClose", // закрытие модального окна после анимации close: "modals::close", // подтверждение внутри модального окна confirm: "modals::confirm", // отмена внутри модального окна cancel: "modals::cancel", // загрузка асинхронного модального окна loading: "modals::loading", // ошибка открытия модального окна error: "modals::error", }; /** * Singleton-экземпляр плагина модальных окон. * @type {Modals|null} */ static instance: Modals | null = null; /** * Текущая ссылка на Fancybox API. * Допускает замену реализации при интеграции. * @type {typeof Fancybox} */ static fancybox: typeof Fancybox = Fancybox; /** * Базовые селекторы модальных блоков и интерактивных элементов. */ static selectors = { modal: "[data-js-modal]", handler: "[data-js-modal-handler]", interaction: "input, textarea, select, form, label, a", }; /** * Стандартная конфигурация Fancybox * @see https://fancyapps.com/fancybox/api/options */ static fancyboxDefaultCfg: TFbOptions = { mainTpl: `
`, mainClass: "modal", animated: false, hideScrollbar: true, closeButton: false, compact: true, idle: false, on: { ["Carousel.ready"]: (instance: FancyboxInstance, slide: CarouselSlide) => null, close: (instance: FancyboxInstance, slide: CarouselSlide) => null, }, trapFocus: false, autoFocus: false, placeFocusBack: false, showClass: "isModalShow", hideClass: "isModalHide", triggerEl: null, }; /** * Конфигурация модального окна по умолчанию. * Используется как база при открытии через `getOpen` и парсинге handler-конфига. * @type {TModalCfg} */ static modalDefaultCfg: TModalCfg = { type: "inline", src: null, async: null, assets: null, redirectAfterClose: null, isReloadAfterClose: false, onAction: null, fancyboxCfg: {}, }; static #getModalElFromInstance(instance: FancyboxInstance) { const container = instance.getContainer(); return container?.querySelector(Modals.selectors.modal) ?? null; } static #getCfg(node: HTMLElement, selector: string): TModalCfg { const attrValue = node.getAttribute(getAttr(selector)); const parsedCfg: TModalCfg = attrValue ? getJSONFromStr(attrValue) : {}; if (selector === Modals.selectors.handler) { if (parsedCfg.assign) { return { assign: parsedCfg.assign, }; } else { return { ...Modals.modalDefaultCfg, ...parsedCfg, src: getCfgProp("src", node, parsedCfg) as TModalCfg["src"], id: getCfgProp("id", node, parsedCfg) as TModalCfg["id"], type: getCfgProp("type", node, parsedCfg) as TModalCfg["type"], action: parsedCfg.action, preset: parsedCfg.preset, triggerEl: node, fancyboxCfg: parsedCfg?.fancyboxCfg ?? {}, }; } } else { const { redirectAfterClose, isReloadAfterClose } = parsedCfg; return { redirectAfterClose, isReloadAfterClose, }; } } /** * Генерирует HTML-кнопку управления Fancybox-панелью. * @returns {Promise} */ static getControlsLayout = async (type: TControls): Promise => { if (!type) { return ""; } const props: Partial<{ localeCode: string; type: IBtn["type"]; icon: IBtn["icon"]; extraAttrs: IComponentBaseProps["extraAttrs"]; }> = { type: "button" as const, icon: type, localeCode: type.toUpperCase(), }; switch (type) { case "zoomOut": props.extraAttrs = { "data-panzoom-action": "zoomOut", }; break; case "zoomIn": props.extraAttrs = { "data-panzoom-action": "zoomIn", }; break; case "download": props.type = "link"; props.extraAttrs = { "download": true, "data-carousel-download": "", }; break; case "close": props.extraAttrs = { "data-fancybox-close": "", }; break; case "fullscreen": props.extraAttrs = { "data-fullscreen-action": "toggle", }; break; } return getStaticMarkupForClient(createElement(Locale, { code: `modals.${props.localeCode}`, attr: "title", }, createElement(Btn, { extraAttrs: props.extraAttrs, label: null, type: props.type, icon: props.icon, iconSize: "medium", }))); }; static #getCN(el?: string, mod?: IComponentBaseProps["extraClasses"], utilClasses?: string[]) { return getClassString("modal", el, mod); } /** * Получает HTML-разметку модального окна на основе готового шаблона * @param preset{TPresets} * @param props{Object=} * @returns {IModal} */ static getLayoutPresetProps = (preset: TPresets, props?: PropsWithChildren): IModal => { const id: string = props?.id ?? getId(9); const cfg = props?.cfg ?? {}; const presetPartials = getPresetPartials(preset, id, Modals.#getCN); const userPartials: TLayout = { children: props?.children || presetPartials.contentSlot, titleSlot: props?.titleSlot, footerSlot: props?.footerSlot, headerSlot: props?.headerSlot, }; const layout: TLayout = getMergedObj(userPartials, presetPartials); return { isInline: true, cfg, id, ...layout, }; }; constructor() { if (Modals.instance) { return Modals.instance; } Modals.instance = this; Modals.locales = locales; this.bindEvents(); } static set locales(locales: TLocales) { const setLocales = async (instance: Localisation) => { const currentLang: string = instance.lang; const langList: string[] = instance.langList; Modals.fancyboxDefaultCfg.l10n = getLocaleCluster(locales, currentLang, langList); // silent mode await instance.getResourceAppend({ modals: locales, }, true, true); }; const instance: Localisation | null = Localisation.instance; if (instance) { void setLocales(instance); } else { void Localisation.create() .then((created) => setLocales(created)); } } static getInstance(id?: TModalId) { if (Modals.instance) { return Modals.fancybox?.getInstance(id) ?? null; } else { return null; } } static async getClose(id: TModalId | undefined): Promise<{ reason: string; instance: FancyboxInstance | null; }> { return new Promise((resolve, reject) => { const resolveClose = (instance: FancyboxInstance | null = null) => { resolve({ reason: Modals.reasons.close, instance, }); }; const closeInstance = (instance: FancyboxInstance) => { Modals.#onAfterAnimated(instance) .finally(() => { resolveClose(instance); }); instance.close(); }; if (Modals.instance) { if (typeof id === "undefined") { Modals.fancybox?.close(true); resolveClose(null); } else { const instance = Modals.getInstance(id); if (instance) { closeInstance(instance); } else { const currentInstance = Modals.getInstance(); if (currentInstance) { console.warn(`[Modals] Instance with id "${id}" not found. The current modal will be closed as fallback`); closeInstance(currentInstance); } else { resolveClose(null); } } } } else { reject({ reason: Modals.reasons.error, instance: null, }); } }); } static #getDraggableCfg(cfg: TFbOptions, isDraggable: boolean): TFbOptions { return { ...cfg, dragToClose: isDraggable, Carousel: { ...cfg?.Carousel ?? {}, gestures: isDraggable ? cfg?.Carousel?.gestures : false, dragFree: isDraggable ? cfg?.Carousel?.dragFree : false, singleClickAction: isDraggable ? cfg?.Carousel?.singleClickAction : false, wheelAction: isDraggable ? cfg?.Carousel?.wheelAction : false, }, }; } static async #getInlineModal(src: string, cfg: TFbOptions) { if (isSelectorValid(src)) { const el = getDocument().querySelector(src); if (el) { const isDraggable = isObjHasOwnProp(cfg, "dragToClose") ? !!cfg.dragToClose : !Modals.#isHasInteraction(el); const instanceCfg = Modals.#getDraggableCfg({ ...cfg, parentEl: isNode(cfg.parentEl) ? cfg.parentEl as HTMLElement : (el?.parentNode ?? getDocument()), }, isDraggable); if (el.tagName.toLowerCase() === "template") { // template element const content = getTemplateLayout(el); if (content.length) { const instance = Modals.fancybox?.show([ { html: content, }, ], instanceCfg) ?? null; // with fallback return instance || Modals.getInstance(cfg.id); } else { return null; } } else { // other elements const instance = Modals.fancybox?.show([ { src, type: "inline", }, ], instanceCfg) ?? null; // with fallback return instance || Modals.getInstance(cfg.id); } } else { return null; } } else { return null; } } static async #getAssetsModal(src: TAsset[], cfg: TFbOptions) { const options: FancyboxOptions = { Carousel: { Thumbs: false, enabled: false, Toolbar: { items: { zoomPlus: { tpl: await Modals.getControlsLayout("zoomOut"), }, zoomMinus: { tpl: await Modals.getControlsLayout("zoomIn"), }, download: { tpl: await Modals.getControlsLayout("download"), }, prev: { tpl: await Modals.getControlsLayout("prev"), }, next: { tpl: await Modals.getControlsLayout("next"), }, fullscreen: { tpl: await Modals.getControlsLayout("fullscreen"), }, close: { tpl: await Modals.getControlsLayout("close"), }, }, display: { left: [], middle: [], right: [ "fullscreen", "download", "zoomPlus", "zoomMinus", "close", ], }, }, Arrows: { prevTpl: await getStaticMarkupForClient(createElement("span", { className: "btn__icon" }, createElement(Icon, { size: "medium", src: "prev" }))), nextTpl: await getStaticMarkupForClient(createElement("span", { className: "btn__icon" }, createElement(Icon, { size: "medium", src: "next" }))), }, }, ...cfg, }; const items = src.map((item, index) => { return { ...item, caption: (typeof item.caption === "function") ? item.caption(index) : item.caption, }; }); const instance = Modals.fancybox?.show(items, options) ?? null; return instance || Modals.getInstance(cfg.id); } static async #getHTMLModal(src: string, cfg: TFbOptions) { // replace `template` tags to `div` const source = `
${src.replace(/<(\/?)template\b([^>]*)>/g, "<$1div$2>")}
`; const html = getSanitizedHTML(source); const instance = Modals.fancybox?.show([ { html, }, ], cfg) ?? null; return instance || Modals.getInstance(cfg.id); } static onClick({ instance, event, onAction }: TCallbackArg) { let action: TActions = "click"; const { target } = event!; if (!!target && (target as HTMLElement).matches(Modals.selectors.handler)) { const handlerCfg = Modals.#getCfg(target as HTMLElement, Modals.selectors.handler); if (handlerCfg.action && handlerCfg.action !== "close") { action = handlerCfg.action; } } if (typeof onAction === "function") { onAction({ instance, action, event, }); } } static #isHasInteraction(el: Element) { return !!el.querySelectorAll(Modals.selectors.interaction).length; } static onRender({ instance, actions, slide, onAction, cfg, }: TCallbackArg) { const container = instance.getContainer(); if (!container) { return; } const arrows = container?.querySelectorAll(".is-arrow") ?? []; arrows.forEach((arrow) => { arrow.classList.add("btn"); arrow.classList.remove("f-button"); }); } static onAfterOpen({ instance, actions, slide, onAction, cfg, }: TCallbackArg) { const options: FancyboxOptions = instance.getOptions(); const container = instance.getContainer(); if (options.dragToClose && container) { container.classList.add("is-draggable"); } const el = Modals.#getModalElFromInstance(instance); if (el) { el.removeAttribute("hidden"); el.setAttribute("aria-hidden", "false"); } Modals.#onAfterAnimated(instance) .finally(() => { if (typeof onAction === "function") { onAction({ instance, action: "open", }); } const container = instance.getContainer(); if (container) { dispatchContentLoaded({ content: container, }); bubble(container || getDocument(), Modals.bubbles.open, { ...cfg, instance }); } actions.resolve({ reason: Modals.reasons.open, instance, }); }); } static async #onAfterAnimated(instance: FancyboxInstance) { const container = instance.getContainer(); if (container) { return Promise.race([ new Promise((resolve) => { const onAnimationEnd = (e: AnimationEvent) => { resolve(e); }; container.addEventListener("animationend", onAnimationEnd, { once: true, }); }), wait(5000).then(() => undefined), ]) .then((event: AnimationEvent | undefined) => { if (!event) { console.error(`[Modals] Force callback after 5000ms: can't catch "animationend" event. Please check animation CSS for this instance: `, instance); } return Promise.resolve(); }); } else { return Promise.resolve(); } } static onBeforeClose({ instance, actions, onAction, cfg, event, customEvent, }: TCallbackArg) { if (event && event.type === "cancel" && customEvent) { // fix the bug of file dialog closing customEvent.preventDefault(); } else { const container = instance.getContainer(); bubble(container || getDocument(), Modals.bubbles.beforeClose, { ...cfg, instance }); } } static onAfterClose({ instance, actions, onAction, cfg, event, }: TCallbackArg) { const el = Modals.#getModalElFromInstance(instance); if (el) { const { redirectAfterClose, isReloadAfterClose, } = Modals.#getCfg(el as HTMLElement, Modals.selectors.modal); switch (true) { case typeof redirectAfterClose === "string": getWindow().location.href = redirectAfterClose.toString(); break; case isReloadAfterClose: getWindow().location.reload(); break; default: } } Modals.#onAfterAnimated(instance) .finally(() => { if (el) { el.setAttribute("aria-hidden", "true"); el.setAttribute("hidden", ""); } if (typeof onAction === "function") { onAction({ instance, action: "close", }); } const bubbleEl = instance.getContainer() || getDocument(); bubble(bubbleEl, Modals.bubbles.close, { ...cfg, instance }); actions.resolve({ reason: Modals.reasons.close, instance, }); }); } static isOpen(id?: TModalId) { const instance = Modals.getInstance(id); if (instance) { const state = instance.getState(); return ![ 2, 3, 4 ].includes(state); } else { return false; } } static async getOpen(cfg: Omit) { const { src, type, preset, fancyboxCfg, id, async, assets, onAction, } = { ...Modals.modalDefaultCfg, ...cfg, }; return new Promise((resolve, reject) => { // Действия с результатом const actions = { resolve: (result: TResult = { reason: Modals.reasons.open, instance: null }) => resolve(result), reject: (result: TResult = { reason: Modals.reasons.error, instance: null }) => reject(result), }; void (async () => { // Адаптированная конфигурация для Fancybox const fbCfg = getMergedObj(Modals.fancyboxDefaultCfg, fancyboxCfg || {}); // Уникальный ID fbCfg.id = id; // Элемент триггера fbCfg.triggerEl = cfg.triggerEl; // Переопредление предварительного закрытия fbCfg.on.shouldClose = (...args: [ FancyboxInstance, CustomEvent, Event ]) => { const [ instance, customEvent, event ] = args; const callbackArg: TCallbackArg = { instance, actions, onAction, cfg, event, customEvent, }; Modals.onBeforeClose(callbackArg); }; // Переопределение закрытия fbCfg.on.close = (...args: [ FancyboxInstance, CarouselSlide, Event ]) => { const [ instance, slide, event ] = args; const callbackArg: TCallbackArg = { instance, slide, actions, onAction, cfg, event, }; Modals.onAfterClose(callbackArg); }; fbCfg.on["Carousel.click"] = (...args: [ FancyboxInstance, CarouselSlide, Event ]) => { const [ instance, slide, event ] = args; const callbackArg: TCallbackArg = { instance, slide, event, actions, onAction, cfg, }; Modals.onClick(callbackArg); }; // Переопределение рендера fbCfg.on["Carousel.render"] = (...args: [ FancyboxInstance, CarouselSlide ]) => { const [ instance, slide ] = args; const callbackArg: TCallbackArg = { instance, slide, actions, onAction, cfg, }; Modals.onRender(callbackArg); }; // Переопределение открытия fbCfg.on["Carousel.ready"] = (...args: [ FancyboxInstance, CarouselSlide ]) => { const [ instance, slide ] = args; const callbackArg: TCallbackArg = { instance, slide, actions, onAction, cfg, }; Modals.onAfterOpen(callbackArg); }; if (preset) { // готовый шаблон (только программное открытие через `getOpen`) switch (preset) { case "assets": if (!!assets && isAssetsValid(assets)) { fbCfg.id = "modalAssets_" + getId(9); // for multiple instances await Modals.#getAssetsModal(assets, fbCfg); } else { console.error(`[Modals] Preset "assets" needs non-empty array in "assets" prop with "type" and "src" in every child`); actions.reject({ reason: Modals.reasons.error, instance: null, }); } break; case "confirm": { fbCfg.id = "modalConfirm_" + getId(9); // for multiple instances fbCfg.dragToClose = true; const props = Modals.getLayoutPresetProps("confirm", { id: fbCfg.id }); const html = await getStaticMarkupForClient(createElement(Modal, props)); await Modals.#getHTMLModal(html, fbCfg); break; } case "success": { fbCfg.id = "modalSuccess_" + getId(9); // for multiple instances fbCfg.dragToClose = true; const props = Modals.getLayoutPresetProps("success", { id: fbCfg.id }); const html = await getStaticMarkupForClient(createElement(Modal, props)); await Modals.#getHTMLModal(html, fbCfg); break; } case "error": { fbCfg.id = "modalError_" + getId(9); // for multiple instances fbCfg.dragToClose = true; const props = Modals.getLayoutPresetProps("error", { id: fbCfg.id }); const html = await getStaticMarkupForClient(createElement(Modal, props)); await Modals.#getHTMLModal(html, fbCfg); break; } default: { console.error(`[Modals] Preset "${preset}" not found. Only "confirm", "success", "error" are supported`); actions.reject({ reason: Modals.reasons.error, instance: null, }); } } } else { if (!!type && !!src) { // Открытие нужного типа окна switch (type) { case "async": { bubble(getDocument(), Modals.bubbles.loading, { ...cfg, instance: null }); return await getAsyncModalLayout({ url: src, ...(async || {}), }) .then((data) => { return Modals.#getHTMLModal(data, fbCfg); }) .catch((msg) => { console.error(`[Modals] ${msg}`); actions.reject({ reason: Modals.reasons.error, instance: null, }); }); } case "inline": { const instance = await Modals.#getInlineModal(src, fbCfg); if (!instance) { console.error(`[Modals] Element with anchor "${src}" not found or empty`); actions.reject({ reason: Modals.reasons.error, instance: null, }); } break; } case "html": { const instance = await Modals.#getHTMLModal(src, fbCfg); if (!instance) { console.error(`[Modals] Can't open HTML modal`); actions.reject({ reason: Modals.reasons.error, instance: null, }); } break; } default: { console.error(`[Modals] Type "${type}" not found. Only "async", "inline", "html" are supported`); actions.reject({ reason: Modals.reasons.error, instance: null, }); } } } else { console.error(`[Modals] Props "type" or "src" are missing`); actions.reject({ reason: Modals.reasons.error, instance: null, }); } } })().catch((error: unknown) => { console.error("[Modals] Unexpected error while opening modal", error); actions.reject({ reason: Modals.reasons.error, instance: null }); }); }); } #lockHandler(handlerNode: HTMLElement, isLock = true) { if (handlerNode.matches("button, input")) { (handlerNode as HTMLButtonElement | HTMLInputElement).disabled = isLock; } handlerNode.classList.toggle("isDisabled", isLock); } async #actionFromHandler(handlerNode: HTMLElement) { // Cfg from clicked element let parsedCfg: TModalCfg = Modals.#getCfg(handlerNode, Modals.selectors.handler); // Labels or links with `assign` cfg if (parsedCfg.assign) { // Original element const getHandler: ((id: string) => HTMLElement) = (id) => { return !!id ? getDocument().getElementById(id) : null; }; // Merge `assign` to cfg const getMergedConfigs: ((handlerCfg: TModalCfg) => TModalCfg) = (handlerCfg) => { const merged: TModalCfg = getMergedObj(parsedCfg.assign || {}, handlerCfg); delete merged.assign; return merged; }; switch (handlerNode.tagName.toLowerCase()) { case "label": if (handlerNode.hasAttribute("for")) { const id = (handlerNode as HTMLLabelElement)?.getAttribute("for") || ""; const handler = getHandler(id); if (handler) { const handlerCfg = Modals.#getCfg(handler, Modals.selectors.handler); handlerNode = handler; parsedCfg = getMergedConfigs(handlerCfg); } } break; case "a": if (handlerNode.hasAttribute("href")) { const id = (handlerNode as HTMLLinkElement).getAttribute("href")?.replace("#", "") ?? ""; const handler = getHandler(id); if (handler) { const handlerCfg = Modals.#getCfg(handler, Modals.selectors.handler); handlerNode = handler; parsedCfg = getMergedConfigs(handlerCfg); } } break; default: console.error(`[Modals] Assign prop in cfg only applyed to "a" or "label" elements with non-empty "href" or "for" attributes`); } } if (parsedCfg.id) { switch (parsedCfg.action) { case "open": this.#lockHandler(handlerNode, true); parsedCfg.onAction = ({ action }) => { if (action === "close") { this.#lockHandler(handlerNode, false); } }; await Modals.getOpen(parsedCfg) .catch(async (err) => { this.#lockHandler(handlerNode, false); bubble(getDocument(), Modals.bubbles.error, { ...parsedCfg, instance: null }); if (err?.reason === Modals.reasons.error) { await Modals.getOpen({ preset: "error", }); } }); break; case "close": await Modals.getClose(parsedCfg.id) .catch((err) => { bubble(getDocument(), Modals.bubbles.error, { ...parsedCfg, instance: null }); throw err; }); break; case "confirm": bubble(handlerNode, Modals.bubbles.confirm, { ...parsedCfg, instance: Modals.getInstance(parsedCfg.id) }); break; case "cancel": bubble(handlerNode, Modals.bubbles.cancel, { ...parsedCfg, instance: Modals.getInstance(parsedCfg.id) }); break; default: console.error(`[Modals] Action for handler not found, only "open", "close", "confirm", "cancel" are supported`); } } else { console.error(`[Modals] Prop "id" for handler not found`); } } #onLangChange(e: CustomEvent) { const instance: Localisation = e.detail.instance; const locales = instance.getResource("modals"); if (locales) { Modals.locales = locales; } } #onClick(e: Event) { const triggerEl = (e.target as HTMLElement).closest(Modals.selectors.handler); if (triggerEl) { e.preventDefault(); this.#actionFromHandler(triggerEl) .catch((error) => { console.error(`[Modals] Can't run action from handler`, error); }); } } bindEvents() { getDocument().addEventListener(constants.localisationBubbles.localisationChange, this.#onLangChange.bind(this)); getDocument().addEventListener("click", this.#onClick.bind(this)); } }