# booking/11.5/includes/page-form-builder/_src/bfb-rightbar-tabs.js

Booking Calendar, version 11.5. 570 lines.

- Page: https://pluginprobe.com/plugins/booking/11.5/code/includes/page-form-builder/_src/bfb-rightbar-tabs.js
- Raw: https://pluginprobe.com/plugins/booking/11.5/raw/includes/page-form-builder/_src/bfb-rightbar-tabs.js
- Modified: 2026-08-04T08:52:12+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/booking/11.5/code/includes/page-form-builder/_src/bfb-rightbar-tabs.js#L10-L20`.

```javascript
/**
 * 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="<panel_id>"]
 * - Tablist: .wpbc_bfb__rightbar_tabs[role="tablist"]
 * - Panels:  .wpbc_bfb__palette_panel#<panel_id> (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="<panel_id>"]`. 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 );

```
