// Select
(((themestrap = {}, $) => {
const STYLE_ID = 'ts-select-styles';
const instanceName = '__pluginSelect';
let _instanceCount = 0;
let _uid = 0;
const SVG_CHEVRON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>`;
const SVG_CHECK = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>`;
const CSS = `
.ts-select {
--ts-select-h: 2.25rem;
--ts-select-trigger-bg: var(--bs-body-bg, #fff);
--ts-select-trigger-border: var(--bs-border-color, #dee2e6);
--ts-select-trigger-radius: 0.375rem;
--ts-select-trigger-color: var(--bs-body-color, #212529);
--ts-select-trigger-muted: var(--bs-secondary-color, #6c757d);
--ts-select-trigger-dim-bg: var(--bs-secondary-bg, #e9ecef);
--ts-select-focus-ring: 0 0 0 0.25rem rgba(13,110,253,.25);
--ts-select-focus-border: #86b7fe;
--ts-select-content-bg: var(--bs-body-bg, #fff);
--ts-select-content-border: var(--bs-border-color, #dee2e6);
--ts-select-content-radius: 0.5rem;
--ts-select-content-shadow: 0 4px 16px -2px rgba(0,0,0,.1), 0 2px 4px -2px rgba(0,0,0,.06);
--ts-select-content-z: 1050;
--ts-select-item-h: 2rem;
--ts-select-item-radius: 0.25rem;
--ts-select-item-color: var(--bs-body-color, #212529);
--ts-select-item-hover-bg: var(--bs-tertiary-bg, #f8f9fa);
--ts-select-separator-bg: var(--bs-border-color, #dee2e6);
position: relative;
display: block;
}
@media (prefers-color-scheme: dark) {
.ts-select {
--ts-select-trigger-bg: var(--bs-body-bg, #212529);
--ts-select-trigger-border: var(--bs-border-color, #495057);
--ts-select-trigger-color: var(--bs-body-color, #dee2e6);
--ts-select-trigger-muted: var(--bs-secondary-color, #adb5bd);
--ts-select-trigger-dim-bg: var(--bs-secondary-bg, #343a40);
--ts-select-content-bg: var(--bs-body-bg, #1a1d20);
--ts-select-content-border: var(--bs-border-color, #495057);
--ts-select-content-shadow: 0 4px 16px -2px rgba(0,0,0,.4), 0 2px 4px -2px rgba(0,0,0,.3);
--ts-select-item-hover-bg: var(--bs-tertiary-bg, #2b2f33);
}
}
html.dark .ts-select,
[data-bs-theme="dark"] .ts-select {
--ts-select-trigger-bg: var(--bs-body-bg, #212529);
--ts-select-trigger-border: var(--bs-border-color, #495057);
--ts-select-trigger-color: var(--bs-body-color, #dee2e6);
--ts-select-trigger-muted: var(--bs-secondary-color, #adb5bd);
--ts-select-trigger-dim-bg: var(--bs-secondary-bg, #343a40);
--ts-select-content-bg: var(--bs-body-bg, #1a1d20);
--ts-select-content-border: var(--bs-border-color, #495057);
--ts-select-content-shadow: 0 4px 16px -2px rgba(0,0,0,.4), 0 2px 4px -2px rgba(0,0,0,.3);
--ts-select-item-hover-bg: var(--bs-tertiary-bg, #2b2f33);
}
[data-bs-theme="light"] .ts-select {
--ts-select-trigger-bg: var(--bs-body-bg, #fff);
--ts-select-trigger-border: var(--bs-border-color, #dee2e6);
--ts-select-trigger-color: var(--bs-body-color, #212529);
--ts-select-trigger-muted: var(--bs-secondary-color, #6c757d);
--ts-select-trigger-dim-bg: var(--bs-secondary-bg, #e9ecef);
--ts-select-content-bg: var(--bs-body-bg, #fff);
--ts-select-content-border: var(--bs-border-color, #dee2e6);
--ts-select-content-shadow: 0 4px 16px -2px rgba(0,0,0,.1), 0 2px 4px -2px rgba(0,0,0,.06);
--ts-select-item-hover-bg: var(--bs-tertiary-bg, #f8f9fa);
}
/* hide the native select — stays in DOM for form submission */
.ts-select select { display: none !important; }
/* trigger */
.ts-select-trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
height: var(--ts-select-h);
padding: 0 0.75rem;
background: var(--ts-select-trigger-bg);
border: 1px solid var(--ts-select-trigger-border);
border-radius: var(--ts-select-trigger-radius);
color: var(--ts-select-trigger-color);
font-family: inherit;
font-size: 0.875rem;
line-height: 1;
cursor: default;
text-align: left;
white-space: nowrap;
user-select: none;
transition: border-color .15s ease, box-shadow .15s ease;
outline: none;
}
.ts-select-trigger:focus-visible {
border-color: var(--ts-select-focus-border);
box-shadow: var(--ts-select-focus-ring);
}
.ts-select-trigger:disabled,
.ts-select--disabled .ts-select-trigger {
background: var(--ts-select-trigger-dim-bg);
color: var(--ts-select-trigger-muted);
cursor: not-allowed;
opacity: 0.65;
}
.ts-select-value {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ts-select-value.ts-select-placeholder {
color: var(--ts-select-trigger-muted);
}
.ts-select-icon {
flex-shrink: 0;
display: flex;
align-items: center;
color: var(--ts-select-trigger-muted);
transition: transform .2s ease;
}
.ts-select--open .ts-select-icon {
transform: rotate(180deg);
}
/* dropdown */
.ts-select-content {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: var(--ts-select-content-z);
background: var(--ts-select-content-bg);
border: 1px solid var(--ts-select-content-border);
border-radius: var(--ts-select-content-radius);
box-shadow: var(--ts-select-content-shadow);
padding: 0.25rem;
overflow: hidden;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-4px) scale(.98);
transform-origin: top center;
transition: opacity .15s ease, transform .15s ease, visibility 0ms .15s;
outline: none;
}
.ts-select-content--open {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: none;
transition-delay: 0ms;
}
/* flip upward when there is more space above */
.ts-select--up .ts-select-content {
top: auto;
bottom: calc(100% + 4px);
transform-origin: bottom center;
}
.ts-select--up .ts-select-content:not(.ts-select-content--open) {
transform: translateY(4px) scale(.98);
}
/* scrollable option list */
.ts-select-viewport {
max-height: 280px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--ts-select-separator-bg) transparent;
}
.ts-select-viewport::-webkit-scrollbar { width: 4px; }
.ts-select-viewport::-webkit-scrollbar-track { background: transparent; }
.ts-select-viewport::-webkit-scrollbar-thumb {
background: var(--ts-select-separator-bg);
border-radius: 2px;
}
/* option items */
.ts-select-item {
position: relative;
display: flex;
align-items: center;
padding: 0 0.5rem 0 2rem;
min-height: var(--ts-select-item-h);
border-radius: var(--ts-select-item-radius);
color: var(--ts-select-item-color);
font-size: 0.875rem;
cursor: default;
user-select: none;
}
.ts-select-item:not(.ts-select-item--disabled):hover,
.ts-select-item:not(.ts-select-item--disabled).ts-select-item--focus {
background: var(--ts-select-item-hover-bg);
}
.ts-select-item--disabled {
color: var(--ts-select-trigger-muted);
opacity: 0.5;
pointer-events: none;
}
/* checkmark indicator */
.ts-select-item-indicator {
position: absolute;
left: 0.5rem;
display: flex;
align-items: center;
width: 1rem;
height: 1rem;
opacity: 0;
}
.ts-select-item--selected .ts-select-item-indicator {
opacity: 1;
}
.ts-select-item-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* option groups */
.ts-select-group:not(:first-child) {
border-top: 1px solid var(--ts-select-separator-bg);
margin-top: 0.25rem;
padding-top: 0.25rem;
}
.ts-select-group-label {
padding: 0.375rem 0.5rem 0.25rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.5;
color: var(--ts-select-trigger-muted);
user-select: none;
pointer-events: none;
}
/* explicit separator element */
.ts-select-separator {
height: 1px;
background: var(--ts-select-separator-bg);
margin: 0.25rem 0;
}
`;
class PluginSelect {
constructor($el, opts) {
return this.initialize($el, opts);
}
initialize($el, opts) {
if ($el.data(instanceName)) { return this; }
this.$el = $el;
this.initialHTML = $el.html();
this.isOpen = false;
this._uid = ++_uid;
this._typeaheadTimer = null;
this._typeaheadBuffer = '';
this.setData().setOptions(opts).build().events();
return this;
}
setData() {
this.$el.data(instanceName, this);
return this;
}
setOptions(opts) {
this.options = $.extend(true, {}, PluginSelect.defaults, opts, { wrapper: this.$el });
return this;
}
build() {
const self = this;
const opts = self.options;
if (!document.getElementById(STYLE_ID)) {
$('<style>').attr('id', STYLE_ID).text(CSS).appendTo('head');
}
_instanceCount++;
self.$native = self.$el.find('select');
if (!self.$native.length) { return this; }
if (self.$native.is(':disabled')) { opts.disabled = true; }
self._items = self._parseSelect();
/* read placeholder text from the first blank <option> */
const $blank = self.$native.find('option').filter(function() {
return $(this).val() === '';
}).first();
if ($blank.length) {
opts.placeholder = $blank.text().trim() || opts.placeholder;
}
const id = `ts-select-${self._uid}`;
const listboxId = `${id}-listbox`;
const rawVal = self.$native.val() || '';
const current = self._findItem(rawVal);
self.currentValue = current ? rawVal : '';
self.currentText = current ? current.text : opts.placeholder;
self.$el.addClass('ts-select');
if (opts.disabled) { self.$el.addClass('ts-select--disabled'); }
/* trigger button */
const $trigger = $('<button>', {
type: 'button',
class: 'ts-select-trigger',
role: 'combobox',
'aria-expanded': 'false',
'aria-haspopup': 'listbox',
'aria-controls': listboxId,
id: `${id}-trigger`
});
if (opts.disabled) {
$trigger.prop('disabled', true).attr('aria-disabled', 'true');
}
self.$value = $('<span>', {
class: 'ts-select-value' + (current ? '' : ' ts-select-placeholder')
}).text(self.currentText);
const $icon = $('<span class="ts-select-icon">').html(SVG_CHEVRON);
$trigger.append(self.$value, $icon);
/* dropdown */
self.$viewport = $('<div class="ts-select-viewport">').html(self._buildItems());
self.$content = $('<div>', {
class: 'ts-select-content',
role: 'listbox',
id: listboxId,
'aria-labelledby': `${id}-trigger`,
tabindex: '-1'
}).append(self.$viewport);
self.$trigger = $trigger;
self.$el.append(self.$trigger, self.$content);
return this;
}
/* parse native <select> into a plain items array */
_parseSelect() {
const items = [];
this.$native.children().each(function() {
const $c = $(this);
if ($c.is('optgroup')) {
const group = { type: 'group', label: $c.attr('label') || '', children: [] };
$c.find('option').each(function() {
const $o = $(this);
group.children.push({ type: 'option', value: $o.val(), text: $o.text().trim(), disabled: $o.is(':disabled') });
});
if (group.children.length) { items.push(group); }
} else if ($c.is('option') && $c.val() !== '') {
items.push({ type: 'option', value: $c.val(), text: $c.text().trim(), disabled: $c.is(':disabled') });
}
});
return items;
}
/* find a non-disabled item by value */
_findItem(value) {
if (!value) { return null; }
for (const item of this._items) {
if (item.type === 'option' && item.value === value && !item.disabled) { return item; }
if (item.type === 'group') {
for (const c of item.children) {
if (c.value === value && !c.disabled) { return c; }
}
}
}
return null;
}
/* build inner HTML for the dropdown viewport */
_buildItems() {
const self = this;
let html = '';
let gi = 0;
self._items.forEach(item => {
if (item.type === 'option') {
html += self._buildOption(item);
} else if (item.type === 'group') {
const lid = `ts-select-${self._uid}-gl-${++gi}`;
html += `<div class="ts-select-group" role="group" aria-labelledby="${lid}">`;
html += `<div class="ts-select-group-label" id="${lid}">${self._esc(item.label)}</div>`;
item.children.forEach(c => { html += self._buildOption(c); });
html += '</div>';
}
});
return html;
}
_buildOption(item) {
const sel = item.value === this.currentValue;
const cls = ['ts-select-item', sel ? 'ts-select-item--selected' : '', item.disabled ? 'ts-select-item--disabled' : ''].filter(Boolean).join(' ');
return `<div class="${cls}" role="option" data-value="${this._esc(item.value)}" aria-selected="${sel}"${item.disabled ? ' aria-disabled="true"' : ''}>`
+ `<span class="ts-select-item-indicator">${SVG_CHECK}</span>`
+ `<span class="ts-select-item-text">${this._esc(item.text)}</span>`
+ '</div>';
}
_esc(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
events() {
const self = this;
/* open / close on trigger click */
self.$trigger.on('click.select', function(e) {
e.stopPropagation();
if (self.$trigger.prop('disabled')) { return; }
self.isOpen ? self.close() : self.open();
});
/* select on item click */
self.$content.on('click.select', '.ts-select-item:not(.ts-select-item--disabled)', function(e) {
e.stopPropagation();
self.select($(this).data('value'));
self.close();
});
/* keyboard on the trigger */
self.$trigger.on('keydown.select', function(e) { self._onTriggerKey(e); });
/* keyboard on the open dropdown */
self.$content.on('keydown.select', function(e) { self._onContentKey(e); });
/* close on outside click */
$(document).on(`click.select.${self._uid}`, function(e) {
if (self.isOpen && !self.$el.is(e.target) && !$.contains(self.$el[0], e.target)) {
self.close();
}
});
return this;
}
_onTriggerKey(e) {
const self = this;
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].indexOf(e.key) !== -1) {
e.preventDefault();
if (!self.isOpen) {
self.open();
const $sel = self.$content.find('[aria-selected="true"]');
if ($sel.length) { self._focusItem($sel); } else { self._moveFocus(0); }
}
} else if (e.key === 'Escape') {
self.close();
}
}
_onContentKey(e) {
const self = this;
const $items = self.$content.find('.ts-select-item:not(.ts-select-item--disabled)');
const idx = $items.index(self.$content.find('.ts-select-item--focus'));
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
self._moveFocus(Math.min(idx + 1, $items.length - 1), $items);
break;
case 'ArrowUp':
e.preventDefault();
self._moveFocus(idx <= 0 ? 0 : idx - 1, $items);
break;
case 'Home':
e.preventDefault();
self._moveFocus(0, $items);
break;
case 'End':
e.preventDefault();
self._moveFocus($items.length - 1, $items);
break;
case 'Enter':
case ' ': {
e.preventDefault();
const $f = self.$content.find('.ts-select-item--focus');
if ($f.length) { self.select($f.data('value')); self.close(); }
break;
}
case 'Escape':
case 'Tab':
e.preventDefault();
self.close();
self.$trigger[0].focus();
break;
default:
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
self._typeahead(e.key, $items);
}
}
}
_focusItem($item) {
const self = this;
self.$content.find('.ts-select-item--focus').removeClass('ts-select-item--focus');
$item.addClass('ts-select-item--focus');
/* scroll into view within the viewport */
const el = $item[0];
const vp = self.$viewport[0];
if (el && vp) {
const t = el.offsetTop;
const b = t + el.offsetHeight;
if (t < vp.scrollTop) { vp.scrollTop = t; }
else if (b > vp.scrollTop + vp.clientHeight) { vp.scrollTop = b - vp.clientHeight; }
}
}
_moveFocus(idx, $items) {
const $all = $items || this.$content.find('.ts-select-item:not(.ts-select-item--disabled)');
if ($all.length && idx >= 0) { this._focusItem($all.eq(idx)); }
}
/* type-ahead character search */
_typeahead(char, $items) {
const self = this;
clearTimeout(self._typeaheadTimer);
self._typeaheadBuffer += char.toLowerCase();
const buf = self._typeaheadBuffer;
const $m = $items.filter(function() {
return $(this).find('.ts-select-item-text').text().trim().toLowerCase().startsWith(buf);
}).first();
if ($m.length) { self._focusItem($m); }
self._typeaheadTimer = setTimeout(() => { self._typeaheadBuffer = ''; }, 500);
}
/* detect whether the dropdown should open above or below */
_position() {
const r = this.$trigger[0].getBoundingClientRect();
const below = window.innerHeight - r.bottom;
const above = r.top;
if (below < 300 && above > below) {
this.$el.addClass('ts-select--up');
} else {
this.$el.removeClass('ts-select--up');
}
}
open() {
const self = this;
if (self.isOpen || self.$trigger.prop('disabled')) { return this; }
self._position();
self.isOpen = true;
self.$el.addClass('ts-select--open');
self.$content.addClass('ts-select-content--open');
self.$trigger.attr('aria-expanded', 'true');
// Long lists open at the selected item instead of at the top
const $sel = self.$viewport.find('.ts-select-item--selected').first();
if ($sel.length) {
const v = self.$viewport[0];
const vr = v.getBoundingClientRect();
// The dropdown opens with a small scale transform, which shortens every measured distance
// (by a hundred pixels or more in a long list), so divide it out
const k = (vr.height / (v.offsetHeight || 1)) || 1;
const y = ($sel[0].getBoundingClientRect().top - vr.top) / k + v.scrollTop;
v.scrollTop = Math.max(0, y - (v.clientHeight - $sel[0].offsetHeight) / 2);
}
self.$content[0].focus();
self.$el[0].dispatchEvent(new CustomEvent('ts.select.open', {
bubbles: true,
detail: { value: self.currentValue }
}));
return this;
}
close() {
const self = this;
if (!self.isOpen) { return this; }
self.isOpen = false;
self.$el.removeClass('ts-select--open');
self.$content.removeClass('ts-select-content--open');
self.$trigger.attr('aria-expanded', 'false');
self.$content.find('.ts-select-item--focus').removeClass('ts-select-item--focus');
self.$el[0].dispatchEvent(new CustomEvent('ts.select.close', {
bubbles: true,
detail: { value: self.currentValue }
}));
return this;
}
select(value) {
const self = this;
const prev = self.currentValue;
const item = self._findItem(value);
if (!item) { return this; }
self.currentValue = value;
self.currentText = item.text;
self.$native.val(value);
self.$value.text(item.text).removeClass('ts-select-placeholder');
self.$content.find('[role="option"]').each(function() {
const $o = $(this);
const s = $o.data('value') === value;
$o.attr('aria-selected', s ? 'true' : 'false').toggleClass('ts-select-item--selected', s);
});
self.$el[0].dispatchEvent(new CustomEvent('ts.select.change', {
bubbles: true,
detail: { value, text: item.text, previousValue: prev }
}));
// The wrapped <select> behaves like a native one: forms, validators and listeners see input and change
if (value !== prev) {
self.$native[0].dispatchEvent(new Event('input', { bubbles: true }));
self.$native[0].dispatchEvent(new Event('change', { bubbles: true }));
}
return this;
}
getValue() { return this.currentValue; }
setValue(value) { return this.select(value); }
reset() {
const self = this;
self.currentValue = '';
self.currentText = self.options.placeholder;
self.$native.val('');
self.$value.text(self.options.placeholder).addClass('ts-select-placeholder');
self.$content.find('[role="option"]').attr('aria-selected', 'false').removeClass('ts-select-item--selected');
return this;
}
enable() {
this.options.disabled = false;
this.$trigger.prop('disabled', false).removeAttr('aria-disabled');
this.$el.removeClass('ts-select--disabled');
return this;
}
disable() {
this.options.disabled = true;
this.$trigger.prop('disabled', true).attr('aria-disabled', 'true');
this.$el.addClass('ts-select--disabled');
return this;
}
destroy() {
const self = this;
clearTimeout(self._typeaheadTimer);
$(document).off(`.select.${self._uid}`);
self.$el.html(self.initialHTML);
self.$el.removeClass('ts-select ts-select--open ts-select--disabled ts-select--up');
self.$el.removeData(instanceName);
_instanceCount--;
if (_instanceCount <= 0) {
const s = document.getElementById(STYLE_ID);
if (s) { s.remove(); }
_instanceCount = 0;
}
return this;
}
}
PluginSelect.defaults = {
placeholder: 'Select an option…',
disabled: false
};
$.extend(themestrap, { PluginSelect });
$.fn.themestrapPluginSelect = 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 PluginSelect($this, typeof opts !== 'string' ? opts : undefined);
});
};
})).apply(this, [window.themestrap, jQuery]);