• File: themestrap.plugin.timepicker.js
  • Full Path: /home/daemondevin/public_html/blog/assets/admin/themestrap.plugin.timepicker.js
  • Date Modified: 10/17/2026 7:00 PM
  • File size: 29.12 KB
  • MIME-type: text/plain
  • Charset: utf-8
/**
 * Themestrap TimePicker
 *
 * A time field with a popover of scrollable hour, minute and AM/PM columns, built to sit beside
 * PluginCalendar. You can also type: "8:30pm", "830", "20.30" and "9" are all understood.
 * The original input stays in the page (hidden) and always holds the value as 24 hour "HH:mm",
 * whatever format is displayed, so forms and server code see one consistent format.
 *
 * Markup:
 *
 *   <input type="text" name="publish_time" value="08:30" data-plugin-timepicker
 *          data-plugin-options='{"format": "12", "minuteStep": 5}'>
 *
 * Public API (via stored instance):
 *   const tp = $('input[name=publish_time]').data('__pluginTimePicker');
 *   tp.getValue();                 // "08:30", or "" when empty
 *   tp.getTime();                  // { hour: 8, minute: 30 } or null
 *   tp.setValue('20:15', silent);  // "HH:mm", "8:15pm", or { hour, minute }. silent skips the events
 *   tp.setNow();                   // the current time
 *   tp.open(); tp.close(); tp.toggle();
 *   tp.enable(); tp.disable();
 *   tp.destroy();
 *
 * Events (native CustomEvent, bubbling, dispatched on the original input):
 *   ts.timepicker.change   detail: { instance, value, hour, minute, display, source }
 *                          source is 'popover', 'typing', 'keyboard', 'now' or 'api'
 *   ts.timepicker.open / ts.timepicker.close
 *   The input also fires ordinary 'input' and 'change' events.
 *
 * Keyboard (in the field):  Up / Down change the minutes (Shift: the hours), Alt + Down or the clock
 * button opens the popover, Enter applies the typed text, Escape closes.
 * Keyboard (in the popover): Up / Down change the focused column, Left / Right move between columns,
 * Home / End jump to the first or last value, Enter or Escape close.
 *
 * CSS custom properties (all optional):
 *   --ts-tp-h, --ts-tp-bg, --ts-tp-border, --ts-tp-border-focus, --ts-tp-radius, --ts-tp-text,
 *   --ts-tp-muted, --ts-tp-pop-bg, --ts-tp-hover, --ts-tp-accent, --ts-tp-accent-text,
 *   --ts-tp-focus-ring, --ts-tp-shadow, --ts-tp-input-width (when not block)
 *
 * Init.js wiring:
 *   if ($.isFunction($.fn['themestrapPluginTimePicker']) && $('[data-plugin-timepicker]').length) {
 *       themestrap.fn.intObsInit('[data-plugin-timepicker]:not(.manual)', 'themestrapPluginTimePicker');
 *   }
 */
// Time Picker
(((themestrap = {}, $) => {
    const instanceName = '__pluginTimePicker';
    const STYLE_ID     = 'ts-timepicker-styles';
    const EVENT        = 'ts.timepicker';

    let instanceCount = 0;
    let seq           = 0;

    const ICON_CLOCK = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';

    const LIGHT = `
        --tp-h:            var(--ts-tp-h, 2.45rem);
        --tp-bg:           var(--ts-tp-bg, #ffffff);
        --tp-border:       var(--ts-tp-border, rgba(0,0,0,.18));
        --tp-border-focus: var(--ts-tp-border-focus, #00a89f);
        --tp-radius:       var(--ts-tp-radius, 7px);
        --tp-text:         var(--ts-tp-text, #1f2937);
        --tp-muted:        var(--ts-tp-muted, rgba(31,41,55,.55));
        --tp-pop-bg:       var(--ts-tp-pop-bg, #ffffff);
        --tp-hover:        var(--ts-tp-hover, rgba(0,0,0,.06));
        --tp-accent:       var(--ts-tp-accent, #00a89f);
        --tp-accent-text:  var(--ts-tp-accent-text, #04211f);
        --tp-ring:         var(--ts-tp-focus-ring, 0 0 0 3px rgba(0,168,159,.20));
        --tp-shadow:       var(--ts-tp-shadow, 0 12px 32px -6px rgba(0,0,0,.22), 0 4px 10px -4px rgba(0,0,0,.12));
    `;
    const DARK = `
        --tp-bg:           var(--ts-tp-bg, rgba(255,255,255,.03));
        --tp-border:       var(--ts-tp-border, rgba(255,255,255,.14));
        --tp-border-focus: var(--ts-tp-border-focus, #00c7be);
        --tp-text:         var(--ts-tp-text, #d5dfeb);
        --tp-muted:        var(--ts-tp-muted, rgba(213,223,235,.55));
        --tp-pop-bg:       var(--ts-tp-pop-bg, #0e2238);
        --tp-hover:        var(--ts-tp-hover, rgba(255,255,255,.08));
        --tp-accent:       var(--ts-tp-accent, #00c7be);
        --tp-ring:         var(--ts-tp-focus-ring, 0 0 0 3px rgba(0,199,190,.22));
        --tp-shadow:       var(--ts-tp-shadow, 0 12px 32px -6px rgba(0,0,0,.6), 0 4px 10px -4px rgba(0,0,0,.4));
    `;

    const CSS_TEXT = `
        .ts-tp { ${LIGHT} }
        @media (prefers-color-scheme: dark) { .ts-tp { ${DARK} } }
        html.dark .ts-tp,
        [data-bs-theme="dark"] .ts-tp { ${DARK} }
        [data-bs-theme="light"] .ts-tp { ${LIGHT} }

        .ts-tp { position: relative; display: inline-block; min-width: 0; color: var(--tp-text); }
        .ts-tp--block { display: block; }
        .ts-tp-field {
            display: flex;
            align-items: center;
            gap: .45rem;
            height: var(--tp-h);
            min-width: 0;
            padding: 0 .35rem 0 .7rem;
            background: var(--tp-bg);
            border: 1px solid var(--tp-border);
            border-radius: var(--tp-radius);
            box-sizing: border-box;
            transition: border-color .15s, box-shadow .15s;
        }
        .ts-tp-field:focus-within, .ts-tp.is-open .ts-tp-field { border-color: var(--tp-border-focus); box-shadow: var(--tp-ring); }
        .ts-tp--disabled { opacity: .55; }
        .ts-tp-icon { display: inline-flex; color: var(--tp-muted); }
        /* The input must not bring its own intrinsic width (about 20 characters) into the field's minimum width,
           or a field in a narrow column overflows it. Fluid in block mode, a fixed width otherwise. */
        input.ts-tp-input {
            flex: 1 1 0%;
            min-width: 0;
            width: 0;
            height: 100%;
            margin: 0;
            padding: 0;
            border: 0;
            outline: 0;
            background: transparent;
            color: inherit;
            font: inherit;
            box-shadow: none;
        }
        input.ts-tp-input::placeholder { color: var(--tp-muted); }
        .ts-tp:not(.ts-tp--block) input.ts-tp-input { flex: 0 0 auto; width: var(--ts-tp-input-width, 5.5rem); }
        .ts-tp-toggle {
            display: inline-flex; align-items: center; justify-content: center;
            width: 1.9rem; height: 1.9rem; padding: 0; border: 0; border-radius: 5px;
            background: transparent; color: var(--tp-muted); cursor: pointer;
        }
        .ts-tp-toggle:hover { background: var(--tp-hover); color: var(--tp-text); }

        .ts-tp-pop {
            display: none;
            position: fixed;
            z-index: 1070;
            padding: 8px;
            background: var(--tp-pop-bg);
            border: 1px solid var(--tp-border);
            border-radius: 10px;
            box-shadow: var(--tp-shadow);
            box-sizing: border-box;
        }
        .ts-tp.is-open > .ts-tp-pop { display: block; }
        .ts-tp--inline > .ts-tp-pop { display: block; position: static; margin-top: 6px; box-shadow: none; }

        .ts-tp-cols { display: flex; gap: 6px; }
        .ts-tp-col {
            position: relative;
            flex: 1 1 0;
            min-width: 3.6rem;
            height: 11.25rem;
            overflow-y: auto;
            padding: 2px;
            border-radius: 8px;
            outline: none;
            scrollbar-width: thin;
            scrollbar-color: var(--tp-border) transparent;
        }
        .ts-tp-col:focus-visible { box-shadow: var(--tp-ring); }
        .ts-tp-item {
            display: flex; align-items: center; justify-content: center;
            height: 2rem; border-radius: 6px; cursor: pointer;
            font-variant-numeric: tabular-nums; user-select: none;
        }
        .ts-tp-item:hover { background: var(--tp-hover); }
        .ts-tp-item[aria-selected="true"] { background: var(--tp-accent); color: var(--tp-accent-text); font-weight: 700; }
        .ts-tp-foot { display: flex; justify-content: space-between; gap: .5rem; padding: 8px 4px 2px; }
        .ts-tp-link {
            padding: 2px 4px; border: 0; background: none; color: var(--tp-accent);
            font: inherit; font-size: .88em; font-weight: 600; cursor: pointer; border-radius: 4px;
        }
        .ts-tp-link:hover { text-decoration: underline; }
        @media (prefers-reduced-motion: reduce) { .ts-tp-field { transition: none; } }
    `;

    const pad = (n) => (n < 10 ? '0' : '') + n;

    // Understands "8:30pm", "830", "2030", "8.30 am", "9", "20:15"
    function parseTime(input) {
        let s = String(input == null ? '' : input).trim().toLowerCase().replace(/\s+/g, '');
        if (s === '') return null;
        let period = '';
        const pm = s.match(/(a\.?m?\.?|p\.?m?\.?)$/);
        if (pm) { period = pm[1][0]; s = s.slice(0, s.length - pm[1].length); }
        let h, m;
        let x;
        if ((x = s.match(/^(\d{1,2})[:.h](\d{1,2})$/))) { h = +x[1]; m = +x[2]; }
        else if ((x = s.match(/^(\d{1,2})$/)))          { h = +x[1]; m = 0; }
        else if ((x = s.match(/^(\d{3,4})$/)))          { h = +x[1].slice(0, -2); m = +x[1].slice(-2); }
        else return null;
        if (period) {
            if (h < 1 || h > 12) return null;
            if (period === 'a') h = h === 12 ? 0 : h;
            else h = h === 12 ? 12 : h + 12;
        }
        if (h < 0 || h > 23 || m < 0 || m > 59) return null;
        return { hour: h, minute: m };
    }

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

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

            this.$el     = $el;
            this._uid    = ++seq;
            this._ns     = `.tstp${this._uid}`;
            this._failed = false;
            this._time   = null;
            this._open   = false;

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

            return this;
        }

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

        setOptions(opts) {
            this.options = $.extend(true, {}, PluginTimePicker.defaults, opts, { wrapper: this.$el });
            let f = this.options.format;
            if (f !== '12' && f !== '24') {
                try { f = new Intl.DateTimeFormat(this.options.locale || undefined, { hour: 'numeric' }).resolvedOptions().hour12 ? '12' : '24'; }
                catch (e) { f = '24'; }
            }
            this.options.format = f;
            this.options.minuteStep = Math.max(1, Math.min(30, parseInt(this.options.minuteStep, 10) || 5));
            return this;
        }

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

            if (!self.$el.is('input')) {
                console.error('PluginTimePicker: the host element must be an <input>.');
                self._failed = true;
                self.$el.removeData(instanceName);
                return this;
            }

            instanceCount++;
            if (!document.getElementById(STYLE_ID)) {
                const style       = document.createElement('style');
                style.id          = STYLE_ID;
                style.textContent = CSS_TEXT;
                document.head.appendChild(style);
            }

            const el = self.$el[0];
            self._origType = el.getAttribute('type');
            self._origId   = el.id;
            const baseId   = self._origId || `ts-tp-${self._uid}`;
            const popId    = `${baseId}-popover`;

            self.$wrap = $('<div class="ts-tp"></div>');
            if (o.block) self.$wrap.addClass('ts-tp--block');
            if (o.inline) self.$wrap.addClass('ts-tp--inline');

            self.$input = $(`<input type="text" class="ts-tp-input" inputmode="text" autocomplete="off" spellcheck="false"
                                    role="combobox" aria-haspopup="dialog" aria-expanded="false" aria-controls="${popId}">`)
                .attr('placeholder', o.placeholder)
                .attr('aria-label', el.getAttribute('aria-label') || o.ariaLabel);
            // The original keeps its id (so $('#id') and .data() work as everywhere else in Themestrap).
            // Labels that point at it are wired to the visible field instead: click focuses it, and it is named by them.
            if (self._origId) {
                self.$input.attr('id', `${self._origId}-input`);
                self.$labels = $(`label[for="${self._origId}"]`);
                const ids = [];
                self.$labels.each(function (i) {
                    if (!this.id) this.id = `${self._origId}-label-${i}`;
                    ids.push(this.id);
                });
                if (ids.length) self.$input.attr('aria-labelledby', ids.join(' ')).removeAttr('aria-label');
                self.$labels.on(`click${self._ns}`, (e) => { e.preventDefault(); self.$input.trigger('focus'); });
            }
            self.$toggle = $(`<button type="button" class="ts-tp-toggle" tabindex="-1" aria-label="${o.labels.choose}">${ICON_CLOCK}</button>`);
            self.$field = $('<div class="ts-tp-field"></div>');
            if (o.icon) self.$field.append($(`<span class="ts-tp-icon" aria-hidden="true">${ICON_CLOCK}</span>`));
            self.$field.append(self.$input);
            if (!o.inline) self.$field.append(self.$toggle);

            self.$pop = $(`<div class="ts-tp-pop" id="${popId}" role="dialog" aria-label="${o.labels.choose}"></div>`);
            self.$cols = $('<div class="ts-tp-cols"></div>');
            self.$hours = self._column(o.labels.hours);
            self.$mins  = self._column(o.labels.minutes);
            self.$cols.append(self.$hours, self.$mins);
            if (o.format === '12') {
                self.$period = self._column(o.labels.period);
                self.$cols.append(self.$period);
            }
            self.$pop.append(self.$cols);
            if (o.showNow) {
                self.$now  = $(`<button type="button" class="ts-tp-link ts-tp-now">${o.labels.now}</button>`);
                self.$done = $(`<button type="button" class="ts-tp-link ts-tp-done">${o.labels.done}</button>`);
                self.$pop.append($('<div class="ts-tp-foot"></div>').append(self.$now, o.inline ? '' : self.$done));
            }

            self.$wrap.insertBefore(self.$el);
            self.$wrap.append(self.$el, self.$field, self.$pop);
            el.setAttribute('type', 'hidden');

            self._buildHours();
            self._buildPeriod();
            const initial = parseTime(el.value);
            self._time = initial;
            self._renderValue();
            if (el.disabled) self._applyDisabled(true);
            if (o.inline) self.$wrap.addClass('is-open');
            return this;
        }

        _column(label) {
            return $(`<div class="ts-tp-col" role="listbox" tabindex="0" aria-label="${label}"></div>`);
        }

        events() {
            const self = this;
            if (self._failed) return this;
            const ns = self._ns;

            self.$toggle.on(`click${ns}`, () => { self.toggle(); self.$input.trigger('focus'); });
            self.$input
                .on(`keydown${ns}`, (e) => self._onFieldKey(e))
                .on(`blur${ns}`, () => self._commitText('typing'))
                .on(`click${ns}`, () => { if (self.options.openOnClick) self.open(); });

            // Picking: pointerdown keeps focus in the field and acts at once
            self.$pop.on(`mousedown${ns}`, (e) => e.preventDefault());
            self.$pop.on(`click${ns}`, '.ts-tp-item', function () {
                const $i = $(this);
                self._pick($i.closest('.ts-tp-col')[0] === self.$hours[0] ? 'h' : ($i.closest('.ts-tp-col')[0] === self.$mins[0] ? 'm' : 'p'), $i.data('v'));
            });
            self.$pop.on(`keydown${ns}`, '.ts-tp-col', (e) => self._onColKey(e));
            if (self.$now) self.$now.on(`click${ns}`, () => self.setNow('now'));
            if (self.$done) self.$done.on(`click${ns}`, () => { self.close(); self.$input.trigger('focus'); });

            if (!self.options.inline) {
                $(document).on(`mousedown${ns} touchstart${ns}`, (e) => {
                    if (self._open && !self.$wrap[0].contains(e.target)) self.close();
                });
                $(document).on(`focusin${ns}`, (e) => {
                    if (self._open && !self.$wrap[0].contains(e.target)) self.close();
                });
                $(window).on(`resize${ns}`, () => { if (self._open) self._position(); });
                document.addEventListener('scroll', self._onScroll = () => { if (self._open) self._position(); }, true);
            }
            return this;
        }

        // Building

        _buildHours() {
            const o = this.options;
            const list = o.format === '12' ? Array.from({ length: 12 }, (_, i) => i + 1) : Array.from({ length: 24 }, (_, i) => i);
            this.$hours.html(list.map((h) => `<div class="ts-tp-item" role="option" data-v="${h}" aria-selected="false">${pad(h)}</div>`).join(''));
        }

        _buildMinutes() {
            const o = this.options;
            const set = [];
            for (let m = 0; m < 60; m += o.minuteStep) set.push(m);
            if (this._time && set.indexOf(this._time.minute) === -1) { set.push(this._time.minute); set.sort((a, b) => a - b); }
            this.$mins.html(set.map((m) => `<div class="ts-tp-item" role="option" data-v="${m}" aria-selected="false">${pad(m)}</div>`).join(''));
        }

        _buildPeriod() {
            if (!this.$period) return;
            const o = this.options;
            this.$period.html(`<div class="ts-tp-item" role="option" data-v="am" aria-selected="false">${o.labels.am}</div><div class="ts-tp-item" role="option" data-v="pm" aria-selected="false">${o.labels.pm}</div>`);
        }

        // Value and display

        _display(t) {
            if (!t) return '';
            const o = this.options;
            if (o.format === '24') return `${pad(t.hour)}:${pad(t.minute)}`;
            const h12 = t.hour % 12 === 0 ? 12 : t.hour % 12;
            return `${h12}:${pad(t.minute)} ${t.hour < 12 ? o.labels.am : o.labels.pm}`;
        }

        _canonical(t) { return t ? `${pad(t.hour)}:${pad(t.minute)}` : ''; }

        _renderValue() {
            const t = this._time;
            this.$el.val(this._canonical(t));
            this.$input.val(this._display(t));
            this._buildMinutes();
            this._markSelected();
        }

        _markSelected() {
            const t = this._time;
            const o = this.options;
            const set = ($col, v) => {
                $col.find('.ts-tp-item').each(function () {
                    const on = t && String($(this).data('v')) === String(v);
                    this.setAttribute('aria-selected', on ? 'true' : 'false');
                });
            };
            set(this.$hours, t ? (o.format === '12' ? (t.hour % 12 === 0 ? 12 : t.hour % 12) : t.hour) : null);
            set(this.$mins, t ? t.minute : null);
            if (this.$period) set(this.$period, t ? (t.hour < 12 ? 'am' : 'pm') : null);
            if (this._open) this._centerSelected();
        }

        _centerSelected() {
            [this.$hours, this.$mins, this.$period].forEach(($c) => {
                if (!$c) return;
                const sel = $c.find('[aria-selected="true"]')[0];
                if (!sel) { $c[0].scrollTop = 0; return; }
                $c[0].scrollTop = Math.max(0, sel.offsetTop - ($c[0].clientHeight - sel.offsetHeight) / 2);
            });
        }

        _emit(source, previous, silent) {
            if (silent) return;
            const el = this.$el[0];
            const t  = this._time;
            el.dispatchEvent(new Event('input',  { bubbles: true }));
            el.dispatchEvent(new Event('change', { bubbles: true }));
            el.dispatchEvent(new CustomEvent(`${EVENT}.change`, {
                bubbles: true,
                detail: { instance: this, value: this._canonical(t), hour: t ? t.hour : null, minute: t ? t.minute : null, display: this._display(t), source, previousValue: previous }
            }));
            if (typeof this.options.onChange === 'function') this.options.onChange.call(this, this._canonical(t));
        }

        _apply(t, source, silent) {
            const prev = this._canonical(this._time);
            this._time = t;
            this._renderValue();
            if (this._canonical(t) !== prev) this._emit(source, prev, silent);
        }

        getValue() { return this._canonical(this._time); }
        getTime()  { return this._time ? { hour: this._time.hour, minute: this._time.minute } : null; }

        setValue(v, silent) {
            let t = null;
            if (v && typeof v === 'object') t = { hour: v.hour, minute: v.minute };
            else if (v !== '' && v != null) t = parseTime(v);
            this._apply(t, 'api', !!silent);
            return this;
        }

        setNow(source) {
            const d = new Date();
            this._apply({ hour: d.getHours(), minute: d.getMinutes() }, source || 'api', false);
            return this;
        }

        // Typing

        _commitText(source) {
            const text = this.$input.val();
            if (text.trim() === '') { if (this._time) this._apply(null, source, false); return; }
            const t = parseTime(text);
            if (t) this._apply(t, source, false);
            else this._renderValue();           // unreadable: put the last good value back
        }

        _onFieldKey(e) {
            if (e.isComposing) return;
            const o = this.options;
            if (e.key === 'ArrowDown' && e.altKey) { e.preventDefault(); this.open(); return; }
            if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
                e.preventDefault();
                this._commitText('typing');
                const dir = e.key === 'ArrowUp' ? 1 : -1;
                const t = this._time || parseTime(o.defaultTime) || { hour: 9, minute: 0 };
                let total = t.hour * 60 + t.minute + dir * (e.shiftKey ? 60 : o.minuteStep);
                if (!this._time) total = t.hour * 60 + t.minute;
                total = ((total % 1440) + 1440) % 1440;
                this._apply({ hour: Math.floor(total / 60), minute: total % 60 }, 'keyboard', false);
                return;
            }
            if (e.key === 'Enter') { this._commitText('typing'); if (this._open && !o.inline) { e.preventDefault(); this.close(); } return; }
            if (e.key === 'Escape' && this._open && !o.inline) { e.preventDefault(); e.stopPropagation(); this.close(); }
        }

        // Picking from the popover

        _pick(part, v) {
            const o = this.options;
            const t = this._time ? { hour: this._time.hour, minute: this._time.minute } : (parseTime(o.defaultTime) || { hour: 9, minute: 0 });
            if (part === 'h') {
                const h = parseInt(v, 10);
                if (o.format === '12') t.hour = (h % 12) + (t.hour >= 12 ? 12 : 0); else t.hour = h;
            } else if (part === 'm') {
                t.minute = parseInt(v, 10);
            } else {
                if (v === 'am' && t.hour >= 12) t.hour -= 12;
                if (v === 'pm' && t.hour < 12) t.hour += 12;
            }
            this._apply(t, 'popover', false);
        }

        _onColKey(e) {
            const $col = $(e.currentTarget);
            const cols = [this.$hours, this.$mins, this.$period].filter(Boolean);
            const idx = cols.findIndex(($c) => $c[0] === $col[0]);
            const part = idx === 0 ? 'h' : (idx === 1 ? 'm' : 'p');
            const items = $col.find('.ts-tp-item');
            let cur = items.index($col.find('[aria-selected="true"]'));
            const go = (i) => {
                const n = Math.max(0, Math.min(items.length - 1, i));
                this._pick(part, $(items[n]).data('v'));
            };
            switch (e.key) {
                case 'ArrowDown': e.preventDefault(); go(cur < 0 ? 0 : cur + 1); break;
                case 'ArrowUp':   e.preventDefault(); go(cur < 0 ? 0 : cur - 1); break;
                case 'Home':      e.preventDefault(); go(0); break;
                case 'End':       e.preventDefault(); go(items.length - 1); break;
                case 'ArrowRight': e.preventDefault(); if (cols[idx + 1]) cols[idx + 1][0].focus(); break;
                case 'ArrowLeft':  e.preventDefault(); if (cols[idx - 1]) cols[idx - 1][0].focus(); break;
                case 'Enter': case 'Escape':
                    if (!this.options.inline) { e.preventDefault(); e.stopPropagation(); this.close(); this.$input.trigger('focus'); }
                    break;
            }
        }

        // Open and close

        _position() {
            const pop = this.$pop[0];
            if (this.options.inline) return;
            const r = this.$field[0].getBoundingClientRect();
            pop.style.minWidth = Math.max(r.width, 0) + 'px';
            const ph = pop.offsetHeight || 260, pw = pop.offsetWidth || 220;
            const vh = window.innerHeight, vw = window.innerWidth;
            const up = vh - r.bottom < ph + 10 && r.top > vh - r.bottom;
            pop.style.top  = Math.max(8, up ? r.top - ph - 6 : r.bottom + 6) + 'px';
            pop.style.left = Math.max(8, Math.min(r.left, vw - pw - 8)) + 'px';
            this.$wrap.toggleClass('ts-tp--up', up);
        }

        open() {
            if (this._open || this.$el[0].disabled || this.options.inline) return this;
            this._open = true;
            this.$wrap.addClass('is-open');
            this.$input.attr('aria-expanded', 'true');
            this._position();
            this._centerSelected();
            this.$el[0].dispatchEvent(new CustomEvent(`${EVENT}.open`, { bubbles: true, detail: { instance: this } }));
            return this;
        }

        close() {
            if (!this._open || this.options.inline) return this;
            this._open = false;
            this.$wrap.removeClass('is-open');
            this.$input.attr('aria-expanded', 'false');
            this.$el[0].dispatchEvent(new CustomEvent(`${EVENT}.close`, { bubbles: true, detail: { instance: this } }));
            return this;
        }

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

        _applyDisabled(on) {
            this.$wrap.toggleClass('ts-tp--disabled', !!on);
            this.$input.prop('disabled', !!on);
            this.$toggle.prop('disabled', !!on);
        }

        enable()  { this.$el.prop('disabled', false); this._applyDisabled(false); return this; }
        disable() { this.close(); this.$el.prop('disabled', true); this._applyDisabled(true); return this; }

        destroy() {
            const self = this;
            if (self._failed) return this;
            self.close();
            $(document).off(self._ns);
            $(window).off(self._ns);
            if (self._onScroll) document.removeEventListener('scroll', self._onScroll, true);
            self.$input.off(self._ns);
            self.$pop.off(self._ns);
            self.$el.insertBefore(self.$wrap);
            self.$wrap.remove();
            if (self.$labels) self.$labels.off(self._ns);
            if (self._origType === null) self.$el.removeAttr('type'); else self.$el.attr('type', self._origType);
            self.$el.removeData(instanceName);

            instanceCount--;
            if (instanceCount <= 0) {
                instanceCount = 0;
                const styleEl = document.getElementById(STYLE_ID);
                if (styleEl) styleEl.remove();
            }
            return this;
        }
    }

    PluginTimePicker.parse = parseTime;

    PluginTimePicker.defaults = {
        format:      'auto',        // '12', '24', or 'auto' to follow the visitor's locale
        locale:      '',            // used when format is 'auto'
        minuteStep:  5,             // minutes between entries in the popover (typing allows any minute)
        defaultTime: '09:00',       // used for the parts you have not chosen yet
        placeholder: 'Time',
        ariaLabel:   'Time',
        block:       false,         // fill the width of the parent
        icon:        true,          // a clock at the left of the field (the clock button stays at the right)
        inline:      false,         // show the columns in the page instead of a popover
        showNow:     true,          // a "Now" link in the popover
        openOnClick: true,          // clicking the field opens the popover
        labels: { choose: 'Choose a time', hours: 'Hours', minutes: 'Minutes', period: 'AM or PM', am: 'AM', pm: 'PM', now: 'Now', done: 'Done' },
        onChange:    null           // function (value) {}
    };

    $.extend(themestrap, { PluginTimePicker });

    $.fn.themestrapPluginTimePicker = function (opts) {
        const args = Array.prototype.slice.call(arguments, 1);
        return this.map(function () {
            const $this = $(this);
            const inst  = $this.data(instanceName);
            if (inst) {
                if (typeof opts === 'string' && typeof inst[opts] === 'function') {
                    return inst[opts].apply(inst, args);
                }
                return inst;
            }
            return new PluginTimePicker($this, typeof opts === 'object' ? opts : undefined);
        });
    };

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