diff --git a/app/assets/javascripts/shared/behaviors.js b/app/assets/javascripts/shared/behaviors.js
index ca2cc320a..03fe4e721 100644
--- a/app/assets/javascripts/shared/behaviors.js
+++ b/app/assets/javascripts/shared/behaviors.js
@@ -133,6 +133,13 @@
}, 1000);
});
+ // Initialize ComboBox
+ $(parentElement)
+ .find('select')
+ .each(function () {
+ new ComboBox($(this));
+ });
+
window.initBehaviors = initBehaviors;
}
diff --git a/app/assets/javascripts/shared/combobox.js b/app/assets/javascripts/shared/combobox.js
new file mode 100644
index 000000000..456d103bf
--- /dev/null
+++ b/app/assets/javascripts/shared/combobox.js
@@ -0,0 +1,264 @@
+class ComboBox {
+ constructor($target) {
+ if (!$target.is('select')) {
+ console.error("Can't initialize a ComboBox on anything but a `select`");
+ return;
+ }
+
+ this.$target = $target;
+
+ this.allowFocusDelay = 150;
+ this.config = (this.$target.data('combobox-config') || '')
+ .split(' ')
+ .filter(Boolean);
+ this.debounceTimer = 250;
+ this.isMultiSelect = this.$target.attr('multiple');
+
+ this.init();
+ }
+
+ init() {
+ const that = this;
+
+ this.$target
+ .addClass('d-none')
+ .wrap('
');
+
+ this.$comboboxContainer = this.$target.parent();
+
+ this.$comboboxContainer.append(
+ `\
+ `
+ );
+
+ this.$combobox = this.$comboboxContainer.find('[data-behavior~=combobox]');
+ this.$comboboxMenu = this.$combobox.next('[data-behavior~=combobox-menu]');
+
+ if (this.config.includes('filter')) {
+ const idSuffix = Math.random().toString(36);
+ this.$comboboxMenu.append(
+ `\
+ \
+ \
+ No results.
+
`
+ );
+ this.$filter = this.$comboboxMenu.find(
+ '[data-behavior~=combobox-filter]'
+ );
+ }
+
+ this.$target.children().each(function () {
+ switch (this.tagName.toLowerCase()) {
+ case 'option':
+ that.appendOption(that.$comboboxMenu, $(this));
+ break;
+
+ case 'optgroup':
+ that.$comboboxMenu.append(
+ `\
+ ${$(this).attr('label')}\
+
`
+ );
+
+ $(this)
+ .children('option')
+ .each(function () {
+ that.appendOption(
+ that.$comboboxMenu
+ .find('[data-behavior~=combobox-optgroup]')
+ .last(),
+ $(this)
+ );
+ });
+ break;
+
+ default:
+ console.warn('Unexpected element: ', this.tagName);
+ break;
+ }
+ });
+
+ this.$comboboxOptions = this.$comboboxMenu.find(
+ '[data-behavior~=combobox-option]'
+ );
+
+ let $initialOption = this.$comboboxOptions.filter(
+ `[data-value="${this.$target.val()}"]`
+ );
+
+ $initialOption = $initialOption.length
+ ? $initialOption
+ : this.$comboboxOptions.first();
+
+ this.selectOptions($initialOption);
+ this.behaviors();
+ }
+
+ behaviors() {
+ const that = this;
+
+ this.$combobox.on('focus', function () {
+ that.$comboboxMenu.css('display', 'block');
+ if (that.$filter) {
+ that.$filter.focus();
+ }
+ });
+
+ this.$comboboxContainer.on('focusout', function () {
+ that.hideMenu(this);
+ });
+
+ this.$comboboxOptions.each(function () {
+ const $option = $(this);
+ $(this).on('click', function (event) {
+ event.stopPropagation();
+ that.selectOptions($option);
+ });
+ });
+
+ if (this.$filter) {
+ this.$filter.on('textchange', function () {
+ that.handleFiltering();
+ });
+ }
+
+ this.$target.on('change', function () {
+ let $options = [];
+
+ if (that.isMultiSelect) {
+ $(this)
+ .val()
+ .forEach(function (value) {
+ $options.push(
+ that.$comboboxOptions.filter(`[data-value="${value}"]`)
+ );
+ });
+ that.$comboboxOptions.removeClass('selected');
+ that.$combobox.find('.combobox-multi-option').remove();
+ } else {
+ $options = that.$comboboxOptions.filter(
+ `[data-value="${$(this).val()}"]`
+ );
+ }
+
+ that.selectOptions($options);
+ });
+ }
+
+ appendOption($parent, $option) {
+ $parent.append(
+ `\
+ ${$option.text()}\
+ `
+ );
+ }
+
+ handleFiltering() {
+ clearTimeout(this.debounceTimeout);
+
+ this.debounceTimeout = setTimeout(() => {
+ const filterText = this.$filter.val().toLowerCase();
+ let matchedOptions = 0;
+
+ this.$comboboxOptions.each(function () {
+ const $option = $(this),
+ optionText = $option.text().toLowerCase(),
+ isOption = $option.is('[data-behavior~=combobox-option]'),
+ isMatch = isOption && optionText.includes(filterText);
+
+ $option.toggleClass('d-none', !isMatch);
+
+ if (isMatch) {
+ matchedOptions++;
+ }
+ });
+
+ this.$comboboxMenu.find('[data-behavior~=combobox-optgroup]');
+
+ this.$filter
+ .next('[data-behavior~=no-results]')
+ .toggleClass('d-none', matchedOptions > 0);
+ }, this.debounceTimer);
+ }
+
+ hideMenu(element) {
+ // Delay the hiding the menu to allow click events to fire on menu children
+ setTimeout(() => {
+ // Only hide the menu if the combobox nor it's children have focus
+ if (!$(element).is(':focus') && !$(element).find(':focus').length) {
+ this.$comboboxMenu.css('display', 'none');
+
+ if (this.$filter) {
+ this.$filter.val(null).trigger('textchange');
+ }
+ }
+ }, this.allowFocusDelay);
+ }
+
+ selectOptions(options) {
+ const that = this;
+
+ if (!Array.isArray(options)) {
+ options = [options];
+ }
+
+ options.forEach(function ($option) {
+ if (that.isMultiSelect) {
+ if (
+ that.$combobox.find(`[data-option-value="${$option.data('value')}"]`)
+ .length
+ ) {
+ return;
+ }
+
+ that.$combobox.append(
+ `\
+
${$option.text()}\
+
\
+ \
+ Unselect option\
+
\
+
`
+ );
+
+ that.$combobox
+ .children()
+ .last()
+ .find('[data-behavior~=unselect-multi-option]')
+ .on('click', function () {
+ that.unselectMultiOption($(this).parent());
+ });
+
+ let selectedValues = that.$target.val() || [];
+ selectedValues.push($option.data('value'));
+ that.$target.val(selectedValues);
+ } else {
+ that.$combobox.text($option.text());
+ that.$target.val($option.data('value'));
+ that.$comboboxOptions.removeClass('selected');
+ }
+
+ $option.addClass('selected');
+ });
+ }
+
+ unselectMultiOption($option) {
+ let selectedValues = this.$target.val();
+ const valueToRemove = $option.data('option-value');
+
+ selectedValues = selectedValues.filter(function (value) {
+ return value != valueToRemove;
+ });
+
+ this.$target.val(selectedValues);
+ this.$target.trigger('change');
+ }
+}
diff --git a/app/assets/javascripts/tylium.js b/app/assets/javascripts/tylium.js
index a2f393ba6..4991b4b81 100644
--- a/app/assets/javascripts/tylium.js
+++ b/app/assets/javascripts/tylium.js
@@ -19,6 +19,7 @@
//= require shared/auto_save/local
//= require shared/charts
+//= require shared/combobox
//= require shared/comments
//= require shared/console_updater
//= require shared/editor_toolbar
diff --git a/app/assets/stylesheets/shared/combobox.scss b/app/assets/stylesheets/shared/combobox.scss
new file mode 100644
index 000000000..360a58289
--- /dev/null
+++ b/app/assets/stylesheets/shared/combobox.scss
@@ -0,0 +1,75 @@
+@mixin combobox-option {
+ color: var(--bs-dropdown-color);
+ cursor: pointer;
+ display: block;
+ font-size: 0.9rem;
+ padding: 0.25rem 1rem;
+ pointer-events: auto;
+
+ &.selected,
+ &:hover,
+ &:focus-visible {
+ background-color: $primaryColor;
+ color: $white;
+ }
+}
+
+.combobox {
+ @extend .form-control, .form-select;
+
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ min-height: 2.25rem;
+
+ &.multiple {
+ --spacing: 0.25rem;
+ padding: var(--spacing) 2.25rem var(--spacing) var(--spacing);
+ }
+}
+
+.combobox-menu {
+ @extend .dropdown-menu;
+
+ width: 100%;
+}
+.combobox-multi-option {
+ align-items: center;
+ border: 1px solid $primaryColor;
+ border-radius: $border-radius;
+ display: flex;
+ gap: 0.35rem;
+ font-size: 0.8rem;
+ width: max-content;
+ padding: 0.15rem 0.5rem;
+ pointer-events: none;
+
+ .unselect-multi-option {
+ cursor: pointer;
+ pointer-events: auto;
+ transition: color 0.2s ease-in-out;
+
+ &:hover {
+ color: $primaryColor;
+ }
+ }
+}
+
+.combobox-optgroup {
+ color: $mutedText;
+ display: block;
+ font-size: 0.8rem;
+ pointer-events: none;
+
+ .combobox-option {
+ @include combobox-option;
+ }
+
+ &:not(:has(.combobox-option:not(.d-none))) {
+ display: none;
+ }
+}
+
+.combobox-option {
+ @include combobox-option;
+}
diff --git a/app/assets/stylesheets/tylium.scss b/app/assets/stylesheets/tylium.scss
index cba0d31e5..f56a0c49e 100644
--- a/app/assets/stylesheets/tylium.scss
+++ b/app/assets/stylesheets/tylium.scss
@@ -19,6 +19,7 @@
// shared styles
@import 'shared/activities';
+@import 'shared/combobox';
@import 'shared/comments';
@import 'shared/datatables';
@import 'shared/editor_toolbar';
diff --git a/app/assets/stylesheets/tylium/base.scss b/app/assets/stylesheets/tylium/base.scss
index 4035c0e18..9603f0377 100644
--- a/app/assets/stylesheets/tylium/base.scss
+++ b/app/assets/stylesheets/tylium/base.scss
@@ -81,7 +81,8 @@ code {
border-top: 1px solid $borderColor;
}
-.form-control {
+.form-control,
+.form-select {
font-size: inherit;
&:focus {