Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace <select>s with a custom Combobox component #1301

Draft
wants to merge 16 commits into
base: develop
Choose a base branch
from
Draft
7 changes: 7 additions & 0 deletions app/assets/javascripts/shared/behaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@
}, 1000);
});

// Initialize ComboBox
$(parentElement)
.find('select')
.each(function () {
new ComboBox($(this));
});

window.initBehaviors = initBehaviors;
}

Expand Down
264 changes: 264 additions & 0 deletions app/assets/javascripts/shared/combobox.js
Original file line number Diff line number Diff line change
@@ -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('<div class="position-relative"></div>');

this.$comboboxContainer = this.$target.parent();

this.$comboboxContainer.append(
`<div class="combobox ${
this.isMultiSelect ? 'multiple' : ''
}" tabindex="0" data-behavior="combobox"></div>\
<div class="combobox-menu" data-behavior="combobox-menu"></div>`
);

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(
`<div class="d-flex flex-column px-2 pt-1">\
<label class="visually-hidden" for="combobox-filter-${idSuffix}">Filter options</label>\
<input type="search" class="form-control mb-2" data-behavior="combobox-filter" id="combobox-filter-${idSuffix}" placeholder="Filter options...">\
<span class="d-block text-secondary text-center d-none pe-none" data-behavior="no-results">No results.</span>
</div>`
);
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(
`<div class="combobox-optgroup" data-behavior="combobox-optgroup">\
<span class="d-block px-2 py-1">${$(this).attr('label')}<span>\
</div>`
);

$(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(
`<span\
class="combobox-option"\
data-behavior="combobox-option"\
data-value="${$option.attr('value')}">\
${$option.text()}\
</span>`
);
}

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(
`<div class="combobox-multi-option" data-option-value="${$option.data(
'value'
)}">\
<span>${$option.text()}</span>\
<div class="unselect-multi-option" data-behavior="unselect-multi-option">\
<i class="fa-solid fa-xmark"></i>\
<span class="visually-hidden">Unselect option</span>\
</div>\
</div>`
);

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');
}
}
1 change: 1 addition & 0 deletions app/assets/javascripts/tylium.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions app/assets/stylesheets/shared/combobox.scss
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions app/assets/stylesheets/tylium.scss
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

// shared styles
@import 'shared/activities';
@import 'shared/combobox';
@import 'shared/comments';
@import 'shared/datatables';
@import 'shared/editor_toolbar';
Expand Down
3 changes: 2 additions & 1 deletion app/assets/stylesheets/tylium/base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ code {
border-top: 1px solid $borderColor;
}

.form-control {
.form-control,
.form-select {
font-size: inherit;

&:focus {
Expand Down
Loading