| 1 |
/** |
| 2 |
* Booking Calendar — Generic UI Tabs Utility (JS) |
| 3 |
* |
| 4 |
* Purpose: Lightweight, dependency-free tabs controller for any small tab group in admin UIs. |
| 5 |
* - Auto-initializes groups marked with data-wpbc-tabs. |
| 6 |
* - Assigns ARIA roles and toggles aria-selected/aria-hidden/tabindex. |
| 7 |
* - Supports keyboard navigation (Left/Right/Home/End). |
| 8 |
* - Public API: window.wpbc_ui_tabs.{init_on, init_group, set_active} |
| 9 |
* - Emits 'wpbc:tabs:change' on the group root when the active tab changes. |
| 10 |
* |
| 11 |
* Markup contract: |
| 12 |
* - Root: [data-wpbc-tabs] |
| 13 |
* - Tabs: [data-wpbc-tab-key="K"] |
| 14 |
* - Panels: [data-wpbc-tab-panel="K"] |
| 15 |
* |
| 16 |
* @package Booking Calendar |
| 17 |
* @subpackage Admin\UI |
| 18 |
* @since 11.0.0 |
| 19 |
* @version 1.0.0 |
| 20 |
* @see /includes/__js/admin/ui_tabs/ui_tabs.js |
| 21 |
* |
| 22 |
* |
| 23 |
* How it works: |
| 24 |
* - Root node must have [data-wpbc-tabs] attribute (any value). |
| 25 |
* - Tab buttons must carry [data-wpbc-tab-key="..."] (unique per group). |
| 26 |
* - Panels must carry [data-wpbc-tab-panel="..."] with matching keys. |
| 27 |
* - Adds WAI-ARIA roles and aria-selected/hidden wiring. |
| 28 |
* |
| 29 |
* <div data-wpbc-tabs="column-styles" data-wpbc-tab-active="1" class="wpbc_ui_tabs_root" > |
| 30 |
* <!-- Top Tabs --> |
| 31 |
* <div data-wpbc-tablist="" role="tablist" class=" wpbc_ui_el__horis_top_bar__wrapper" > |
| 32 |
* <div class="wpbc_ui_el__horis_top_bar__content"> |
| 33 |
* <h2 class="wpbc_ui_el__horis_nav_label">Column:</h2> |
| 34 |
* |
| 35 |
* <div class="wpbc_ui_el__horis_nav_item wpbc_ui_el__horis_nav_item__1"> |
| 36 |
* <a |
| 37 |
* data-wpbc-tab-key="1" |
| 38 |
* aria-selected="true" role="tab" tabindex="0" aria-controls="wpbc_tab_panel_col_1" |
| 39 |
* |
| 40 |
* href="javascript:void(0);" |
| 41 |
* class="wpbc_ui_el__horis_nav_item__a wpbc_ui_el__horis_nav_item__single" |
| 42 |
* id="wpbc_tab_col_1" |
| 43 |
* title="Column 1" |
| 44 |
* ><span class="wpbc_ui_el__horis_nav_title">Title 1</span></a> |
| 45 |
* </div> |
| 46 |
* ... |
| 47 |
* </div> |
| 48 |
* </div> |
| 49 |
* <!-- Tabs Content --> |
| 50 |
* <div class="wpbc_tab__panel group__fields" data-wpbc-tab-panel="1" id="wpbc_tab_panel_col_1" role="tabpanel" aria-labelledby="wpbc_tab_col_1"> |
| 51 |
* ... |
| 52 |
* </div> |
| 53 |
* ... |
| 54 |
* </div> |
| 55 |
* |
| 56 |
* Public API: |
| 57 |
* - wpbc_ui_tabs.init_on(root_or_selector) // find and init groups within a container |
| 58 |
* - wpbc_ui_tabs.init_group(root_el) // init a single group root |
| 59 |
* - wpbc_ui_tabs.set_active(root_el, key) // programmatically change active tab |
| 60 |
* |
| 61 |
* Events: |
| 62 |
* - Dispatches CustomEvent 'wpbc:tabs:change' on root when tab changes: |
| 63 |
* detail: { active_key: '2', prev_key: '1' } |
| 64 |
* |
| 65 |
* Switch a local (generic) tabs group to tab 3: var group = document.querySelector('[data-wpbc-tabs="column-styles"]'); if ( group ) { wpbc_ui_tabs.set_active(group, '3'); } |
| 66 |
*/ |
| 67 |
(function ( w ) { |
| 68 |
'use strict'; |
| 69 |
|
| 70 |
if ( w.wpbc_ui_tabs ) { |
| 71 |
return; |
| 72 |
} |
| 73 |
|
| 74 |
/** |
| 75 |
* Internal: toggle active state. |
| 76 |
* |
| 77 |
* @param {HTMLElement} root_el |
| 78 |
* @param {string} key |
| 79 |
* @param {boolean} should_emit |
| 80 |
*/ |
| 81 |
function set_active_internal( root_el, key, should_emit ) { |
| 82 |
var tab_btns = root_el.querySelectorAll( '[data-wpbc-tab-key]' ); |
| 83 |
var panels = root_el.querySelectorAll( '[data-wpbc-tab-panel]' ); |
| 84 |
|
| 85 |
var prev_key = root_el.getAttribute( 'data-wpbc-tab-active' ) || null; |
| 86 |
if ( String( prev_key ) === String( key ) ) { |
| 87 |
return; |
| 88 |
} |
| 89 |
|
| 90 |
// Buttons: aria + class |
| 91 |
for ( var i = 0; i < tab_btns.length; i++ ) { |
| 92 |
var btn = tab_btns[i]; |
| 93 |
var b_key = btn.getAttribute( 'data-wpbc-tab-key' ); |
| 94 |
var is_on = String( b_key ) === String( key ); |
| 95 |
|
| 96 |
btn.setAttribute( 'role', 'tab' ); |
| 97 |
btn.setAttribute( 'aria-selected', is_on ? 'true' : 'false' ); |
| 98 |
btn.setAttribute( 'tabindex', is_on ? '0' : '-1' ); |
| 99 |
|
| 100 |
if ( is_on ) { |
| 101 |
btn.classList.add( 'active' ); |
| 102 |
} else { |
| 103 |
btn.classList.remove( 'active' ); |
| 104 |
} |
| 105 |
} |
| 106 |
|
| 107 |
// Panels: aria + visibility |
| 108 |
for ( var j = 0; j < panels.length; j++ ) { |
| 109 |
var pn = panels[j]; |
| 110 |
var pkey = pn.getAttribute( 'data-wpbc-tab-panel' ); |
| 111 |
var show = String( pkey ) === String( key ); |
| 112 |
|
| 113 |
pn.setAttribute( 'role', 'tabpanel' ); |
| 114 |
pn.setAttribute( 'aria-hidden', show ? 'false' : 'true' ); |
| 115 |
if ( show ) { |
| 116 |
pn.removeAttribute( 'hidden' ); |
| 117 |
} else { |
| 118 |
pn.setAttribute( 'hidden', '' ); |
| 119 |
} |
| 120 |
} |
| 121 |
|
| 122 |
root_el.setAttribute( 'data-wpbc-tab-active', String( key ) ); |
| 123 |
|
| 124 |
if ( should_emit ) { |
| 125 |
try { |
| 126 |
var ev = new w.CustomEvent( 'wpbc:tabs:change', { |
| 127 |
bubbles : true, |
| 128 |
detail : { active_key : String( key ), prev_key : prev_key } |
| 129 |
} ); |
| 130 |
root_el.dispatchEvent( ev ); |
| 131 |
} catch ( _e ) {} |
| 132 |
} |
| 133 |
} |
| 134 |
|
| 135 |
/** |
| 136 |
* Internal: get ordered keys from buttons. |
| 137 |
* |
| 138 |
* @param {HTMLElement} root_el |
| 139 |
* @returns {string[]} |
| 140 |
*/ |
| 141 |
function get_keys( root_el ) { |
| 142 |
var list = []; |
| 143 |
var btns = root_el.querySelectorAll( '[data-wpbc-tab-key]' ); |
| 144 |
for ( var i = 0; i < btns.length; i++ ) { |
| 145 |
var k = btns[i].getAttribute( 'data-wpbc-tab-key' ); |
| 146 |
if ( k != null && k !== '' ) { |
| 147 |
list.push( String( k ) ); |
| 148 |
} |
| 149 |
} |
| 150 |
return list; |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* Internal: move focus between tabs using keyboard. |
| 155 |
* |
| 156 |
* @param {HTMLElement} root_el |
| 157 |
* @param {number} dir +1 (next) / -1 (prev) |
| 158 |
*/ |
| 159 |
function focus_relative( root_el, dir ) { |
| 160 |
var keys = get_keys( root_el ); |
| 161 |
var current = root_el.getAttribute( 'data-wpbc-tab-active' ) || keys[0] || null; |
| 162 |
var idx = Math.max( 0, keys.indexOf( String( current ) ) ); |
| 163 |
var next = keys[ ( idx + ( dir > 0 ? 1 : keys.length - 1 ) ) % keys.length ]; |
| 164 |
|
| 165 |
var next_btn = root_el.querySelector( '[data-wpbc-tab-key="' + next + '"]' ); |
| 166 |
if ( next_btn ) { |
| 167 |
next_btn.focus(); |
| 168 |
set_active_internal( root_el, next, true ); |
| 169 |
} |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* Initialize a single tabs group root. |
| 174 |
* |
| 175 |
* @param {HTMLElement} root_el |
| 176 |
*/ |
| 177 |
function init_group( root_el ) { |
| 178 |
if ( ! root_el || root_el.__wpbc_tabs_inited ) { |
| 179 |
return; |
| 180 |
} |
| 181 |
root_el.__wpbc_tabs_inited = true; |
| 182 |
|
| 183 |
// Roles |
| 184 |
var tablist = root_el.querySelector( '[data-wpbc-tablist]' ) || root_el; |
| 185 |
tablist.setAttribute( 'role', 'tablist' ); |
| 186 |
|
| 187 |
// Default active: from attribute or first button |
| 188 |
var keys = get_keys( root_el ); |
| 189 |
var def = root_el.getAttribute( 'data-wpbc-tab-active' ) || ( keys[0] || '1' ); |
| 190 |
set_active_internal( root_el, def, false ); |
| 191 |
|
| 192 |
// Clicks |
| 193 |
root_el.addEventListener( 'click', function ( e ) { |
| 194 |
var btn = e.target.closest ? e.target.closest( '[data-wpbc-tab-key]' ) : null; |
| 195 |
if ( ! btn || ! root_el.contains( btn ) ) { |
| 196 |
return; |
| 197 |
} |
| 198 |
e.preventDefault(); |
| 199 |
var key = btn.getAttribute( 'data-wpbc-tab-key' ); |
| 200 |
if ( key != null ) { |
| 201 |
set_active_internal( root_el, key, true ); |
| 202 |
} |
| 203 |
}, true ); |
| 204 |
|
| 205 |
// Keyboard (Left/Right/Home/End) |
| 206 |
root_el.addEventListener( 'keydown', function ( e ) { |
| 207 |
var tgt = e.target; |
| 208 |
if ( ! tgt || ! tgt.hasAttribute || ! tgt.hasAttribute( 'data-wpbc-tab-key' ) ) { |
| 209 |
return; |
| 210 |
} |
| 211 |
switch ( e.key ) { |
| 212 |
case 'ArrowLeft': |
| 213 |
e.preventDefault(); focus_relative( root_el, -1 ); break; |
| 214 |
case 'ArrowRight': |
| 215 |
e.preventDefault(); focus_relative( root_el, +1 ); break; |
| 216 |
case 'Home': |
| 217 |
e.preventDefault(); set_active_internal( root_el, ( get_keys( root_el )[0] || '1' ), true ); break; |
| 218 |
case 'End': |
| 219 |
e.preventDefault(); var ks = get_keys( root_el ); set_active_internal( root_el, ( ks[ ks.length - 1 ] || '1' ), true ); break; |
| 220 |
} |
| 221 |
}, true ); |
| 222 |
} |
| 223 |
|
| 224 |
/** |
| 225 |
* Initialize all groups within a container (or document). |
| 226 |
* |
| 227 |
* @param {HTMLElement|string|null} container |
| 228 |
*/ |
| 229 |
function init_on( container ) { |
| 230 |
var ctx = container ? ( typeof container === 'string' ? document.querySelector( container ) : container ) : document; |
| 231 |
if ( ! ctx ) { |
| 232 |
return; |
| 233 |
} |
| 234 |
var groups = ctx.querySelectorAll( '[data-wpbc-tabs]' ); |
| 235 |
for ( var i = 0; i < groups.length; i++ ) { |
| 236 |
init_group( groups[i] ); |
| 237 |
} |
| 238 |
} |
| 239 |
|
| 240 |
/** |
| 241 |
* Programmatically set active tab by key. |
| 242 |
* |
| 243 |
* @param {HTMLElement} root_el |
| 244 |
* @param {string|number} key |
| 245 |
*/ |
| 246 |
function set_active( root_el, key ) { |
| 247 |
if ( root_el && root_el.hasAttribute && root_el.hasAttribute( 'data-wpbc-tabs' ) ) { |
| 248 |
set_active_internal( root_el, String( key ), true ); |
| 249 |
} |
| 250 |
} |
| 251 |
|
| 252 |
// Public API (snake_case) |
| 253 |
w.wpbc_ui_tabs = { |
| 254 |
init_on : init_on, |
| 255 |
init_group : init_group, |
| 256 |
set_active : set_active |
| 257 |
}; |
| 258 |
|
| 259 |
// Auto-init on DOM ready |
| 260 |
if ( document.readyState === 'loading' ) { |
| 261 |
document.addEventListener( 'DOMContentLoaded', function () { init_on( document ); } ); |
| 262 |
} else { |
| 263 |
init_on( document ); |
| 264 |
} |
| 265 |
|
| 266 |
})( window ); |
| 267 |
|