# contact-forms/trunk/assets/js/frontend/post-select.js

Contact Forms by Cimatti, version trunk. 689 lines.

- Page: https://pluginprobe.com/plugins/contact-forms/trunk/code/assets/js/frontend/post-select.js
- Raw: https://pluginprobe.com/plugins/contact-forms/trunk/raw/assets/js/frontend/post-select.js
- Modified: 2026-08-21T08:39:40+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/contact-forms/trunk/code/assets/js/frontend/post-select.js#L10-L20`.

```javascript
/**
 * PostSelect - Accessible searchable dropdown for post selection
 *
 * Enhances native <select> elements with AJAX-powered search and pagination.
 * Fully keyboard accessible and WCAG 2.2 AA compliant.
 *
 * @package Contact Forms
 * @since 2.0.0-beta.29
 */

(function() {
  'use strict';

  /**
   * PostSelect class - manages a single post-select dropdown
   */
  class PostSelect {
    constructor(wrapper) {
      this.wrapper = wrapper;
      this.select = wrapper.querySelector('select');
      this.config = wrapper.querySelector('.pfbc-post-select-config');
      
      if (!this.select || !this.config) {
        return;
      }

      // Configuration from data attributes
      this.postType = this.config.dataset.postType || 'page';
      this.ajaxUrl = this.config.dataset.ajaxUrl;
      this.nonce = this.config.dataset.nonce;
      this.extraArgs = this.config.dataset.extraArgs || '';
      this.selectedValue = this.config.dataset.selected || '';

      // Context
      this.isInlineLabelMode = !!this.wrapper.closest('.accua-form-view-inlinelabel');
      this.inlineLabelText = this.getInlineLabelText();

      // State
      this.isOpen = false;
      this.isLoading = false;
      this.currentPage = 1;
      this.hasMore = true;
      this.searchTerm = '';
      this.options = [];
      this.highlightedIndex = -1;
      this.debounceTimer = null;
      this.requestSeq = 0;

      // Translations (can be overridden via wp_localize_script)
      this.i18n = window.accuaPostSelectI18n || {
        select: 'Select...',
        search: 'Search...',
        loading: 'Loading...',
        noResults: 'No results found',
        loadMore: 'Loading more...'
      };

      // Separate text for trigger button vs dropdown option in inline-label mode
      // Trigger button: empty in inline-label mode (floating label shows above), "Select..." in standard mode
      // Dropdown option: inline label text in inline-label mode (e.g. "Favourite Post"), "Select..." in standard mode
      this.emptyTriggerText = this.isInlineLabelMode ? '' : this.i18n.select;
      this.emptyOptionText = (this.isInlineLabelMode && this.inlineLabelText) ? this.inlineLabelText : this.i18n.select;

      this.init();
    }

    init() {
      // Mark as enhanced
      this.wrapper.dataset.enhanced = 'true';
      
      // Hide native select
      this.select.style.display = 'none';
      this.select.setAttribute('aria-hidden', 'true');
      this.select.setAttribute('tabindex', '-1');
      
      // Build custom UI
      this.buildUI();
      
      // Bind events
      this.bindEvents();
      
      // Watch for validation error messages inserted after hidden select
      this.observeErrors();
      
      // Load initial options
      this.loadOptions();
    }

    /**
     * Observe validation error messages and sync invalid state to trigger button
     * 
     * The form validation JS inserts error messages after the hidden <select>.
     * 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;

})();

```
