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

321 lines 10.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 // Boot once DOM is ready.
310 const instance = new WPBC_BFB_Rightbar_Tabs();
311 if ( d.readyState === 'loading' ) {
312 d.addEventListener( 'DOMContentLoaded', () => instance.init() );
313 } else {
314 instance.init();
315 }
316
317 // (Optional) expose for debugging:
318 // w.WPBC_BFB_Rightbar_Tabs = instance;
319
320 })( window, document );
321