mirror of
https://github.com/cosmocode/dokuwiki-plugin-struct.git
synced 2025-08-01 15:54:34 +00:00
395 lines
13 KiB
JavaScript
395 lines
13 KiB
JavaScript
/**
|
|
* A custom web component that transforms a standard HTML select into a combo box,
|
|
* allowing users to both select from a dropdown and search by typing.
|
|
*/
|
|
/* */
|
|
class VanillaCombobox extends HTMLElement {
|
|
#select;
|
|
#input;
|
|
#dropdown;
|
|
#separator;
|
|
#multiple;
|
|
#placeholder;
|
|
#outsideClickListener;
|
|
|
|
// region initialization
|
|
|
|
/**
|
|
* Creates a new VanillaCombobox instance.
|
|
* Initializes the shadow DOM.
|
|
*/
|
|
constructor() {
|
|
super();
|
|
this.attachShadow({mode: 'open'});
|
|
}
|
|
|
|
/**
|
|
* Called when the element is connected to the DOM.
|
|
* Initializes the component, sets up the shadow DOM, and binds event listeners.
|
|
* @returns {void}
|
|
*/
|
|
connectedCallback() {
|
|
this.#separator = this.getAttribute('separator') || ',';
|
|
this.#placeholder = this.getAttribute('placeholder') || '';
|
|
this.#setupShadowDOM();
|
|
this.#initializeStyles();
|
|
this.#updateInputFromSelect();
|
|
this.#registerEventListeners();
|
|
}
|
|
|
|
/**
|
|
* Most event handlers will be garbage collected when the element is removed from the DOM.
|
|
* However, we need to remove the outside click listener attached to the document to prevent memory leaks.
|
|
* @returns {void}
|
|
*/
|
|
disconnectedCallback() {
|
|
document.removeEventListener('click', this.#outsideClickListener);
|
|
}
|
|
|
|
/**
|
|
* Sets up the shadow DOM with the necessary elements and styles.
|
|
* Creates the input field and dropdown container.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
#setupShadowDOM() {
|
|
// Get the select element from the light DOM
|
|
this.#select = this.querySelector('select');
|
|
if (!this.#select) {
|
|
console.error('VanillaCombobox: No select element found');
|
|
return;
|
|
}
|
|
|
|
this.#multiple = this.#select.multiple;
|
|
|
|
// Create the input field
|
|
this.#input = document.createElement('input');
|
|
this.#input.type = 'text';
|
|
this.#input.autocomplete = 'off';
|
|
this.#input.part = 'input';
|
|
this.#input.placeholder = this.#placeholder;
|
|
this.#input.required = this.#select.required;
|
|
this.#input.disabled = this.#select.disabled;
|
|
|
|
// Create the dropdown container
|
|
this.#dropdown = document.createElement('div');
|
|
this.#dropdown.className = 'dropdown';
|
|
this.#dropdown.part = 'dropdown';
|
|
this.#dropdown.style.display = 'none';
|
|
|
|
// Add styles to the shadow DOM
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
:host {
|
|
display: inline-block;
|
|
position: relative;
|
|
}
|
|
.dropdown {
|
|
position: absolute;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
border: 1px solid FieldText;
|
|
background-color: Field;
|
|
color: FieldText;
|
|
z-index: 1000;
|
|
width: max-content;
|
|
box-sizing: border-box;
|
|
}
|
|
.option {
|
|
padding: 5px 10px;
|
|
cursor: pointer;
|
|
}
|
|
.option:hover, .option.selected {
|
|
background-color: Highlight;
|
|
color: HighlightText;
|
|
}
|
|
`;
|
|
|
|
// Append elements to the shadow DOM
|
|
this.shadowRoot.appendChild(style);
|
|
|
|
this.shadowRoot.appendChild(this.#input);
|
|
this.shadowRoot.appendChild(this.#dropdown);
|
|
}
|
|
|
|
/**
|
|
* Initializes the styles for the combobox components.
|
|
* Copies styles from browser defaults and applies them to the custom elements.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
#initializeStyles() {
|
|
// create a temporary input element to copy styles from
|
|
const input = document.createElement('input');
|
|
this.parentElement.insertBefore(input, this);
|
|
const defaultStyles = window.getComputedStyle(input);
|
|
|
|
// browser default styles
|
|
const inputStyles = window.getComputedStyle(this.#input);
|
|
const dropdownStyles = window.getComputedStyle(this.#dropdown);
|
|
|
|
// copy styles from the temporary input to the input element
|
|
for (const property of defaultStyles) {
|
|
const newValue = defaultStyles.getPropertyValue(property);
|
|
const oldValue = inputStyles.getPropertyValue(property);
|
|
if (newValue === oldValue) continue;
|
|
this.#input.style.setProperty(property, newValue);
|
|
}
|
|
this.#input.style.outline = 'none';
|
|
|
|
// copy select styles to the dropdown
|
|
for (const property of defaultStyles) {
|
|
const newValue = defaultStyles.getPropertyValue(property);
|
|
const oldValue = dropdownStyles.getPropertyValue(property);
|
|
if (newValue === oldValue) continue;
|
|
if (!property.match(/^(border|color|background|font|padding)/)) continue;
|
|
this.#dropdown.style.setProperty(property, newValue);
|
|
}
|
|
this.#dropdown.style.minWidth = `${this.#input.offsetWidth}px`;
|
|
this.#dropdown.style.borderTop = 'none';
|
|
|
|
// remove the temporary input element
|
|
this.parentElement.removeChild(input);
|
|
}
|
|
|
|
// endregion
|
|
// region Event Handling
|
|
|
|
/**
|
|
* Registers all event listeners for the combobox.
|
|
* Sets up input, focus, blur, and keyboard events.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
#registerEventListeners() {
|
|
this.#input.addEventListener('focus', this.#onFocus.bind(this));
|
|
// Delay to allow click event on dropdown
|
|
this.#input.addEventListener('blur', () => setTimeout(() => this.#onBlur(), 150));
|
|
this.#input.addEventListener('input', this.#onInput.bind(this));
|
|
this.#input.addEventListener('keydown', this.#onKeyDown.bind(this));
|
|
|
|
this.#outsideClickListener = (event) => {
|
|
if (this.contains(event.target) || this.shadowRoot.contains(event.target)) return;
|
|
this.#closeDropdown();
|
|
};
|
|
document.addEventListener('click', this.#outsideClickListener);
|
|
}
|
|
|
|
|
|
/**
|
|
* Handles the focus event on the input field.
|
|
* Updates the values and appends the separator if multiple selection is enabled.
|
|
* Shows the dropdown with available options.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
#onFocus() {
|
|
this.#updateInputFromSelect();
|
|
this.#showDropdown();
|
|
}
|
|
|
|
/**
|
|
* Handles the blur event on the input field.
|
|
* Closes the dropdown and updates the input field (removes the separator).
|
|
* Synchronizes the select element with the input value.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
#onBlur() {
|
|
this.#closeDropdown();
|
|
this.#updateSelectFromInput();
|
|
this.#updateInputFromSelect();
|
|
}
|
|
|
|
/**
|
|
* Handles the input event on the input field.
|
|
* Shows the dropdown with filtered options based on the current input.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
#onInput() {
|
|
this.#showDropdown();
|
|
}
|
|
|
|
/**
|
|
* Handles keyboard navigation in the dropdown.
|
|
* Supports arrow keys, Enter, and Escape.
|
|
* @private
|
|
* @param {KeyboardEvent} event - The keyboard event
|
|
* @returns {void}
|
|
*/
|
|
#onKeyDown(event) {
|
|
// Only handle keyboard navigation if dropdown is visible
|
|
if (this.#dropdown.style.display !== 'block') return;
|
|
|
|
const items = this.#dropdown.querySelectorAll('.option');
|
|
const selectedItem = this.#dropdown.querySelector('.option.selected');
|
|
let selectedIndex = Array.from(items).indexOf(selectedItem);
|
|
|
|
switch (event.key) {
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
selectedIndex = (selectedIndex + 1) % items.length;
|
|
this.#highlightItem(items, selectedIndex);
|
|
break;
|
|
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
|
|
this.#highlightItem(items, selectedIndex);
|
|
break;
|
|
|
|
case 'Enter':
|
|
event.preventDefault();
|
|
if (selectedItem) {
|
|
selectedItem.click();
|
|
}
|
|
break;
|
|
|
|
case 'Escape':
|
|
event.preventDefault();
|
|
this.#dropdown.style.display = 'none';
|
|
break;
|
|
}
|
|
}
|
|
|
|
// endregion
|
|
// region Data Handling
|
|
|
|
/**
|
|
* Updates the input field value based on the selected options in the select element.
|
|
* Joins multiple selections with the separator if multiple selection is enabled.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
#updateInputFromSelect() {
|
|
const selectedOptions = Array.from(this.#select.selectedOptions);
|
|
|
|
if (selectedOptions.length > 0) {
|
|
this.#input.value = selectedOptions
|
|
.map(option => option.textContent)
|
|
.join(`${this.#separator} `);
|
|
} else {
|
|
this.#input.value = '';
|
|
}
|
|
|
|
// If the input is focused and multiple selection is enabled, append the separator
|
|
if ((this.shadowRoot.activeElement === this.#input) && this.#multiple && this.#input.value !== '') {
|
|
this.#input.value += this.#separator + ' ';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets all selected options and unselects those whose text is no longer in the input.
|
|
* Synchronizes the select element with the input field content.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
#updateSelectFromInput() {
|
|
const selectedOptions = Array.from(this.#select.selectedOptions);
|
|
let inputTexts = [this.#input.value];
|
|
if (this.#multiple) {
|
|
inputTexts = this.#input.value.split(this.#separator).map(text => text.trim());
|
|
}
|
|
|
|
selectedOptions.forEach(option => {
|
|
if (!inputTexts.includes(option.textContent)) {
|
|
option.selected = false;
|
|
}
|
|
})
|
|
}
|
|
|
|
// endregion
|
|
// region Dropdown Handling
|
|
|
|
/**
|
|
* Shows the dropdown with options filtered by the current input value.
|
|
* Creates option elements for each matching option.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
#showDropdown() {
|
|
// get the currently edited value
|
|
let query = this.#input.value.trim();
|
|
if (this.#multiple) {
|
|
query = query.split(this.#separator).pop().trim()
|
|
}
|
|
|
|
// Filter the options matching the input value
|
|
const options = Array.from(this.#select.options);
|
|
const filteredOptions = options.filter(
|
|
option => option.textContent.toLowerCase().includes(query.toLowerCase())
|
|
);
|
|
if (filteredOptions.length === 0) {
|
|
this.#closeDropdown();
|
|
return;
|
|
}
|
|
|
|
// Create the dropdown items
|
|
this.#dropdown.innerHTML = '';
|
|
filteredOptions.forEach(option => {
|
|
if (this.#multiple && option.value === '') return; // Ignore empty options in multiple mode
|
|
|
|
const div = document.createElement('div');
|
|
div.textContent = option.textContent;
|
|
div.className = 'option';
|
|
div.part = 'option';
|
|
this.#dropdown.appendChild(div);
|
|
|
|
// Add click event to each option
|
|
div.addEventListener('click', () => this.#selectOption(option));
|
|
});
|
|
|
|
// Show the dropdown
|
|
this.#dropdown.style.display = 'block';
|
|
}
|
|
|
|
/**
|
|
* Closes the dropdown by hiding it.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
#closeDropdown() {
|
|
this.#dropdown.style.display = 'none';
|
|
}
|
|
|
|
/**
|
|
* Highlights a specific item in the dropdown.
|
|
* Removes selection from all items and adds it to the specified one.
|
|
* @private
|
|
* @param {NodeListOf<Element>} items - The dropdown items
|
|
* @param {number} index - The index of the item to highlight
|
|
* @returns {void}
|
|
*/
|
|
#highlightItem(items, index) {
|
|
// Remove selection from all items
|
|
items.forEach(item => item.classList.remove('selected'));
|
|
|
|
// Add selection to current item
|
|
if (items[index]) {
|
|
items[index].classList.add('selected');
|
|
// Ensure the selected item is visible in the dropdown
|
|
items[index].scrollIntoView({block: 'nearest'});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Selects an option from the dropdown.
|
|
* Updates the select element and input field, then closes the dropdown.
|
|
* @private
|
|
* @param {HTMLOptionElement} option - The option to select
|
|
* @returns {void}
|
|
*/
|
|
#selectOption(option) {
|
|
option.selected = true;
|
|
this.#updateInputFromSelect();
|
|
this.#closeDropdown();
|
|
this.#input.focus();
|
|
}
|
|
|
|
// endregion
|
|
}
|
|
|
|
// Register the custom element
|
|
customElements.define('vanilla-combobox', VanillaCombobox);
|