PluginProbe
Booking Calendar / 11.2
Booking Calendar v11.2
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 10.11.2 All 203 releases
booking / includes / __js / admin / collapsible_groups / collapsible_groups.js

collapsible_groups.js in Booking Calendar 11.2, at includes/__js/admin/collapsible_groups/collapsible_groups.js

515 lines 16.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * WPBC Collapsible Groups
3 *
4 * Universal, dependency-free controller for expanding/collapsing grouped sections in right-side panels (Inspector/Library/Form Settings, or any other WPBC page).
5 *
6 * === How to use it (quick) ? ===
7 *
8 * -- 1. Markup (independent mode: multiple open allowed) --
9 * <div class="wpbc_collapsible">
10 * <section class="wpbc_ui__collapsible_group is-open">
11 * <button type="button" class="group__header"><h3>General</h3></button>
12 * <div class="group__fields">…</div>
13 * </section>
14 * <section class="wpbc_ui__collapsible_group">
15 * <button type="button" class="group__header"><h3>Advanced</h3></button>
16 * <div class="group__fields">…</div>
17 * </section>
18 * </div>
19 *
20 * -- 2. Exclusive/accordion mode (one open at a time) --
21 * <div class="wpbc_collapsible wpbc_collapsible--exclusive">…</div>
22 *
23 * -- 3. Auto-init --
24 * The script auto-initializes on DOMContentLoaded. No extra code needed.
25 *
26 * -- 4. Programmatic control (optional)
27 * const root = document.querySelector('#wpbc_bfb__inspector');
28 * const api = root.__wpbc_collapsible_instance; // set by auto-init
29 *
30 * api.open_by_heading('Validation'); // open by heading text
31 * api.open_by_index(0); // open the first group
32 *
33 * -- 5.Listen to events (e.g., to persist “open group” state) --
34 * root.addEventListener('wpbc:collapsible:open', (e) => { console.log( e.detail.group ); });
35 * root.addEventListener('wpbc:collapsible:close', (e) => { console.log( e.detail.group ); });
36 *
37 *
38 *
39 * Markup expectations (minimal):
40 * <div class="wpbc_collapsible [wpbc_collapsible--exclusive]">
41 * <section class="wpbc_ui__collapsible_group [is-open]">
42 * <button type="button" class="group__header"> ... </button>
43 * <div class="group__fields"> ... </div>
44 * </section>
45 * ... more <section> ...
46 * </div>
47 *
48 * Notes:
49 * - Add `is-open` to any section you want initially expanded.
50 * - Add `wpbc_collapsible--exclusive` to the container for "open one at a time" behavior.
51 * - Works with your existing BFB markup (classes used there are the defaults).
52 *
53 * Accessibility:
54 * - Sets aria-expanded on .group__header
55 * - Sets aria-hidden + [hidden] on .group__fields
56 * - ArrowUp/ArrowDown move focus between headers; Enter/Space toggles
57 *
58 * Events (bubbles from the <section>):
59 * - 'wpbc:collapsible:open' (detail: { group, root, instance })
60 * - 'wpbc:collapsible:close' (detail: { group, root, instance })
61 *
62 * Public API (instance methods):
63 * - init(), destroy(), refresh()
64 * - expand(group, [exclusive]), collapse(group), toggle(group)
65 * - open_by_index(index), open_by_heading(text)
66 * - is_exclusive(), is_open(group)
67 *
68 * @version 2025-08-26
69 * @since 2025-08-26
70 */
71 // ---------------------------------------------------------------------------------------------------------------------
72 // == File /collapsible_groups.js == Time point: 2025-08-26 14:13
73 // ---------------------------------------------------------------------------------------------------------------------
74 (function (w, d) {
75 'use strict';
76
77 class WPBC_Collapsible_Groups {
78
79 /**
80 * Create a collapsible controller for a container.
81 *
82 * @param {HTMLElement|string} root_el
83 * The container element (or CSS selector) that wraps collapsible groups.
84 * The container usually has the class `.wpbc_collapsible`.
85 * @param {Object} [opts={}]
86 * @param {string} [opts.group_selector='.wpbc_ui__collapsible_group']
87 * Selector for each collapsible group inside the container.
88 * @param {string} [opts.header_selector='.group__header']
89 * Selector for the clickable header inside a group.
90 * @param {string} [opts.fields_selector='.group__fields']
91 * Selector for the content/panel element inside a group.
92 * @param {string} [opts.open_class='is-open']
93 * Class name that indicates the group is open.
94 * @param {boolean} [opts.exclusive=false]
95 * If true, only one group can be open at a time in this container.
96 *
97 * @constructor
98 * @since 2025-08-26
99 */
100 constructor(root_el, opts = {}) {
101 this.root = (typeof root_el === 'string') ? d.querySelector( root_el ) : root_el;
102 this.opts = Object.assign( {
103 group_selector : '.wpbc_ui__collapsible_group',
104 header_selector: '.group__header',
105 fields_selector: '.group__fields,.group__content',
106 open_class : 'is-open',
107 exclusive : false
108 }, opts );
109
110 // Bound handlers (for add/removeEventListener symmetry).
111 /** @private */
112 this._on_click = this._on_click.bind( this );
113 /** @private */
114 this._on_keydown = this._on_keydown.bind( this );
115
116 /** @type {HTMLElement[]} @private */
117 this._groups = [];
118 /** @type {MutationObserver|null} @private */
119 this._observer = null;
120 }
121
122 /**
123 * Initialize the controller: cache groups, attach listeners, set ARIA,
124 * and start observing DOM changes inside the container.
125 *
126 * @returns {WPBC_Collapsible_Groups} The instance (chainable).
127 * @listens click
128 * @listens keydown
129 * @since 2025-08-26
130 */
131 init() {
132 if ( !this.root ) {
133 return this;
134 }
135 this._groups = Array.prototype.slice.call(
136 this.root.querySelectorAll( this.opts.group_selector )
137 );
138 this.root.addEventListener( 'click', this._on_click, false );
139 this.root.addEventListener( 'keydown', this._on_keydown, false );
140
141 // Observe dynamic inserts/removals (Inspector re-renders).
142 this._observer = new MutationObserver( () => {
143 this.refresh();
144 } );
145 this._observer.observe( this.root, { childList: true, subtree: true } );
146
147 this._sync_all_aria();
148 return this;
149 }
150
151 /**
152 * Tear down the controller: detach listeners, stop the observer,
153 * and drop internal references.
154 *
155 * @returns {void}
156 * @since 2025-08-26
157 */
158 destroy() {
159 if ( !this.root ) {
160 return;
161 }
162 this.root.removeEventListener( 'click', this._on_click, false );
163 this.root.removeEventListener( 'keydown', this._on_keydown, false );
164 if ( this._observer ) {
165 this._observer.disconnect();
166 this._observer = null;
167 }
168 this._groups = [];
169 }
170
171 /**
172 * Re-scan the DOM for current groups and re-apply ARIA to all of them.
173 * Useful after dynamic (re)renders.
174 *
175 * @returns {void}
176 * @since 2025-08-26
177 */
178 refresh() {
179 if ( !this.root ) {
180 return;
181 }
182 this._groups = Array.prototype.slice.call(
183 this.root.querySelectorAll( this.opts.group_selector )
184 );
185 this._sync_all_aria();
186 }
187
188 /**
189 * Check whether the container is in exclusive (accordion) mode.
190 *
191 * Order of precedence:
192 * 1) Explicit option `opts.exclusive`
193 * 2) Container has class `.wpbc_collapsible--exclusive`
194 * 3) Container matches `[data-wpbc-accordion="exclusive"]`
195 *
196 * @returns {boolean} True if exclusive mode is active.
197 * @since 2025-08-26
198 */
199 is_exclusive() {
200 return !!(
201 this.opts.exclusive ||
202 this.root.classList.contains( 'wpbc_collapsible--exclusive' ) ||
203 this.root.matches( '[data-wpbc-accordion="exclusive"]' )
204 );
205 }
206
207 /**
208 * Determine whether a specific group is open.
209 *
210 * @param {HTMLElement} group The group element to test.
211 * @returns {boolean} True if the group is currently open.
212 * @since 2025-08-26
213 */
214 is_open(group) {
215 return group.classList.contains( this.opts.open_class );
216 }
217
218 /**
219 * Open a group. Honors exclusive mode by collapsing all sibling groups
220 * (queried from the live DOM at call-time).
221 *
222 * @param {HTMLElement} group The group element to open.
223 * @param {boolean} [exclusive]
224 * If provided, overrides container mode for this action only.
225 * @returns {void}
226 * @fires CustomEvent#wpbc:collapsible:open
227 * @since 2025-08-26
228 */
229 expand(group, exclusive) {
230 if ( !group ) {
231 return;
232 }
233 const do_exclusive = (typeof exclusive === 'boolean') ? exclusive : this.is_exclusive();
234 if ( do_exclusive ) {
235 // Always use the live DOM, not the cached list.
236 Array.prototype.forEach.call(
237 this.root.querySelectorAll( this.opts.group_selector ),
238 (g) => {
239 if ( g !== group ) {
240 this._set_open( g, false );
241 }
242 }
243 );
244 }
245 this._set_open( group, true );
246 }
247
248 /**
249 * Close a group.
250 *
251 * @param {HTMLElement} group The group element to close.
252 * @returns {void}
253 * @fires CustomEvent#wpbc:collapsible:close
254 * @since 2025-08-26
255 */
256 collapse(group) {
257 if ( !group ) {
258 return;
259 }
260 this._set_open( group, false );
261 }
262
263 /**
264 * Toggle a group's open/closed state.
265 *
266 * @param {HTMLElement} group The group element to toggle.
267 * @returns {void}
268 * @since 2025-08-26
269 */
270 toggle(group) {
271 if ( !group ) {
272 return;
273 }
274 this[this.is_open( group ) ? 'collapse' : 'expand']( group );
275 }
276
277 /**
278 * Open a group by its index within the container (0-based).
279 *
280 * @param {number} index Zero-based index of the group.
281 * @returns {void}
282 * @since 2025-08-26
283 */
284 open_by_index(index) {
285 const group = this._groups[index];
286 if ( group ) {
287 this.expand( group );
288 }
289 }
290
291 /**
292 * Open a group by matching text contained within the <h3> inside the header.
293 * The comparison is case-insensitive and substring-based.
294 *
295 * @param {string} text Text to match against the heading contents.
296 * @returns {void}
297 * @since 2025-08-26
298 */
299 open_by_heading(text) {
300 if ( !text ) {
301 return;
302 }
303 const t = String( text ).toLowerCase();
304 const match = this._groups.find( (g) => {
305 const h = g.querySelector( this.opts.header_selector + ' h3' );
306 return h && h.textContent.toLowerCase().indexOf( t ) !== -1;
307 } );
308 if ( match ) {
309 this.expand( match );
310 }
311 }
312
313 // -------------------------------------------------------------------------------------------------------------
314 // Internal
315 // -------------------------------------------------------------------------------------------------------------
316
317 /**
318 * Delegated click handler for headers.
319 *
320 * @private
321 * @param {MouseEvent} ev The click event.
322 * @returns {void}
323 * @since 2025-08-26
324 */
325 _on_click(ev) {
326 const btn = ev.target.closest( this.opts.header_selector );
327 if ( !btn || !this.root.contains( btn ) ) {
328 return;
329 }
330 ev.preventDefault();
331 ev.stopPropagation();
332 const group = btn.closest( this.opts.group_selector );
333 if ( group ) {
334 this.toggle( group );
335 }
336 }
337
338 /**
339 * Keyboard handler for header interactions and roving focus:
340 * - Enter/Space toggles the active group.
341 * - ArrowUp/ArrowDown moves focus between group headers.
342 *
343 * @private
344 * @param {KeyboardEvent} ev The keyboard event.
345 * @returns {void}
346 * @since 2025-08-26
347 */
348 _on_keydown(ev) {
349 const btn = ev.target.closest( this.opts.header_selector );
350 if ( !btn ) {
351 return;
352 }
353
354 const key = ev.key;
355
356 // Toggle on Enter / Space.
357 if ( key === 'Enter' || key === ' ' ) {
358 ev.preventDefault();
359 const group = btn.closest( this.opts.group_selector );
360 if ( group ) {
361 this.toggle( group );
362 }
363 return;
364 }
365
366 // Move focus with ArrowUp/ArrowDown between headers in this container.
367 if ( key === 'ArrowUp' || key === 'ArrowDown' ) {
368 ev.preventDefault();
369 const headers = Array.prototype.map.call(
370 this.root.querySelectorAll( this.opts.group_selector ),
371 (g) => g.querySelector( this.opts.header_selector )
372 ).filter( Boolean );
373 const idx = headers.indexOf( btn );
374 if ( idx !== -1 ) {
375 const next_idx = (key === 'ArrowDown')
376 ? Math.min( headers.length - 1, idx + 1 )
377 : Math.max( 0, idx - 1 );
378 headers[next_idx].focus();
379 }
380 }
381 }
382
383 /**
384 * Apply ARIA synchronization to all known groups based on their open state.
385 *
386 * @private
387 * @returns {void}
388 * @since 2025-08-26
389 */
390 _sync_all_aria() {
391 this._groups.forEach( (g) => this._sync_group_aria( g ) );
392 }
393
394 /**
395 * Sync ARIA attributes and visibility on a single group.
396 *
397 * @private
398 * @param {HTMLElement} group The group element to sync.
399 * @returns {void}
400 * @since 2025-08-26
401 */
402 _sync_group_aria(group) {
403 const is_open = this.is_open( group );
404 const header = group.querySelector( this.opts.header_selector );
405 // Only direct children that match.
406 const panels = Array.prototype.filter.call( group.children, (el) => el.matches( this.opts.fields_selector ) );
407
408 // Header ARIA.
409 if ( header ) {
410 header.setAttribute( 'role', 'button' );
411 header.setAttribute( 'aria-expanded', is_open ? 'true' : 'false' );
412
413 if ( panels.length ) {
414 // Ensure each panel has an id; then wire aria-controls with space-separated ids.
415 const ids = panels.map( (p) => {
416 if ( !p.id ) p.id = this._generate_id( 'wpbc_collapsible_panel' );
417 return p.id;
418 } );
419 header.setAttribute( 'aria-controls', ids.join( ' ' ) );
420 }
421 }
422
423 // (3) Panels ARIA + visibility.
424 panels.forEach( (p) => {
425 p.hidden = !is_open; // actual visibility.
426 p.setAttribute( 'aria-hidden', is_open ? 'false' : 'true' ); // ARIA.
427 } );
428 }
429
430 /**
431 * Internal state change: set a group's open/closed state, sync ARIA,
432 * manage focus on collapse, and emit a custom event.
433 *
434 * @private
435 * @param {HTMLElement} group The group element to mutate.
436 * @param {boolean} open Whether the group should be open.
437 * @returns {void}
438 * @fires CustomEvent#wpbc:collapsible:open
439 * @fires CustomEvent#wpbc:collapsible:close
440 * @since 2025-08-26
441 */
442 _set_open(group, open) {
443 if ( !open && group.contains( document.activeElement ) ) {
444 const header = group.querySelector( this.opts.header_selector );
445 header && header.focus();
446 }
447 group.classList.toggle( this.opts.open_class, open );
448 this._sync_group_aria( group );
449 const ev_name = open ? 'wpbc:collapsible:open' : 'wpbc:collapsible:close';
450 group.dispatchEvent( new CustomEvent( ev_name, {
451 bubbles: true,
452 detail : { group, root: this.root, instance: this }
453 } ) );
454 }
455
456 /**
457 * Generate a unique DOM id with the specified prefix.
458 *
459 * @private
460 * @param {string} prefix The id prefix to use.
461 * @returns {string} A unique element id not present in the document.
462 * @since 2025-08-26
463 */
464 _generate_id(prefix) {
465 let i = 1;
466 let id;
467 do {
468 id = prefix + '_' + (i++);
469 }
470 while ( d.getElementById( id ) );
471 return id;
472 }
473 }
474
475 /**
476 * Auto-initialize collapsible controllers on the page.
477 * Finds top-level `.wpbc_collapsible` containers (ignoring nested ones),
478 * and instantiates {@link WPBC_Collapsible_Groups} on each.
479 *
480 * @function WPBC_Collapsible_AutoInit
481 * @returns {void}
482 * @since 2025-08-26
483 * @example
484 * // Runs automatically on DOMContentLoaded; can also be called manually:
485 * WPBC_Collapsible_AutoInit();
486 */
487 function wpbc_collapsible__auto_init() {
488 var ROOT = '.wpbc_collapsible';
489 var nodes = Array.prototype.slice.call( d.querySelectorAll( ROOT ) )
490 .filter( function (n) {
491 return !n.parentElement || !n.parentElement.closest( ROOT );
492 } );
493
494 nodes.forEach( function (node) {
495 if ( node.__wpbc_collapsible_instance ) {
496 return;
497 }
498 var exclusive = node.classList.contains( 'wpbc_collapsible--exclusive' ) || node.matches( '[data-wpbc-accordion="exclusive"]' );
499
500 node.__wpbc_collapsible_instance = new WPBC_Collapsible_Groups( node, { exclusive } ).init();
501 } );
502 }
503
504 // Export to global for manual control if needed.
505 w.WPBC_Collapsible_Groups = WPBC_Collapsible_Groups;
506 w.WPBC_Collapsible_AutoInit = wpbc_collapsible__auto_init;
507
508 // DOM-ready auto init.
509 if ( d.readyState === 'loading' ) {
510 d.addEventListener( 'DOMContentLoaded', wpbc_collapsible__auto_init, { once: true } );
511 } else {
512 wpbc_collapsible__auto_init();
513 }
514 })( window, document );
515