• File: themestrap.plugin.alert.js
  • Full Path: /home/daemondevin/public_html/portableapps/personal/themestrap/themestrap.plugin.alert.js
  • Date Modified: 10/23/2026 3:45 AM
  • File size: 32.21 KB
  • MIME-type: text/plain
  • Charset: utf-8
// Alert
(((themestrap = {}, $) => {
    const instanceName = '__pluginAlert';

    // Injected stylesheet — keyed to STYLE_ID so it runs only once per page
    const STYLE_ID = 'ts-alert-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 = `/* Themestrap — PluginAlert */
.alert-ts-primary {
    --alert-ts-bg           : var(--primary-rgba-10);
    --alert-ts-border       : var(--primary);
    --alert-ts-icon         : var(--primary);
    --alert-ts-title        : var(--primary-300);
    --alert-ts-text         : var(--primary-200);
    --alert-ts-action       : var(--primary-200);
    --alert-ts-action-hover : var(--primary-300);
    --alert-ts-close        : var(--primary--200);
    --alert-ts-close-hover  : var(--primary-300);
}

.alert-ts-secondary {
    --alert-ts-bg           : var(--secondary-rgba-10);
    --alert-ts-border       : var(--secondary);
    --alert-ts-icon         : var(--secondary);
    --alert-ts-title        : var(--secondary-300);
    --alert-ts-text         : var(--secondary-200);
    --alert-ts-action       : var(--secondary-200);
    --alert-ts-action-hover : var(--secondary-300);
    --alert-ts-close        : var(--secondary--200);
    --alert-ts-close-hover  : var(--secondary-300);
}

.alert-ts-tertiary {
    --alert-ts-bg           : var(--tertiary-rgba-10);
    --alert-ts-border       : var(--tertiary);
    --alert-ts-icon         : var(--tertiary);
    --alert-ts-title        : var(--tertiary-300);
    --alert-ts-text         : var(--tertiary-200);
    --alert-ts-action       : var(--tertiary-200);
    --alert-ts-action-hover : var(--tertiary-300);
    --alert-ts-close        : var(--tertiary--200);
    --alert-ts-close-hover  : var(--tertiary-300);
}

.alert-ts-quaternary {
    --alert-ts-bg           : var(--quaternary-rgba-10);
    --alert-ts-border       : var(--quaternary);
    --alert-ts-icon         : var(--quaternary);
    --alert-ts-title        : var(--quaternary-300);
    --alert-ts-text         : var(--quaternary-200);
    --alert-ts-action       : var(--quaternary-200);
    --alert-ts-action-hover : var(--quaternary-300);
    --alert-ts-close        : var(--quaternary--200);
    --alert-ts-close-hover  : var(--quaternary-300);
}

.alert-ts-light {
    --alert-ts-bg           : var(--light-100);
    --alert-ts-border       : var(--grey-800);
    --alert-ts-icon         : var(--light-300);
    --alert-ts-title        : var(--dark-300);
    --alert-ts-text         : var(--dark-200);
    --alert-ts-action       : var(--dark-200);
    --alert-ts-action-hover : var(--dark-300);
    --alert-ts-close        : var(--dark--200);
    --alert-ts-close-hover  : var(--dark-300);
}

.alert-ts-dark {
    --alert-ts-bg           : var(--dark--300);
    --alert-ts-border       : var(--dark-300);
    --alert-ts-icon         : var(--dark-300);
    --alert-ts-title        : var(--light-300);
    --alert-ts-text         : var(--light-200);
    --alert-ts-action       : var(--light-200);
    --alert-ts-action-hover : var(--light-300);
    --alert-ts-close        : var(--light--200);
    --alert-ts-close-hover  : var(--light-300);
}

.alert-ts-info {
    --alert-ts-bg           : #eff6ff;
    --alert-ts-border       : #3b82f6;
    --alert-ts-icon         : #3b82f6;
    --alert-ts-title        : #1e40af;
    --alert-ts-text         : #1d4ed8;
    --alert-ts-action       : #1d4ed8;
    --alert-ts-action-hover : #1e40af;
    --alert-ts-close        : #93c5fd;
    --alert-ts-close-hover  : #1d4ed8;
}

.alert-ts-success {
    --alert-ts-bg           : #f0fdf4;
    --alert-ts-border       : #22c55e;
    --alert-ts-icon         : #22c55e;
    --alert-ts-title        : #14532d;
    --alert-ts-text         : #15803d;
    --alert-ts-action       : #15803d;
    --alert-ts-action-hover : #14532d;
    --alert-ts-close        : #86efac;
    --alert-ts-close-hover  : #15803d;
}

.alert-ts-warning {
    --alert-ts-bg           : #fffbeb;
    --alert-ts-border       : #f59e0b;
    --alert-ts-icon         : #f59e0b;
    --alert-ts-title        : #78350f;
    --alert-ts-text         : #92400e;
    --alert-ts-action       : #92400e;
    --alert-ts-action-hover : #78350f;
    --alert-ts-close        : #fcd34d;
    --alert-ts-close-hover  : #92400e;
}

.alert-ts-danger {
    --alert-ts-bg           : #fef2f2;
    --alert-ts-border       : #ef4444;
    --alert-ts-icon         : #ef4444;
    --alert-ts-title        : #7f1d1d;
    --alert-ts-text         : #991b1b;
    --alert-ts-action       : #991b1b;
    --alert-ts-action-hover : #7f1d1d;
    --alert-ts-close        : #fca5a5;
    --alert-ts-close-hover  : #991b1b;
}

.alert-ts-neutral {
    --alert-ts-bg           : #f9fafb;
    --alert-ts-border       : #6b7280;
    --alert-ts-icon         : #6b7280;
    --alert-ts-title        : #111827;
    --alert-ts-text         : #374151;
    --alert-ts-action       : #374151;
    --alert-ts-action-hover : #111827;
    --alert-ts-close        : #d1d5db;
    --alert-ts-close-hover  : #374151;
}


/* Base alert-ts layout */
.alert.alert-ts {
    display          : flex;
    align-items      : flex-start;
    gap              : 0.75rem;
    padding          : 1rem 1rem 1rem 1.25rem;
    border-radius    : 0.5rem;
    border-width     : 0;
    border-left      : 4px solid var(--alert-ts-border);
    background-color : var(--alert-ts-bg);
    position         : relative;
    overflow         : hidden;   /* clips countdown bar */
}

.alert.alert-ts.alert-sm {
    padding: 5px 10px;
    font-size: 0.9em;
}

.alert.alert-ts.alert-lg {
    padding: 20px;
    font-size: 1.2em;
}

/* Remove Bootstrap's default coloured border/bg — our tokens take over */
.alert.alert-ts[class*="alert-primary"],
.alert.alert-ts[class*="alert-secondary"],
.alert.alert-ts[class*="alert-success"],
.alert.alert-ts[class*="alert-danger"],
.alert.alert-ts[class*="alert-warning"],
.alert.alert-ts[class*="alert-info"],
.alert.alert-ts[class*="alert-light"],
.alert.alert-ts[class*="alert-dark"] {
    background-color : var(--alert-ts-bg);
    border-color     : transparent;
    border-left-color: var(--alert-ts-border);
    color            : var(--alert-ts-text);
}


/* Icon */
.alert-ts-icon {
    flex-shrink    : 0;
    width          : 1.25rem;   /* 20 px */
    height         : 1.25rem;
    color          : var(--alert-ts-icon);
    margin-top     : 1px;       /* optical alignment with first line of text */
}

.alert-ts-icon svg {
    display : block;
    width   : 100%;
    height  : 100%;
}

/* Body */
.alert-ts-body {
    flex    : 1 1 auto;
    min-width: 0;
}

.alert-ts-title {
    font-size   : 0.975rem;   /* 14 px */
    font-weight : 600;
    line-height : 1.4;
    color       : var(--alert-ts-title);
    margin      : 0 0 0.25rem;
}

.alert-ts-text {
    font-size   : 0.975rem;
    line-height : 1.5;
    color       : var(--alert-ts-text);
}

.alert-ts-text p:last-child {
    margin-bottom: 0;
}


/* Action buttons */
.alert-ts-actions {
    display     : flex;
    flex-wrap   : wrap;
    gap         : 0.5rem;
    margin-top  : 0.75rem;
}

.alert-ts-action {
    display         : inline-flex;
    align-items     : center;
    padding         : 0.375rem 0.75rem;
    font-size       : 0.875rem;
    font-weight     : 500;
    line-height     : 1;
    border-radius   : 0.375rem;
    border          : 1.5px solid transparent;
    cursor          : pointer;
    transition      : background-color 0.15s ease,
                      border-color     0.15s ease,
                      color            0.15s ease;
    text-decoration : none;
    white-space     : nowrap;
}

/* Primary action — filled */
.alert-ts-action-primary {
    background-color : var(--alert-ts-action);
    border-color     : var(--alert-ts-action);
    color            : #fff;
}

.alert-ts-action-primary:hover,
.alert-ts-action-primary:focus-visible {
    background-color : var(--alert-ts-action-hover);
    border-color     : var(--alert-ts-action-hover);
    color            : #fff;
}

/* Secondary action — ghost / outlined */
.alert-ts-action-secondary {
    background-color : transparent;
    border-color     : var(--alert-ts-action);
    color            : var(--alert-ts-action);
}

.alert-ts-action-secondary:hover,
.alert-ts-action-secondary:focus-visible {
    background-color : var(--alert-ts-action);
    color            : #fff;
}

/* Link-style action — no border, just tinted text */
.alert-ts-action-link {
    background  : none;
    border-color: transparent;
    color       : var(--alert-ts-action);
    padding-left : 0;
    padding-right: 0;
}

.alert-ts-action-link:hover,
.alert-ts-action-link:focus-visible {
    color           : var(--alert-ts-action-hover);
    text-decoration : underline;
}

.alert-ts-action:focus-visible {
    outline        : 2px solid var(--alert-ts-border);
    outline-offset : 2px;
}


/* Dismiss (×) button */
.alert-ts-close {
    flex-shrink      : 0;
    display          : inline-flex;
    align-items      : center;
    justify-content  : center;
    width            : 1.5rem;
    height           : 1.5rem;
    padding          : 0;
    margin-left      : auto;
    background       : none;
    border           : none;
    border-radius    : 0.375rem;
    color            : var(--alert-ts-close);
    cursor           : pointer;
    transition       : color 0.15s ease, background-color 0.15s ease;
}

.alert-ts-close svg {
    display : block;
    width   : 1.25rem;
    height  : 1.25rem;
}

.alert-ts-close:hover {
    color            : var(--alert-ts-close-hover);
    background-color : color-mix(in srgb, var(--alert-ts-border) 12%, transparent);
}

.alert-ts-close:focus-visible {
    outline        : 2px solid var(--alert-ts-border);
    outline-offset : 2px;
    color          : var(--alert-ts-close-hover);
}


/* Countdown progress bar */
.alert-ts-countdown {
    position         : absolute;
    bottom           : 0;
    left             : 0;
    width            : 100%;
    height           : 3px;
    background-color : var(--alert-ts-border);
    opacity          : 0.45;
    transform        : scaleX(1);
    transform-origin : left center;
    transition-property    : transform;
    transition-timing-function: linear;
    /* transitionDuration is set dynamically by JS */
}


/* Toast stack (programmatic / create()) */
.alert-toast-stack {
    position   : fixed;
    top        : 1.25rem;
    right      : 1.25rem;
    z-index    : 1090;
    display    : flex;
    flex-direction : column;
    gap        : 0.625rem;
    width      : clamp(280px, 90vw, 400px);
    pointer-events: none;
}

.alert-toast-stack > .alert-ts {
    pointer-events : auto;
    box-shadow     : 0 4px 6px -1px rgba(0,0,0,.08),
                     0 2px 4px -2px rgba(0,0,0,.06);
}

/* Slide-in entrance for toasts */
.alert-ts-toast {
    animation: alertTsSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
}

@keyframes alertTsSlideIn {
    from {
        opacity   : 0;
        transform : translateX(2rem);
    }
    to {
        opacity   : 1;
        transform : translateX(0);
    }
}

/* Dark-mode palette */
html.dark .alert-ts-info {
    --alert-ts-bg           : #172554;
    --alert-ts-border       : #3b82f6;
    --alert-ts-icon         : #60a5fa;
    --alert-ts-title        : #bfdbfe;
    --alert-ts-text         : #93c5fd;
    --alert-ts-action       : #93c5fd;
    --alert-ts-action-hover : #bfdbfe;
    --alert-ts-close        : #3b82f6;
    --alert-ts-close-hover  : #bfdbfe;
}

html.dark .alert-ts-success {
    --alert-ts-bg           : #052e16;
    --alert-ts-border       : #22c55e;
    --alert-ts-icon         : #4ade80;
    --alert-ts-title        : #bbf7d0;
    --alert-ts-text         : #86efac;
    --alert-ts-action       : #86efac;
    --alert-ts-action-hover : #bbf7d0;
    --alert-ts-close        : #22c55e;
    --alert-ts-close-hover  : #bbf7d0;
}

html.dark .alert-ts-warning {
    --alert-ts-bg           : #27170a;
    --alert-ts-border       : #f59e0b;
    --alert-ts-icon         : #fbbf24;
    --alert-ts-title        : #fde68a;
    --alert-ts-text         : #fcd34d;
    --alert-ts-action       : #fcd34d;
    --alert-ts-action-hover : #fde68a;
    --alert-ts-close        : #f59e0b;
    --alert-ts-close-hover  : #fde68a;
}

html.dark .alert-ts-danger {
    --alert-ts-bg           : #2d0a0a;
    --alert-ts-border       : #ef4444;
    --alert-ts-icon         : #f87171;
    --alert-ts-title        : #fecaca;
    --alert-ts-text         : #fca5a5;
    --alert-ts-action       : #fca5a5;
    --alert-ts-action-hover : #fecaca;
    --alert-ts-close        : #ef4444;
    --alert-ts-close-hover  : #fecaca;
}

html.dark .alert-ts-neutral {
    --alert-ts-bg           : #111827;
    --alert-ts-border       : #6b7280;
    --alert-ts-icon         : #9ca3af;
    --alert-ts-title        : #f9fafb;
    --alert-ts-text         : #d1d5db;
    --alert-ts-action       : #d1d5db;
    --alert-ts-action-hover : #f9fafb;
    --alert-ts-close        : #6b7280;
    --alert-ts-close-hover  : #f9fafb;
}

.alert.alert-ts {
    box-shadow: inset 0 0 0 1px rgba(255,255,255,.06);
}

/* Reduced-motion overrides */
@media (prefers-reduced-motion: reduce) {
    .alert-ts-toast {
        animation: none;
    }

    .alert-ts-countdown {
        transition: none;
    }
}`;

    // 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);
    }

    const ICONS = {
        info: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
	<path fill="currentColor" fill-rule="evenodd" d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2s10 4.477 10 10m-10 5.75a.75.75 0 0 0 .75-.75v-6a.75.75 0 0 0-1.5 0v6c0 .414.336.75.75.75M12 7a1 1 0 1 1 0 2a1 1 0 0 1 0-2" clip-rule="evenodd" />
</svg>`,
        success: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
	<path fill="currentColor" fill-rule="evenodd" d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2s10 4.477 10 10m-5.97-3.03a.75.75 0 0 1 0 1.06l-5 5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06l1.47 1.47l2.235-2.235L14.97 8.97a.75.75 0 0 1 1.06 0" clip-rule="evenodd" />
</svg>`,
        warning: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
	<path fill="currentColor" fill-rule="evenodd" d="M5.312 10.762C8.23 5.587 9.689 3 12 3s3.77 2.587 6.688 7.762l.364.644c2.425 4.3 3.638 6.45 2.542 8.022S17.786 21 12.364 21h-.728c-5.422 0-8.134 0-9.23-1.572s.117-3.722 2.542-8.022zM12 7.25a.75.75 0 0 1 .75.75v5a.75.75 0 0 1-1.5 0V8a.75.75 0 0 1 .75-.75M12 17a1 1 0 1 0 0-2a1 1 0 0 0 0 2" clip-rule="evenodd" />
</svg>`,
        danger: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
	<path fill="currentColor" fill-rule="evenodd" d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2s10 4.477 10 10M8.97 8.97a.75.75 0 0 1 1.06 0L12 10.94l1.97-1.97a.75.75 0 0 1 1.06 1.06L13.06 12l1.97 1.97a.75.75 0 0 1-1.06 1.06L12 13.06l-1.97 1.97a.75.75 0 0 1-1.06-1.06L10.94 12l-1.97-1.97a.75.75 0 0 1 0-1.06" clip-rule="evenodd" />
</svg>`,
        neutral: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
	<path fill="currentColor" fill-rule="evenodd" d="M9.25 18.709c0-.42.336-.76.75-.76h4c.414 0 .75.34.75.76s-.336.76-.75.76h-4a.755.755 0 0 1-.75-.76m.667 2.532c0-.42.335-.76.75-.76h2.666c.415 0 .75.34.75.76a.754.754 0 0 1-.75.759h-2.666a.755.755 0 0 1-.75-.76" clip-rule="evenodd" />
	<path fill="currentColor" d="m7.41 13.828l1.105 1.053c.31.295.485.707.485 1.137c0 .647.518 1.172 1.157 1.172h3.686c.639 0 1.157-.525 1.157-1.172c0-.43.176-.842.485-1.137l1.104-1.053c1.542-1.48 2.402-3.425 2.41-5.446L19 8.297C19 4.842 15.866 2 12 2S5 4.842 5 8.297v.085c.009 2.021.87 3.966 2.41 5.446" />
</svg>`
    };

    //Bootstrap class ? plugin type 
    const BS_TYPE_MAP = {
        'alert-default'    : 'neutral',
        'alert-primary'    : 'primary',
        'alert-secondary'  : 'secondary',
        'alert-tertiary'   : 'tertiary',
        'alert-quaternary' : 'quaternary',
        'alert-info'       : 'info',
        'alert-success'    : 'success',
        'alert-warning'    : 'warning',
        'alert-danger'     : 'danger',
        'alert-secondary'  : 'secondary',
        'alert-light'      : 'light',
        'alert-dark'       : 'dark'
    };

    // Close-button SVG
    const CLOSE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                         <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/>
                       </svg>`;

    class PluginAlert {

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

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

            this.$el          = $el;
            this.$countdown   = null;
            this._timer       = null;
            this._startTime   = null;
            this._remaining   = null;
            this._initialHTML = $el.html();   // saved before build mutates the DOM

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

            return this;
        }

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

        setOptions(opts) {
            const pluginOptions = themestrap.fn.getOptions(this.$el.data('plugin-options'));
            this.options = $.extend(true, {}, PluginAlert.defaults, pluginOptions, opts, {
                wrapper: this.$el
            });
            return this;
        }

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

            // Resolve type from Bootstrap classes when not explicitly provided
            if (!o.type) {
                o.type = self._detectType();
            }

            // Apply skin classes
            self.$el
                .addClass(`alert-ts alert-ts-${o.type}`)
                .attr('role', 'alert');

            // Restructure markup
            self._buildStructure();

            // Countdown progress bar
            if (o.showCountdown && o.autoDismiss) {
                self.$el.css('position', 'relative');

                self.$countdown = $('<div>', { class: 'alert-ts-countdown' })
                    .appendTo(self.$el);

                requestAnimationFrame(() => {
                    requestAnimationFrame(() => {
                        if (self.$countdown) {
                            self.$countdown.css({
                                transitionDuration : `${o.delay}ms`,
                                transform          : 'scaleX(0)'
                            });
                        }
                    });
                });
            }

            // Auto-dismiss timer
            if (o.autoDismiss) {
                self._remaining = o.delay;
                self._startTimer();
            }

            return this;
        }

        _detectType() {
            let type = 'info';
            $.each(BS_TYPE_MAP, (cls, mapped) => {
                if (this.$el.hasClass(cls)) {
                    type = mapped;
                    return false; // break $.each
                }
            });
            return type;
        }

        /**
         * Rearranges the alert's inner HTML
         * Works for HTML-authored alerts and create()-generated ones.
         */
        _buildStructure() {
            const self = this;
            const o    = self.options;

            // Detach any existing Bootstrap close button
            const $bsClose = self.$el.find('.btn-close').detach();

            // Capture remaining content as the message (only when no explicit message option)
            const messageHTML = (o.message != null)
                ? o.message
                : self.$el.html().trim();

            self.$el.empty();

            // Icon 
            if (o.showIcon) {
                $('<div>', { class: 'alert-ts-icon', 'aria-hidden': 'true' })
                    .html(ICONS[o.type] || ICONS.info)
                    .appendTo(self.$el);
            }

            // Body
            const $body = $('<div>', { class: 'alert-ts-body' }).appendTo(self.$el);

            if (o.title) {
                $('<p>', { class: 'alert-ts-title' }).text(o.title).appendTo($body);
            }

            if (messageHTML) {
                $('<div>', { class: 'alert-ts-text' }).html(messageHTML).appendTo($body);
            }

            // Action links / buttons
            if (o.actions && o.actions.length) {
                const $actions = $('<div>', { class: 'alert-ts-actions' });
                o.actions.forEach(action => {
                    $('<button>', {
                        type         : 'button',
                        class        : `alert-ts-action alert-ts-action-${action.variant || 'primary'} ${action.class || ''}`.trim(),
                        text         : action.label,
                        'data-action': action.key || ''
                    }).appendTo($actions);
                });
                $actions.appendTo($body);
            }

            // Dismiss button
            if (o.dismissible) {
                $('<button>', {
                    type        : 'button',
                    class       : 'alert-ts-close',
                    'aria-label': 'Dismiss alert'
                }).html(CLOSE_SVG).appendTo(self.$el);
            } else if ($bsClose.length) {
                // Preserve original Bootstrap close button when present
                $bsClose.addClass('alert-ts-close').appendTo(self.$el);
            }
        }

        events() {
            const self = this;
            const o    = self.options;

            // Dismiss on close-button click
            self.$el.on('click.pluginalert', '.alert-ts-close, .btn-close', e => {
                e.preventDefault();
                e.stopPropagation();
                self.dismiss();
            });

            // Action button clicks — fire a namespaced jQuery event and optional callback
            self.$el.on('click.pluginalert', '.alert-ts-action', function() {
                const key = $(this).data('action');
                self.$el.trigger('action.alert', [key, self]);
                if ($.isFunction(o.onAction)) {
                    o.onAction.call(self, key);
                }
            });

            // Hover-pause
            if (o.pauseOnHover && o.autoDismiss) {
                self.$el
                    .on('mouseenter.pluginalert', () => self._pauseTimer())
                    .on('mouseleave.pluginalert', () => self._resumeTimer());
            }

            return this;
        }

        _startTimer() {
            this._startTime = Date.now();
            this._timer     = setTimeout(() => this.dismiss(), this._remaining);
            return this;
        }

        _pauseTimer() {
            if (!this._timer) return this;

            clearTimeout(this._timer);
            this._timer     = null;
            this._remaining -= Date.now() - this._startTime;

            if (this.$countdown) {
                const ratio = Math.max(0, this._remaining / this.options.delay);
                this.$countdown.css({
                    transitionDuration : '0ms',
                    transform          : `scaleX(${ratio})`
                });
            }
            return this;
        }

        _resumeTimer() {
            if (this._timer) return this;
            this._startTimer();

            if (this.$countdown) {
                requestAnimationFrame(() => {
                    requestAnimationFrame(() => {
                        if (this.$countdown) {
                            this.$countdown.css({
                                transitionDuration : `${this._remaining}ms`,
                                transform          : 'scaleX(0)'
                            });
                        }
                    });
                });
            }
            return this;
        }

        /**
         * Animate the alert out, then remove or hide it.
         * Fires close.bs.alert and closed.bs.alert to preserve Bootstrap compatibility.
         */
        dismiss() {
            const self = this;
            const o    = self.options;

            clearTimeout(self._timer);
            self._timer = null;

            self.$el.trigger('close.bs.alert');

            if (o.animation === 'slide') {
                self.$el.css({
                    overflow   : 'hidden',
                    maxHeight  : `${self.$el.outerHeight(true)}px`,
                    transition : `max-height ${o.animationDuration}ms ease,
                                  opacity     ${o.animationDuration}ms ease,
                                  margin      ${o.animationDuration}ms ease,
                                  padding     ${o.animationDuration}ms ease`
                });

                setTimeout(() => {
                    self.$el.css({
                        maxHeight     : '0px',
                        opacity       : 0,
                        marginTop     : 0,
                        marginBottom  : 0,
                        paddingTop    : 0,
                        paddingBottom : 0
                    });
                    setTimeout(() => self._afterDismiss(), o.animationDuration);
                }, 20);

            } else {
                self.$el.css({
                    transition : `opacity ${o.animationDuration}ms ease`,
                    opacity    : 0
                });
                setTimeout(() => self._afterDismiss(), o.animationDuration);
            }

            return this;
        }

        _afterDismiss() {
            const self = this;
            self.$el.trigger('closed.bs.alert');

            if ($.isFunction(self.options.onDismiss)) {
                self.options.onDismiss.call(self);
            }

            if (self.options.remove) {
                self.$el.remove();
            } else {
                self.$el.css('display', 'none');
            }
        }

        destroy() {
            const self = this;

            clearTimeout(self._timer);
            self._timer = null;

            self.$el.off('.pluginalert');
            self.$el.html(self._initialHTML);
            self.$el.removeClass((_, cls) =>
                cls.split(' ').filter(c => c.startsWith('alert-ts')).join(' ')
            );
            self.$el.css({
                position      : '',
                overflow      : '',
                maxHeight     : '',
                opacity       : '',
                transition    : '',
                marginTop     : '',
                marginBottom  : '',
                paddingTop    : '',
                paddingBottom : ''
            });

            self.$countdown = null;
            self.$el.removeData(instanceName);
            return this;
        }
    }

    PluginAlert.defaults = {
        // Type: 'info' | 'success' | 'warning' | 'danger' | 'neutral'
        // Auto-detected from Bootstrap classes when null.
        type              : null,

        // Layout / content
        showIcon          : false,   // inject icon matching the type
        dismissible       : false,   // render dismiss button
        title             : null,    // bold heading above the message
        message           : null,    // message HTML (used by create(); HTML alerts auto-detect)
        actions           : [],      // [{label, key, variant, class}]

        // Behaviour
        autoDismiss       : false,
        delay             : 5000,    // ms before auto-dismiss
        pauseOnHover      : true,

        // Animation
        animation         : 'fade',  // 'slide' | 'fade'
        animationDuration : 400,
        showCountdown     : true,    // shrinking bar along the bottom edge

        // Lifecycle callbacks
        onDismiss         : null,    // fn() — called after the alert is removed
        onAction          : null,    // fn(key) — called when an action button is clicked

        remove            : true     // true: remove from DOM; false: display:none
    };

    /**
     * PluginAlert.create(containerSelector, opts)
     *
     * Programmatically create and inject a new alert. If no container is given,
     * alerts stack inside a shared `.alert-toast-stack` (auto-created on <body>).
     *
     * @param {string|jQuery} container  Target selector / jQuery object (optional).
     * @param {object}        opts       Any PluginAlert options. `message` is expected.
     * @returns {PluginAlert}            The newly-created plugin instance.
     *
     * Examples:
     *
     *   // Toast with a title and two action buttons
     *   themestrap.PluginAlert.create({
     *       type    : 'warning',
     *       title   : 'Unsaved changes',
     *       message : 'You have unsaved changes. Do you want to save them?',
     *       delay   : 8000,
     *       actions : [
     *           { label: 'Save',    key: 'save',    variant: 'primary' },
     *           { label: 'Discard', key: 'discard', variant: 'secondary' }
     *       ],
     *       onAction(key) {
     *           if (key === 'save') saveChanges();
     *           this.dismiss();
     *       }
     *   });
     *
     *   // Inject into a specific container
     *   themestrap.PluginAlert.create('#page-notifications', {
     *       type    : 'success',
     *       message : 'Profile updated successfully.'
     *   });
     */
    PluginAlert.create = function(container, opts) {
        // Allow omitting container argument
        if (container && typeof container === 'object' && !container.jquery) {
            opts      = container;
            container = null;
        }

        const o = $.extend(true, {}, PluginAlert.defaults, opts);

        // Resolve / create container
        let $container = container ? $(container) : null;
        if (!$container || !$container.length) {
            $container = $('.alert-toast-stack');
            if (!$container.length) {
                $container = $('<div>', { class: 'alert-toast-stack' }).appendTo('body');
            }
        }

        // Build a minimal Bootstrap-compatible alert element
        const $alert = $('<div>', {
            class : `alert alert-ts alert-ts-${o.type || 'info'} alert-ts-toast`,
            role  : 'alert'
        }).appendTo($container);

        return new PluginAlert($alert, o);
    };

    $.extend(themestrap, { PluginAlert });

    $.fn.themestrapPluginAlert = function(opts) {
        return this.each(function() {
            const $this    = $(this);
            const instance = $this.data(instanceName);

            if (typeof opts === 'string') {
                if (instance && $.isFunction(instance[opts])) {
                    instance[opts]();
                } else if (opts !== 'dismiss' && opts !== 'destroy') {
                    console.warn(`[PluginAlert] Unknown command: "${opts}"`);
                }
                return;
            }

            if (!instance) {
                new PluginAlert($this, opts);
            }
        });
    };

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