PluginProbe
Booking Calendar / 11.7
Booking Calendar v11.7
11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 10.11 All 204 releases
booking / includes / page-form-builder / _src / bfb-rightbar-tabs.js

bfb-rightbar-tabs.js in Booking Calendar 11.7, at includes/page-form-builder/_src/bfb-rightbar-tabs.js

570 lines 18.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Booking Calendar — Rightbar Tabs Controller (JS)
3 *
4 * Purpose: Handles the main right sidebar tabs (Library / Inspector / Settings) in the Booking Form Builder.
5 * - Manages keyboard and mouse navigation for tabs.
6 * - Keeps ARIA attributes in sync and shows/hides matching tabpanels.
7 * - Supports programmatic switching via the 'wpbc_bfb:show_panel' event and emits 'wpbc_bfb:panel_shown'.
8 * - Uses hard-wired selectors for rightbar markup; optionally uses WPBC_BFB_Sanitize for safe selectors.
9 *
10 * Markup contract:
11 * - Tabs: [role="tab"][aria-controls="<panel_id>"]
12 * - Tablist: .wpbc_bfb__rightbar_tabs[role="tablist"]
13 * - Panels: .wpbc_bfb__palette_panel#<panel_id> (with aria-labelledby)
14 *
15 * @package Booking Calendar
16 * @subpackage Admin\UI
17 * @since 11.0.0
18 * @version 1.0.0
19 * @see File ../includes/page-form-builder/_src/bfb-rightbar-tabs.js
20 */
21 (function (w, d) {
22 'use strict';
23
24 const Core = w.WPBC_BFB_Core || {};
25 const Sanit = Core.WPBC_BFB_Sanitize || null;
26
27 /**
28 * Accessible tabs controller for the right-side palettes (Library / Inspector / Settings)
29 * of the Booking Form Builder UI. Handles:
30 * - Mouse and keyboard navigation (delegated on the tablist container).
31 * - Showing/hiding associated tabpanels and keeping ARIA in sync.
32 * - Programmatic switching via the `wpbc_bfb:show_panel` CustomEvent (listened on document).
33 *
34 * If present, {@link WPBC_BFB_Sanitize.esc_attr_value_for_selector} is used to safely
35 * select the tab that controls a given panel id.
36 *
37 * @version 2025-08-26
38 */
39 class WPBC_BFB_Rightbar_Tabs {
40
41 /**
42 * Constructor.
43 *
44 * @param {Object} [opts]
45 * @param {Object} [opts.selectors]
46 * @param {string} [opts.selectors.panels='.wpbc_bfb__palette_panel'] CSS selector that matches tabpanels.
47 * @param {string} [opts.selectors.tablist='.wpbc_bfb__rightbar_tabs[role="tablist"]'] CSS selector for tablist roots.
48 */
49 constructor(opts = {}) {
50 const def = {
51 panels : '.wpbc_bfb__palette_panel',
52 tablist: '.wpbc_bfb__rightbar_tabs[role="tablist"]'
53 };
54 this.selectors = Object.assign( {}, def, opts.selectors || {} );
55 this._on_keydown = this._on_keydown.bind( this );
56 this._on_click = this._on_click.bind( this );
57 this._on_show_panel_evt = this._on_show_panel_evt.bind( this );
58 this._tablists = [];
59 }
60
61 /**
62 * Attach DOM listeners to each tablist container and perform initial ARIA sync.
63 * Keyboard & mouse handlers are scoped to the tablist(s) for easier debugging.
64 *
65 * @returns {void}
66 */
67 init() {
68 this._tablists = Array.from( d.querySelectorAll( this.selectors.tablist ) );
69 this._tablists.forEach( (list) => {
70 list.addEventListener( 'keydown', this._on_keydown, true );
71 list.addEventListener( 'click', this._on_click, false );
72 } );
73 // Programmatic switching kept on document for back-compat with existing dispatches.
74 d.addEventListener( 'wpbc_bfb:show_panel', this._on_show_panel_evt );
75
76 this.sync_initial_aria();
77 }
78
79 /**
80 * Remove listeners attached in {@link init}.
81 *
82 * @returns {void}
83 */
84 destroy() {
85 this._tablists.forEach( (list) => {
86 list.removeEventListener( 'keydown', this._on_keydown, true );
87 list.removeEventListener( 'click', this._on_click, false );
88 } );
89 this._tablists = [];
90 d.removeEventListener( 'wpbc_bfb:show_panel', this._on_show_panel_evt );
91 }
92
93 /**
94 * Show a specific panel and update the selected tab state.
95 * - Hides all panels matched by {@link selectors.panels} by setting
96 * `hidden` and `aria-hidden="true"`.
97 * - Reveals the target panel by removing `hidden` and setting `aria-hidden="false"`.
98 * - If a tab element is provided (or discoverable by aria-controls),
99 * marks that tab `aria-selected="true"` and clears others in its tablist.
100 *
101 * @param {string} panel_id The id attribute of the panel (tabpanel) to show.
102 * @param {HTMLElement} [tab_el] An explicit tab element to mark selected (optional).
103 * @returns {void}
104 */
105 show_panel(panel_id, tab_el) {
106 const panel = d.getElementById( panel_id );
107 if ( ! panel ) {
108 console.warn( '[WPBC] Panel not found:', panel_id );
109 return;
110 }
111
112 this._hide_all_panels();
113 panel.removeAttribute( 'hidden' );
114 panel.setAttribute( 'aria-hidden', 'false' );
115
116 const tab = tab_el || this._get_tab_for_panel( panel_id );
117 if ( ! tab ) {
118 return;
119 }
120
121 const tablist = tab.closest( '[role="tablist"]' ) || d.querySelector( this.selectors.tablist );
122 if ( ! tablist ) {
123 return;
124 }
125
126 tablist.querySelectorAll( '[role="tab"]' ).forEach( (t) => t.setAttribute( 'aria-selected', 'false' ) );
127 tab.setAttribute( 'aria-selected', 'true' );
128
129 // Fire a hook when a panel changes.
130 d.dispatchEvent( new CustomEvent( 'wpbc_bfb:panel_shown', { detail: { panel_id, tab_el: tab } } ) );
131 }
132
133 /**
134 * Ensure a consistent initial ARIA state:
135 * - If a panel is already visible, mark it and its controlling tab as active.
136 * - Otherwise, reveal the first panel and mark its tab selected.
137 *
138 * @returns {void}
139 */
140 sync_initial_aria() {
141 const visible = d.querySelector( `${this.selectors.panels}:not([hidden])` );
142 if ( visible ) {
143 visible.setAttribute( 'aria-hidden', 'false' );
144 const labelled_by = visible.getAttribute( 'aria-labelledby' );
145 const tab = labelled_by ? d.getElementById( labelled_by ) : this._get_tab_for_panel( visible.id );
146 if ( tab ) {
147 const tablist = tab.closest( '[role="tablist"]' ) || d.querySelector( this.selectors.tablist );
148 if ( tablist ) {
149 tablist.querySelectorAll( '[role="tab"]' ).forEach( (t) => t.setAttribute( 'aria-selected', 'false' ) );
150 }
151 tab.setAttribute( 'aria-selected', 'true' );
152 }
153 return;
154 }
155 const first = d.querySelector( this.selectors.panels );
156 if ( first ) {
157 first.removeAttribute( 'hidden' );
158 first.setAttribute( 'aria-hidden', 'false' );
159 const labelled_by = first.getAttribute( 'aria-labelledby' );
160 const tab = labelled_by ? d.getElementById( labelled_by ) : this._get_tab_for_panel( first.id );
161 if ( tab ) {
162 const tablist = tab.closest( '[role="tablist"]' ) || d.querySelector( this.selectors.tablist );
163 if ( tablist ) tablist.querySelectorAll( '[role="tab"]' ).forEach( (t) => t.setAttribute( 'aria-selected', 'false' ) );
164 tab.setAttribute( 'aria-selected', 'true' );
165 }
166 }
167 }
168
169 // ---- private helpers ----
170
171 /**
172 * Get all tabpanel elements matched by {@link selectors.panels}.
173 *
174 * @private
175 * @returns {HTMLElement[]} Array of panels.
176 */
177 _panels() {
178 return Array.from( d.querySelectorAll( this.selectors.panels ) );
179 }
180
181 /**
182 * Hide every panel (set `hidden` and `aria-hidden="true"`).
183 *
184 * @private
185 * @returns {void}
186 */
187 _hide_all_panels() {
188 this._panels().forEach( (p) => {
189 p.setAttribute( 'hidden', 'true' );
190 p.setAttribute( 'aria-hidden', 'true' );
191 } );
192 }
193
194 /**
195 * Find the tab element that controls the given panel id by matching
196 * `[role="tab"][aria-controls="<panel_id>"]`. If the sanitize helper is available,
197 * it is used to escape the id for a safe CSS attribute selector.
198 *
199 * @private
200 * @param {string} panel_id
201 * @returns {HTMLElement|null} The matching tab element, or null if not found.
202 */
203 _get_tab_for_panel(panel_id) {
204 const esc = (val) => {
205 if ( Sanit && typeof Sanit.esc_attr_value_for_selector === 'function' ) {
206 return Sanit.esc_attr_value_for_selector( val );
207 }
208 return String( val )
209 .replace( /\\/g, '\\\\' )
210 .replace( /"/g, '\\"' )
211 .replace( /\n/g, '\\A ' )
212 .replace( /\]/g, '\\]' );
213 };
214 return d.querySelector( `[role="tab"][aria-controls="${esc( panel_id )}"]` );
215 }
216
217 /**
218 * Keyboard interaction for tabs (delegated on tablist element):
219 * ArrowRight/ArrowDown -> focus next tab
220 * ArrowLeft/ArrowUp -> focus previous tab
221 * Home/End -> focus first/last tab
222 * Enter/Space -> activate focused tab
223 *
224 * @private
225 * @param {KeyboardEvent} e
226 * @returns {void}
227 */
228 _on_keydown(e) {
229 const tab = e.target && e.target.closest && e.target.closest( '[role="tab"]' );
230 if ( !tab ) return;
231
232 const list = tab.closest( '[role="tablist"]' );
233 if ( ! list ) {
234 return;
235 }
236 const tabs = Array.from( list.querySelectorAll( '[role="tab"]' ) );
237 const idx = tabs.indexOf( tab );
238 const focus = (i) => {
239 if ( tabs[i] ) tabs[i].focus();
240 };
241
242 switch ( e.key ) {
243 case 'ArrowRight':
244 case 'ArrowDown':
245 e.preventDefault();
246 focus( (idx + 1) % tabs.length );
247 break;
248 case 'ArrowLeft':
249 case 'ArrowUp':
250 e.preventDefault();
251 focus( (idx - 1 + tabs.length) % tabs.length );
252 break;
253 case 'Home':
254 e.preventDefault();
255 focus( 0 );
256 break;
257 case 'End':
258 e.preventDefault();
259 focus( tabs.length - 1 );
260 break;
261 case 'Enter':
262 case ' ':
263 e.preventDefault();
264 this.show_panel( tab.getAttribute( 'aria-controls' ), tab );
265 break;
266 }
267 }
268
269 /**
270 * Mouse interaction for tabs (delegated on tablist element).
271 *
272 * @private
273 * @param {MouseEvent} e
274 * @returns {void}
275 */
276 _on_click(e) {
277 const tab = e.target && e.target.closest && e.target.closest( '[role="tab"]' );
278 if ( !tab ) {
279 return;
280 }
281 const panel_id = tab.getAttribute( 'aria-controls' );
282 if ( panel_id ) {
283 e.preventDefault();
284 this.show_panel( panel_id, tab );
285 }
286 }
287
288 /**
289 * Programmatic switching via CustomEvent listened on document:
290 * detail = { panel_id: string, tab_el?: HTMLElement, tab_id?: string, tab_selector?: string }
291 *
292 * @private
293 * @param {CustomEvent} e
294 * @returns {void}
295 */
296 _on_show_panel_evt(e) {
297 const detail = (e && e.detail) || {};
298 const panel_id = detail.panel_id;
299 const tab_el = detail.tab_el
300 || (detail.tab_id ? d.getElementById( detail.tab_id ) : null)
301 || (detail.tab_selector ? d.querySelector( detail.tab_selector ) : null);
302
303 if ( panel_id ) {
304 this.show_panel( panel_id, tab_el || undefined );
305 }
306 }
307 }
308
309 function esc_attr_selector_value(value) {
310 if ( Sanit && typeof Sanit.esc_attr_value_for_selector === 'function' ) {
311 return Sanit.esc_attr_value_for_selector( value );
312 }
313 return String( value == null ? '' : value )
314 .replace( /\\/g, '\\\\' )
315 .replace( /"/g, '\\"' )
316 .replace( /\n/g, '\\A ' )
317 .replace( /\]/g, '\\]' );
318 }
319
320 function get_url_params() {
321 try {
322 return new URLSearchParams( w.location.search || '' );
323 } catch ( _e ) {
324 return null;
325 }
326 }
327
328 function open_settings_group(group_key) {
329 const panel = d.getElementById( 'wpbc_bfb__inspector_form_settings' ) || d;
330 const group = panel.querySelector( '.wpbc_bfb__inspector__group[data-group="' + esc_attr_selector_value( group_key ) + '"]' );
331 if ( ! group ) {
332 return false;
333 }
334
335 const header = group.querySelector( '.group__header' );
336 const fields = group.querySelector( '.group__fields' );
337
338 group.classList.add( 'is-open' );
339 if ( header ) {
340 header.setAttribute( 'aria-expanded', 'true' );
341 }
342 if ( fields ) {
343 fields.removeAttribute( 'hidden' );
344 fields.setAttribute( 'aria-hidden', 'false' );
345 }
346
347 return true;
348 }
349
350 function focus_settings_row(row_key) {
351 const panel = d.getElementById( 'wpbc_bfb__inspector_form_settings' ) || d;
352 const row = panel.querySelector( '.wpbc-setting[data-key="' + esc_attr_selector_value( row_key ) + '"]' );
353 if ( ! row ) {
354 return false;
355 }
356
357 try {
358 row.scrollIntoView( { behavior: 'smooth', block: 'center', inline: 'nearest' } );
359 } catch ( _e ) {
360 row.scrollIntoView( true );
361 }
362
363 row.classList.remove( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' );
364 void row.offsetWidth;
365 row.classList.add( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' );
366
367 setTimeout( () => {
368 row.classList.remove( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' );
369 }, 2200 );
370
371 const control = row.querySelector( '[data-wpbc-bfb-fs-key="' + esc_attr_selector_value( row_key ) + '"]' )
372 || row.querySelector( 'select,input,textarea,button' );
373
374 if ( control && typeof control.focus === 'function' ) {
375 setTimeout( () => {
376 try {
377 control.focus( { preventScroll: true } );
378 } catch ( _e ) {
379 control.focus();
380 }
381 }, 250 );
382 }
383
384 return true;
385 }
386
387 /**
388 * Open one collapsible Add Fields palette group.
389 *
390 * The setup-wizard deep link uses this helper to reveal a field pack without
391 * inserting or otherwise changing the current booking form.
392 *
393 * @param {string} group_key Palette group key from its data-group attribute.
394 * @returns {boolean} Whether the requested palette group was found and opened.
395 */
396 function open_palette_group(group_key) {
397 const panel = d.getElementById( 'wpbc_bfb__palette_add_new' ) || d;
398 const group = panel.querySelector( '.wpbc_bfb__inspector__group[data-group="' + esc_attr_selector_value( group_key ) + '"]' );
399 if ( ! group ) {
400 return false;
401 }
402
403 const header = group.querySelector( '.group__header' );
404 const fields = group.querySelector( '.group__fields' );
405
406 group.classList.add( 'is-open' );
407 if ( header ) {
408 header.setAttribute( 'aria-expanded', 'true' );
409 }
410 if ( fields ) {
411 fields.removeAttribute( 'hidden' );
412 fields.setAttribute( 'aria-hidden', 'false' );
413 }
414
415 return true;
416 }
417
418 /**
419 * Focus and visually highlight one Add Fields palette item.
420 *
421 * @param {string} field_type Registered Form Builder field type.
422 * @returns {boolean} Whether the requested field pack palette item was found.
423 */
424 function focus_palette_field(field_type) {
425 const panel = d.getElementById( 'wpbc_bfb__palette_add_new' ) || d;
426 const field = panel.querySelector( '.wpbc_bfb__field[data-type="' + esc_attr_selector_value( field_type ) + '"]' );
427 if ( ! field ) {
428 return false;
429 }
430
431 try {
432 field.scrollIntoView( { behavior: 'smooth', block: 'center', inline: 'nearest' } );
433 } catch ( _e ) {
434 field.scrollIntoView( true );
435 }
436
437 field.classList.remove( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' );
438 void field.offsetWidth;
439 field.classList.add( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' );
440 field.setAttribute( 'tabindex', '-1' );
441
442 setTimeout( () => {
443 field.classList.remove( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' );
444 }, 2200 );
445 setTimeout( () => {
446 try {
447 field.focus( { preventScroll: true } );
448 } catch ( _e ) {
449 field.focus();
450 }
451 }, 250 );
452
453 return true;
454 }
455
456 let deep_link_done = false;
457 let deep_link_ajax_listener_bound = false;
458
459 function has_initial_deep_link() {
460 const params = get_url_params();
461 return !! (
462 params
463 && -1 !== [ 'form_settings', 'add_fields' ].indexOf( params.get( 'wpbc_bfb_panel' ) )
464 );
465 }
466
467 function handle_initial_deep_link(tabs, attempt = 0) {
468 if ( deep_link_done ) {
469 return;
470 }
471
472 const params = get_url_params();
473 if ( ! params || -1 === [ 'form_settings', 'add_fields' ].indexOf( params.get( 'wpbc_bfb_panel' ) ) ) {
474 return;
475 }
476
477 const panel_mode = params.get( 'wpbc_bfb_panel' );
478 const panel_id = 'add_fields' === panel_mode ? 'wpbc_bfb__palette_add_new' : 'wpbc_bfb__inspector_form_settings';
479 const tab = d.getElementById( 'add_fields' === panel_mode ? 'wpbc_tab_library' : 'wpbc_tab_form' );
480 const panel = d.getElementById( panel_id );
481 if ( ! tab || ! panel ) {
482 if ( attempt < 25 ) {
483 setTimeout( () => handle_initial_deep_link( tabs, attempt + 1 ), 80 );
484 }
485 return;
486 }
487
488 tabs.show_panel( panel_id, tab );
489
490 const group_key = params.get( 'wpbc_bfb_group' );
491 const row_key = params.get( 'wpbc_bfb_focus' );
492 const group_ok = group_key
493 ? ( 'add_fields' === panel_mode ? open_palette_group( group_key ) : open_settings_group( group_key ) )
494 : true;
495 const row_ok = row_key
496 ? ( 'add_fields' === panel_mode ? focus_palette_field( row_key ) : focus_settings_row( row_key ) )
497 : true;
498
499 if ( ( ! group_ok || ! row_ok ) && attempt < 25 ) {
500 setTimeout( () => handle_initial_deep_link( tabs, attempt + 1 ), 80 );
501 return;
502 }
503
504 deep_link_done = group_ok && row_ok;
505 }
506
507 function schedule_initial_deep_link(tabs, delay = 0) {
508 if ( deep_link_done || ! has_initial_deep_link() ) {
509 return;
510 }
511
512 setTimeout( () => handle_initial_deep_link( tabs ), delay );
513 }
514
515 function bind_initial_deep_link_after_form_load(tabs, attempt = 0) {
516 if ( ! has_initial_deep_link() ) {
517 return;
518 }
519
520 if ( ! deep_link_ajax_listener_bound ) {
521 deep_link_ajax_listener_bound = true;
522 d.addEventListener( 'wpbc:bfb:form:ajax_loaded', () => {
523 // Legacy/blank forms do not always emit STRUCTURE_LOADED; wait until add_page() and UI defaults settle.
524 schedule_initial_deep_link( tabs, 450 );
525 }, { once: true } );
526 }
527
528 if ( ! w.wpbc_bfb_api || ! w.wpbc_bfb_api.ready || typeof w.wpbc_bfb_api.ready.then !== 'function' ) {
529 if ( attempt < 25 ) {
530 setTimeout( () => bind_initial_deep_link_after_form_load( tabs, attempt + 1 ), 80 );
531 }
532 return;
533 }
534
535 w.wpbc_bfb_api.ready.then( (builder) => {
536 const events = ( w.WPBC_BFB_Core && w.WPBC_BFB_Core.WPBC_BFB_Events ) || {};
537 const event_name = events.STRUCTURE_LOADED || 'wpbc:bfb:structure:loaded';
538 if ( ! builder || ! builder.bus || typeof builder.bus.on !== 'function' ) {
539 return;
540 }
541
542 const on_structure_loaded = () => {
543 if ( builder.bus && typeof builder.bus.off === 'function' ) {
544 builder.bus.off( event_name, on_structure_loaded );
545 }
546 // Run after selection clearing/inspector defaults attached to the same load event.
547 schedule_initial_deep_link( tabs, 0 );
548 };
549
550 builder.bus.on( event_name, on_structure_loaded );
551 } );
552 }
553
554 // Boot once DOM is ready.
555 const instance = new WPBC_BFB_Rightbar_Tabs();
556 const boot = () => {
557 instance.init();
558 bind_initial_deep_link_after_form_load( instance );
559 };
560 if ( d.readyState === 'loading' ) {
561 d.addEventListener( 'DOMContentLoaded', boot );
562 } else {
563 boot();
564 }
565
566 // (Optional) expose for debugging:
567 // w.WPBC_BFB_Rightbar_Tabs = instance;
568
569 })( window, document );
570