Skip to content

Commit

Permalink
Fix Jumpy Combobox (#2480)
Browse files Browse the repository at this point in the history
* Refactor combobox to fix jumpy effects and other bad UX
  • Loading branch information
elias-ba authored Sep 12, 2024
1 parent cd3a2a7 commit 86165d4
Show file tree
Hide file tree
Showing 3 changed files with 173 additions and 149 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ and this project adheres to

### Fixed

- Fix jumpy combobox for scope switcher
[#2469](https://github.com/OpenFn/lightning/issues/2469)

## [v2.9.3] - 2024-09-11

### Added
Expand Down
309 changes: 165 additions & 144 deletions assets/js/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,218 +22,239 @@ export const Combobox = {
mounted() {
this.input = this.el.querySelector('input');
this.dropdown = this.el.querySelector('ul');
this.options = this.el.querySelectorAll('li');
this.options = Array.from(this.el.querySelectorAll('li'));
this.toggleButton = this.el.querySelector('button');
this.highlightedOption = null;
this.highlightedIndex = -1;
this.navigatingWithKeys = false;
this.navigatingWithMouse = false;

this.input.addEventListener('focus', () => this.handleInputFocus());
this.input.addEventListener(
'input',
this.debounce(event => this.handleInput(event), 300)
);
this.input.addEventListener('click', () => this.handleInputClick());
this.input.addEventListener('keydown', event => this.handleKeydown(event));
this.toggleButton.addEventListener('click', () =>
this.handleToggleButtonClick()
this.debounce(e => this.handleInput(e), 300)
);
this.input.addEventListener('keydown', e => this.handleKeydown(e));
this.toggleButton.addEventListener('click', () => this.toggleDropdown());

this.options.forEach(option => {
option.addEventListener('click', event => {
event.preventDefault();
this.selectOption(option);
});
option.addEventListener('mouseenter', () => this.highlightOption(option));
option.addEventListener('mouseleave', () => this.restoreHighlight());
this.options.forEach((option, index) => {
option.addEventListener('click', () => this.selectOption(index));
option.addEventListener('mouseenter', () => this.handleMouseEnter(index));
option.addEventListener('mousemove', () => this.handleMouseMove(index));
});

document.addEventListener('click', event => {
if (!this.el.contains(event.target)) {
this.hideDropdown();
}
document.addEventListener('click', e => {
if (!this.el.contains(e.target)) this.hideDropdown();
});

// Highlight the initially selected option, if any
this.highlightSelectedOption();
this.initializeSelectedOption();
},

handleInput(event) {
this.filterOptions(event);
handleInputFocus() {
this.showDropdown();
this.highlightFirstOption();
},

handleInputClick() {
this.input.select();
this.showAllOptions();
this.showDropdown();
this.highlightSelectedOption();
},

handleToggleButtonClick() {
this.toggleDropdown();
if (!this.dropdown.classList.contains('hidden')) {
this.input.focus();
this.input.select();
this.showAllOptions();
this.highlightSelectedOption();
}
handleInput(event) {
this.filterOptions(event.target.value);
this.showDropdown();
this.highlightFirstMatch();
this.navigatingWithKeys = false;
this.navigatingWithMouse = false;
},

handleKeydown(event) {
if (event.key === 'Enter' && this.highlightedOption) {
event.preventDefault();
this.selectOption(this.highlightedOption);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
this.showDropdown();
this.highlightNextOption();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.showDropdown();
this.highlightPreviousOption();
if (!this.isDropdownVisible()) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
this.showDropdown();
}
return;
}
},

toggleDropdown() {
if (this.dropdown.classList.contains('hidden')) {
this.showDropdown();
} else {
this.hideDropdown();
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.navigatingWithKeys = true;
this.navigatingWithMouse = false;
this.highlightNextOption();
break;
case 'ArrowUp':
event.preventDefault();
this.navigatingWithKeys = true;
this.navigatingWithMouse = false;
this.highlightPreviousOption();
break;
case 'Enter':
event.preventDefault();
if (this.highlightedIndex !== -1) {
this.selectOption(this.highlightedIndex);
}
break;
case 'Escape':
this.hideDropdown();
break;
}
},

showDropdown() {
this.dropdown.classList.remove('hidden');
this.input.setAttribute('aria-expanded', 'true');
handleMouseEnter(index) {
if (!this.navigatingWithKeys) {
this.navigatingWithMouse = true;
this.highlightOption(index);
}
},

hideDropdown() {
this.dropdown.classList.add('hidden');
this.input.setAttribute('aria-expanded', 'false');
this.clearHighlight();
handleMouseMove(index) {
if (this.navigatingWithKeys) {
this.navigatingWithKeys = false;
this.navigatingWithMouse = true;
this.highlightOption(index);
}
},

filterOptions(event) {
const searchTerm = event.target.value.toLowerCase();
let visibleCount = 0;
filterOptions(searchTerm) {
const lowercaseSearchTerm = searchTerm.toLowerCase();
let hasVisibleOptions = false;

this.options.forEach(option => {
const text = option.textContent.toLowerCase();
if (text.includes(searchTerm)) {
if (text.includes(lowercaseSearchTerm)) {
option.style.display = 'block';
visibleCount++;
hasVisibleOptions = true;
} else {
option.style.display = 'none';
}
});

if (visibleCount === 0) {
this.showNoResultsMessage();
} else {
this.hideNoResultsMessage();
}
this.updateNoResultsMessage(!hasVisibleOptions);
return hasVisibleOptions;
},

showAllOptions() {
this.options.forEach(option => (option.style.display = 'block'));
this.hideNoResultsMessage();
highlightFirstMatch() {
const visibleOptions = this.getVisibleOptions();
if (visibleOptions.length > 0) {
this.highlightedIndex = 0;
this.updateHighlight();
} else {
this.highlightedIndex = -1;
this.updateHighlight();
}
},

showNoResultsMessage() {
updateNoResultsMessage(show) {
let noResultsEl = this.dropdown.querySelector('.no-results');
if (!noResultsEl) {
noResultsEl = document.createElement('li');
noResultsEl.className =
'no-results text-gray-500 py-2 px-3 text-sm cursor-default hover:bg-gray-100';
noResultsEl.textContent = 'No projects found';
this.dropdown.appendChild(noResultsEl);
if (show) {
if (!noResultsEl) {
noResultsEl = document.createElement('li');
noResultsEl.className =
'no-results text-gray-500 py-2 px-3 text-sm cursor-default';
noResultsEl.textContent = 'No results found';
this.dropdown.appendChild(noResultsEl);
}
noResultsEl.style.display = 'block';
} else if (noResultsEl) {
noResultsEl.style.display = 'none';
}
noResultsEl.style.display = 'block';
},

hideNoResultsMessage() {
const noResultsEl = this.dropdown.querySelector('.no-results');
if (noResultsEl) {
noResultsEl.style.display = 'none';
}
getVisibleOptions() {
return this.options.filter(option => option.style.display !== 'none');
},

navigateToItem(url) {
window.location.href = url;
highlightNextOption() {
const visibleOptions = this.getVisibleOptions();
if (visibleOptions.length === 0) return;
this.highlightedIndex = (this.highlightedIndex + 1) % visibleOptions.length;
this.updateHighlight();
},

highlightFirstOption() {
const firstVisibleOption = Array.from(this.options).find(
option => option.style.display !== 'none'
);
if (firstVisibleOption) {
this.highlightOption(firstVisibleOption);
} else {
this.clearHighlight();
}
highlightPreviousOption() {
const visibleOptions = this.getVisibleOptions();
if (visibleOptions.length === 0) return;
this.highlightedIndex =
(this.highlightedIndex - 1 + visibleOptions.length) %
visibleOptions.length;
this.updateHighlight();
},

updateHighlight() {
const visibleOptions = this.getVisibleOptions();
visibleOptions.forEach((option, index) => {
if (index === this.highlightedIndex) {
option.setAttribute('data-highlighted', 'true');
if (this.navigatingWithKeys) {
option.scrollIntoView({ block: 'nearest' });
}
} else {
option.removeAttribute('data-highlighted');
}
});
},

restoreHighlight() {
if (this.selectedOption) {
this.highlightOption(this.selectedOption);
} else {
this.clearHighlight();
}
highlightOption(index) {
const visibleOptions = this.getVisibleOptions();
this.highlightedIndex = visibleOptions.indexOf(this.options[index]);
this.updateHighlight();
},

selectOption(option) {
this.input.value = option.querySelector('span').textContent.trim();
selectOption(index) {
const visibleOptions = this.getVisibleOptions();
const selectedOption = visibleOptions[index];
this.input.value = selectedOption.textContent.trim();
this.hideDropdown();
this.selectedOption = option;
this.navigateToItem(option.dataset.url);
this.navigateToItem(selectedOption.dataset.url);
},

highlightSelectedOption() {
this.selectedOption = Array.from(this.options).find(
option =>
option.querySelector('span').textContent.trim() === this.input.value
);
if (this.selectedOption) {
this.highlightOption(this.selectedOption);
} else {
this.clearHighlight();
navigateToItem(url) {
if (url) {
window.location.href = url;
}
},

highlightOption(option) {
this.clearHighlight();
option.setAttribute('data-highlighted', 'true');
this.highlightedOption = option;
option.scrollIntoView({ block: 'nearest' });
toggleDropdown() {
if (this.isDropdownVisible()) {
this.hideDropdown();
} else {
this.showDropdown();
}
},

clearHighlight() {
if (this.highlightedOption) {
this.highlightedOption.removeAttribute('data-highlighted');
this.highlightedOption = null;
showDropdown() {
this.dropdown.classList.remove('hidden');
this.scrollToSelectedOption();
if (this.highlightedIndex === -1) {
this.highlightedIndex = this.getSelectedOptionIndex();
this.updateHighlight();
}
},

highlightNextOption() {
const visibleOptions = Array.from(this.options).filter(
option => option.style.display !== 'none'
);
const currentIndex = visibleOptions.indexOf(this.highlightedOption);
const nextOption = visibleOptions[currentIndex + 1] || visibleOptions[0];
if (nextOption) {
this.highlightOption(nextOption);
hideDropdown() {
this.dropdown.classList.add('hidden');
this.highlightedIndex = -1;
this.navigatingWithKeys = false;
this.navigatingWithMouse = false;
},

isDropdownVisible() {
return !this.dropdown.classList.contains('hidden');
},

initializeSelectedOption() {
const selectedOptionIndex = this.getSelectedOptionIndex();
if (selectedOptionIndex !== -1) {
this.input.value = this.options[selectedOptionIndex].textContent.trim();
}
},

highlightPreviousOption() {
const visibleOptions = Array.from(this.options).filter(
option => option.style.display !== 'none'
getSelectedOptionIndex() {
return this.options.findIndex(option =>
option.hasAttribute('data-item-selected')
);
const currentIndex = visibleOptions.indexOf(this.highlightedOption);
const previousOption =
visibleOptions[currentIndex - 1] ||
visibleOptions[visibleOptions.length - 1];
if (previousOption) {
this.highlightOption(previousOption);
},

scrollToSelectedOption() {
const selectedOptionIndex = this.getSelectedOptionIndex();
if (selectedOptionIndex !== -1) {
this.options[selectedOptionIndex].scrollIntoView({ block: 'nearest' });
}
},

Expand Down
Loading

0 comments on commit 86165d4

Please sign in to comment.