Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
File Manager
/
portableapps
/
assets
/
admin
/
themestrap
:
themestrap.plugin.dialog.js
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
/** * 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]);