PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 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 All 205 releases
booking / includes / page-form-builder / ui-parts / bfb-ui__nav_horis.php

bfb-ui__nav_horis.php in Booking Calendar 11.9, at includes/page-form-builder/ui-parts/bfb-ui__nav_horis.php

431 lines 14.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin UI — Top Tabs (Universal Renderer)
4 *
5 * @package Booking Calendar
6 * @subpackage Form Builder
7 * @since 11.0.0
8 * @file includes/page-form-builder/ui-parts/bfb-ui__nav_horis.php
9 *
10 *
11 * This class prints a “tabs navigation bar” and works together with the small JS switcher
12 * (bfb-top-tabs.js) to show/hide tab panels in a scoped container.
13 *
14 * -------------------------------------------------------------------------------------
15 * How it works (PHP + JS contract)
16 * -------------------------------------------------------------------------------------
17 *
18 * 1) Navigation container (printed by ::render()):
19 *
20 * The nav container MUST have these attributes (the class prints them automatically):
21 *
22 * - data-wpbc-bfb-tabs-nav="1"
23 * Marker for JS to auto-init this nav.
24 *
25 * - data-active-tab="builder_tab"
26 * Initial active panel tab id. JS updates this attribute on each click.
27 *
28 * - data-wpbc-bfb-panel-class="wpbc_bfb__top_tab_section"
29 * The “base class” used to find panels inside the panels root.
30 *
31 * - data-wpbc-bfb-panels-root="#wpbc_bfb__top_panels" (optional but recommended)
32 * CSS selector of the container that holds panels for this nav.
33 * If omitted, JS falls back to document scope (not recommended when there are nested tabs).
34 *
35 * 2) Tab links (printed for type="panel"):
36 *
37 * Each panel tab link MUST contain:
38 * - data-wpbc-bfb-action="panel"
39 * - data-wpbc-bfb-tab="{tab_id}"
40 *
41 * JS uses event delegation and listens for clicks on these links.
42 *
43 * 3) Panels markup (you write this in the page template):
44 *
45 * Panels must exist inside the panels root (data-wpbc-bfb-panels-root) and follow class naming:
46 *
47 * - Base class: .{panel_base_class}
48 * - Tab-specific class: .{panel_base_class}__{tab_id}
49 *
50 * Example for tab_id="builder_tab" and panel_base_class="wpbc_bfb__top_tab_section":
51 * <div class="wpbc_bfb__top_tab_section wpbc_bfb__top_tab_section__builder_tab"> ... </div>
52 *
53 * JS hides all ".{panel_base_class}" inside the root, and shows only the matching
54 * ".{panel_base_class}__{active_tab_id}".
55 *
56 * IMPORTANT (nested tabs):
57 * - Always set a unique panels root and a unique panel_base_class per tab group.
58 * - Otherwise a “parent” tab switch can hide “child” panels (classic nested tabs bug).
59 *
60 * -------------------------------------------------------------------------------------
61 * Tabs config format
62 * -------------------------------------------------------------------------------------
63 *
64 * $tabs_config = array(
65 * 'builder_tab' => array(
66 * 'title' => 'Form Builder',
67 * 'type' => 'panel', // panel|url
68 * 'css_classes' => '', // extra wrapper classes
69 * 'font_icon' => '', // optional icon class
70 * 'hint' => array( // optional tooltip
71 * 'title' => 'Tooltip text',
72 * 'position' => 'top',
73 * ),
74 * 'data_attr' => array( // optional extra link attrs (data- /aria- /title/role)
75 * 'data-foo' => '1',
76 * ),
77 * ),
78 * 'docs' => array(
79 * 'title' => 'Docs',
80 * 'type' => 'url',
81 * 'url' => admin_url( 'admin.php?page=...' ),
82 * ),
83 * );
84 *
85 * -------------------------------------------------------------------------------------
86 * Usage examples
87 * -------------------------------------------------------------------------------------
88 *
89 * Example A: Top-level tabs (recommended pattern)
90 *
91 * $active_tab_id = WPBC_BFB_UI_Top_Tabs::resolve_active_tab_id( $tabs_config, 'builder_tab' );
92 * WPBC_BFB_UI_Top_Tabs::render( array(
93 * 'active_tab' => $active_tab_id,
94 * 'tabs' => $tabs_config,
95 * 'nav_container_id' => 'wpbc_bfb__top_horisontal_nav',
96 * 'panels_root_id' => 'wpbc_bfb__top_panels',
97 * 'panel_base_class' => 'wpbc_bfb__top_tab_section',
98 * ) );
99 *
100 * <div id="wpbc_bfb__top_panels">
101 * <div class="wpbc_bfb__top_tab_section wpbc_bfb__top_tab_section__builder_tab" <?php echo WPBC_BFB_UI_Top_Tabs::get_panel_hidden_attr( 'builder_tab', $active_tab_id ); ?>>
102 * ...
103 * </div>
104 *
105 * <div class="wpbc_bfb__top_tab_section wpbc_bfb__top_tab_section__advanced_tab" <?php echo WPBC_BFB_UI_Top_Tabs::get_panel_hidden_attr( 'advanced_tab', $active_tab_id ); ?>>
106 * ...
107 * </div>
108 * </div>
109 *
110 * Example B: Nested tabs inside a panel (use different base + root)
111 *
112 * WPBC_BFB_UI_Top_Tabs::render( array(
113 * 'active_tab' => 'tab_one',
114 * 'tabs' => $inner_tabs,
115 * 'nav_container_id' => 'my_inner_tabs_nav',
116 * 'panels_root_id' => 'my_inner_tabs_panels',
117 * 'panel_base_class' => 'my_inner_tab_panel',
118 * ) );
119 *
120 * <div id="my_inner_tabs_panels">
121 * <div class="my_inner_tab_panel my_inner_tab_panel__tab_one">...</div>
122 * <div class="my_inner_tab_panel my_inner_tab_panel__tab_two">...</div>
123 * </div>
124 *
125 * Notes:
126 * - Use get_panel_hidden_attr() only for initial PHP render (JS controls switching after load).
127 */
128
129
130 if ( ! defined( 'ABSPATH' ) ) {
131 exit;
132 }
133
134 class WPBC_BFB_UI_Top_Tabs {
135
136 /**
137 * HTML id of the default top navigation container.
138 *
139 * @var string
140 */
141 const NAV_CONTAINER_ID = 'wpbc_bfb__top_horisontal_nav';
142
143 /**
144 * Default panel base class used by JS to find/hide/show panels.
145 *
146 * Panels:
147 * - base: .wpbc_bfb__tab_section
148 * - by id: .wpbc_bfb__tab_section__{tab_id}
149 *
150 * @var string
151 */
152 const DEFAULT_PANEL_BASE_CLASS = 'wpbc_bfb__tab_section';
153
154 /**
155 * Resolve active tab id to a valid "panel" tab id.
156 *
157 * @param array $tabs_config Tabs config (tab_id => args).
158 * @param string $requested_tab_id Requested tab id.
159 * @param string $fallback_tab_id Optional fallback id (if no panel tab found).
160 *
161 * @return string
162 */
163 public static function resolve_active_tab_id( $tabs_config, $requested_tab_id, $fallback_tab_id = '' ) {
164
165 $tabs_config = is_array( $tabs_config ) ? $tabs_config : array();
166 $requested_tab_id = sanitize_key( (string) $requested_tab_id );
167 $fallback_tab_id = sanitize_key( (string) $fallback_tab_id );
168
169 // 1) Requested tab if valid panel.
170 if ( isset( $tabs_config[ $requested_tab_id ] ) && is_array( $tabs_config[ $requested_tab_id ] ) ) {
171 $tab_type = isset( $tabs_config[ $requested_tab_id ]['type'] ) ? (string) $tabs_config[ $requested_tab_id ]['type'] : 'panel';
172 if ( 'panel' === $tab_type ) {
173 return $requested_tab_id;
174 }
175 }
176
177 // 2) First explicitly marked active panel.
178 foreach ( $tabs_config as $tab_id => $tab_args ) {
179 $tab_id = sanitize_key( (string) $tab_id );
180 $tab_args = is_array( $tab_args ) ? $tab_args : array();
181 $tab_type = isset( $tab_args['type'] ) ? (string) $tab_args['type'] : 'panel';
182 $is_active = ! empty( $tab_args['active'] );
183
184 if ( $is_active && 'panel' === $tab_type ) {
185 return $tab_id;
186 }
187 }
188
189 // 3) First panel tab.
190 foreach ( $tabs_config as $tab_id => $tab_args ) {
191 $tab_id = sanitize_key( (string) $tab_id );
192 $tab_args = is_array( $tab_args ) ? $tab_args : array();
193 $tab_type = isset( $tab_args['type'] ) ? (string) $tab_args['type'] : 'panel';
194
195 if ( 'panel' === $tab_type ) {
196 return $tab_id;
197 }
198 }
199
200 // 4) Fallback.
201 return $fallback_tab_id ? $fallback_tab_id : 'builder_tab';
202 }
203
204 /**
205 * Inline attribute to hide inactive panels on initial PHP render.
206 *
207 * @param string $panel_id_suffix Panel suffix.
208 * @param string $active_tab_id Active tab id.
209 *
210 * @return string
211 */
212 public static function get_panel_hidden_attr( $panel_id_suffix, $active_tab_id ) {
213
214 $panel_id_suffix = sanitize_key( (string) $panel_id_suffix );
215 $active_tab_id = sanitize_key( (string) $active_tab_id );
216
217 return ( $panel_id_suffix === $active_tab_id ) ? '' : ' style="display:none;"';
218 }
219
220 /**
221 * Render the tabs navigation.
222 *
223 * Args:
224 * - active_tab (string)
225 * - tabs (array)
226 * - nav_container_id (string)
227 * - panels_root_id (string)
228 * - panels_root_selector (string)
229 * - panel_base_class (string)
230 *
231 * @param array $args
232 *
233 * @return void
234 */
235 public static function render( $args = array() ) {
236
237 $args = wp_parse_args(
238 (array) $args,
239 array(
240 'active_tab' => '',
241 'tabs' => array(),
242 'nav_container_id' => self::NAV_CONTAINER_ID,
243 'panels_root_id' => '',
244 'panels_root_selector' => '',
245 'panel_base_class' => self::DEFAULT_PANEL_BASE_CLASS,
246 )
247 );
248
249 $tabs_config = ( ! empty( $args['tabs'] ) && is_array( $args['tabs'] ) ) ? $args['tabs'] : array();
250 $active_tab = self::resolve_active_tab_id( $tabs_config, (string) $args['active_tab'] );
251
252 $nav_container_id = sanitize_html_class( (string) $args['nav_container_id'] );
253 if ( '' === $nav_container_id ) {
254 $nav_container_id = self::NAV_CONTAINER_ID;
255 }
256
257 $panel_base_class = sanitize_html_class( (string) $args['panel_base_class'] );
258 if ( '' === $panel_base_class ) {
259 $panel_base_class = self::DEFAULT_PANEL_BASE_CLASS;
260 }
261
262 $panels_root_selector = '';
263 if ( ! empty( $args['panels_root_selector'] ) && is_string( $args['panels_root_selector'] ) ) {
264 $panels_root_selector = trim( (string) $args['panels_root_selector'] );
265 } elseif ( ! empty( $args['panels_root_id'] ) && is_string( $args['panels_root_id'] ) ) {
266 $panels_root_selector = '#' . sanitize_html_class( (string) $args['panels_root_id'] );
267 }
268
269 echo '<div class="wpbc_ui_el__horis_top_bar__wrapper wpbc_bfb_ui__elements_hide_in_preview">';
270 echo '<div'
271 . ' id="' . esc_attr( $nav_container_id ) . '"'
272 . ' class="wpbc_ui_el__horis_top_bar__content"'
273 . ' data-wpbc-bfb-tabs-nav="1"'
274 . ' data-active-tab="' . esc_attr( $active_tab ) . '"'
275 . ' data-wpbc-bfb-panel-class="' . esc_attr( $panel_base_class ) . '"';
276
277 if ( '' !== $panels_root_selector ) {
278 echo ' data-wpbc-bfb-panels-root="' . esc_attr( $panels_root_selector ) . '"';
279 }
280
281 echo '>';
282
283 do_action( 'wpbc_bfb_ui__top_horisontal_nav__start' );
284 foreach ( $tabs_config as $tab_id => $tab_args ) {
285 self::render_tab_item( $tab_id, $tab_args, $active_tab );
286 }
287 do_action( 'wpbc_bfb_ui__top_horisontal_nav__end' );
288
289 echo '</div></div>';
290 }
291
292 private static function render_tab_item( $tab_id, $tab_args, $active_tab_id ) {
293
294 $tab_id = sanitize_key( (string) $tab_id );
295
296 $tab_args = wp_parse_args(
297 (array) $tab_args,
298 array(
299 'title' => '',
300 'type' => 'panel',
301 'url' => '',
302 'css_classes' => '',
303 'font_icon' => '',
304 'hint' => array(),
305 'data_attr' => array(),
306 )
307 );
308
309 $is_active = ( $tab_id === sanitize_key( (string) $active_tab_id ) );
310
311 $wrapper_css = 'wpbc_ui_el__horis_nav_item wpbc_ui_el__horis_nav_item__' . $tab_id;
312 $wrapper_css .= ( '' !== $tab_args['css_classes'] ) ? ' ' . (string) $tab_args['css_classes'] : '';
313 $wrapper_css .= $is_active ? ' active' : '';
314
315 $href = '#';
316 $link_attrs = array();
317
318 if ( 'url' === (string) $tab_args['type'] ) {
319 $href = ( '' !== (string) $tab_args['url'] ) ? (string) $tab_args['url'] : '#';
320 } else {
321 $link_attrs['data-wpbc-bfb-action'] = 'panel';
322 $link_attrs['data-wpbc-bfb-tab'] = $tab_id;
323 }
324
325 // Tooltip (optional).
326 if ( ! empty( $tab_args['hint']['title'] ) ) {
327 $link_attrs['title'] = $tab_args['hint']['title'];
328 $link_attrs['data-original-title'] = $tab_args['hint']['title'];
329 }
330
331 // Extra attrs (data_attr).
332 if ( ! empty( $tab_args['data_attr'] ) && is_array( $tab_args['data_attr'] ) ) {
333 foreach ( $tab_args['data_attr'] as $attr_name => $attr_value ) {
334 $link_attrs[ (string) $attr_name ] = $attr_value;
335 }
336 }
337
338 $tooltip_pos_class = '';
339 if ( ! empty( $tab_args['hint']['position'] ) ) {
340 $tooltip_pos_class = ' tooltip_' . sanitize_key( (string) $tab_args['hint']['position'] );
341 }
342
343 ?>
344 <div class="<?php echo esc_attr( $wrapper_css ); ?>">
345 <a href="<?php echo esc_url( $href ); ?>"
346 class="wpbc_ui_el__horis_nav_item__a wpbc_ui_el__horis_nav_item__single<?php echo esc_attr( $tooltip_pos_class ); ?>"
347 <?php echo self::build_allowed_attr_string( $link_attrs ); /* phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped */ ?>
348 >
349 <?php if ( ! empty( $tab_args['font_icon'] ) ) { ?>
350 <i class="wpbc_ui_el__horis_nav_icon tooltip_top menu_icon icon-1x <?php echo esc_attr( $tab_args['font_icon'] ); ?>"
351 data-original-title="<?php echo esc_attr( $tab_args['title'] ); ?>"></i>
352 <?php } ?>
353 <span class="wpbc_ui_el__horis_nav_title"><?php echo wp_kses_post( $tab_args['title'] ); ?></span>
354 </a>
355 </div>
356 <?php
357 }
358
359 /**
360 * Build HTML attributes string from array, allowing only safe/expected attributes.
361 *
362 * Allowed:
363 * - data-*
364 * - aria-*
365 * - title
366 * - data-original-title
367 * - role
368 *
369 * Values are escaped with esc_attr().
370 * Empty values are skipped, except value "0" which is kept.
371 *
372 * @param array $attrs Attributes map.
373 *
374 * @return string Example: ` data-foo="1" aria-label="X"`.
375 */
376 private static function build_allowed_attr_string( $attrs ) {
377
378 if ( empty( $attrs ) || ! is_array( $attrs ) ) {
379 return '';
380 }
381
382 $out = '';
383
384 foreach ( $attrs as $name => $value ) {
385
386 $name = is_scalar( $name ) ? trim( (string) $name ) : '';
387 if ( '' === $name ) {
388 continue;
389 }
390
391 // Allow only expected attribute namespaces.
392 $is_allowed =
393 ( 0 === strpos( $name, 'data-' ) ) ||
394 ( 0 === strpos( $name, 'aria-' ) ) ||
395 ( 'title' === $name ) ||
396 ( 'data-original-title' === $name ) ||
397 ( 'role' === $name );
398
399 if ( ! $is_allowed ) {
400 continue;
401 }
402
403 // Validate name characters (basic HTML attribute name safety).
404 if ( ! preg_match( '/^[a-zA-Z_:][a-zA-Z0-9:._-]*$/', $name ) ) {
405 continue;
406 }
407
408 // Boolean attribute support (true => render as name="name" or just name).
409 // Here we keep it simple: true => name="1".
410 if ( true === $value ) {
411 $out .= ' ' . $name . '="1"';
412 continue;
413 }
414
415 // Skip null/empty (but keep "0").
416 if ( null === $value ) {
417 continue;
418 }
419
420 $value = is_scalar( $value ) ? (string) $value : '';
421 if ( '' === $value && '0' !== $value ) {
422 continue;
423 }
424
425 $out .= ' ' . $name . '="' . esc_attr( $value ) . '"';
426 }
427
428 return $out;
429 }
430 }
431