PluginProbe
Booking Calendar / 11.4.1
Booking Calendar v11.4.1
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.4.1, at includes/page-form-builder/_src/bfb-rightbar-tabs.js

493 lines 15.5 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 let deep_link_done = false;
388 let deep_link_ajax_listener_bound = false;
389
390 function has_initial_deep_link() {
391 const params = get_url_params();
392 return !! ( params && 'form_settings' === params.get( 'wpbc_bfb_panel' ) );
393 }
394
395 function handle_initial_deep_link(tabs, attempt = 0) {
396 if ( deep_link_done ) {
397 return;
398 }
399
400 const params = get_url_params();
401 if ( ! params || 'form_settings' !== params.get( 'wpbc_bfb_panel' ) ) {
402 return;
403 }
404
405 const panel_id = 'wpbc_bfb__inspector_form_settings';
406 const tab = d.getElementById( 'wpbc_tab_form' );
407 const panel = d.getElementById( panel_id );
408 if ( ! tab || ! panel ) {
409 if ( attempt < 25 ) {
410 setTimeout( () => handle_initial_deep_link( tabs, attempt + 1 ), 80 );
411 }
412 return;
413 }
414
415 tabs.show_panel( panel_id, tab );
416
417 const group_key = params.get( 'wpbc_bfb_group' );
418 const row_key = params.get( 'wpbc_bfb_focus' );
419 const group_ok = group_key ? open_settings_group( group_key ) : true;
420 const row_ok = row_key ? focus_settings_row( row_key ) : true;
421
422 if ( ( ! group_ok || ! row_ok ) && attempt < 25 ) {
423 setTimeout( () => handle_initial_deep_link( tabs, attempt + 1 ), 80 );
424 return;
425 }
426
427 deep_link_done = group_ok && row_ok;
428 }
429
430 function schedule_initial_deep_link(tabs, delay = 0) {
431 if ( deep_link_done || ! has_initial_deep_link() ) {
432 return;
433 }
434
435 setTimeout( () => handle_initial_deep_link( tabs ), delay );
436 }
437
438 function bind_initial_deep_link_after_form_load(tabs, attempt = 0) {
439 if ( ! has_initial_deep_link() ) {
440 return;
441 }
442
443 if ( ! deep_link_ajax_listener_bound ) {
444 deep_link_ajax_listener_bound = true;
445 d.addEventListener( 'wpbc:bfb:form:ajax_loaded', () => {
446 // Legacy/blank forms do not always emit STRUCTURE_LOADED; wait until add_page() and UI defaults settle.
447 schedule_initial_deep_link( tabs, 450 );
448 }, { once: true } );
449 }
450
451 if ( ! w.wpbc_bfb_api || ! w.wpbc_bfb_api.ready || typeof w.wpbc_bfb_api.ready.then !== 'function' ) {
452 if ( attempt < 25 ) {
453 setTimeout( () => bind_initial_deep_link_after_form_load( tabs, attempt + 1 ), 80 );
454 }
455 return;
456 }
457
458 w.wpbc_bfb_api.ready.then( (builder) => {
459 const events = ( w.WPBC_BFB_Core && w.WPBC_BFB_Core.WPBC_BFB_Events ) || {};
460 const event_name = events.STRUCTURE_LOADED || 'wpbc:bfb:structure:loaded';
461 if ( ! builder || ! builder.bus || typeof builder.bus.on !== 'function' ) {
462 return;
463 }
464
465 const on_structure_loaded = () => {
466 if ( builder.bus && typeof builder.bus.off === 'function' ) {
467 builder.bus.off( event_name, on_structure_loaded );
468 }
469 // Run after selection clearing/inspector defaults attached to the same load event.
470 schedule_initial_deep_link( tabs, 0 );
471 };
472
473 builder.bus.on( event_name, on_structure_loaded );
474 } );
475 }
476
477 // Boot once DOM is ready.
478 const instance = new WPBC_BFB_Rightbar_Tabs();
479 const boot = () => {
480 instance.init();
481 bind_initial_deep_link_after_form_load( instance );
482 };
483 if ( d.readyState === 'loading' ) {
484 d.addEventListener( 'DOMContentLoaded', boot );
485 } else {
486 boot();
487 }
488
489 // (Optional) expose for debugging:
490 // w.WPBC_BFB_Rightbar_Tabs = instance;
491
492 })( window, document );
493