PluginProbe
Contact Forms by Cimatti / 2.2.0
Contact Forms by Cimatti v2.2.0
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.2.0, at assets/js/frontend/post-select.js

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