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 {