/** * PostSelect - Accessible searchable dropdown for post selection * * Enhances native . * CSS flexbox order handles visual positioning, so we only need to: * 1. Sync the invalid class/aria to the visible trigger button * 2. Forward blur events from trigger to select for validation */ observeErrors() { // Watch for error elements being added const errorObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { // Check if an error message was added if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('pfbc-inline-error')) { // Propagate the invalid styling to the trigger button this.trigger.classList.add('pfbc-invalid'); this.trigger.setAttribute('aria-invalid', 'true'); } }); // Check if error was removed mutation.removedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('pfbc-inline-error')) { // Remove invalid styling from trigger this.trigger.classList.remove('pfbc-invalid'); this.trigger.removeAttribute('aria-invalid'); } }); }); }); // Observe the wrapper for added/removed error elements errorObserver.observe(this.wrapper, { childList: true, subtree: false }); // Forward blur from trigger to select so validation can fire this.trigger.addEventListener('blur', () => { // Dispatch blur event on the hidden select for validation const blurEvent = new FocusEvent('blur', { bubbles: true }); this.select.dispatchEvent(blurEvent); }); // Listen to select's change event to clear invalid state when user selects a value this.select.addEventListener('change', () => { if (this.select.value) { this.trigger.classList.remove('pfbc-invalid'); this.trigger.removeAttribute('aria-invalid'); } }); } buildUI() { // Main container this.container = document.createElement('div'); this.container.className = 'pfbc-post-select-container'; // Trigger button (shows selected value) this.trigger = document.createElement('button'); this.trigger.type = 'button'; this.trigger.className = 'pfbc-post-select-trigger'; this.trigger.setAttribute('aria-haspopup', 'listbox'); this.trigger.setAttribute('aria-expanded', 'false'); this.trigger.setAttribute('aria-label', this.inlineLabelText || this.i18n.select); // Generate unique ID for listbox this.listboxId = 'pfbc-post-select-listbox-' + Math.random().toString(36).substr(2, 9); this.trigger.setAttribute('aria-controls', this.listboxId); // Display text this.triggerText = document.createElement('span'); this.triggerText.className = 'pfbc-post-select-text'; this.triggerText.textContent = this.getSelectedText() || this.emptyTriggerText; this.trigger.appendChild(this.triggerText); // Arrow indicator const arrow = document.createElement('span'); arrow.className = 'pfbc-post-select-arrow'; arrow.setAttribute('aria-hidden', 'true'); this.trigger.appendChild(arrow); this.container.appendChild(this.trigger); // Dropdown panel this.dropdown = document.createElement('div'); this.dropdown.className = 'pfbc-post-select-dropdown'; this.dropdown.setAttribute('role', 'presentation'); this.dropdown.style.display = 'none'; // Search input this.searchContainer = document.createElement('div'); this.searchContainer.className = 'pfbc-post-select-search'; this.searchInput = document.createElement('input'); this.searchInput.type = 'text'; this.searchInput.className = 'pfbc-post-select-search-input'; this.searchInput.placeholder = this.i18n.search; this.searchInput.setAttribute('aria-label', this.i18n.search); this.searchInput.setAttribute('role', 'searchbox'); this.searchInput.setAttribute('aria-controls', this.listboxId); this.searchInput.setAttribute('aria-autocomplete', 'list'); this.searchInput.setAttribute('autocomplete', 'off'); this.searchInput.setAttribute('autocorrect', 'off'); this.searchInput.setAttribute('autocapitalize', 'off'); this.searchInput.setAttribute('spellcheck', 'false'); this.searchContainer.appendChild(this.searchInput); this.dropdown.appendChild(this.searchContainer); // Options list this.listbox = document.createElement('ul'); this.listbox.className = 'pfbc-post-select-options'; this.listbox.id = this.listboxId; this.listbox.setAttribute('role', 'listbox'); this.listbox.setAttribute('aria-label', this.inlineLabelText || this.select.getAttribute('aria-label') || 'Options'); this.listbox.setAttribute('tabindex', '-1'); this.dropdown.appendChild(this.listbox); // Loading indicator this.loadingIndicator = document.createElement('div'); this.loadingIndicator.className = 'pfbc-post-select-loading'; this.loadingIndicator.textContent = this.i18n.loading; this.loadingIndicator.style.display = 'none'; this.dropdown.appendChild(this.loadingIndicator); // Status for screen readers this.liveRegion = document.createElement('div'); this.liveRegion.className = 'pfbc-post-select-live-region'; this.liveRegion.setAttribute('role', 'status'); this.liveRegion.setAttribute('aria-live', 'polite'); this.liveRegion.setAttribute('aria-atomic', 'true'); this.dropdown.appendChild(this.liveRegion); this.container.appendChild(this.dropdown); // Insert at beginning of wrapper so validation errors (inserted after select) appear after us this.wrapper.prepend(this.container); } bindEvents() { // Trigger click this.trigger.addEventListener('click', (e) => { e.preventDefault(); this.toggle(); }); // Trigger keyboard this.trigger.addEventListener('keydown', (e) => { this.handleTriggerKeydown(e); }); // Search input this.searchInput.addEventListener('input', () => { this.handleSearchInput(); }); this.searchInput.addEventListener('keydown', (e) => { this.handleSearchKeydown(e); }); // Listbox scroll for infinite loading this.listbox.addEventListener('scroll', () => { this.handleScroll(); }); // Click outside to close document.addEventListener('click', (e) => { if (!this.container.contains(e.target)) { this.close(); } }); // Escape to close document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.isOpen) { this.close(); this.trigger.focus(); } }); } handleTriggerKeydown(e) { switch (e.key) { case 'Enter': case ' ': case 'ArrowDown': case 'ArrowUp': e.preventDefault(); this.open(); break; } } handleSearchKeydown(e) { switch (e.key) { case 'ArrowDown': e.preventDefault(); this.highlightNext(); break; case 'ArrowUp': e.preventDefault(); this.highlightPrevious(); break; case 'Enter': e.preventDefault(); if (this.highlightedIndex >= 0) { this.selectOption(this.highlightedIndex); } break; case 'Escape': e.preventDefault(); this.close(); this.trigger.focus(); break; case 'Tab': this.close(); break; } } handleSearchInput() { clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { this.searchTerm = this.searchInput.value.trim(); this.currentPage = 1; this.hasMore = true; this.options = []; this.loadOptions(); }, 300); } handleScroll() { if (this.isLoading || !this.hasMore) { return; } const scrollTop = this.listbox.scrollTop; const scrollHeight = this.listbox.scrollHeight; const clientHeight = this.listbox.clientHeight; // Load more when near bottom if (scrollTop + clientHeight >= scrollHeight - 50) { this.currentPage++; this.loadOptions(true); } } async loadOptions(append = false) { // Scroll pagination must not fire twice for the same page while a // request is in flight. A fresh load (open/search) instead always runs: // it supersedes any in-flight request via the sequence token below, so // a search typed while the initial load is still loading is not dropped. if (append && this.isLoading) { return; } const requestSeq = ++this.requestSeq; this.isLoading = true; this.showLoading(append); try { const params = new URLSearchParams({ action: 'accua_forms_get_posts', _nonce: this.nonce, post_type: this.postType, search: this.searchTerm, page: this.currentPage, per_page: 50, extra_args: this.extraArgs, selected: append ? '' : this.selectedValue }); const response = await fetch(this.ajaxUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: params.toString() }); const data = await response.json(); // A newer request was started while this one was in flight - discard // this (stale) response instead of overwriting the newer results. if (requestSeq !== this.requestSeq) { return; } if (data.success && data.data) { const results = data.data.results || []; this.hasMore = data.data.more || false; if (append) { this.options = this.options.concat(results); } else { this.options = results; } this.renderOptions(append); this.announceResults(); } } catch (error) { if (requestSeq !== this.requestSeq) { return; } console.error('PostSelect: Failed to load options', error); this.announceError(); } finally { if (requestSeq === this.requestSeq) { this.isLoading = false; this.hideLoading(); } } } renderOptions(append = false) { if (!append) { this.listbox.innerHTML = ''; this.highlightedIndex = -1; } // Note: We don't add an empty "placeholder" option here. // The floating label already shows the field name, and adding an empty // selectable option caused issues. Users can close the dropdown to keep current value. const startIndex = append ? this.listbox.children.length : 0; this.options.forEach((option, index) => { const actualIndex = startIndex + index; const optionEl = this.createOptionElement(option.id, option.text, actualIndex); this.listbox.appendChild(optionEl); }); // Show "no results" if empty if (this.options.length === 0 && this.searchTerm) { const noResults = document.createElement('li'); noResults.className = 'pfbc-post-select-no-results'; noResults.textContent = this.i18n.noResults; noResults.setAttribute('role', 'presentation'); this.listbox.appendChild(noResults); } } createOptionElement(value, text, index) { const option = document.createElement('li'); option.className = 'pfbc-post-select-option'; option.setAttribute('role', 'option'); option.setAttribute('data-value', value); option.setAttribute('data-index', index); option.id = this.listboxId + '-option-' + index; // Check if selected const currentValue = this.select.value; if (value.toString() === currentValue.toString()) { option.classList.add('selected'); option.setAttribute('aria-selected', 'true'); } else { option.setAttribute('aria-selected', 'false'); } option.textContent = text; // Click to select option.addEventListener('click', () => { this.selectByValue(value, text); }); // Mouse hover to highlight option.addEventListener('mouseenter', () => { this.setHighlight(index); }); return option; } selectOption(index) { const option = this.listbox.querySelector(`[data-index="${index}"]`); if (option) { const value = option.dataset.value; const text = option.textContent; this.selectByValue(value, text); } } selectByValue(value, text) { // Ensure the option exists in native select before setting value // This is needed because options are loaded via AJAX and may not exist in native select if (value) { let option = this.select.querySelector(`option[value="${CSS.escape(value)}"]`); if (!option) { option = document.createElement('option'); option.value = value; option.textContent = text; this.select.appendChild(option); } } // Update native select value this.select.value = value; // Trigger change event for form validation and floating label update const event = new Event('change', { bubbles: true }); this.select.dispatchEvent(event); // Update display text this.triggerText.textContent = value ? text : this.emptyTriggerText; // Update selected state in options this.listbox.querySelectorAll('.pfbc-post-select-option').forEach((opt) => { if (opt.dataset.value === value.toString()) { opt.classList.add('selected'); opt.setAttribute('aria-selected', 'true'); } else { opt.classList.remove('selected'); opt.setAttribute('aria-selected', 'false'); } }); // Close dropdown this.close(); this.trigger.focus(); } highlightNext() { const options = this.listbox.querySelectorAll('.pfbc-post-select-option'); if (options.length === 0) return; let newIndex = this.highlightedIndex + 1; if (newIndex >= options.length) { newIndex = 0; } this.setHighlight(newIndex); } highlightPrevious() { const options = this.listbox.querySelectorAll('.pfbc-post-select-option'); if (options.length === 0) return; let newIndex = this.highlightedIndex - 1; if (newIndex < 0) { newIndex = options.length - 1; } this.setHighlight(newIndex); } setHighlight(index) { // Remove previous highlight const previousOption = this.listbox.querySelector('.highlighted'); if (previousOption) { previousOption.classList.remove('highlighted'); } // Set new highlight const option = this.listbox.querySelector(`[data-index="${index}"]`); if (option) { option.classList.add('highlighted'); this.highlightedIndex = index; // Update aria-activedescendant this.searchInput.setAttribute('aria-activedescendant', option.id); // Scroll into view option.scrollIntoView({ block: 'nearest' }); } } toggle() { if (this.isOpen) { this.close(); } else { this.open(); } } open() { if (this.isOpen) return; this.isOpen = true; this.dropdown.style.display = 'block'; this.trigger.setAttribute('aria-expanded', 'true'); this.container.classList.add('open'); // Focus search input this.searchInput.focus(); // Clear previous search this.searchInput.value = ''; this.searchTerm = ''; // Reload options if empty if (this.options.length === 0) { this.currentPage = 1; this.hasMore = true; this.loadOptions(); } } close() { if (!this.isOpen) return; this.isOpen = false; this.dropdown.style.display = 'none'; this.trigger.setAttribute('aria-expanded', 'false'); this.container.classList.remove('open'); this.highlightedIndex = -1; this.searchInput.removeAttribute('aria-activedescendant'); } showLoading(append) { if (append) { this.loadingIndicator.textContent = this.i18n.loadMore; } else { this.loadingIndicator.textContent = this.i18n.loading; } this.loadingIndicator.style.display = 'block'; } hideLoading() { this.loadingIndicator.style.display = 'none'; } getSelectedText() { const selectedOption = this.select.options[this.select.selectedIndex]; if (selectedOption && selectedOption.value) { return selectedOption.textContent; } return null; } getInlineLabelText() { const inlineWrapper = this.wrapper.closest('.pfbc-inline-label-wrapper'); if (inlineWrapper) { const floatingLabel = inlineWrapper.querySelector('.pfbc-floating-label'); if (floatingLabel && floatingLabel.textContent) { return floatingLabel.textContent.trim(); } } const ariaLabel = this.select.getAttribute('aria-label'); return ariaLabel ? ariaLabel.trim() : ''; } announceResults() { const count = this.options.length; let message = ''; if (count === 0 && this.searchTerm) { message = this.i18n.noResults; } else if (count === 1) { message = '1 result'; } else { message = count + ' results'; } if (this.hasMore) { message += ', scroll for more'; } this.liveRegion.textContent = message; } announceError() { this.liveRegion.textContent = 'Error loading results'; } } /** * Initialize all post-select elements */ function initPostSelects() { document.querySelectorAll('.pfbc-post-select-wrapper[data-enhanced="false"]').forEach((wrapper) => { new PostSelect(wrapper); }); } // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initPostSelects); } else { initPostSelects(); } // Re-initialize when new content is added (for AJAX-loaded forms) const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.addedNodes.length) { initPostSelects(); } }); }); observer.observe(document.body, { childList: true, subtree: true }); // Export for manual initialization window.AccuaPostSelect = PostSelect; window.initAccuaPostSelects = initPostSelects; })();