• File: themestrap.plugin.dialog.js
  • Full Path: /home/daemondevin/public_html/portableapps/assets/admin/themestrap/themestrap.plugin.dialog.js
  • Date Modified: 10/17/2026 7:00 PM
  • File size: 24.43 KB
  • MIME-type: application/javascript
  • Charset: utf-8
/**
 * Themestrap Dialog Plugin
 * Accessible, focus-trapped dialogs with backdrop, scroll-lock, and transition support.
 * Part of the Themestrap component library for MODX 3
 *
 * Markup anatomy:
 *
 *   <!-- Trigger (anywhere in the DOM) -->
 *   <button data-dialog-open="payment-dialog">Open</button>
 *
 *   <!-- Dialog root -->
 *   <div data-plugin-dialog id="payment-dialog"
 *        data-plugin-options='{"animationIn": "fadeInDown", "closeOnBackdrop": true}'>
 *
 *     <!-- Backdrop: click-to-close overlay -->
 *     <div data-dialog-backdrop></div>
 *
 *     <!-- Panel: the visible card -->
 *     <div data-dialog-panel>
 *
 *       <!-- Title & description wired to ARIA automatically -->
 *       <h2 data-dialog-title>Confirm Payment</h2>
 *       <p data-dialog-description>Review the details below before confirming.</p>
 *
 *       <!-- Any content here -->
 *
 *       <!-- Dedicated close trigger(s) inside the panel -->
 *       <button data-dialog-close>Cancel</button>
 *       <button data-dialog-close>Confirm</button>
 *     </div>
 *   </div>
 *
 * Public API (via stored instance):
 *   const dlg = $('#payment-dialog').data('__pluginDialog');
 *   dlg.open();
 *   dlg.close();
 *   dlg.toggle();
 *
 * Prompts (replace window.confirm and window.alert, no markup needed):
 *   themestrap.PluginDialog.confirm({ title: 'Delete this post?', message: 'This cannot be undone.',
 *                                     confirmLabel: 'Delete', cancelLabel: 'Keep it', danger: true })
 *       .then(function (yes) { if (yes) { ... } });
 *   themestrap.PluginDialog.alert({ title: 'Saved', message: 'Your changes are live.' })
 *       .then(function () { ... });
 *   Both return a Promise. confirm resolves true or false (Escape, backdrop and Cancel resolve false).
 *   A plain string is shorthand for { message }. Text is inserted as text, never as HTML.
 *   danger: true focuses Cancel instead of the confirm button and styles the confirm button as destructive.
 *
 * Events fired on the dialog root element:
 *   dialog:open   — after open animation starts   (receives instance as arg)
 *   dialog:close  — after close animation ends     (receives instance as arg)
 *
 * Init.js wiring (DOMReady-immediate — dialogs must be ready before any trigger fires):
 *   if ($.isFunction($.fn['themestrapPluginDialog']) && $('[data-plugin-dialog]').length) {
 *       $(() => {
 *           $('[data-plugin-dialog]:not(.manual)').each(function () {
 *               const $this = $(this);
 *               const opts = themestrap.fn.getOptions($this.data('plugin-options')) || undefined;
 *               $this.themestrapPluginDialog(opts);
 *           });
 *       });
 *   }
 */
(((themestrap = {}, $) => {
    const instanceName = '__pluginDialog';

    // Focusable selector (ARIA-compliant set)
    const FOCUSABLE = [
        'a[href]',
        'button:not([disabled])',
        'input:not([disabled])',
        'select:not([disabled])',
        'textarea:not([disabled])',
        '[tabindex]:not([tabindex="-1"])',
        '[contenteditable="true"]',
    ].join(', ');

    // generate a short collision-resistant ID
    let _seq = 0;
    const uid = (prefix) => `${prefix}-${++_seq}-${Math.random().toString(36).slice(2, 7)}`;
    
    // Injected stylesheet — keyed to STYLE_ID so it runs only once per page
    const STYLE_ID = 'ts-dialog-styles';
    // CSS_TEXT is injected lazily by injectStyles() on first build (see below),
    // so loading this script never adds CSS to pages that don't use the plugin.
    const CSS_TEXT = `/**
 * PluginDialog — Themestrap Dialog Styles
 *
 * Structure expected:
 * .dialog-root                    ← [data-plugin-dialog] — full-screen container
 * ├── [data-dialog-backdrop]      ← dim overlay
 * └── [data-dialog-panel]         ← visible card / content region
 *     ├── [data-dialog-title]
 *     ├── [data-dialog-description]
 *     └── …content…
 *
 * State classes (toggled by the plugin):
 * .dialog-root.dialog-hidden      ← closed (display:none equivalent via CSS)
 * .dialog-root.dialog-is-open     ← open
 * body.dialog-scroll-lock         ← scroll disabled while a dialog is open
 */
.dialog-root {
    position: fixed;
    inset: 0;                  /* top/right/bottom/left: 0 */
    z-index: 1060;             /* above Bootstrap modals (1055) */
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1rem;
    isolation: isolate;        /* keep backdrop/panel layering deterministic */

    /* Hidden state — plugin toggles this class */
    &.dialog-hidden {
        display: none;
        pointer-events: none;
        visibility: hidden;
    }
}

[data-dialog-backdrop] {
    position: absolute;
    inset: 0;
    z-index: 0;
    background-color: rgba(0, 0, 0, 0.6);
    backdrop-filter: blur(2px);
    -webkit-backdrop-filter: blur(2px);

    /* Fade in with the dialog */
    .dialog-is-open & {
        animation: dialogBackdropIn 0.2s ease forwards;
    }
}

@keyframes dialogBackdropIn {
    from { opacity: 0; }
    to   { opacity: 1; }
}

[data-dialog-panel] {
    position: relative;         /* sit above backdrop */
    z-index: 1;
    background: #fff;
    border-radius: 0.75rem;
    box-shadow:
        0 20px 60px rgba(0, 0, 0, 0.25),
        0 4px  12px rgba(0, 0, 0, 0.12);
    padding: 2rem;
    width: 100%;
    max-width: 32rem;           /* ~512px — override with data-plugin-options if needed */
    max-height: calc(100vh - 4rem);
    overflow-y: auto;

    /* Scrollbar styling (WebKit) */
    scrollbar-width: thin;
    &::-webkit-scrollbar       { width: 4px; }
    &::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); border-radius: 2px; }
}

[data-dialog-title] {
    font-size: 1.25rem;
    font-weight: 600;
    line-height: 1.3;
    margin-top: 0;
    margin-bottom: 0.5rem;
    color: inherit;
}

[data-dialog-description] {
    font-size: 0.9375rem;
    color: #6b7280;
    margin-bottom: 1.5rem;
    line-height: 1.5;
}

body.ts-scroll-lock {
    overflow: hidden;
    /* Prevent layout shift from scrollbar disappearing */
    padding-right: var(--ts-scrollbar-width, 0px);
}

/* The plugin adds these classes to [data-dialog-panel].
   Uses Animate.css-style naming so Themestrap's existing
   animationIn/Out options work with zero extra CSS.               */
@keyframes dialogFadeIn {
    from { opacity: 0; transform: scale(0.97) translateY(-6px); }
    to   { opacity: 1; transform: scale(1)    translateY(0); }
}

@keyframes dialogFadeOut {
    from { opacity: 1; transform: scale(1)    translateY(0); }
    to   { opacity: 0; transform: scale(0.97) translateY(-6px); }
}

[data-dialog-panel].fadeIn  { animation: dialogFadeIn  0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
[data-dialog-panel].fadeOut { animation: dialogFadeOut 0.2s  ease-in                        forwards; }

@keyframes dialogFadeInDown {
    from { opacity: 0; transform: translateY(-24px); }
    to   { opacity: 1; transform: translateY(0); }
}

@keyframes dialogFadeOutUp {
    from { opacity: 1; transform: translateY(0); }
    to   { opacity: 0; transform: translateY(-24px); }
}

[data-dialog-panel].fadeInDown { animation: dialogFadeInDown 0.28s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
[data-dialog-panel].fadeOutUp  { animation: dialogFadeOutUp  0.2s  ease-in                        forwards; }

@keyframes dialogFadeInUp {
    from { opacity: 0; transform: translateY(24px); }
    to   { opacity: 1; transform: translateY(0); }
}

@keyframes dialogFadeOutDown {
    from { opacity: 1; transform: translateY(0); }
    to   { opacity: 0; transform: translateY(24px); }
}

[data-dialog-panel].fadeInUp   { animation: dialogFadeInUp   0.28s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
[data-dialog-panel].fadeOutDown{ animation: dialogFadeOutDown 0.2s  ease-in                        forwards; }

@keyframes dialogZoomIn {
    from { opacity: 0; transform: scale(0.85); }
    to   { opacity: 1; transform: scale(1); }
}

@keyframes dialogZoomOut {
    from { opacity: 1; transform: scale(1); }
    to   { opacity: 0; transform: scale(0.85); }
}

[data-dialog-panel].zoomIn  { animation: dialogZoomIn  0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; }
[data-dialog-panel].zoomOut { animation: dialogZoomOut 0.18s ease-in                            forwards; }

@media (max-width: 575.98px) {
    .dialog-root {
        align-items: flex-end;
        padding: 0;
    }

    [data-dialog-panel] {
        border-radius: 1rem 1rem 0 0;
        max-width: 100%;
        max-height: 92vh;
        padding: 1.5rem 1.25rem 2rem;
    }

    /* Override panel animations with a sheet-style slide-up on mobile */
    [data-dialog-panel].fadeIn,
    [data-dialog-panel].fadeInDown,
    [data-dialog-panel].fadeInUp,
    [data-dialog-panel].zoomIn {
        animation: dialogFadeInUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
    }

    [data-dialog-panel].fadeOut,
    [data-dialog-panel].fadeOutUp,
    [data-dialog-panel].fadeOutDown,
    [data-dialog-panel].zoomOut {
        animation: dialogFadeOutDown 0.2s ease-in forwards;
    }
}

/* Add .dialog-sm / .dialog-lg / .dialog-xl / .dialog-full to [data-dialog-panel] */
[data-dialog-panel].dialog-sm   { max-width: 22rem; }
[data-dialog-panel].dialog-lg   { max-width: 48rem; }
[data-dialog-panel].dialog-xl   { max-width: 64rem; }
[data-dialog-panel].dialog-full {
    max-width: calc(100vw - 2rem);
    max-height: calc(100vh - 2rem);
    width: 100%;
}

/* Add .dialog-alert to [data-plugin-dialog] for a compact centered alert. */
.dialog-alert [data-dialog-panel] {
    text-align: center;
    padding: 2.5rem 2rem;
    max-width: 26rem;
}

.dialog-alert [data-dialog-title] {
    font-size: 1.1rem;
    margin-bottom: 0.75rem;
}

@media (prefers-color-scheme: dark) {
    [data-dialog-panel] {
        background: #1e2939;
        color: #f3f4f6;
        box-shadow:
            0 20px 60px rgba(0, 0, 0, 0.55),
            0 4px  12px rgba(0, 0, 0, 0.3);
    }

    [data-dialog-description] {
        color: #9ca3af;
    }
}

/* Prompts built by PluginDialog.confirm() and PluginDialog.alert() */
.ts-dlg-prompt [data-dialog-panel] { max-width: 26rem; padding: 1.5rem; }
.ts-dlg-prompt [data-dialog-description] { margin-bottom: 0; white-space: pre-line; }
.ts-dlg-prompt [data-dialog-description]:empty { display: none; }
.ts-dlg-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    justify-content: flex-end;
    margin-top: 1.25rem;
}
`;

    // Inject the stylesheet only when the plugin is actually used (called
    // from build()), so loading the script never adds CSS to unused pages.
    function injectStyles() {
        if (document.getElementById(STYLE_ID)) return;
        const style = document.createElement('style');
        style.id = STYLE_ID;
        style.textContent = CSS_TEXT;
        (document.head || document.documentElement).appendChild(style);
    }

    class PluginDialog {
        constructor($el, opts) {
            return this.initialize($el, opts);
        }

        initialize($el, opts) {
            if ($el.data(instanceName)) {
                return this;
            }

            this.$el            = $el;
            this.$previousFocus = null;
            this.isOpen         = false;
            this._uid           = uid('dialog');

            this
                .setData()
                .setOptions(opts)
                .build()
                .events();

            return this;
        }

        setData() {
            this.$el.data(instanceName, this);
            return this;
        }

        setOptions(opts) {
            this.options = $.extend(true, {}, PluginDialog.defaults, opts, {
                wrapper: this.$el,
            });
            return this;
        }

        build() {
            // Inject component CSS lazily - only when an instance is built.
            injectStyles();
            const self = this;
            const $el  = self.$el;
            const opts = self.options;

            // ARIA role / modal flag
            if (!$el.attr('role')) $el.attr('role', 'dialog');
            $el.attr('aria-modal', 'true');

            // Auto-wire title → aria-labelledby
            const $title = $el.find('[data-dialog-title]').first();
            if ($title.length && !$el.attr('aria-labelledby')) {
                const id = $title.attr('id') || uid('dialog-title');
                $title.attr('id', id);
                $el.attr('aria-labelledby', id);
            }

            // Auto-wire description → aria-describedby
            const $desc = $el.find('[data-dialog-description]').first();
            if ($desc.length && !$el.attr('aria-describedby')) {
                const id = $desc.attr('id') || uid('dialog-desc');
                $desc.attr('id', id);
                $el.attr('aria-describedby', id);
            }

            // Ensure a backdrop element exists when option is on
            if (opts.backdrop) {
                self.$backdrop = $el.find('[data-dialog-backdrop]');
                if (!self.$backdrop.length) {
                    self.$backdrop = $('<div data-dialog-backdrop></div>');
                    $el.prepend(self.$backdrop);
                }
            }

            // Grab the panel (content card) — may be absent for simple dialogs
            self.$panel = $el.find('[data-dialog-panel]');

            // Hidden by default — use CSS class rather than inline style so
            // theme CSS retains full control of transitions.
            $el
                .addClass('dialog-root')
                .attr('aria-hidden', 'true')
                .attr('tabindex', '-1');

            if (!$el.hasClass('dialog-is-open')) {
                $el.addClass('dialog-hidden');
            }

            return this;
        }

        events() {
            const self     = this;
            const $el      = self.$el;
            const opts     = self.options;
            const dialogId = $el.attr('id');

            // External trigger buttons (open)
            if (dialogId) {
                $(document).on(
                    `click.dialog.${self._uid}`,
                    `[data-dialog-open="${dialogId}"]`,
                    function (e) {
                        e.preventDefault();
                        self.open();
                    }
                );
            }

            // Internal close buttons
            $el.on('click.dialog', '[data-dialog-close]', function (e) {
                e.preventDefault();
                self.close();
            });

            // Backdrop click to close
            if (opts.closeOnBackdrop && self.$backdrop && self.$backdrop.length) {
                $el.on('click.dialog.backdrop', function (e) {
                    if ($(e.target).is('[data-dialog-backdrop]')) {
                        self.close();
                    }
                });
            }

            // Escape key (document-level, scoped to this instance)
            if (opts.closeOnEscape) {
                $(document).on(`keydown.dialog.${self._uid}`, function (e) {
                    if (self.isOpen && (e.key === 'Escape' || e.keyCode === 27)) {
                        e.preventDefault();
                        self.close();
                    }
                });
            }

            return this;
        }

        open() {
            const self = this;
            const $el  = self.$el;
            const opts = self.options;

            if (self.isOpen) return this;
            self.isOpen = true;

            // Remember where focus was so we can restore it on close
            self.$previousFocus = $(document.activeElement);

            // Scroll lock: add class to <body>.
            // CSS rule:  body.ts-scroll-lock { overflow: hidden; padding-right: var(--ts-scrollbar-width, 0px); }
            // The padding-right compensates for the scrollbar disappearing, preventing layout shift.
            if (opts.scrollLock) {
                $('body').addClass('ts-scroll-lock');
            }

            // Reveal
            $el
                .removeClass('dialog-hidden')
                .addClass('dialog-is-open')
                .attr('aria-hidden', 'false');

            // Panel-level entrance animation
            const $animTarget = self.$panel.length ? self.$panel : $el;
            if (opts.animationIn) {
                $animTarget
                    .addClass(`dialog-anim-enter ${opts.animationIn}`)
                    .one('animationend webkitAnimationEnd', function () {
                        $animTarget.removeClass(`dialog-anim-enter ${opts.animationIn}`);
                    });

                setTimeout(() => {
                    $animTarget.removeClass(`dialog-anim-enter ${opts.animationIn}`);
                }, opts.animationDuration + 50);
            }

            // Focus management
            setTimeout(() => self._focusFirst(), 50);

            // Focus trap: intercept Tab / Shift+Tab
            $el.on('keydown.dialog.trap', (e) => {
                if (e.key === 'Tab' || e.keyCode === 9) {
                    self._trapFocus(e);
                }
            });

            // Callback & event
            if (typeof opts.onOpen === 'function') opts.onOpen.call(self, $el);
            $el.trigger('dialog:open', [self]);

            return this;
        }

        close() {
            const self = this;
            const $el  = self.$el;
            const opts = self.options;

            if (!self.isOpen) return this;

            $el.off('keydown.dialog.trap');

            const $animTarget = self.$panel.length ? self.$panel : $el;

            if (opts.animationOut) {
                $animTarget
                    .addClass(`dialog-anim-leave ${opts.animationOut}`)
                    .one('animationend webkitAnimationEnd', function () {
                        $animTarget.removeClass(`dialog-anim-leave ${opts.animationOut}`);
                        self._finishClose();
                    });

                setTimeout(() => {
                    $animTarget.removeClass(`dialog-anim-leave ${opts.animationOut}`);
                    self._finishClose();
                }, opts.animationDuration + 50);
            } else {
                self._finishClose();
            }

            return this;
        }

        toggle() {
            return this.isOpen ? this.close() : this.open();
        }

        _finishClose() {
            const self = this;
            const $el  = self.$el;
            const opts = self.options;

            if (!self.isOpen) return;
            self.isOpen = false;

            $el
                .addClass('dialog-hidden')
                .removeClass('dialog-is-open')
                .attr('aria-hidden', 'true');

            if (opts.scrollLock) {
                $('body').removeClass('ts-scroll-lock');
            }

            // Restore focus to the element that opened the dialog
            if (self.$previousFocus && self.$previousFocus.length) {
                self.$previousFocus.trigger('focus');
                self.$previousFocus = null;
            }

            if (typeof opts.onClose === 'function') opts.onClose.call(self, $el);
            $el.trigger('dialog:close', [self]);
        }

        _focusFirst() {
            // An element marked data-dialog-autofocus wins, so prompts can pick a safe default button
            const $pref = this.$el.find('[data-dialog-autofocus]').first();
            if ($pref.length) {
                $pref.trigger('focus');
                return;
            }
            const focusable = this._focusable();
            if (focusable.length) {
                focusable.first().trigger('focus');
            } else {
                this.$el.trigger('focus');
            }
        }

        _trapFocus(e) {
            const focusable = this._focusable();
            if (!focusable.length) return;

            const $first   = focusable.first();
            const $last    = focusable.last();
            const $current = $(document.activeElement);

            if (e.shiftKey) {
                if ($current.is($first)) {
                    e.preventDefault();
                    $last.trigger('focus');
                }
            } else {
                if ($current.is($last)) {
                    e.preventDefault();
                    $first.trigger('focus');
                }
            }
        }

        _focusable() {
            return this.$el.find(FOCUSABLE).filter(':visible').not('[data-dialog-backdrop]');
        }

        destroy() {
            const self = this;
            const $el  = self.$el;

            if (self.isOpen) self.close();

            $(document).off(`click.dialog.${self._uid}`);
            $(document).off(`keydown.dialog.${self._uid}`);
            $el.off('.dialog');

            $el
                .removeData(instanceName)
                .removeAttr('role aria-modal aria-hidden aria-labelledby aria-describedby tabindex')
                .removeClass('dialog-root dialog-hidden dialog-is-open');

            return this;
        }
    }

    const PROMPT_DEFAULTS = {
        title:           '',
        message:         '',
        confirmLabel:    'OK',
        cancelLabel:     'Cancel',
        danger:          false,       // destructive action: Cancel is focused first
        closeOnBackdrop: true,
        closeOnEscape:   true,
        classes: {
            confirm: 'btn btn-primary',
            danger:  'btn btn-danger',
            cancel:  'btn btn-light',
            actions: ''
        }
    };

    // Builds a throwaway dialog, resolves when it closes, then removes every trace of it
    function prompt(kind, input) {
        const o = $.extend(true, {}, PROMPT_DEFAULTS, typeof input === 'string' ? { message: input } : input);
        if (!o.title) o.title = kind === 'alert' ? 'Notice' : 'Are you sure?';

        return new Promise((resolve) => {
            let result = false;

            const $root  = $('<div data-plugin-dialog class="manual ts-dlg-prompt" role="alertdialog"></div>');
            const $panel = $('<div data-dialog-panel></div>').appendTo($root);
            $('<h2 data-dialog-title></h2>').text(o.title).appendTo($panel);
            $('<p data-dialog-description></p>').text(o.message).appendTo($panel);

            const $actions = $('<div class="ts-dlg-actions"></div>').addClass(o.classes.actions).appendTo($panel);
            let $cancel = $();
            if (kind === 'confirm') {
                $cancel = $('<button type="button" data-dialog-close></button>')
                    .addClass(o.classes.cancel).text(o.cancelLabel).appendTo($actions);
            }
            const $ok = $('<button type="button" data-dialog-ok></button>')
                .addClass(o.danger ? o.classes.danger : o.classes.confirm).text(o.confirmLabel).appendTo($actions);

            // Safe default focus: Cancel for destructive prompts, otherwise the confirm button
            ($cancel.length && o.danger ? $cancel : $ok).attr('data-dialog-autofocus', '');

            $root.appendTo(document.body);
            $root.themestrapPluginDialog({
                closeOnBackdrop: o.closeOnBackdrop,
                closeOnEscape:   o.closeOnEscape
            });
            const dlg = $root.data(instanceName);

            $ok.on('click', () => { result = true; dlg.close(); });
            $root.on('dialog:close', () => {
                dlg.destroy();
                $root.remove();
                resolve(kind === 'alert' ? true : result);
            });

            dlg.open();
        });
    }

    PluginDialog.confirm = (opts) => prompt('confirm', opts);
    PluginDialog.alert   = (opts) => prompt('alert', opts);

    PluginDialog.defaults = {
        closeOnBackdrop  : true,    // close when [data-dialog-backdrop] is clicked
        closeOnEscape    : true,    // close on Escape key
        backdrop         : true,    // inject/require a [data-dialog-backdrop] element
        animationIn      : 'fadeIn',        // CSS animation class applied to [data-dialog-panel]
        animationOut     : 'fadeOut',       // CSS animation class applied on close
        animationDuration: 300,             // ms — fallback if animationend never fires
        scrollLock       : true,    // add .ts-scroll-lock to <body> while open
        onOpen           : null,    // function(dialogElement) {}
        onClose          : null,    // function(dialogElement) {}
    };

    $.extend(themestrap, { PluginDialog });

    $.fn.themestrapPluginDialog = function (opts) {
        return this.map(function () {
            const $this = $(this);
            if ($this.data(instanceName)) {
                return $this.data(instanceName);
            }
            return new PluginDialog($this, opts);
        });
    };

})).apply(this, [window.themestrap, jQuery]);