• File: themestrap.plugin.calendar.js
  • Full Path: /home/daemondevin/public_html/portableapps/assets/admin/themestrap/themestrap.plugin.calendar.js
  • Date Modified: 10/17/2026 7:00 PM
  • File size: 25.35 KB
  • MIME-type: text/plain
  • Charset: utf-8
// Calendar
(((themestrap = {}, $) => {
    const instanceName = '__pluginCalendar';
    const STYLE_ID     = 'ts-calendar-styles';

    const CSS = `
/* PluginCalendar */
.ts-calendar {
    display: inline-flex;
    flex-direction: column;
    font-family: var(--font-ui, system-ui, sans-serif);
    font-size: 13px;
    color: var(--text-normal, #c0c0c0);
    background: var(--bg-raised, #252627);
    border: 1px solid var(--border-soft, rgba(255,255,255,0.12));
    border-radius: 8px;
    padding: 14px 12px;
    user-select: none;
}

.ts-cal-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 4px;
    margin-bottom: 10px;
}

.ts-cal-caption {
    flex: 1;
    text-align: center;
    font-size: 13px;
    font-weight: 600;
    color: var(--text-bright, #f0f0f0);
    letter-spacing: 0.03em;
}

.ts-cal-nav {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    width: 26px;
    height: 26px;
    padding: 0 0 1px;
    border: 1px solid var(--border-soft, rgba(255,255,255,0.12));
    border-radius: 5px;
    background: transparent;
    color: var(--text-muted, rgba(255,255,255,0.35));
    font-size: 18px;
    line-height: 1;
    cursor: pointer;
    transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.ts-cal-nav:hover {
    background: var(--bg-hover, #2e2f30);
    color: var(--text-bright, #f0f0f0);
    border-color: var(--border-mid, rgba(255,255,255,0.20));
}
.ts-cal-nav:focus-visible {
    outline: 2px solid var(--accent-cyan, #5ecfdb);
    outline-offset: 2px;
}

.ts-cal-table {
    border-collapse: collapse;
    table-layout: fixed;
    width: 100%;
}

.ts-cal-head-cell {
    width: calc(100% / 7);
    padding: 0 0 6px;
    font-size: 10px;
    font-weight: 500;
    text-align: center;
    text-transform: uppercase;
    letter-spacing: 0.07em;
    color: var(--text-muted, rgba(255,255,255,0.35));
}

.ts-cal-cell {
    padding: 0;
    text-align: center;
}
.ts-cal-cell--in-range {
    background: rgba(94,207,219,0.10);
}
.ts-cal-cell--range-start {
    background: linear-gradient(to right, transparent 50%, rgba(94,207,219,0.10) 50%);
}
.ts-cal-cell--range-end {
    background: linear-gradient(to left, transparent 50%, rgba(94,207,219,0.10) 50%);
}

.ts-cal-day {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    position: relative;
    width: 36px;
    height: 36px;
    margin: 1px 0;
    padding: 0;
    border: none;
    border-radius: 6px;
    background: transparent;
    color: var(--text-normal, #c0c0c0);
    font-family: var(--font-ui, system-ui, sans-serif);
    font-size: 12.5px;
    cursor: pointer;
    transition: background 0.1s, color 0.1s;
}
.ts-cal-day:hover:not([disabled]) {
    background: var(--bg-hover, #2e2f30);
    color: var(--text-bright, #f0f0f0);
}
.ts-cal-day:focus-visible {
    outline: 2px solid var(--accent-cyan, #5ecfdb);
    outline-offset: 1px;
    z-index: 1;
}

.ts-cal-day--outside {
    color: var(--text-dim, rgba(255,255,255,0.18));
}
.ts-cal-day--outside:hover:not([disabled]) {
    color: var(--text-muted, rgba(255,255,255,0.40));
}

.ts-cal-day--today {
    color: var(--accent-cyan, #5ecfdb);
    font-weight: 600;
}
.ts-cal-day--today::after {
    content: '';
    position: absolute;
    bottom: 3px;
    left: 50%;
    transform: translateX(-50%);
    width: 4px;
    height: 4px;
    border-radius: 50%;
    background: var(--accent-cyan, #5ecfdb);
}

.ts-cal-day--disabled {
    color: var(--text-dim, rgba(255,255,255,0.18));
    cursor: not-allowed;
    opacity: 0.45;
    pointer-events: none;
}

.ts-cal-day--selected {
    background: var(--accent-cyan, #5ecfdb);
    color: #0d0e0f;
    font-weight: 600;
}
.ts-cal-day--selected:hover {
    background: var(--accent-cyan, #5ecfdb);
    color: #0d0e0f;
    opacity: 0.9;
}
.ts-cal-day--selected.ts-cal-day--today::after {
    background: #0d0e0f;
}

.ts-cal-day--range-start,
.ts-cal-day--range-end {
    background: var(--accent-cyan, #5ecfdb);
    color: #0d0e0f;
    font-weight: 600;
    border-radius: 6px;
}
.ts-cal-day--range-start:hover,
.ts-cal-day--range-end:hover {
    background: var(--accent-cyan, #5ecfdb);
    color: #0d0e0f;
    opacity: 0.9;
}
.ts-cal-day--range-start.ts-cal-day--today::after,
.ts-cal-day--range-end.ts-cal-day--today::after {
    background: #0d0e0f;
}

.ts-cal-footer {
    display: flex;
    justify-content: center;
    margin-top: 10px;
    padding-top: 10px;
    border-top: 1px solid var(--border-dim, rgba(255,255,255,0.06));
}
.ts-cal-today-btn {
    padding: 4px 14px;
    border: 1px solid var(--border-soft, rgba(255,255,255,0.12));
    border-radius: 4px;
    background: transparent;
    color: var(--text-muted, rgba(255,255,255,0.35));
    font-family: var(--font-ui, system-ui, sans-serif);
    font-size: 11.5px;
    cursor: pointer;
    transition: background 0.12s, color 0.12s;
}
.ts-cal-today-btn:hover {
    background: var(--bg-hover, #2e2f30);
    color: var(--text-bright, #f0f0f0);
}
    `;

    const MONTHS = [
        'January', 'February', 'March',    'April',
        'May',     'June',     'July',      'August',
        'September','October', 'November',  'December',
    ];

    const DAY_LABELS_SUN = ['Su','Mo','Tu','We','Th','Fr','Sa'];
    const DAY_LABELS_MON = ['Mo','Tu','We','Th','Fr','Sa','Su'];

    class PluginCalendar {

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

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

            this.$el = $el;

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

            return this;
        }

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

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

        injectStyles() {
            if (!document.getElementById(STYLE_ID)) {
                const s = document.createElement('style');
                s.id = STYLE_ID;
                s.textContent = CSS;
                document.head.appendChild(s);
            }
            return this;
        }

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

            const seed      = o.initialMonth ? self._parseDate(o.initialMonth) : new Date();
            self._viewDate  = new Date(seed.getFullYear(), seed.getMonth(), 1);
            self._selected  = self._normalizeSelected(o.selected, o.mode);
            self._hoverDate = null;
            self._rangeAnchor = null;

            self.$cal = $('<div class="ts-calendar"></div>');
            self.$el.empty().append(self.$cal);
            self._render();

            return this;
        }

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

            self.$cal.on('click.calendar', '.ts-cal-prev', function() {
                self._viewDate = new Date(
                    self._viewDate.getFullYear(),
                    self._viewDate.getMonth() - 1,
                    1
                );
                self._render();
                self._dispatch('ts.calendar.monthChange', {
                    month: self._viewDate.getMonth(),
                    year:  self._viewDate.getFullYear(),
                });
            });

            self.$cal.on('click.calendar', '.ts-cal-next', function() {
                self._viewDate = new Date(
                    self._viewDate.getFullYear(),
                    self._viewDate.getMonth() + 1,
                    1
                );
                self._render();
                self._dispatch('ts.calendar.monthChange', {
                    month: self._viewDate.getMonth(),
                    year:  self._viewDate.getFullYear(),
                });
            });

            self.$cal.on('click.calendar', '.ts-cal-today-btn', function() {
                self.goToToday();
            });

            self.$cal.on('click.calendar', '.ts-cal-day:not([disabled])', function() {
                const date = self._parseDate($(this).data('date'));
                if (!date) return;

                if (o.mode === 'single') {
                    self._selected = date;
                    self._render();
                    self._fireSelect(date, date);

                } else if (o.mode === 'multiple') {
                    if (!Array.isArray(self._selected)) self._selected = [];
                    const idx = self._selected.findIndex(d => self._sameDay(d, date));
                    if (idx >= 0) {
                        self._selected.splice(idx, 1);
                    } else {
                        self._selected.push(new Date(date));
                    }
                    const copy = self._selected.map(d => new Date(d));
                    self._render();
                    self._fireSelect(date, copy);

                } else if (o.mode === 'range') {
                    if (self._rangeAnchor === null) {
                        self._rangeAnchor = date;
                        self._selected    = { from: date, to: null };
                    } else {
                        let from = self._rangeAnchor;
                        let to   = date;
                        if (from > to) { [from, to] = [to, from]; }
                        self._selected    = { from, to };
                        self._rangeAnchor = null;
                        self._hoverDate   = null;
                        self._fireSelect(date, { from: new Date(from), to: new Date(to) });
                    }
                    self._render();
                }
            });

            if (o.mode === 'range') {
                self.$cal.on('mouseover.calendar', '.ts-cal-day:not([disabled])', function() {
                    if (self._rangeAnchor === null) return;
                    const date = self._parseDate($(this).data('date'));
                    if (!date || self._sameDay(date, self._hoverDate)) return;
                    self._hoverDate = date;
                    self._render();
                });

                self.$cal.on('mouseleave.calendar', function() {
                    if (self._rangeAnchor === null || !self._hoverDate) return;
                    self._hoverDate = null;
                    self._render();
                });
            }

            self.$cal.on('keydown.calendar', '.ts-cal-day', function(e) {
                const date = self._parseDate($(this).data('date'));
                if (!date) return;

                let moved = null;
                switch (e.key) {
                    case 'ArrowLeft':  moved = self._addDays(date, -1);   break;
                    case 'ArrowRight': moved = self._addDays(date,  1);   break;
                    case 'ArrowUp':    moved = self._addDays(date, -7);   break;
                    case 'ArrowDown':  moved = self._addDays(date,  7);   break;
                    case 'PageUp':     moved = self._addMonths(date, -1); break;
                    case 'PageDown':   moved = self._addMonths(date,  1); break;
                    case 'Home':       moved = new Date(date.getFullYear(), date.getMonth(), 1);  break;
                    case 'End':        moved = new Date(date.getFullYear(), date.getMonth() + 1, 0); break;
                    default: return;
                }

                e.preventDefault();
                if (!moved) return;

                if (
                    moved.getMonth()    !== self._viewDate.getMonth() ||
                    moved.getFullYear() !== self._viewDate.getFullYear()
                ) {
                    self._viewDate = new Date(moved.getFullYear(), moved.getMonth(), 1);
                    self._render();
                }

                const $target = self.$cal.find(`.ts-cal-day[data-date="${self._toISODate(moved)}"]`);
                if ($target.length) {
                    self.$cal.find('.ts-cal-day[tabindex="0"]').attr('tabindex', '-1');
                    $target.attr('tabindex', '0').focus();
                }
            });

            return this;
        }

        destroy() {
            PluginCalendar.instances--;
            if (PluginCalendar.instances <= 0) {
                PluginCalendar.instances = 0;
                const s = document.getElementById(STYLE_ID);
                if (s) s.remove();
            }
            this.$cal && this.$cal.off('.calendar');
            this.$el.off('.calendar').removeData(instanceName).empty();
            return this;
        }

        getValue() {
            return this._selected;
        }

        setValue(val) {
            this._selected    = this._normalizeSelected(val, this.options.mode);
            this._rangeAnchor = null;
            this._hoverDate   = null;
            this._render();
            return this;
        }

        goToMonth(year, month) {
            this._viewDate = new Date(year, month, 1);
            this._render();
            return this;
        }

        goToToday() {
            const n = new Date();
            this._viewDate = new Date(n.getFullYear(), n.getMonth(), 1);
            this._render();
            return this;
        }

        _normalizeSelected(val, mode) {
            if (!val) {
                if (mode === 'multiple') return [];
                if (mode === 'range')   return { from: null, to: null };
                return null;
            }
            if (mode === 'single') {
                return this._parseDate(val);
            }
            if (mode === 'multiple') {
                return (Array.isArray(val) ? val : [val])
                    .map(d => this._parseDate(d))
                    .filter(Boolean);
            }
            if (mode === 'range') {
                return {
                    from: val.from ? this._parseDate(val.from) : null,
                    to:   val.to   ? this._parseDate(val.to)   : null,
                };
            }
            return null;
        }

        _render() {
            const self = this;
            const o    = self.options;
            const vd   = self._viewDate;
            const dayLabels = o.weekStartsOn === 1 ? DAY_LABELS_MON : DAY_LABELS_SUN;
            const today     = new Date();

            /* Determine the roving-tabindex focus date */
            let focusDate = null;
            if (o.mode === 'single' && self._selected) {
                focusDate = self._selected;
            } else if (o.mode === 'multiple' && self._selected && self._selected.length) {
                focusDate = self._selected[0];
            } else if (o.mode === 'range' && self._selected && self._selected.from) {
                focusDate = self._selected.from;
            }

            const inView = d => d
                && d.getFullYear() === vd.getFullYear()
                && d.getMonth()    === vd.getMonth();

            if (!inView(focusDate)) {
                focusDate = inView(today)
                    ? today
                    : new Date(vd.getFullYear(), vd.getMonth(), 1);
            }

            const html = [];

            /* Header */
            html.push('<div class="ts-cal-header">');
            html.push('<button class="ts-cal-nav ts-cal-prev" aria-label="Previous month">&#8249;</button>');
            html.push(`<div class="ts-cal-caption" aria-live="polite">${MONTHS[vd.getMonth()]} ${vd.getFullYear()}</div>`);
            html.push('<button class="ts-cal-nav ts-cal-next" aria-label="Next month">&#8250;</button>');
            html.push('</div>');

            /* Grid */
            html.push(`<table class="ts-cal-table" role="grid" aria-label="${MONTHS[vd.getMonth()]} ${vd.getFullYear()}">`);
            html.push('<thead><tr class="ts-cal-head-row">');
            dayLabels.forEach(d => html.push(`<th class="ts-cal-head-cell" scope="col">${d}</th>`));
            html.push('</tr></thead><tbody>');

            const cells = self._buildDayGrid(vd, o.weekStartsOn || 0, o.fixedWeeks);
            let rowOpen = false;
            cells.forEach((cell, i) => {
                if (i % 7 === 0) {
                    if (rowOpen) html.push('</tr>');
                    html.push('<tr class="ts-cal-row">');
                    rowOpen = true;
                }
                html.push(self._renderCell(cell, focusDate));
            });
            if (rowOpen) html.push('</tr>');
            html.push('</tbody></table>');

            /* Footer */
            if (o.showFooter) {
                html.push('<div class="ts-cal-footer">');
                html.push('<button class="ts-cal-today-btn">Today</button>');
                html.push('</div>');
            }

            self.$cal.html(html.join(''));
        }

        _renderCell(cell, focusDate) {
            const self = this;
            const o    = self.options;

            const dateStr  = self._toISODate(cell.date);
            const dayClass = ['ts-cal-day'];
            const tdClass  = ['ts-cal-cell'];
            const attrs    = [
                `data-date="${dateStr}"`,
                `aria-label="${cell.date.toLocaleDateString(undefined, {
                    weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
                })}"`,
            ];

            if (cell.outside)  dayClass.push('ts-cal-day--outside');
            if (cell.today)    dayClass.push('ts-cal-day--today');
            if (cell.disabled) {
                dayClass.push('ts-cal-day--disabled');
                attrs.push('disabled', 'aria-disabled="true"');
            }

            let isSelected = false;

            if (o.mode === 'single') {
                isSelected = !!(self._selected && self._sameDay(cell.date, self._selected));

            } else if (o.mode === 'multiple') {
                isSelected = Array.isArray(self._selected)
                    && self._selected.some(d => self._sameDay(d, cell.date));

            } else if (o.mode === 'range') {
                let from = null;
                let to   = null;

                if (self._rangeAnchor) {
                    from = self._rangeAnchor;
                    to   = self._hoverDate || null;
                    if (from && to && from > to) { [from, to] = [to, from]; }
                } else if (self._selected) {
                    from = self._selected.from;
                    to   = self._selected.to;
                }

                const isStart = from && self._sameDay(cell.date, from);
                const isEnd   = to   && self._sameDay(cell.date, to);
                const isRange = from && to && cell.date > from && cell.date < to;

                if (isStart) {
                    isSelected = true;
                    dayClass.push('ts-cal-day--range-start');
                    tdClass.push('ts-cal-cell--range-start');
                }
                if (isEnd) {
                    isSelected = true;
                    dayClass.push('ts-cal-day--range-end');
                    tdClass.push('ts-cal-cell--range-end');
                }
                if (isRange) {
                    tdClass.push('ts-cal-cell--in-range');
                }
            }

            if (isSelected) {
                dayClass.push('ts-cal-day--selected');
                attrs.push('aria-selected="true"');
            }

            const isFocus = focusDate && self._sameDay(cell.date, focusDate);
            attrs.push(`tabindex="${isFocus ? '0' : '-1'}"`);

            return `<td class="${tdClass.join(' ')}" role="gridcell">`
                 + `<button class="${dayClass.join(' ')}" ${attrs.join(' ')}>`
                 + cell.date.getDate()
                 + '</button></td>';
        }

        _buildDayGrid(viewDate, weekStartsOn, fixedWeeks) {
            const self  = this;
            const o     = self.options;
            const year  = viewDate.getFullYear();
            const month = viewDate.getMonth();
            const today = new Date();

            const firstDay = new Date(year, month, 1);
            const lastDay  = new Date(year, month + 1, 0);
            const startDow = (firstDay.getDay() - weekStartsOn + 7) % 7;

            const cells = [];

            /* Padding from previous month */
            for (let i = startDow; i > 0; i--) {
                const d = new Date(year, month, 1 - i);
                cells.push({
                    date:     d,
                    outside:  true,
                    today:    false,
                    disabled: !o.showOutsideDays || self._isDisabled(d),
                });
            }

            /* Current month */
            for (let n = 1; n <= lastDay.getDate(); n++) {
                const d = new Date(year, month, n);
                cells.push({
                    date:     d,
                    outside:  false,
                    today:    self._sameDay(d, today),
                    disabled: self._isDisabled(d),
                });
            }

            /* Trailing days from next month */
            const rowCount = fixedWeeks ? 6 : Math.ceil(cells.length / 7);
            const total    = rowCount * 7;
            let nxt = 1;
            while (cells.length < total) {
                const d = new Date(year, month + 1, nxt++);
                cells.push({
                    date:     d,
                    outside:  true,
                    today:    false,
                    disabled: !o.showOutsideDays || self._isDisabled(d),
                });
            }

            return cells;
        }

        _isDisabled(date) {
            const o = this.options;
            if (o.minDate) {
                const min = this._parseDate(o.minDate);
                if (min && date < min) return true;
            }
            if (o.maxDate) {
                const max = this._parseDate(o.maxDate);
                if (max && date > max) return true;
            }
            if (typeof o.disabled === 'function') return !!o.disabled(date);
            if (Array.isArray(o.disabled)) {
                return o.disabled.some(d => this._sameDay(this._parseDate(d), date));
            }
            return false;
        }

        _sameDay(a, b) {
            if (!a || !b) return false;
            return a.getFullYear() === b.getFullYear()
                && a.getMonth()    === b.getMonth()
                && a.getDate()     === b.getDate();
        }

        _parseDate(d) {
            if (!d) return null;
            if (d instanceof Date) {
                return new Date(d.getFullYear(), d.getMonth(), d.getDate());
            }
            if (typeof d === 'string') {
                const p = d.split('-').map(Number);
                if (p.length === 3) return new Date(p[0], p[1] - 1, p[2]);
            }
            return null;
        }

        _toISODate(date) {
            if (!date) return '';
            const y = date.getFullYear();
            const m = String(date.getMonth() + 1).padStart(2, '0');
            const d = String(date.getDate()).padStart(2, '0');
            return `${y}-${m}-${d}`;
        }

        _addDays(date, n) {
            const d = new Date(date);
            d.setDate(d.getDate() + n);
            return d;
        }

        _addMonths(date, n) {
            const d = new Date(date);
            d.setMonth(d.getMonth() + n);
            return d;
        }

        _fireSelect(date, selected) {
            this._dispatch('ts.calendar.select', { date, selected });
            if (typeof this.options.onSelect === 'function') {
                this.options.onSelect(selected);
            }
        }

        _dispatch(eventName, detail) {
            this.$el[0].dispatchEvent(new CustomEvent(eventName, {
                bubbles:    true,
                cancelable: true,
                detail,
            }));
        }

    }

    PluginCalendar.instances = 0;

    PluginCalendar.defaults = {
        /* Selection mode: 'single' | 'multiple' | 'range' */
        mode:            'single',
        /* Pre-selected value. Shape matches mode:
             single:   Date | 'YYYY-MM-DD' | null
             multiple: Date[] | null
             range:    { from: Date|null, to: Date|null } | null */
        selected:        null,
        /* Month to open on. Defaults to the current month. */
        initialMonth:    null,
        /* Render days from the previous/next month in the first/last row */
        showOutsideDays: true,
        /* 0 = week starts Sunday, 1 = week starts Monday */
        weekStartsOn:    0,
        /* Lower bound for selectable dates (inclusive) */
        minDate:         null,
        /* Upper bound for selectable dates (inclusive) */
        maxDate:         null,
        /* Specific dates to disable. Accepts Date[], 'YYYY-MM-DD'[], or
           a function (date) => boolean */
        disabled:        [],
        /* Always render exactly 6 rows regardless of the month */
        fixedWeeks:      false,
        /* Show a "Today" button beneath the grid */
        showFooter:      false,
        /* Callback fired after a selection is committed.
           Receives the same value shape as `selected`. */
        onSelect:        null,
    };

    $.extend(themestrap, { PluginCalendar });

    $.fn.themestrapPluginCalendar = function(opts) {
        return this.map(function() {
            const $this = $(this);
            if ($this.data(instanceName)) {
                return $this.data(instanceName);
            } else {
                return new PluginCalendar($this, opts);
            }
        });
    };
})).apply(this, [window.themestrap, jQuery]);