/** * Booking Calendar — Rightbar Tabs Controller (JS) * * Purpose: Handles the main right sidebar tabs (Library / Inspector / Settings) in the Booking Form Builder. * - Manages keyboard and mouse navigation for tabs. * - Keeps ARIA attributes in sync and shows/hides matching tabpanels. * - Supports programmatic switching via the 'wpbc_bfb:show_panel' event and emits 'wpbc_bfb:panel_shown'. * - Uses hard-wired selectors for rightbar markup; optionally uses WPBC_BFB_Sanitize for safe selectors. * * Markup contract: * - Tabs: [role="tab"][aria-controls=""] * - Tablist: .wpbc_bfb__rightbar_tabs[role="tablist"] * - Panels: .wpbc_bfb__palette_panel# (with aria-labelledby) * * @package Booking Calendar * @subpackage Admin\UI * @since 11.0.0 * @version 1.0.0 * @see File ../includes/page-form-builder/_src/bfb-rightbar-tabs.js */ (function (w, d) { 'use strict'; const Core = w.WPBC_BFB_Core || {}; const Sanit = Core.WPBC_BFB_Sanitize || null; /** * Accessible tabs controller for the right-side palettes (Library / Inspector / Settings) * of the Booking Form Builder UI. Handles: * - Mouse and keyboard navigation (delegated on the tablist container). * - Showing/hiding associated tabpanels and keeping ARIA in sync. * - Programmatic switching via the `wpbc_bfb:show_panel` CustomEvent (listened on document). * * If present, {@link WPBC_BFB_Sanitize.esc_attr_value_for_selector} is used to safely * select the tab that controls a given panel id. * * @version 2025-08-26 */ class WPBC_BFB_Rightbar_Tabs { /** * Constructor. * * @param {Object} [opts] * @param {Object} [opts.selectors] * @param {string} [opts.selectors.panels='.wpbc_bfb__palette_panel'] CSS selector that matches tabpanels. * @param {string} [opts.selectors.tablist='.wpbc_bfb__rightbar_tabs[role="tablist"]'] CSS selector for tablist roots. */ constructor(opts = {}) { const def = { panels : '.wpbc_bfb__palette_panel', tablist: '.wpbc_bfb__rightbar_tabs[role="tablist"]' }; this.selectors = Object.assign( {}, def, opts.selectors || {} ); this._on_keydown = this._on_keydown.bind( this ); this._on_click = this._on_click.bind( this ); this._on_show_panel_evt = this._on_show_panel_evt.bind( this ); this._tablists = []; } /** * Attach DOM listeners to each tablist container and perform initial ARIA sync. * Keyboard & mouse handlers are scoped to the tablist(s) for easier debugging. * * @returns {void} */ init() { this._tablists = Array.from( d.querySelectorAll( this.selectors.tablist ) ); this._tablists.forEach( (list) => { list.addEventListener( 'keydown', this._on_keydown, true ); list.addEventListener( 'click', this._on_click, false ); } ); // Programmatic switching kept on document for back-compat with existing dispatches. d.addEventListener( 'wpbc_bfb:show_panel', this._on_show_panel_evt ); this.sync_initial_aria(); } /** * Remove listeners attached in {@link init}. * * @returns {void} */ destroy() { this._tablists.forEach( (list) => { list.removeEventListener( 'keydown', this._on_keydown, true ); list.removeEventListener( 'click', this._on_click, false ); } ); this._tablists = []; d.removeEventListener( 'wpbc_bfb:show_panel', this._on_show_panel_evt ); } /** * Show a specific panel and update the selected tab state. * - Hides all panels matched by {@link selectors.panels} by setting * `hidden` and `aria-hidden="true"`. * - Reveals the target panel by removing `hidden` and setting `aria-hidden="false"`. * - If a tab element is provided (or discoverable by aria-controls), * marks that tab `aria-selected="true"` and clears others in its tablist. * * @param {string} panel_id The id attribute of the panel (tabpanel) to show. * @param {HTMLElement} [tab_el] An explicit tab element to mark selected (optional). * @returns {void} */ show_panel(panel_id, tab_el) { const panel = d.getElementById( panel_id ); if ( ! panel ) { console.warn( '[WPBC] Panel not found:', panel_id ); return; } this._hide_all_panels(); panel.removeAttribute( 'hidden' ); panel.setAttribute( 'aria-hidden', 'false' ); const tab = tab_el || this._get_tab_for_panel( panel_id ); if ( ! tab ) { return; } const tablist = tab.closest( '[role="tablist"]' ) || d.querySelector( this.selectors.tablist ); if ( ! tablist ) { return; } tablist.querySelectorAll( '[role="tab"]' ).forEach( (t) => t.setAttribute( 'aria-selected', 'false' ) ); tab.setAttribute( 'aria-selected', 'true' ); // Fire a hook when a panel changes. d.dispatchEvent( new CustomEvent( 'wpbc_bfb:panel_shown', { detail: { panel_id, tab_el: tab } } ) ); } /** * Ensure a consistent initial ARIA state: * - If a panel is already visible, mark it and its controlling tab as active. * - Otherwise, reveal the first panel and mark its tab selected. * * @returns {void} */ sync_initial_aria() { const visible = d.querySelector( `${this.selectors.panels}:not([hidden])` ); if ( visible ) { visible.setAttribute( 'aria-hidden', 'false' ); const labelled_by = visible.getAttribute( 'aria-labelledby' ); const tab = labelled_by ? d.getElementById( labelled_by ) : this._get_tab_for_panel( visible.id ); if ( tab ) { const tablist = tab.closest( '[role="tablist"]' ) || d.querySelector( this.selectors.tablist ); if ( tablist ) { tablist.querySelectorAll( '[role="tab"]' ).forEach( (t) => t.setAttribute( 'aria-selected', 'false' ) ); } tab.setAttribute( 'aria-selected', 'true' ); } return; } const first = d.querySelector( this.selectors.panels ); if ( first ) { first.removeAttribute( 'hidden' ); first.setAttribute( 'aria-hidden', 'false' ); const labelled_by = first.getAttribute( 'aria-labelledby' ); const tab = labelled_by ? d.getElementById( labelled_by ) : this._get_tab_for_panel( first.id ); if ( tab ) { const tablist = tab.closest( '[role="tablist"]' ) || d.querySelector( this.selectors.tablist ); if ( tablist ) tablist.querySelectorAll( '[role="tab"]' ).forEach( (t) => t.setAttribute( 'aria-selected', 'false' ) ); tab.setAttribute( 'aria-selected', 'true' ); } } } // ---- private helpers ---- /** * Get all tabpanel elements matched by {@link selectors.panels}. * * @private * @returns {HTMLElement[]} Array of panels. */ _panels() { return Array.from( d.querySelectorAll( this.selectors.panels ) ); } /** * Hide every panel (set `hidden` and `aria-hidden="true"`). * * @private * @returns {void} */ _hide_all_panels() { this._panels().forEach( (p) => { p.setAttribute( 'hidden', 'true' ); p.setAttribute( 'aria-hidden', 'true' ); } ); } /** * Find the tab element that controls the given panel id by matching * `[role="tab"][aria-controls=""]`. If the sanitize helper is available, * it is used to escape the id for a safe CSS attribute selector. * * @private * @param {string} panel_id * @returns {HTMLElement|null} The matching tab element, or null if not found. */ _get_tab_for_panel(panel_id) { const esc = (val) => { if ( Sanit && typeof Sanit.esc_attr_value_for_selector === 'function' ) { return Sanit.esc_attr_value_for_selector( val ); } return String( val ) .replace( /\\/g, '\\\\' ) .replace( /"/g, '\\"' ) .replace( /\n/g, '\\A ' ) .replace( /\]/g, '\\]' ); }; return d.querySelector( `[role="tab"][aria-controls="${esc( panel_id )}"]` ); } /** * Keyboard interaction for tabs (delegated on tablist element): * ArrowRight/ArrowDown -> focus next tab * ArrowLeft/ArrowUp -> focus previous tab * Home/End -> focus first/last tab * Enter/Space -> activate focused tab * * @private * @param {KeyboardEvent} e * @returns {void} */ _on_keydown(e) { const tab = e.target && e.target.closest && e.target.closest( '[role="tab"]' ); if ( !tab ) return; const list = tab.closest( '[role="tablist"]' ); if ( ! list ) { return; } const tabs = Array.from( list.querySelectorAll( '[role="tab"]' ) ); const idx = tabs.indexOf( tab ); const focus = (i) => { if ( tabs[i] ) tabs[i].focus(); }; switch ( e.key ) { case 'ArrowRight': case 'ArrowDown': e.preventDefault(); focus( (idx + 1) % tabs.length ); break; case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); focus( (idx - 1 + tabs.length) % tabs.length ); break; case 'Home': e.preventDefault(); focus( 0 ); break; case 'End': e.preventDefault(); focus( tabs.length - 1 ); break; case 'Enter': case ' ': e.preventDefault(); this.show_panel( tab.getAttribute( 'aria-controls' ), tab ); break; } } /** * Mouse interaction for tabs (delegated on tablist element). * * @private * @param {MouseEvent} e * @returns {void} */ _on_click(e) { const tab = e.target && e.target.closest && e.target.closest( '[role="tab"]' ); if ( !tab ) { return; } const panel_id = tab.getAttribute( 'aria-controls' ); if ( panel_id ) { e.preventDefault(); this.show_panel( panel_id, tab ); } } /** * Programmatic switching via CustomEvent listened on document: * detail = { panel_id: string, tab_el?: HTMLElement, tab_id?: string, tab_selector?: string } * * @private * @param {CustomEvent} e * @returns {void} */ _on_show_panel_evt(e) { const detail = (e && e.detail) || {}; const panel_id = detail.panel_id; const tab_el = detail.tab_el || (detail.tab_id ? d.getElementById( detail.tab_id ) : null) || (detail.tab_selector ? d.querySelector( detail.tab_selector ) : null); if ( panel_id ) { this.show_panel( panel_id, tab_el || undefined ); } } } function esc_attr_selector_value(value) { if ( Sanit && typeof Sanit.esc_attr_value_for_selector === 'function' ) { return Sanit.esc_attr_value_for_selector( value ); } return String( value == null ? '' : value ) .replace( /\\/g, '\\\\' ) .replace( /"/g, '\\"' ) .replace( /\n/g, '\\A ' ) .replace( /\]/g, '\\]' ); } function get_url_params() { try { return new URLSearchParams( w.location.search || '' ); } catch ( _e ) { return null; } } function open_settings_group(group_key) { const panel = d.getElementById( 'wpbc_bfb__inspector_form_settings' ) || d; const group = panel.querySelector( '.wpbc_bfb__inspector__group[data-group="' + esc_attr_selector_value( group_key ) + '"]' ); if ( ! group ) { return false; } const header = group.querySelector( '.group__header' ); const fields = group.querySelector( '.group__fields' ); group.classList.add( 'is-open' ); if ( header ) { header.setAttribute( 'aria-expanded', 'true' ); } if ( fields ) { fields.removeAttribute( 'hidden' ); fields.setAttribute( 'aria-hidden', 'false' ); } return true; } function focus_settings_row(row_key) { const panel = d.getElementById( 'wpbc_bfb__inspector_form_settings' ) || d; const row = panel.querySelector( '.wpbc-setting[data-key="' + esc_attr_selector_value( row_key ) + '"]' ); if ( ! row ) { return false; } try { row.scrollIntoView( { behavior: 'smooth', block: 'center', inline: 'nearest' } ); } catch ( _e ) { row.scrollIntoView( true ); } row.classList.remove( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' ); void row.offsetWidth; row.classList.add( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' ); setTimeout( () => { row.classList.remove( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' ); }, 2200 ); const control = row.querySelector( '[data-wpbc-bfb-fs-key="' + esc_attr_selector_value( row_key ) + '"]' ) || row.querySelector( 'select,input,textarea,button' ); if ( control && typeof control.focus === 'function' ) { setTimeout( () => { try { control.focus( { preventScroll: true } ); } catch ( _e ) { control.focus(); } }, 250 ); } return true; } /** * Open one collapsible Add Fields palette group. * * The setup-wizard deep link uses this helper to reveal a field pack without * inserting or otherwise changing the current booking form. * * @param {string} group_key Palette group key from its data-group attribute. * @returns {boolean} Whether the requested palette group was found and opened. */ function open_palette_group(group_key) { const panel = d.getElementById( 'wpbc_bfb__palette_add_new' ) || d; const group = panel.querySelector( '.wpbc_bfb__inspector__group[data-group="' + esc_attr_selector_value( group_key ) + '"]' ); if ( ! group ) { return false; } const header = group.querySelector( '.group__header' ); const fields = group.querySelector( '.group__fields' ); group.classList.add( 'is-open' ); if ( header ) { header.setAttribute( 'aria-expanded', 'true' ); } if ( fields ) { fields.removeAttribute( 'hidden' ); fields.setAttribute( 'aria-hidden', 'false' ); } return true; } /** * Focus and visually highlight one Add Fields palette item. * * @param {string} field_type Registered Form Builder field type. * @returns {boolean} Whether the requested field pack palette item was found. */ function focus_palette_field(field_type) { const panel = d.getElementById( 'wpbc_bfb__palette_add_new' ) || d; const field = panel.querySelector( '.wpbc_bfb__field[data-type="' + esc_attr_selector_value( field_type ) + '"]' ); if ( ! field ) { return false; } try { field.scrollIntoView( { behavior: 'smooth', block: 'center', inline: 'nearest' } ); } catch ( _e ) { field.scrollIntoView( true ); } field.classList.remove( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' ); void field.offsetWidth; field.classList.add( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' ); field.setAttribute( 'tabindex', '-1' ); setTimeout( () => { field.classList.remove( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' ); }, 2200 ); setTimeout( () => { try { field.focus( { preventScroll: true } ); } catch ( _e ) { field.focus(); } }, 250 ); return true; } let deep_link_done = false; let deep_link_ajax_listener_bound = false; function has_initial_deep_link() { const params = get_url_params(); return !! ( params && -1 !== [ 'form_settings', 'add_fields' ].indexOf( params.get( 'wpbc_bfb_panel' ) ) ); } function handle_initial_deep_link(tabs, attempt = 0) { if ( deep_link_done ) { return; } const params = get_url_params(); if ( ! params || -1 === [ 'form_settings', 'add_fields' ].indexOf( params.get( 'wpbc_bfb_panel' ) ) ) { return; } const panel_mode = params.get( 'wpbc_bfb_panel' ); const panel_id = 'add_fields' === panel_mode ? 'wpbc_bfb__palette_add_new' : 'wpbc_bfb__inspector_form_settings'; const tab = d.getElementById( 'add_fields' === panel_mode ? 'wpbc_tab_library' : 'wpbc_tab_form' ); const panel = d.getElementById( panel_id ); if ( ! tab || ! panel ) { if ( attempt < 25 ) { setTimeout( () => handle_initial_deep_link( tabs, attempt + 1 ), 80 ); } return; } tabs.show_panel( panel_id, tab ); const group_key = params.get( 'wpbc_bfb_group' ); const row_key = params.get( 'wpbc_bfb_focus' ); const group_ok = group_key ? ( 'add_fields' === panel_mode ? open_palette_group( group_key ) : open_settings_group( group_key ) ) : true; const row_ok = row_key ? ( 'add_fields' === panel_mode ? focus_palette_field( row_key ) : focus_settings_row( row_key ) ) : true; if ( ( ! group_ok || ! row_ok ) && attempt < 25 ) { setTimeout( () => handle_initial_deep_link( tabs, attempt + 1 ), 80 ); return; } deep_link_done = group_ok && row_ok; } function schedule_initial_deep_link(tabs, delay = 0) { if ( deep_link_done || ! has_initial_deep_link() ) { return; } setTimeout( () => handle_initial_deep_link( tabs ), delay ); } function bind_initial_deep_link_after_form_load(tabs, attempt = 0) { if ( ! has_initial_deep_link() ) { return; } if ( ! deep_link_ajax_listener_bound ) { deep_link_ajax_listener_bound = true; d.addEventListener( 'wpbc:bfb:form:ajax_loaded', () => { // Legacy/blank forms do not always emit STRUCTURE_LOADED; wait until add_page() and UI defaults settle. schedule_initial_deep_link( tabs, 450 ); }, { once: true } ); } if ( ! w.wpbc_bfb_api || ! w.wpbc_bfb_api.ready || typeof w.wpbc_bfb_api.ready.then !== 'function' ) { if ( attempt < 25 ) { setTimeout( () => bind_initial_deep_link_after_form_load( tabs, attempt + 1 ), 80 ); } return; } w.wpbc_bfb_api.ready.then( (builder) => { const events = ( w.WPBC_BFB_Core && w.WPBC_BFB_Core.WPBC_BFB_Events ) || {}; const event_name = events.STRUCTURE_LOADED || 'wpbc:bfb:structure:loaded'; if ( ! builder || ! builder.bus || typeof builder.bus.on !== 'function' ) { return; } const on_structure_loaded = () => { if ( builder.bus && typeof builder.bus.off === 'function' ) { builder.bus.off( event_name, on_structure_loaded ); } // Run after selection clearing/inspector defaults attached to the same load event. schedule_initial_deep_link( tabs, 0 ); }; builder.bus.on( event_name, on_structure_loaded ); } ); } // Boot once DOM is ready. const instance = new WPBC_BFB_Rightbar_Tabs(); const boot = () => { instance.init(); bind_initial_deep_link_after_form_load( instance ); }; if ( d.readyState === 'loading' ) { d.addEventListener( 'DOMContentLoaded', boot ); } else { boot(); } // (Optional) expose for debugging: // w.WPBC_BFB_Rightbar_Tabs = instance; })( window, document );