PluginProbe
Contact Forms by Cimatti / 2.3.6
Contact Forms by Cimatti v2.3.6
2.3.6 2.3.5 2.3.0 2.2.32 2.2.4 2.2.0 2.1.2 2.1.1 trunk 1.0 1.1 1.2 1.2.1 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 All 62 releases
contact-forms / assets / js / frontend / post-select.js

post-select.js in Contact Forms by Cimatti 2.3.6, at assets/js/frontend/post-select.js

689 lines 22.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * PostSelect - Accessible searchable dropdown for post selection
3 *
4 * Enhances native <select> elements with AJAX-powered search and pagination.
5 * Fully keyboard accessible and WCAG 2.2 AA compliant.
6 *
7 * @package Contact Forms
8 * @since 2.0.0-beta.29
9 */
10
11 (function() {
12 'use strict';
13
14 /**
15 * PostSelect class - manages a single post-select dropdown
16 */
17 class PostSelect {
18 constructor(wrapper) {
19 this.wrapper = wrapper;
20 this.select = wrapper.querySelector('select');
21 this.config = wrapper.querySelector('.pfbc-post-select-config');
22
23 if (!this.select || !this.config) {
24 return;
25 }
26
27 // Configuration from data attributes
28 this.postType = this.config.dataset.postType || 'page';
29 this.ajaxUrl = this.config.dataset.ajaxUrl;
30 this.nonce = this.config.dataset.nonce;
31 this.extraArgs = this.config.dataset.extraArgs || '';
32 this.selectedValue = this.config.dataset.selected || '';
33
34 // Context
35 this.isInlineLabelMode = !!this.wrapper.closest('.accua-form-view-inlinelabel');
36 this.inlineLabelText = this.getInlineLabelText();
37
38 // State
39 this.isOpen = false;
40 this.isLoading = false;
41 this.currentPage = 1;
42 this.hasMore = true;
43 this.searchTerm = '';
44 this.options = [];
45 this.highlightedIndex = -1;
46 this.debounceTimer = null;
47 this.requestSeq = 0;
48
49 // Translations (can be overridden via wp_localize_script)
50 this.i18n = window.accuaPostSelectI18n || {
51 select: 'Select...',
52 search: 'Search...',
53 loading: 'Loading...',
54 noResults: 'No results found',
55 loadMore: 'Loading more...'
56 };
57
58 // Separate text for trigger button vs dropdown option in inline-label mode
59 // Trigger button: empty in inline-label mode (floating label shows above), "Select..." in standard mode
60 // Dropdown option: inline label text in inline-label mode (e.g. "Favourite Post"), "Select..." in standard mode
61 this.emptyTriggerText = this.isInlineLabelMode ? '' : this.i18n.select;
62 this.emptyOptionText = (this.isInlineLabelMode && this.inlineLabelText) ? this.inlineLabelText : this.i18n.select;
63
64 this.init();
65 }
66
67 init() {
68 // Mark as enhanced
69 this.wrapper.dataset.enhanced = 'true';
70
71 // Hide native select
72 this.select.style.display = 'none';
73 this.select.setAttribute('aria-hidden', 'true');
74 this.select.setAttribute('tabindex', '-1');
75
76 // Build custom UI
77 this.buildUI();
78
79 // Bind events
80 this.bindEvents();
81
82 // Watch for validation error messages inserted after hidden select
83 this.observeErrors();
84
85 // Load initial options
86 this.loadOptions();
87 }
88
89 /**
90 * Observe validation error messages and sync invalid state to trigger button
91 *
92 * The form validation JS inserts error messages after the hidden <select>.
93 * CSS flexbox order handles visual positioning, so we only need to:
94 * 1. Sync the invalid class/aria to the visible trigger button
95 * 2. Forward blur events from trigger to select for validation
96 */
97 observeErrors() {
98 // Watch for error elements being added
99 const errorObserver = new MutationObserver((mutations) => {
100 mutations.forEach((mutation) => {
101 mutation.addedNodes.forEach((node) => {
102 // Check if an error message was added
103 if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('pfbc-inline-error')) {
104 // Propagate the invalid styling to the trigger button
105 this.trigger.classList.add('pfbc-invalid');
106 this.trigger.setAttribute('aria-invalid', 'true');
107 }
108 });
109
110 // Check if error was removed
111 mutation.removedNodes.forEach((node) => {
112 if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('pfbc-inline-error')) {
113 // Remove invalid styling from trigger
114 this.trigger.classList.remove('pfbc-invalid');
115 this.trigger.removeAttribute('aria-invalid');
116 }
117 });
118 });
119 });
120
121 // Observe the wrapper for added/removed error elements
122 errorObserver.observe(this.wrapper, {
123 childList: true,
124 subtree: false
125 });
126
127 // Forward blur from trigger to select so validation can fire
128 this.trigger.addEventListener('blur', () => {
129 // Dispatch blur event on the hidden select for validation
130 const blurEvent = new FocusEvent('blur', { bubbles: true });
131 this.select.dispatchEvent(blurEvent);
132 });
133
134 // Listen to select's change event to clear invalid state when user selects a value
135 this.select.addEventListener('change', () => {
136 if (this.select.value) {
137 this.trigger.classList.remove('pfbc-invalid');
138 this.trigger.removeAttribute('aria-invalid');
139 }
140 });
141 }
142
143 buildUI() {
144 // Main container
145 this.container = document.createElement('div');
146 this.container.className = 'pfbc-post-select-container';
147
148 // Trigger button (shows selected value)
149 this.trigger = document.createElement('button');
150 this.trigger.type = 'button';
151 this.trigger.className = 'pfbc-post-select-trigger';
152 this.trigger.setAttribute('aria-haspopup', 'listbox');
153 this.trigger.setAttribute('aria-expanded', 'false');
154 this.trigger.setAttribute('aria-label', this.inlineLabelText || this.i18n.select);
155
156 // Generate unique ID for listbox
157 this.listboxId = 'pfbc-post-select-listbox-' + Math.random().toString(36).substr(2, 9);
158 this.trigger.setAttribute('aria-controls', this.listboxId);
159
160 // Display text
161 this.triggerText = document.createElement('span');
162 this.triggerText.className = 'pfbc-post-select-text';
163 this.triggerText.textContent = this.getSelectedText() || this.emptyTriggerText;
164 this.trigger.appendChild(this.triggerText);
165
166 // Arrow indicator
167 const arrow = document.createElement('span');
168 arrow.className = 'pfbc-post-select-arrow';
169 arrow.setAttribute('aria-hidden', 'true');
170 this.trigger.appendChild(arrow);
171
172 this.container.appendChild(this.trigger);
173
174 // Dropdown panel
175 this.dropdown = document.createElement('div');
176 this.dropdown.className = 'pfbc-post-select-dropdown';
177 this.dropdown.setAttribute('role', 'presentation');
178 this.dropdown.style.display = 'none';
179
180 // Search input
181 this.searchContainer = document.createElement('div');
182 this.searchContainer.className = 'pfbc-post-select-search';
183
184 this.searchInput = document.createElement('input');
185 this.searchInput.type = 'text';
186 this.searchInput.className = 'pfbc-post-select-search-input';
187 this.searchInput.placeholder = this.i18n.search;
188 this.searchInput.setAttribute('aria-label', this.i18n.search);
189 this.searchInput.setAttribute('role', 'searchbox');
190 this.searchInput.setAttribute('aria-controls', this.listboxId);
191 this.searchInput.setAttribute('aria-autocomplete', 'list');
192 this.searchInput.setAttribute('autocomplete', 'off');
193 this.searchInput.setAttribute('autocorrect', 'off');
194 this.searchInput.setAttribute('autocapitalize', 'off');
195 this.searchInput.setAttribute('spellcheck', 'false');
196
197 this.searchContainer.appendChild(this.searchInput);
198 this.dropdown.appendChild(this.searchContainer);
199
200 // Options list
201 this.listbox = document.createElement('ul');
202 this.listbox.className = 'pfbc-post-select-options';
203 this.listbox.id = this.listboxId;
204 this.listbox.setAttribute('role', 'listbox');
205 this.listbox.setAttribute('aria-label', this.inlineLabelText || this.select.getAttribute('aria-label') || 'Options');
206 this.listbox.setAttribute('tabindex', '-1');
207
208 this.dropdown.appendChild(this.listbox);
209
210 // Loading indicator
211 this.loadingIndicator = document.createElement('div');
212 this.loadingIndicator.className = 'pfbc-post-select-loading';
213 this.loadingIndicator.textContent = this.i18n.loading;
214 this.loadingIndicator.style.display = 'none';
215 this.dropdown.appendChild(this.loadingIndicator);
216
217 // Status for screen readers
218 this.liveRegion = document.createElement('div');
219 this.liveRegion.className = 'pfbc-post-select-live-region';
220 this.liveRegion.setAttribute('role', 'status');
221 this.liveRegion.setAttribute('aria-live', 'polite');
222 this.liveRegion.setAttribute('aria-atomic', 'true');
223 this.dropdown.appendChild(this.liveRegion);
224
225 this.container.appendChild(this.dropdown);
226
227 // Insert at beginning of wrapper so validation errors (inserted after select) appear after us
228 this.wrapper.prepend(this.container);
229 }
230
231 bindEvents() {
232 // Trigger click
233 this.trigger.addEventListener('click', (e) => {
234 e.preventDefault();
235 this.toggle();
236 });
237
238 // Trigger keyboard
239 this.trigger.addEventListener('keydown', (e) => {
240 this.handleTriggerKeydown(e);
241 });
242
243 // Search input
244 this.searchInput.addEventListener('input', () => {
245 this.handleSearchInput();
246 });
247
248 this.searchInput.addEventListener('keydown', (e) => {
249 this.handleSearchKeydown(e);
250 });
251
252 // Listbox scroll for infinite loading
253 this.listbox.addEventListener('scroll', () => {
254 this.handleScroll();
255 });
256
257 // Click outside to close
258 document.addEventListener('click', (e) => {
259 if (!this.container.contains(e.target)) {
260 this.close();
261 }
262 });
263
264 // Escape to close
265 document.addEventListener('keydown', (e) => {
266 if (e.key === 'Escape' && this.isOpen) {
267 this.close();
268 this.trigger.focus();
269 }
270 });
271 }
272
273 handleTriggerKeydown(e) {
274 switch (e.key) {
275 case 'Enter':
276 case ' ':
277 case 'ArrowDown':
278 case 'ArrowUp':
279 e.preventDefault();
280 this.open();
281 break;
282 }
283 }
284
285 handleSearchKeydown(e) {
286 switch (e.key) {
287 case 'ArrowDown':
288 e.preventDefault();
289 this.highlightNext();
290 break;
291 case 'ArrowUp':
292 e.preventDefault();
293 this.highlightPrevious();
294 break;
295 case 'Enter':
296 e.preventDefault();
297 if (this.highlightedIndex >= 0) {
298 this.selectOption(this.highlightedIndex);
299 }
300 break;
301 case 'Escape':
302 e.preventDefault();
303 this.close();
304 this.trigger.focus();
305 break;
306 case 'Tab':
307 this.close();
308 break;
309 }
310 }
311
312 handleSearchInput() {
313 clearTimeout(this.debounceTimer);
314 this.debounceTimer = setTimeout(() => {
315 this.searchTerm = this.searchInput.value.trim();
316 this.currentPage = 1;
317 this.hasMore = true;
318 this.options = [];
319 this.loadOptions();
320 }, 300);
321 }
322
323 handleScroll() {
324 if (this.isLoading || !this.hasMore) {
325 return;
326 }
327
328 const scrollTop = this.listbox.scrollTop;
329 const scrollHeight = this.listbox.scrollHeight;
330 const clientHeight = this.listbox.clientHeight;
331
332 // Load more when near bottom
333 if (scrollTop + clientHeight >= scrollHeight - 50) {
334 this.currentPage++;
335 this.loadOptions(true);
336 }
337 }
338
339 async loadOptions(append = false) {
340 // Scroll pagination must not fire twice for the same page while a
341 // request is in flight. A fresh load (open/search) instead always runs:
342 // it supersedes any in-flight request via the sequence token below, so
343 // a search typed while the initial load is still loading is not dropped.
344 if (append && this.isLoading) {
345 return;
346 }
347 const requestSeq = ++this.requestSeq;
348
349 this.isLoading = true;
350 this.showLoading(append);
351
352 try {
353 const params = new URLSearchParams({
354 action: 'accua_forms_get_posts',
355 _nonce: this.nonce,
356 post_type: this.postType,
357 search: this.searchTerm,
358 page: this.currentPage,
359 per_page: 50,
360 extra_args: this.extraArgs,
361 selected: append ? '' : this.selectedValue
362 });
363
364 const response = await fetch(this.ajaxUrl, {
365 method: 'POST',
366 headers: {
367 'Content-Type': 'application/x-www-form-urlencoded',
368 },
369 body: params.toString()
370 });
371
372 const data = await response.json();
373
374 // A newer request was started while this one was in flight - discard
375 // this (stale) response instead of overwriting the newer results.
376 if (requestSeq !== this.requestSeq) {
377 return;
378 }
379
380 if (data.success && data.data) {
381 const results = data.data.results || [];
382 this.hasMore = data.data.more || false;
383
384 if (append) {
385 this.options = this.options.concat(results);
386 } else {
387 this.options = results;
388 }
389
390 this.renderOptions(append);
391 this.announceResults();
392 }
393 } catch (error) {
394 if (requestSeq !== this.requestSeq) {
395 return;
396 }
397 console.error('PostSelect: Failed to load options', error);
398 this.announceError();
399 } finally {
400 if (requestSeq === this.requestSeq) {
401 this.isLoading = false;
402 this.hideLoading();
403 }
404 }
405 }
406
407 renderOptions(append = false) {
408 if (!append) {
409 this.listbox.innerHTML = '';
410 this.highlightedIndex = -1;
411 }
412
413 // Note: We don't add an empty "placeholder" option here.
414 // The floating label already shows the field name, and adding an empty
415 // selectable option caused issues. Users can close the dropdown to keep current value.
416
417 const startIndex = append ? this.listbox.children.length : 0;
418
419 this.options.forEach((option, index) => {
420 const actualIndex = startIndex + index;
421 const optionEl = this.createOptionElement(option.id, option.text, actualIndex);
422 this.listbox.appendChild(optionEl);
423 });
424
425 // Show "no results" if empty
426 if (this.options.length === 0 && this.searchTerm) {
427 const noResults = document.createElement('li');
428 noResults.className = 'pfbc-post-select-no-results';
429 noResults.textContent = this.i18n.noResults;
430 noResults.setAttribute('role', 'presentation');
431 this.listbox.appendChild(noResults);
432 }
433 }
434
435 createOptionElement(value, text, index) {
436 const option = document.createElement('li');
437 option.className = 'pfbc-post-select-option';
438 option.setAttribute('role', 'option');
439 option.setAttribute('data-value', value);
440 option.setAttribute('data-index', index);
441 option.id = this.listboxId + '-option-' + index;
442
443 // Check if selected
444 const currentValue = this.select.value;
445 if (value.toString() === currentValue.toString()) {
446 option.classList.add('selected');
447 option.setAttribute('aria-selected', 'true');
448 } else {
449 option.setAttribute('aria-selected', 'false');
450 }
451
452 option.textContent = text;
453
454 // Click to select
455 option.addEventListener('click', () => {
456 this.selectByValue(value, text);
457 });
458
459 // Mouse hover to highlight
460 option.addEventListener('mouseenter', () => {
461 this.setHighlight(index);
462 });
463
464 return option;
465 }
466
467 selectOption(index) {
468 const option = this.listbox.querySelector(`[data-index="${index}"]`);
469 if (option) {
470 const value = option.dataset.value;
471 const text = option.textContent;
472 this.selectByValue(value, text);
473 }
474 }
475
476 selectByValue(value, text) {
477 // Ensure the option exists in native select before setting value
478 // This is needed because options are loaded via AJAX and may not exist in native select
479 if (value) {
480 let option = this.select.querySelector(`option[value="${CSS.escape(value)}"]`);
481 if (!option) {
482 option = document.createElement('option');
483 option.value = value;
484 option.textContent = text;
485 this.select.appendChild(option);
486 }
487 }
488
489 // Update native select value
490 this.select.value = value;
491
492 // Trigger change event for form validation and floating label update
493 const event = new Event('change', { bubbles: true });
494 this.select.dispatchEvent(event);
495
496 // Update display text
497 this.triggerText.textContent = value ? text : this.emptyTriggerText;
498
499 // Update selected state in options
500 this.listbox.querySelectorAll('.pfbc-post-select-option').forEach((opt) => {
501 if (opt.dataset.value === value.toString()) {
502 opt.classList.add('selected');
503 opt.setAttribute('aria-selected', 'true');
504 } else {
505 opt.classList.remove('selected');
506 opt.setAttribute('aria-selected', 'false');
507 }
508 });
509
510 // Close dropdown
511 this.close();
512 this.trigger.focus();
513 }
514
515 highlightNext() {
516 const options = this.listbox.querySelectorAll('.pfbc-post-select-option');
517 if (options.length === 0) return;
518
519 let newIndex = this.highlightedIndex + 1;
520 if (newIndex >= options.length) {
521 newIndex = 0;
522 }
523 this.setHighlight(newIndex);
524 }
525
526 highlightPrevious() {
527 const options = this.listbox.querySelectorAll('.pfbc-post-select-option');
528 if (options.length === 0) return;
529
530 let newIndex = this.highlightedIndex - 1;
531 if (newIndex < 0) {
532 newIndex = options.length - 1;
533 }
534 this.setHighlight(newIndex);
535 }
536
537 setHighlight(index) {
538 // Remove previous highlight
539 const previousOption = this.listbox.querySelector('.highlighted');
540 if (previousOption) {
541 previousOption.classList.remove('highlighted');
542 }
543
544 // Set new highlight
545 const option = this.listbox.querySelector(`[data-index="${index}"]`);
546 if (option) {
547 option.classList.add('highlighted');
548 this.highlightedIndex = index;
549
550 // Update aria-activedescendant
551 this.searchInput.setAttribute('aria-activedescendant', option.id);
552
553 // Scroll into view
554 option.scrollIntoView({ block: 'nearest' });
555 }
556 }
557
558 toggle() {
559 if (this.isOpen) {
560 this.close();
561 } else {
562 this.open();
563 }
564 }
565
566 open() {
567 if (this.isOpen) return;
568
569 this.isOpen = true;
570 this.dropdown.style.display = 'block';
571 this.trigger.setAttribute('aria-expanded', 'true');
572 this.container.classList.add('open');
573
574 // Focus search input
575 this.searchInput.focus();
576
577 // Clear previous search
578 this.searchInput.value = '';
579 this.searchTerm = '';
580
581 // Reload options if empty
582 if (this.options.length === 0) {
583 this.currentPage = 1;
584 this.hasMore = true;
585 this.loadOptions();
586 }
587 }
588
589 close() {
590 if (!this.isOpen) return;
591
592 this.isOpen = false;
593 this.dropdown.style.display = 'none';
594 this.trigger.setAttribute('aria-expanded', 'false');
595 this.container.classList.remove('open');
596 this.highlightedIndex = -1;
597 this.searchInput.removeAttribute('aria-activedescendant');
598 }
599
600 showLoading(append) {
601 if (append) {
602 this.loadingIndicator.textContent = this.i18n.loadMore;
603 } else {
604 this.loadingIndicator.textContent = this.i18n.loading;
605 }
606 this.loadingIndicator.style.display = 'block';
607 }
608
609 hideLoading() {
610 this.loadingIndicator.style.display = 'none';
611 }
612
613 getSelectedText() {
614 const selectedOption = this.select.options[this.select.selectedIndex];
615 if (selectedOption && selectedOption.value) {
616 return selectedOption.textContent;
617 }
618 return null;
619 }
620
621 getInlineLabelText() {
622 const inlineWrapper = this.wrapper.closest('.pfbc-inline-label-wrapper');
623 if (inlineWrapper) {
624 const floatingLabel = inlineWrapper.querySelector('.pfbc-floating-label');
625 if (floatingLabel && floatingLabel.textContent) {
626 return floatingLabel.textContent.trim();
627 }
628 }
629 const ariaLabel = this.select.getAttribute('aria-label');
630 return ariaLabel ? ariaLabel.trim() : '';
631 }
632
633 announceResults() {
634 const count = this.options.length;
635 let message = '';
636 if (count === 0 && this.searchTerm) {
637 message = this.i18n.noResults;
638 } else if (count === 1) {
639 message = '1 result';
640 } else {
641 message = count + ' results';
642 }
643 if (this.hasMore) {
644 message += ', scroll for more';
645 }
646 this.liveRegion.textContent = message;
647 }
648
649 announceError() {
650 this.liveRegion.textContent = 'Error loading results';
651 }
652 }
653
654 /**
655 * Initialize all post-select elements
656 */
657 function initPostSelects() {
658 document.querySelectorAll('.pfbc-post-select-wrapper[data-enhanced="false"]').forEach((wrapper) => {
659 new PostSelect(wrapper);
660 });
661 }
662
663 // Initialize on DOM ready
664 if (document.readyState === 'loading') {
665 document.addEventListener('DOMContentLoaded', initPostSelects);
666 } else {
667 initPostSelects();
668 }
669
670 // Re-initialize when new content is added (for AJAX-loaded forms)
671 const observer = new MutationObserver((mutations) => {
672 mutations.forEach((mutation) => {
673 if (mutation.addedNodes.length) {
674 initPostSelects();
675 }
676 });
677 });
678
679 observer.observe(document.body, {
680 childList: true,
681 subtree: true
682 });
683
684 // Export for manual initialization
685 window.AccuaPostSelect = PostSelect;
686 window.initAccuaPostSelects = initPostSelects;
687
688 })();
689