• File: themestrap.plugin.hovercard.js
  • Full Path: /home/daemondevin/public_html/portableapps/assets/admin/themestrap/themestrap.plugin.hovercard.js
  • Date Modified: 10/26/2026 5:36 AM
  • File size: 26.02 KB
  • MIME-type: application/javascript
  • Charset: utf-8
/**
 * Themestrap HoverCard Plugin
 * Hover-triggered preview cards with delay, side-aware animations,
 * auto-flip, portaling, optional arrow, and keyboard accessibility.
 *
 * Part of the Themestrap component library.
 *
 * Markup anatomy
 *
 *   <!-- Wrapper (the plugin root) -->
 *   <div data-plugin-hovercard
 *        data-plugin-options='{"side": "bottom", "delay": [200, 300]}'>
 *
 *     <!-- Trigger: any inline element -->
 *     <a data-hovercard-trigger href="#">@daemon_devin</a>
 *
 *     <!-- Content panel: shown on hover -->
 *     <div data-hovercard-content>
 *       <div class="hc-avatar">
 *         <img src="avatar.jpg" alt="daemon_devin">
 *       </div>
 *       <div class="hc-name">daemon.devin</div>
 *       <div class="hc-handle">@daemon_devin</div>
 *       <p class="hc-bio">Building Themestrap — a zero-build jQuery plugin ecosystem.</p>
 *       <div class="hc-meta">
 *         <span>128 <span class="hc-meta-label">Following</span></span>
 *         <span>4.2k <span class="hc-meta-label">Followers</span></span>
 *       </div>
 *     </div>
 *   </div>
 *
 * Options (data-plugin-options JSON or JS object)
 *
 *   side              "bottom"              where the card appears: top|bottom|left|right
 *   align             "start"               alignment along the side axis: start|center|end
 *   offset            12                    gap between trigger and card (px)
 *   arrow             false                 show a pointing CSS arrow
 *   portaling         false                 append content to <body> to escape overflow:hidden
 *   delay             [200, 300]            [openDelayMs, closeDelayMs]; single number for both
 *   animationDuration 180                   ms — fallback if animationend never fires
 *   ariaLabel         "Hover card"          aria-label on the content panel
 *   onShow            null                  callback(instance)
 *   onHide            null                  callback(instance)
 *
 * Public API
 *
 *   const hc = $('#my-hovercard').data('__pluginHoverCard');
 *   hc.show();
 *   hc.hide();
 *   hc.update();  // reposition without toggling visibility
 *
 * Events dispatched on [data-plugin-hovercard]  (native CustomEvent, bubbles)
 *
 *   ts.hovercard.show   — after card becomes visible   (detail: instance)
 *   ts.hovercard.hide   — after card is hidden         (detail: instance)
 *
 * Init.js wiring
 *
 *   if ($.isFunction($.fn['themestrapPluginHoverCard']) && $('[data-plugin-hovercard]').length) {
 *       $(() => {
 *           $('[data-plugin-hovercard]:not(.manual)').each(function () {
 *               const $this = $(this);
 *               const opts  = themestrap.fn.getOptions($this.data('plugin-options')) || undefined;
 *               $this.themestrapPluginHoverCard(opts);
 *           });
 *       });
 *   }
 */
(((themestrap = {}, $) => {
    const instanceName = '__pluginHoverCard';

    let _seq = 0;
    const uid = (prefix) => `${prefix}-${++_seq}-${Math.random().toString(36).slice(2, 7)}`;

    const STYLE_ID = 'ts-hovercard-styles';

    const CSS_TEXT = `/* Themestrap HoverCard */

[data-plugin-hovercard] {
    position: relative;
    display: inline-block;
}

[data-hovercard-content] {
    position: absolute;
    z-index: 9994;
    min-width: 240px;
    max-width: 340px;
    background: #fff;
    border: 1px solid rgba(0, 0, 0, 0.08);
    border-radius: 0.625rem;
    box-shadow:
        0 4px  6px -1px rgba(0, 0, 0, 0.07),
        0 10px 15px -3px rgba(0, 0, 0, 0.10);
    padding: 1rem;
    line-height: 1.5;
    color: #1a1a2e;
    display: none;
    pointer-events: none;
}

[data-hovercard-content].ts-hc-visible {
    display: block;
    pointer-events: auto;
}

/* Side-aware entry animations */
@keyframes tsHcInUp    { from { opacity:0; transform:scale(.96) translateY( 4px);  } to { opacity:1; transform:scale(1) translateY(0); } }
@keyframes tsHcOutDown { from { opacity:1; transform:scale(1)   translateY(0);    } to { opacity:0; transform:scale(.96) translateY( 4px); } }
@keyframes tsHcInDown  { from { opacity:0; transform:scale(.96) translateY(-4px); } to { opacity:1; transform:scale(1) translateY(0); } }
@keyframes tsHcOutUp   { from { opacity:1; transform:scale(1)   translateY(0);    } to { opacity:0; transform:scale(.96) translateY(-4px); } }
@keyframes tsHcInLeft  { from { opacity:0; transform:scale(.96) translateX( 4px); } to { opacity:1; transform:scale(1) translateX(0); } }
@keyframes tsHcOutRight{ from { opacity:1; transform:scale(1)   translateX(0);    } to { opacity:0; transform:scale(.96) translateX( 4px); } }
@keyframes tsHcInRight { from { opacity:0; transform:scale(.96) translateX(-4px); } to { opacity:1; transform:scale(1) translateX(0); } }
@keyframes tsHcOutLeft { from { opacity:1; transform:scale(1)   translateX(0);    } to { opacity:0; transform:scale(.96) translateX(-4px); } }

[data-hovercard-content].ts-hc-in-top     { animation: tsHcInUp     0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
[data-hovercard-content].ts-hc-out-top    { animation: tsHcOutDown  0.13s ease-in forwards; }
[data-hovercard-content].ts-hc-in-bottom  { animation: tsHcInDown   0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
[data-hovercard-content].ts-hc-out-bottom { animation: tsHcOutUp    0.13s ease-in forwards; }
[data-hovercard-content].ts-hc-in-left    { animation: tsHcInLeft   0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
[data-hovercard-content].ts-hc-out-left   { animation: tsHcOutRight 0.13s ease-in forwards; }
[data-hovercard-content].ts-hc-in-right   { animation: tsHcInRight  0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
[data-hovercard-content].ts-hc-out-right  { animation: tsHcOutLeft  0.13s ease-in forwards; }

/* Arrow */
[data-hovercard-content]::before,
[data-hovercard-content]::after {
    content: '';
    display: none;
    position: absolute;
    width: 0;
    height: 0;
    border: 6px solid transparent;
    pointer-events: none;
}

[data-hovercard-content].ts-hc-arrow::before,
[data-hovercard-content].ts-hc-arrow::after {
    display: block;
}

[data-hovercard-content].ts-hc-side-top::after    { top:  100%;                border-top-color: #fff; }
[data-hovercard-content].ts-hc-side-top::before   { top:  calc(100% + 1px);   border-top-color: rgba(0,0,0,0.08); }
[data-hovercard-content].ts-hc-side-bottom::after  { bottom: 100%;             border-bottom-color: #fff; }
[data-hovercard-content].ts-hc-side-bottom::before { bottom: calc(100% + 1px); border-bottom-color: rgba(0,0,0,0.08); }
[data-hovercard-content].ts-hc-side-left::after   { left:  100%;               border-left-color: #fff; }
[data-hovercard-content].ts-hc-side-left::before  { left:  calc(100% + 1px);   border-left-color: rgba(0,0,0,0.08); }
[data-hovercard-content].ts-hc-side-right::after  { right: 100%;               border-right-color: #fff; }
[data-hovercard-content].ts-hc-side-right::before { right: calc(100% + 1px);   border-right-color: rgba(0,0,0,0.08); }

/* Convenience layout helpers */
.hc-avatar       { margin-bottom: 0.625rem; }
.hc-avatar img   { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; display: block; }

.hc-name         { font-size: 0.9375rem; font-weight: 700; line-height: 1.25; color: inherit; }
.hc-handle       { font-size: 0.8125rem; color: #6b7280; margin-bottom: 0.375rem; }
.hc-bio          { font-size: 0.8125rem; color: #374151; margin: 0 0 0.625rem; }

.hc-meta         { display: flex; gap: 1rem; font-size: 0.8125rem; font-weight: 600; color: inherit; }
.hc-meta-label   { font-weight: 400; color: #6b7280; }

.hc-divider      { border: none; border-top: 1px solid rgba(0,0,0,0.06); margin: 0.625rem 0; }

/* Dark mode */
html.dark [data-hovercard-content] {
    background: var(--dark-300, #0a0c0d);
    color: var(--default, #777);
    border-color: var(--dark-rgba-50, rgba(33, 37, 41, 0.5));
    box-shadow:
        0 4px  6px -1px rgba(0,0,0,0.3),
        0 10px 15px -3px rgba(0,0,0,0.4);
}

html.dark [data-hovercard-content].ts-hc-side-top::after    { border-top-color: var(--dark-300, #0a0c0d); }
html.dark [data-hovercard-content].ts-hc-side-top::before   { border-top-color: var(--dark-rgba-50, rgba(33, 37, 41, 0.5)); }
html.dark [data-hovercard-content].ts-hc-side-bottom::after  { border-bottom-color: var(--dark-300, #0a0c0d); }
html.dark [data-hovercard-content].ts-hc-side-bottom::before { border-bottom-color: var(--dark-rgba-50, rgba(33, 37, 41, 0.5)); }
html.dark [data-hovercard-content].ts-hc-side-left::after   { border-left-color: var(--dark-300, #0a0c0d); }
html.dark [data-hovercard-content].ts-hc-side-left::before  { border-left-color: var(--dark-rgba-50, rgba(33, 37, 41, 0.5)); }
html.dark [data-hovercard-content].ts-hc-side-right::after  { border-right-color: var(--dark-300, #0a0c0d); }
html.dark [data-hovercard-content].ts-hc-side-right::before { border-right-color: var(--dark-rgba-50, rgba(33, 37, 41, 0.5)); }

html.dark .hc-handle,
html.dark .hc-meta-label { color: var(--color-grey-300, #9ca3af); }
html.dark .hc-bio        { color: var(--color-grey-200, #c0c8d4); }
html.dark .hc-divider    { border-top-color: var(--dark-rgba-50, rgba(33, 37, 41, 0.5)); }

@media (prefers-reduced-motion: reduce) {
    [data-hovercard-content] { animation: none !important; }
}
`;

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

    /* Position math — relative layout (portaling: false) */
    function computePosition($trigger, $content, side, align, offset) {
        const tw = $trigger.outerWidth();
        const th = $trigger.outerHeight();
        const cw = $content.outerWidth();
        const ch = $content.outerHeight();

        let top = 0, left = 0;

        switch (side) {
            case 'top':    top  = -(ch + offset); left = 0;              break;
            case 'bottom': top  =  th + offset;   left = 0;              break;
            case 'left':   top  = 0;              left = -(cw + offset); break;
            case 'right':  top  = 0;              left =  tw + offset;   break;
        }

        if (side === 'top' || side === 'bottom') {
            if (align === 'center') left = (tw - cw) / 2;
            if (align === 'end')    left = tw - cw;
        } else {
            if (align === 'center') top = (th - ch) / 2;
            if (align === 'end')    top = th - ch;
        }

        let arrowOffset = null;
        if (side === 'top' || side === 'bottom') {
            arrowOffset = Math.max(10, Math.min(cw - 10, (tw / 2) - left)) - 6;
        } else {
            arrowOffset = Math.max(10, Math.min(ch - 10, (th / 2) - top)) - 6;
        }

        return { top, left, arrowOffset };
    }

    /* Position math — portaled layout (portaling: true) */
    function computePortaledPosition($trigger, $content, side, align, offset) {
        const rect      = $trigger[0].getBoundingClientRect();
        const scrollTop = window.scrollY  || document.documentElement.scrollTop;
        const scrollLeft= window.scrollX  || document.documentElement.scrollLeft;

        const tTop  = rect.top  + scrollTop;
        const tLeft = rect.left + scrollLeft;
        const tw    = rect.width;
        const th    = rect.height;
        const cw    = $content.outerWidth();
        const ch    = $content.outerHeight();

        let top = 0, left = 0;

        switch (side) {
            case 'top':    top  = tTop  - ch - offset;  left = tLeft;                break;
            case 'bottom': top  = tTop  + th + offset;  left = tLeft;                break;
            case 'left':   top  = tTop;                 left = tLeft - cw - offset;  break;
            case 'right':  top  = tTop;                 left = tLeft + tw + offset;  break;
        }

        if (side === 'top' || side === 'bottom') {
            if (align === 'center') left = tLeft + (tw / 2) - (cw / 2);
            if (align === 'end')    left = tLeft + tw - cw;
        } else {
            if (align === 'center') top = tTop + (th / 2) - (ch / 2);
            if (align === 'end')    top = tTop + th - ch;
        }

        let arrowOffset = null;
        if (side === 'top' || side === 'bottom') {
            arrowOffset = Math.max(10, Math.min(cw - 10, (tLeft + (tw / 2)) - left)) - 6;
        } else {
            arrowOffset = Math.max(10, Math.min(ch - 10, (tTop + (th / 2)) - top)) - 6;
        }

        return { top, left, arrowOffset };
    }

    /* Auto-flip: return the best side given available viewport space */
    function resolvedSide($trigger, side, offset) {
        const rect = $trigger[0].getBoundingClientRect();
        const vw   = window.innerWidth;
        const vh   = window.innerHeight;

        switch (side) {
            case 'top':    return rect.top    < (200 + offset)        ? 'bottom' : side;
            case 'bottom': return rect.bottom > (vh - 200 - offset)   ? 'top'    : side;
            case 'left':   return rect.left   < (200 + offset)        ? 'right'  : side;
            case 'right':  return rect.right  > (vw - 200 - offset)   ? 'left'   : side;
            default:       return side;
        }
    }

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

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

            this.$el         = $el;
            this.isVisible   = false;
            this._uid        = uid('hovercard');
            this._showTimer  = null;
            this._hideTimer  = null;
            this._activeSide = null;
            this._addedTabindex = false;

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

            return this;
        }

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

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

            if (typeof this.options.delay === 'number') {
                this.options.delay = [this.options.delay, this.options.delay];
            }

            return this;
        }

        build() {
            injectStyles();

            const self = this;
            const $el  = self.$el;
            const opts = self.options;

            self.$trigger = $el.find('[data-hovercard-trigger]').first();

            /* External trigger pattern: trigger elsewhere points to this card by id */
            if (!self.$trigger.length) {
                const cardId = $el.attr('id');
                if (cardId) {
                    self.$trigger = $(`[data-hovercard-trigger="${cardId}"]`).first();
                }
            }

            self.$content = $el.find('[data-hovercard-content]').first();

            if (!self.$content.length) {
                console.warn('[PluginHoverCard] No [data-hovercard-content] found in', $el[0]);
                return this;
            }

            self.$contentOriginalParent = self.$content.parent();

            const contentId = self.$content.attr('id') || uid('hc-content');
            self.$content.attr({
                id          : contentId,
                role        : 'group',
                'aria-label': opts.ariaLabel
            });

            /* Link trigger to card for screen readers */
            if (self.$trigger.length) {
                self.$trigger.attr('aria-describedby', contentId);

                if (!self.$trigger.is('a[href], button, input, select, textarea, [tabindex]')) {
                    self.$trigger.attr('tabindex', '0');
                    self._addedTabindex = true;
                }
            }

            if (opts.arrow) {
                self.$content.addClass('ts-hc-arrow');
            }

            if (opts.portaling) {
                self.$content.appendTo(document.body);
            }

            return this;
        }

        events() {
            const self = this;
            const ns   = `.hovercard.${self._uid}`;

            if (self.$trigger && self.$trigger.length) {
                self.$trigger
                    .on(`mouseenter${ns}`, () => self._scheduleShow())
                    .on(`mouseleave${ns}`, () => self._scheduleHide())
                    .on(`focus${ns}`,      () => self._scheduleShow())
                    .on(`blur${ns}`,       () => self._scheduleHide());
            }

            if (self.$content && self.$content.length) {
                /* Hovering inside the card keeps it open */
                self.$content
                    .on(`mouseenter${ns}`, () => self._cancelHide())
                    .on(`mouseleave${ns}`, () => self._scheduleHide());
            }

            $(window).on(`resize${ns} scroll${ns}`, () => {
                if (self.isVisible) self._position();
            });

            return this;
        }

        _scheduleShow() {
            const self = this;
            self._cancelHide();
            if (self.isVisible) return this;

            const delay = self.options.delay[0];
            self._showTimer = delay > 0
                ? setTimeout(() => self.show(), delay)
                : (self.show(), null);

            return this;
        }

        _scheduleHide() {
            const self = this;
            self._cancelShow();
            if (!self.isVisible) return this;

            const delay = self.options.delay[1];
            self._hideTimer = delay > 0
                ? setTimeout(() => self.hide(), delay)
                : (self.hide(), null);

            return this;
        }

        _cancelShow() {
            clearTimeout(this._showTimer);
            this._showTimer = null;
            return this;
        }

        _cancelHide() {
            clearTimeout(this._hideTimer);
            this._hideTimer = null;
            return this;
        }

        show() {
            const self = this;

            if (self.isVisible || !self.$content || !self.$content.length) return this;

            self.isVisible = true;

            const side    = resolvedSide(
                self.$trigger && self.$trigger.length ? self.$trigger : self.$el,
                self.options.side,
                self.options.offset
            );
            self._activeSide = side;

            const inClass   = `ts-hc-in-${side}`;
            const allIn     = 'ts-hc-in-top ts-hc-in-bottom ts-hc-in-left ts-hc-in-right';
            const allOut    = 'ts-hc-out-top ts-hc-out-bottom ts-hc-out-left ts-hc-out-right';
            const allSides  = 'ts-hc-side-top ts-hc-side-bottom ts-hc-side-left ts-hc-side-right';

            self.$content
                .removeClass(`${allOut} ${allIn} ${allSides}`)
                .addClass(`ts-hc-visible ts-hc-side-${side}`);

            self._position();

            self.$content
                .addClass(inClass)
                .one('animationend webkitAnimationEnd', function () {
                    self.$content.removeClass(inClass);
                });

            setTimeout(() => {
                if (self.isVisible) self.$content.removeClass(inClass);
            }, self.options.animationDuration + 30);

            self.$el[0].dispatchEvent(new CustomEvent('ts.hovercard.show', {
                bubbles: true,
                detail : self
            }));

            if (typeof self.options.onShow === 'function') {
                self.options.onShow.call(self);
            }

            return this;
        }

        hide() {
            const self = this;

            if (!self.isVisible || !self.$content || !self.$content.length) return this;

            self.isVisible = false;

            const side     = self._activeSide || self.options.side;
            const outClass = `ts-hc-out-${side}`;
            const allIn    = 'ts-hc-in-top ts-hc-in-bottom ts-hc-in-left ts-hc-in-right';

            self.$content
                .removeClass(allIn)
                .addClass(outClass)
                .one('animationend webkitAnimationEnd', function () {
                    self._finishHide(outClass);
                });

            setTimeout(() => {
                if (!self.isVisible) self._finishHide(outClass);
            }, self.options.animationDuration + 30);

            return this;
        }

        _finishHide(outClass) {
            const self = this;

            if (self.isVisible) return; /* re-opened during animation */

            const allSides = 'ts-hc-side-top ts-hc-side-bottom ts-hc-side-left ts-hc-side-right';

            self.$content
                .removeClass(`ts-hc-visible ${outClass} ${allSides}`)
                .css({ top: '', left: '' })
                .find('style.ts-hc-arrow-style').remove();

            self.$el[0].dispatchEvent(new CustomEvent('ts.hovercard.hide', {
                bubbles: true,
                detail : self
            }));

            if (typeof self.options.onHide === 'function') {
                self.options.onHide.call(self);
            }
        }

        update() {
            if (this.isVisible) this._position();
            return this;
        }

        _position() {
            const self = this;
            const opts = self.options;
            const $ref = self.$trigger && self.$trigger.length ? self.$trigger : self.$el;

            const side    = resolvedSide($ref, opts.side, opts.offset);
            self._activeSide = side;

            const compute = opts.portaling ? computePortaledPosition : computePosition;
            const { top, left, arrowOffset } = compute($ref, self.$content, side, opts.align, opts.offset);

            const allSides = 'ts-hc-side-top ts-hc-side-bottom ts-hc-side-left ts-hc-side-right';

            self.$content
                .removeClass(allSides)
                .addClass(`ts-hc-side-${side}`)
                .css({ top, left });

            if (opts.arrow && arrowOffset !== null) {
                self.$content.find('style.ts-hc-arrow-style').remove();
                const arrowStyle  = document.createElement('style');
                arrowStyle.className = 'ts-hc-arrow-style';
                const prop = (side === 'top' || side === 'bottom') ? 'left' : 'top';
                arrowStyle.textContent = `
                    #${self.$content.attr('id')}::before,
                    #${self.$content.attr('id')}::after {
                        ${prop}: ${arrowOffset}px;
                    }
                `;
                self.$content.append(arrowStyle);
            }

            return this;
        }

        destroy() {
            const self = this;
            const ns   = `.hovercard.${self._uid}`;

            self._cancelShow();
            self._cancelHide();

            if (self.isVisible) {
                self.isVisible = false;
                if (self.$content) {
                    const allClasses = [
                        'ts-hc-visible', 'ts-hc-arrow',
                        'ts-hc-side-top', 'ts-hc-side-bottom', 'ts-hc-side-left', 'ts-hc-side-right',
                        'ts-hc-in-top', 'ts-hc-in-bottom', 'ts-hc-in-left', 'ts-hc-in-right',
                        'ts-hc-out-top', 'ts-hc-out-bottom', 'ts-hc-out-left', 'ts-hc-out-right'
                    ].join(' ');
                    self.$content
                        .removeClass(allClasses)
                        .css({ top: '', left: '' })
                        .find('style.ts-hc-arrow-style').remove();
                }
            }

            if (self.$trigger && self.$trigger.length) {
                self.$trigger.off(ns).removeAttr('aria-describedby');
                if (self._addedTabindex) {
                    self.$trigger.removeAttr('tabindex');
                }
            }

            if (self.$content && self.$content.length) {
                self.$content.off(ns).removeAttr('id role aria-label');

                if (self.options.portaling && self.$contentOriginalParent && self.$contentOriginalParent.length) {
                    self.$content.appendTo(self.$contentOriginalParent);
                }
            }

            $(window).off(ns);
            self.$el.removeData(instanceName);

            return this;
        }
    }

    PluginHoverCard.defaults = {
        side             : 'bottom',      // top | bottom | left | right
        align            : 'start',       // start | center | end
        offset           : 12,            // px gap between trigger and card
        arrow            : false,         // show a CSS triangle arrow
        portaling        : false,         // append content to <body> to escape stacking contexts
        delay            : [200, 300],    // [openDelayMs, closeDelayMs]; single number for both
        animationDuration: 180,           // ms — fallback if animationend never fires
        ariaLabel        : 'Hover card',  // aria-label on the content panel
        onShow           : null,          // callback(instance)
        onHide           : null,          // callback(instance)
    };

    $.extend(themestrap, { PluginHoverCard });

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

            if (typeof opts === 'string') {
                if (instance && $.isFunction(instance[opts])) {
                    instance[opts]();
                }
                return instance;
            }

            if (instance) {
                return instance;
            }

            return new PluginHoverCard($this, opts);
        });
    };

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