diff --git a/script.js b/script.js index ecc66b5..ac7a709 100644 --- a/script.js +++ b/script.js @@ -5,6 +5,7 @@ jQuery(function () { /* DOKUWIKI:include script/AggregationEditor.js */ /* DOKUWIKI:include script/InlineEditor.js */ /* DOKUWIKI:include script/StructFilter.js */ + /* DOKUWIKI:include_once script/vanilla-combobox.js */ function init() { EntryEditor(jQuery('#dw__editform, form.bureaucracy__plugin')); diff --git a/script/vanilla-combobox.js b/script/vanilla-combobox.js new file mode 100644 index 0000000..237e331 --- /dev/null +++ b/script/vanilla-combobox.js @@ -0,0 +1,394 @@ +/** + * 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} 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); diff --git a/types/Dropdown.php b/types/Dropdown.php index 659233b..8b274ab 100644 --- a/types/Dropdown.php +++ b/types/Dropdown.php @@ -5,7 +5,8 @@ namespace dokuwiki\plugin\struct\types; class Dropdown extends AbstractBaseType { protected $config = [ - 'values' => 'one, two, three' + 'values' => 'one, two, three', + 'combobox' => false, ]; /** @@ -50,6 +51,10 @@ class Dropdown extends AbstractBaseType } $html .= ''; + if ($this->config['combobox']){ + $html = "$html"; + } + return $html; } @@ -84,6 +89,11 @@ class Dropdown extends AbstractBaseType } $html .= ' '; $html .= '' . $this->getLang('multidropdown') . ''; + + if ($this->config['combobox']){ + $html = "$html"; + } + return $html; } } diff --git a/types/Lookup.php b/types/Lookup.php index a3a2c79..c4e8ba6 100644 --- a/types/Lookup.php +++ b/types/Lookup.php @@ -16,7 +16,11 @@ use dokuwiki\plugin\struct\meta\Value; class Lookup extends Dropdown { - protected $config = ['schema' => '', 'field' => '']; + protected $config = [ + 'schema' => '', + 'field' => '', + 'combobox' => false, + ]; /** @var Column caches the referenced column */ protected $column;