PluginProbe
Booking Calendar / 11.1
Booking Calendar v11.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 / __js / core / bfb-ui.js

bfb-ui.js in Booking Calendar 11.1, at includes/page-form-builder/__js/core/bfb-ui.js

2,834 lines 97.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // ---------------------------------------------------------------------------------------------------------------------
2 // == File /includes/page-form-builder/_out/core/bfb-ui.js == | 2025-09-10 15:47
3 // ---------------------------------------------------------------------------------------------------------------------
4 (function (w, d) {
5 'use strict';
6
7 // Single global namespace (idempotent & load-order safe).
8 const Core = (w.WPBC_BFB_Core = w.WPBC_BFB_Core || {});
9 const UI = (Core.UI = Core.UI || {});
10
11 // --- Highlight Element, like Generator brn - Tiny UI helpers ------------------------------------
12 UI._pulse_timers = UI._pulse_timers || new Map(); // el -> timer_id
13 UI._pulse_meta = UI._pulse_meta || new Map(); // el -> { token, last_ts, debounce_id, color_set }
14 // Pulse tuning (milliseconds).
15 UI.PULSE_THROTTLE_MS = Number.isFinite( UI.PULSE_THROTTLE_MS ) ? UI.PULSE_THROTTLE_MS : 500;
16 UI.PULSE_DEBOUNCE_MS = Number.isFinite( UI.PULSE_DEBOUNCE_MS ) ? UI.PULSE_DEBOUNCE_MS : 750;
17
18 // Debounce STRUCTURE_CHANGE for continuous inspector controls (sliders / scrubbing).
19 // Tune: 180..350 is usually a sweet spot.
20 UI.STRUCTURE_CHANGE_DEBOUNCE_MS = Number.isFinite( UI.STRUCTURE_CHANGE_DEBOUNCE_MS ) ? UI.STRUCTURE_CHANGE_DEBOUNCE_MS : 180;
21 // Change this to tune speed: 50..120 ms is a good range. Can be configured in <div data-len-group data-len-throttle="180">...</div>.
22 UI.VALUE_SLIDER_THROTTLE_MS = Number.isFinite( UI.VALUE_SLIDER_THROTTLE_MS ) ? UI.VALUE_SLIDER_THROTTLE_MS : 120;
23
24 /**
25 * Cancel any running pulse sequence for an element.
26 * Uses token invalidation so already-scheduled callbacks become no-ops.
27 *
28 * @param {HTMLElement} el
29 */
30 UI.cancel_pulse = function (el) {
31 if ( !el ) { return; }
32 try {
33 clearTimeout( UI._pulse_timers.get( el ) );
34 } catch ( _ ) {}
35 UI._pulse_timers.delete( el );
36
37 var meta = UI._pulse_meta.get( el ) || {};
38 meta.token = (Number.isFinite( meta.token ) ? meta.token : 0) + 1;
39 meta.color_set = false;
40 try { el.classList.remove( 'wpbc_bfb__scroll-pulse', 'wpbc_bfb__highlight-pulse' ); } catch ( _ ) {}
41 try { el.style.removeProperty( '--wpbc-bfb-pulse-color' ); } catch ( _ ) {}
42 UI._pulse_meta.set( el, meta );
43 try { clearTimeout( meta.debounce_id ); } catch ( _ ) {}
44 meta.debounce_id = 0;
45 };
46
47 /**
48 * Force-restart a CSS animation on a class.
49 * @param {HTMLElement} el
50 * @param {string} cls
51 */
52 UI._restart_css_animation = function (el, cls) {
53 if ( ! el ) { return; }
54 try {
55 el.classList.remove( cls );
56 } catch ( _ ) {}
57 // Force reflow so the next add() retriggers the keyframes.
58 void el.offsetWidth;
59 try {
60 el.classList.add( cls );
61 } catch ( _ ) {}
62 };
63
64 /**
65 Single pulse (back-compat).
66 @param {HTMLElement} el
67 @param {number} dur_ms
68 */
69 UI.pulse_once = function (el, dur_ms) {
70 if ( ! el ) { return; }
71 var cls = 'wpbc_bfb__scroll-pulse';
72 var ms = Number.isFinite( dur_ms ) ? dur_ms : 700;
73
74 UI.cancel_pulse( el );
75
76 var meta = UI._pulse_meta.get( el ) || {};
77 var token = (Number.isFinite( meta.token ) ? meta.token : 0) + 1;
78 meta.token = token;
79 UI._pulse_meta.set( el, meta );
80
81 UI._restart_css_animation( el, cls );
82 var t = setTimeout( function () {
83 // ignore if a newer pulse started.
84 var m = UI._pulse_meta.get( el ) || {};
85 if ( m.token !== token ) { return; }
86 try {
87 el.classList.remove( cls );
88 } catch ( _ ) {}
89 UI._pulse_timers.delete( el );
90 }, ms );
91 UI._pulse_timers.set( el, t );
92 };
93
94 /**
95 Multi-blink sequence with optional per-call color override.
96 @param {HTMLElement} el
97 @param {number} [times=3]
98 @param {number} [on_ms=280]
99 @param {number} [off_ms=180]
100 @param {string} [hex_color] Optional CSS color (e.g. '#ff4d4f' or 'rgb(...)').
101 */
102 UI.pulse_sequence = function (el, times, on_ms, off_ms, hex_color) {
103 if ( !el || !d.body.contains( el ) ) {
104 return;
105 }
106 var cls = 'wpbc_bfb__highlight-pulse';
107 var count = Number.isFinite( times ) ? times : 2;
108 var on = Number.isFinite( on_ms ) ? on_ms : 280;
109 var off = Number.isFinite( off_ms ) ? off_ms : 180;
110
111 // Throttle: avoid reflow spam if called repeatedly while typing/dragging.
112 var meta = UI._pulse_meta.get( el ) || {};
113 var now = Date.now();
114 var throttle_ms = Number.isFinite( UI.PULSE_THROTTLE_MS ) ? UI.PULSE_THROTTLE_MS : 120;
115 if ( Number.isFinite( meta.last_ts ) && (now - meta.last_ts) < throttle_ms ) {
116 return;
117 }
118 meta.last_ts = now;
119
120 // cancel any running pulse and reset class (token invalidation).
121 UI.cancel_pulse( el );
122
123 // new token for this run
124 var token = (Number.isFinite( meta.token ) ? meta.token : 0) + 1;
125 meta.token = token;
126
127 var have_color = !!hex_color && typeof hex_color === 'string';
128 if ( have_color ) {
129 try {
130 el.style.setProperty( '--wpbc-bfb-pulse-color', hex_color );
131 } catch ( _ ) {}
132 meta.color_set = true;
133 }
134 UI._pulse_meta.set( el, meta );
135
136 var i = 0;
137 (function tick() {
138 var m = UI._pulse_meta.get( el ) || {};
139 if ( m.token !== token ) {
140 // canceled/replaced
141 return;
142 }
143 if ( i >= count ) {
144 UI._pulse_timers.delete( el );
145 if ( have_color ) {
146 try {
147 el.style.removeProperty( '--wpbc-bfb-pulse-color' );
148 } catch ( _ ) {}
149 }
150 return;
151 }
152 UI._restart_css_animation( el, cls );
153 UI._pulse_timers.set( el, setTimeout( function () { // ON -> OFF
154 var m2 = UI._pulse_meta.get( el ) || {};
155 if ( m2.token !== token ) { return; }
156 try {
157 el.classList.remove( cls );
158 } catch ( _ ) {
159 }
160 UI._pulse_timers.set( el, setTimeout( function () { // OFF gap -> next
161 var m3 = UI._pulse_meta.get( el ) || {};
162 if ( m3.token !== token ) { return; }
163 i++;
164 tick();
165 }, off ) );
166 }, on ) );
167 })();
168 };
169
170
171 /**
172 * Debounced query + pulse.
173 * Useful for `input` events (sliders / typing) to avoid forced reflow spam.
174 *
175 * @param {HTMLElement|string} root_or_selector
176 * @param {string} selector
177 * @param {number} wait_ms
178 * @param {number} [a]
179 * @param {number} [b]
180 * @param {number} [c]
181 * @param {string} [color]
182 */
183 UI.pulse_query_debounced = function (root_or_selector, selector, wait_ms, a, b, c, color) {
184 var root = (typeof root_or_selector === 'string') ? d : (root_or_selector || d);
185 var sel = (typeof root_or_selector === 'string') ? root_or_selector : selector;
186 if ( !sel ) { return; }
187 var el = root.querySelector( sel );
188 if ( !el ) { return; }
189
190 var def_ms = Number.isFinite( UI.PULSE_DEBOUNCE_MS ) ? UI.PULSE_DEBOUNCE_MS : 120;
191 var ms = Number.isFinite( wait_ms ) ? wait_ms : def_ms;
192 var meta = UI._pulse_meta.get( el ) || {};
193 try { clearTimeout( meta.debounce_id ); } catch ( _ ) {}
194 meta.debounce_id = setTimeout( function () {
195 UI.pulse_sequence( el, a, b, c, color );
196 }, ms );
197 UI._pulse_meta.set( el, meta );
198 };
199
200 /**
201 Query + pulse:
202 (BC) If only 3rd arg is a number and no 4th/5th -> single long pulse.
203 Otherwise -> strong sequence (defaults 3×280/180).
204 Optional 6th arg: color.
205 @param {HTMLElement|string} root_or_selector
206 @param {string} [selector]
207 @param {number} [a]
208 @param {number} [b]
209
210 @param {number} [c]
211
212 @param {string} [color]
213 */
214 UI.pulse_query = function (root_or_selector, selector, a, b, c, color) {
215 var root = (typeof root_or_selector === 'string') ? d : (root_or_selector || d);
216 var sel = (typeof root_or_selector === 'string') ? root_or_selector : selector;
217 if ( !sel ) {
218 return;
219 }
220
221 var el = root.querySelector( sel );
222 if ( !el ) {
223 return;
224 }
225
226 // Back-compat: UI.pulseQuery(root, sel, dur_ms)
227 if ( Number.isFinite( a ) && b === undefined && c === undefined ) {
228 return UI.pulse_once( el, a );
229 }
230 // New: sequence; params optional; supports optional color.
231 UI.pulse_sequence( el, a, b, c, color );
232 };
233
234 /**
235 Convenience helper (snake_case) to call a strong pulse with options.
236
237 @param {HTMLElement} el
238
239 @param {Object} [opts]
240
241 @param {number} [opts.times=3]
242
243 @param {number} [opts.on_ms=280]
244
245 @param {number} [opts.off_ms=180]
246
247 @param {string} [opts.color]
248 */
249 UI.pulse_sequence_strong = function (el, opts) {
250 opts = opts || {};
251 UI.pulse_sequence(
252 el,
253 Number.isFinite( opts.times ) ? opts.times : 3,
254 Number.isFinite( opts.on_ms ) ? opts.on_ms : 280,
255 Number.isFinite( opts.off_ms ) ? opts.off_ms : 180,
256 opts.color
257 );
258 };
259
260
261 /**
262 * Base class for BFB modules.
263 */
264 UI.WPBC_BFB_Module = class {
265 /** @param {WPBC_Form_Builder} builder */
266 constructor(builder) {
267 this.builder = builder;
268 }
269
270 /** Initialize the module. */
271 init() {
272 }
273
274 /** Cleanup the module. */
275 destroy() {
276 }
277 };
278
279 /**
280 * Central overlay/controls manager for fields/sections.
281 * Pure UI composition; all actions route back into the builder instance.
282 */
283 UI.WPBC_BFB_Overlay = class {
284
285 /**
286 * Ensure an overlay exists and is wired up on the element.
287 * @param {WPBC_Form_Builder} builder
288 * @param {HTMLElement} el - field or section element
289 */
290 static ensure(builder, el) {
291
292 if ( !el ) {
293 return;
294 }
295 const isSection = el.classList.contains( 'wpbc_bfb__section' );
296
297 // let overlay = el.querySelector( Core.WPBC_BFB_DOM.SELECTORS.overlay );
298 let overlay = el.querySelector( `:scope > ${Core.WPBC_BFB_DOM.SELECTORS.overlay}` );
299 if ( !overlay ) {
300 overlay = Core.WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__overlay-controls' );
301 el.prepend( overlay );
302 }
303
304 // Drag handle.
305 if ( !overlay.querySelector( '.wpbc_bfb__drag-handle' ) ) {
306 const dragClass = isSection ? 'wpbc_bfb__drag-handle section-drag-handle' : 'wpbc_bfb__drag-handle';
307 overlay.appendChild(
308 Core.WPBC_Form_Builder_Helper.create_element( 'span', dragClass, '<span class="wpbc_icn_drag_indicator"></span>' )
309 );
310 }
311
312 // SETTINGS button (shown for both fields & sections).
313 if ( !overlay.querySelector( '.wpbc_bfb__settings-btn' ) ) {
314 const settings_btn = Core.WPBC_Form_Builder_Helper.create_element( 'button', 'wpbc_bfb__settings-btn', '<i class="menu_icon icon-1x wpbc_icn_settings"></i>' );
315 settings_btn.type = 'button';
316 settings_btn.title = 'Open settings';
317 settings_btn.onclick = (e) => {
318 e.preventDefault();
319 // Select THIS element and scroll it into view.
320 builder.select_field( el, { scrollIntoView: true } );
321
322 // Auto-open Inspector from the overlay “Settings” button.
323 wpbc_bfb__dispatch_event_safe(
324 'wpbc_bfb:show_panel',
325 {
326 panel_id: 'wpbc_bfb__inspector',
327 tab_id : 'wpbc_tab_inspector'
328 }
329 );
330
331 // Try to bring the inspector into view / focus first input.
332 const ins = document.getElementById( 'wpbc_bfb__inspector' );
333 if ( ins ) {
334 ins.scrollIntoView( { behavior: 'smooth', block: 'nearest' } );
335 // Focus first interactive control (best-effort).
336 setTimeout( () => {
337 const focusable = ins.querySelector( 'input,select,textarea,button,[contenteditable],[tabindex]:not([tabindex="-1"])' );
338 focusable?.focus?.();
339 }, 260 );
340 }
341 };
342
343 overlay.appendChild( settings_btn );
344 }
345
346 overlay.setAttribute( 'role', 'toolbar' );
347 overlay.setAttribute( 'aria-label', el.classList.contains( 'wpbc_bfb__section' ) ? 'Section tools' : 'Field tools' );
348
349 return overlay;
350 }
351 };
352
353 /**
354 * WPBC Layout Chips helper - visual layout picker (chips), e.g., "50%/50%", to a section overlay.
355 *
356 * Renders Equal/Presets/Custom chips into a host container and wires them to apply the layout.
357 */
358 UI.WPBC_BFB_Layout_Chips = class {
359
360 /** Read per-column min (px) from CSS var set by the guard. */
361 static _get_col_min_px(col) {
362 const v = getComputedStyle( col ).getPropertyValue( '--wpbc-col-min' ) || '0';
363 const n = parseFloat( v );
364 return Number.isFinite( n ) ? Math.max( 0, n ) : 0;
365 }
366
367 /**
368 * Turn raw weights (e.g. [1,1], [2,1,1]) into effective "available-%" bases that
369 * (a) sum to the row's available %, and (b) meet every column's min px.
370 * Returns an array of bases (numbers) or null if impossible to satisfy mins.
371 */
372 static _fit_weights_respecting_min(builder, row, weights) {
373 const cols = Array.from( row.querySelectorAll( ':scope > .wpbc_bfb__column' ) );
374 const n = cols.length;
375 if ( !n ) return null;
376 if ( !Array.isArray( weights ) || weights.length !== n ) return null;
377
378 // available % after gaps (from LayoutService)
379 const gp = builder.col_gap_percent;
380 const eff = builder.layout.compute_effective_bases_from_row( row, gp );
381 const availPct = eff.available; // e.g. 94 if 2 cols and 3% gap
382 const rowPx = row.getBoundingClientRect().width;
383 const availPx = rowPx * (availPct / 100);
384
385 // collect minima in % of "available"
386 const minPct = cols.map( (c) => {
387 const minPx = UI.WPBC_BFB_Layout_Chips._get_col_min_px( c );
388 if ( availPx <= 0 ) return 0;
389 return (minPx / availPx) * availPct;
390 } );
391
392 // If mins alone don't fit, bail.
393 const sumMin = minPct.reduce( (a, b) => a + b, 0 );
394 if ( sumMin > availPct - 1e-6 ) {
395 return null; // impossible to respect mins; don't apply preset
396 }
397
398 // Target percentages from weights, normalized to availPct.
399 const wSum = weights.reduce( (a, w) => a + (Number( w ) || 0), 0 ) || n;
400 const targetPct = weights.map( (w) => ((Number( w ) || 0) / wSum) * availPct );
401
402 // Lock columns that would be below min, then distribute the remainder
403 // across the remaining columns proportionally to their targetPct.
404 const locked = new Array( n ).fill( false );
405 let lockedSum = 0;
406 for ( let i = 0; i < n; i++ ) {
407 if ( targetPct[i] < minPct[i] ) {
408 locked[i] = true;
409 lockedSum += minPct[i];
410 }
411 }
412
413 let remaining = availPct - lockedSum;
414 const freeIdx = [];
415 let freeTargetSum = 0;
416 for ( let i = 0; i < n; i++ ) {
417 if ( !locked[i] ) {
418 freeIdx.push( i );
419 freeTargetSum += targetPct[i];
420 }
421 }
422
423 const result = new Array( n ).fill( 0 );
424 // Seed locked with their minima.
425 for ( let i = 0; i < n; i++ ) {
426 if ( locked[i] ) result[i] = minPct[i];
427 }
428
429 if ( freeIdx.length === 0 ) {
430 // everything locked exactly at min; any leftover (shouldn't happen)
431 // would be ignored to keep simplicity and stability.
432 return result;
433 }
434
435 if ( remaining <= 0 ) {
436 // nothing left to distribute; keep exactly mins on locked,
437 // nothing for free (degenerate but consistent)
438 return result;
439 }
440
441 if ( freeTargetSum <= 0 ) {
442 // distribute equally among free columns
443 const each = remaining / freeIdx.length;
444 freeIdx.forEach( (i) => (result[i] = each) );
445 return result;
446 }
447
448 // Distribute remaining proportionally to free columns' targetPct
449 freeIdx.forEach( (i) => {
450 result[i] = remaining * (targetPct[i] / freeTargetSum);
451 } );
452 return result;
453 }
454
455 /** Apply a preset but guard it by minima; returns true if applied, false if skipped. */
456 static _apply_preset_with_min_guard(builder, section_el, weights) {
457 const row = section_el.querySelector( ':scope > .wpbc_bfb__row' );
458 if ( !row ) return false;
459
460 const fitted = UI.WPBC_BFB_Layout_Chips._fit_weights_respecting_min( builder, row, weights );
461 if ( !fitted ) {
462 builder?._announce?.( 'Not enough space for this layout because of fields’ minimum widths.' );
463 return false;
464 }
465
466 // `fitted` already sums to the row’s available %, so we can apply bases directly.
467 builder.layout.apply_bases_to_row( row, fitted );
468 return true;
469 }
470
471
472 /**
473 * Build and append layout chips for a section.
474 *
475 * @param {WPBC_Form_Builder} builder - The form builder instance.
476 * @param {HTMLElement} section_el - The .wpbc_bfb__section element.
477 * @param {HTMLElement} host_el - Container where chips should be rendered.
478 * @returns {void}
479 */
480 static render_for_section(builder, section_el, host_el) {
481
482 if ( !builder || !section_el || !host_el ) {
483 return;
484 }
485
486 const row = section_el.querySelector( ':scope > .wpbc_bfb__row' );
487 if ( !row ) {
488 return;
489 }
490
491 const cols = row.querySelectorAll( ':scope > .wpbc_bfb__column' ).length || 1;
492
493 // Clear host.
494 host_el.innerHTML = '';
495
496 // Equal chip.
497 host_el.appendChild(
498 UI.WPBC_BFB_Layout_Chips._make_chip( builder, section_el, Array( cols ).fill( 1 ), 'Equal' )
499 );
500
501 // Presets based on column count.
502 const presets = builder.layout.build_presets_for_columns( cols );
503 presets.forEach( (weights) => {
504 host_el.appendChild(
505 UI.WPBC_BFB_Layout_Chips._make_chip( builder, section_el, weights, null )
506 );
507 } );
508
509 // Custom chip.
510 const customBtn = document.createElement( 'button' );
511 customBtn.type = 'button';
512 customBtn.className = 'wpbc_bfb__layout_chip';
513 customBtn.textContent = 'Custom…';
514 customBtn.title = `Enter ${cols} percentages`;
515 customBtn.addEventListener( 'click', () => {
516 const example = (cols === 2) ? '50,50' : (cols === 3 ? '20,60,20' : '25,25,25,25');
517 const text = prompt( `Enter ${cols} percentages (comma or space separated):`, example );
518 if ( text == null ) return;
519 const weights = builder.layout.parse_weights( text );
520 if ( weights.length !== cols ) {
521 alert( `Please enter exactly ${cols} numbers.` );
522 return;
523 }
524 // OLD:
525 // builder.layout.apply_layout_preset( section_el, weights, builder.col_gap_percent );
526 // Guarded apply:.
527 if ( !UI.WPBC_BFB_Layout_Chips._apply_preset_with_min_guard( builder, section_el, weights ) ) {
528 return;
529 }
530 host_el.querySelectorAll( '.wpbc_bfb__layout_chip' ).forEach( c => c.classList.remove( 'is-active' ) );
531 customBtn.classList.add( 'is-active' );
532 } );
533 host_el.appendChild( customBtn );
534 }
535
536 /**
537 * Create a single layout chip button.
538 *
539 * @private
540 * @param {WPBC_Form_Builder} builder
541 * @param {HTMLElement} section_el
542 * @param {number[]} weights
543 * @param {string|null} label
544 * @returns {HTMLButtonElement}
545 */
546 static _make_chip(builder, section_el, weights, label = null) {
547
548 const btn = document.createElement( 'button' );
549 btn.type = 'button';
550 btn.className = 'wpbc_bfb__layout_chip';
551
552 const title = label || builder.layout.format_preset_label( weights );
553 btn.title = title;
554
555 // Visual miniature.
556 const vis = document.createElement( 'div' );
557 vis.className = 'wpbc_bfb__layout_chip-vis';
558 const sum = weights.reduce( (a, b) => a + (Number( b ) || 0), 0 ) || 1;
559 weights.forEach( (w) => {
560 const bar = document.createElement( 'span' );
561 bar.style.flex = `0 0 calc( ${((Number( w ) || 0) / sum * 100).toFixed( 3 )}% - 1.5px )`;
562 vis.appendChild( bar );
563 } );
564 btn.appendChild( vis );
565
566 const txt = document.createElement( 'span' );
567 txt.className = 'wpbc_bfb__layout_chip-label';
568 txt.textContent = label || builder.layout.format_preset_label( weights );
569 btn.appendChild( txt );
570
571 btn.addEventListener( 'click', () => {
572 // OLD:
573 // builder.layout.apply_layout_preset( section_el, weights, builder.col_gap_percent );
574
575 // NEW:
576 if ( !UI.WPBC_BFB_Layout_Chips._apply_preset_with_min_guard( builder, section_el, weights ) ) {
577 return; // do not toggle active if we didn't change layout
578 }
579
580 btn.parentElement?.querySelectorAll( '.wpbc_bfb__layout_chip' ).forEach( c => c.classList.remove( 'is-active' ) );
581 btn.classList.add( 'is-active' );
582 } );
583
584 return btn;
585 }
586 };
587
588 /**
589 * Selection controller for fields and announcements.
590 */
591 UI.WPBC_BFB_Selection_Controller = class extends UI.WPBC_BFB_Module {
592
593 init() {
594
595 this._selected_uid = null;
596 this.builder.select_field = this.select_field.bind( this );
597 this.builder.get_selected_field = this.get_selected_field.bind( this );
598 this._on_clear = this.on_clear.bind( this );
599
600 // Centralized delete command used by keyboard + inspector + overlay.
601 this.builder.delete_item = (el) => {
602 if ( !el ) {
603 return null;
604 }
605 const b = this.builder;
606 const neighbor = b._find_neighbor_selectable?.( el ) || null;
607 el.remove();
608 // Use local Core constants (not a global) to avoid ReferenceErrors.
609 b.bus?.emit?.( Core.WPBC_BFB_Events.FIELD_REMOVE, { el, id: el?.dataset?.id, uid: el?.dataset?.uid } );
610 b.usage?.update_palette_ui?.();
611 // Notify generic structure listeners, too:
612 b.bus?.emit?.( Core.WPBC_BFB_Events.STRUCTURE_CHANGE, { reason: 'delete', el } );
613 // Defer selection a tick so the DOM is fully settled before Inspector hydrates.
614 requestAnimationFrame( () => {
615 // This calls inspector.bind_to_field() and opens the Inspector panel.
616 b.select_field?.( neighbor || null, { scrollIntoView: !!neighbor } );
617 } );
618 return neighbor;
619 };
620 this.builder.bus.on( Core.WPBC_BFB_Events.CLEAR_SELECTION, this._on_clear );
621 this.builder.bus.on( Core.WPBC_BFB_Events.STRUCTURE_LOADED, this._on_clear );
622 // delegated click selection (capture ensures we win before bubbling to containers).
623 this._on_canvas_click = this._handle_canvas_click.bind( this );
624 this.builder.pages_container.addEventListener( 'click', this._on_canvas_click, true );
625 }
626
627 destroy() {
628 this.builder.bus.off( Core.WPBC_BFB_Events.CLEAR_SELECTION, this._on_clear );
629
630 if ( this._on_canvas_click ) {
631 this.builder.pages_container.removeEventListener( 'click', this._on_canvas_click, true );
632 this._on_canvas_click = null;
633 }
634 }
635
636 /**
637 * Delegated canvas click -> select closest field/section (inner beats outer).
638 * @private
639 * @param {MouseEvent} e
640 */
641 _handle_canvas_click(e) {
642 const root = this.builder.pages_container;
643 if ( !root ) return;
644
645 // Ignore clicks on controls/handles/resizers, etc.
646 const IGNORE = [
647 '.wpbc_bfb__overlay-controls',
648 '.wpbc_bfb__layout_picker',
649 '.wpbc_bfb__drag-handle',
650 '.wpbc_bfb__field-remove-btn',
651 '.wpbc_bfb__field-move-up',
652 '.wpbc_bfb__field-move-down',
653 '.wpbc_bfb__column-resizer'
654 ].join( ',' );
655
656 if ( e.target.closest( IGNORE ) ) {
657 return; // let those controls do their own thing.
658 }
659
660 // Find the closest selectable (field OR section) from the click target.
661 let hit = e.target.closest?.(
662 `${Core.WPBC_BFB_DOM.SELECTORS.validField}, ${Core.WPBC_BFB_DOM.SELECTORS.section}, .wpbc_bfb__column`
663 );
664
665 if ( !hit || !root.contains( hit ) ) {
666 this.select_field( null ); // Clear selection on blank click.
667 return; // Empty space is handled elsewhere.
668 }
669
670 // NEW: if user clicked a COLUMN -> remember tab key on its SECTION, but still select the section.
671 let preselect_tab_key = null;
672 if ( hit.classList.contains( 'wpbc_bfb__column' ) ) {
673 const row = hit.closest( '.wpbc_bfb__row' );
674 const cols = row ? Array.from( row.querySelectorAll( ':scope > .wpbc_bfb__column' ) ) : [];
675 const idx = Math.max( 0, cols.indexOf( hit ) );
676 const sec = hit.closest( '.wpbc_bfb__section' );
677 if ( sec ) {
678 preselect_tab_key = String( idx + 1 ); // tabs are 1-based in ui-column-styles.js
679 // Hint for the renderer (it reads this BEFORE rendering and restores the tab).
680 sec.dataset.col_styles_active_tab = preselect_tab_key;
681 // promote selection to the section (same UX as before).
682 hit = sec;
683 // NEW: visually mark which column is being edited
684 if ( UI && UI.WPBC_BFB_Column_Styles && UI.WPBC_BFB_Column_Styles.set_selected_col_flag ) {
685 UI.WPBC_BFB_Column_Styles.set_selected_col_flag( sec, preselect_tab_key );
686 }
687 }
688 }
689
690 // Select and stop bubbling so outer containers don’t reselect a parent.
691 this.select_field( hit );
692 e.stopPropagation();
693
694 // Also set the tab after the inspector renders (works even if it was already open).
695 if ( preselect_tab_key ) {
696 (window.requestAnimationFrame || setTimeout)( function () {
697 try {
698 const ins = document.getElementById( 'wpbc_bfb__inspector' );
699 const tabs = ins && ins.querySelector( '[data-bfb-slot="column_styles"] [data-wpbc-tabs]' );
700 if ( tabs && window.wpbc_ui_tabs && typeof window.wpbc_ui_tabs.set_active === 'function' ) {
701 window.wpbc_ui_tabs.set_active( tabs, preselect_tab_key );
702 }
703 } catch ( _e ) {
704 }
705 }, 0 );
706
707 // Politely ask the Inspector to focus/open the "Column Styles" group and tab.
708 wpbc_bfb__dispatch_event_safe(
709 'wpbc_bfb:inspector_focus',
710 {
711 group : 'column_styles',
712 tab_key: preselect_tab_key
713 }
714 );
715 }
716 }
717
718
719 /**
720 * Select a field element or clear selection.
721 *
722 * @param {HTMLElement|null} field_el
723 * @param {{scrollIntoView?: boolean}} [opts = {}]
724 */
725 select_field(field_el, { scrollIntoView = false } = {}) {
726 const root = this.builder.pages_container;
727 const prevEl = this.get_selected_field?.() || null; // the one we’re leaving.
728
729 // Ignore elements not in the canvas.
730 if ( field_el && !root.contains( field_el ) ) {
731 field_el = null; // treat as "no selection".
732 }
733
734 // NEW: if we are leaving a section, clear its column highlight
735 if (
736 prevEl && prevEl !== field_el &&
737 prevEl.classList?.contains( 'wpbc_bfb__section' ) &&
738 UI?.WPBC_BFB_Column_Styles?.clear_selected_col_flag
739 ) {
740 UI.WPBC_BFB_Column_Styles.clear_selected_col_flag( prevEl );
741 }
742
743 // If we're leaving a field, permanently stop auto-name for it.
744 if ( prevEl && prevEl !== field_el && prevEl.classList?.contains( 'wpbc_bfb__field' ) ) {
745 prevEl.dataset.autoname = '0';
746 prevEl.dataset.fresh = '0';
747 }
748
749 root.querySelectorAll( '.is-selected' ).forEach( (n) => {
750 n.classList.remove( 'is-selected' );
751 } );
752 if ( !field_el ) {
753 const prev = this._selected_uid || null;
754 this._selected_uid = null;
755 this.builder.inspector?.clear?.();
756 root.classList.remove( 'has-selection' );
757 this.builder.bus.emit( Core.WPBC_BFB_Events.CLEAR_SELECTION, { prev_uid: prev, source: 'builder' } );
758
759 // Auto-open "Add Fields" when nothing is selected.
760 wpbc_bfb__dispatch_event_safe(
761 'wpbc_bfb:show_panel',
762 {
763 panel_id: 'wpbc_bfb__palette_add_new',
764 tab_id : 'wpbc_tab_library'
765 }
766 );
767
768 return;
769 }
770 field_el.classList.add( 'is-selected' );
771 this._selected_uid = field_el.getAttribute( 'data-uid' ) || null;
772
773 // Fallback: ensure sections announce themselves as type="section".
774 if ( field_el.classList.contains( 'wpbc_bfb__section' ) && !field_el.dataset.type ) {
775 field_el.dataset.type = 'section';
776 }
777
778 if ( scrollIntoView ) {
779 field_el.scrollIntoView( { behavior: 'smooth', block: 'center' } );
780 }
781 this.builder.inspector?.bind_to_field?.( field_el );
782
783 // Fallback: ensure inspector enhancers (incl. ValueSlider) run every bind.
784 try {
785 const ins = document.getElementById( 'wpbc_bfb__inspector' )
786 || document.querySelector( '.wpbc_bfb__inspector' );
787 if ( ins ) {
788 UI.InspectorEnhancers?.scan?.( ins ); // runs all enhancers
789 UI.WPBC_BFB_ValueSlider?.init_on?.( ins ); // extra belt-and-suspenders
790 }
791 } catch ( _ ) {
792 }
793
794 // NEW: when selecting a section, reflect its active tab as the highlighted column.
795 if ( field_el.classList.contains( 'wpbc_bfb__section' ) &&
796 UI?.WPBC_BFB_Column_Styles?.set_selected_col_flag ) {
797 var k = (field_el.dataset && field_el.dataset.col_styles_active_tab)
798 ? field_el.dataset.col_styles_active_tab : '1';
799 UI.WPBC_BFB_Column_Styles.set_selected_col_flag( field_el, k );
800 }
801
802 // Keep sections & fields in the same flow:
803 // 1) Generic hydrator for simple dataset-backed controls.
804 if ( field_el ) {
805 UI.WPBC_BFB_Inspector_Bridge._generic_hydrate_controls?.( this.builder, field_el );
806 UI.WPBC_BFB_Inspector_Bridge._hydrate_special_controls?.( this.builder, field_el );
807 }
808
809 // Auto-open Inspector when a user selects a field/section .
810 wpbc_bfb__dispatch_event_safe(
811 'wpbc_bfb:show_panel',
812 {
813 panel_id: 'wpbc_bfb__inspector',
814 tab_id : 'wpbc_tab_inspector'
815 }
816 );
817
818 root.classList.add( 'has-selection' );
819 this.builder.bus.emit( Core.WPBC_BFB_Events.SELECT, { uid: this._selected_uid, el: field_el } );
820 const label = field_el?.querySelector( '.wpbc_bfb__field-label' )?.textContent || (field_el.classList.contains( 'wpbc_bfb__section' ) ? 'section' : '') || field_el?.dataset?.id || 'item';
821 this.builder._announce( 'Selected ' + label + '.' );
822 }
823
824 /** @returns {HTMLElement|null} */
825 get_selected_field() {
826 if ( !this._selected_uid ) {
827 return null;
828 }
829 const esc_attr = Core.WPBC_BFB_Sanitize.esc_attr_value_for_selector( this._selected_uid );
830 return this.builder.pages_container.querySelector( `.wpbc_bfb__field[data-uid="${esc_attr}"], .wpbc_bfb__section[data-uid="${esc_attr}"]` );
831 }
832
833 /** @param {CustomEvent} ev */
834 on_clear(ev) {
835 const src = ev?.detail?.source ?? ev?.source;
836 if ( src !== 'builder' ) {
837 this.select_field( null );
838 }
839 }
840
841 };
842
843 /**
844 * Bridges the builder with the Inspector and sanitizes id/name edits.
845 */
846 UI.WPBC_BFB_Inspector_Bridge = class extends UI.WPBC_BFB_Module {
847
848 init() {
849 this._attach_inspector();
850 this._bind_id_sanitizer();
851 this._open_inspector_after_field_added();
852 this._bind_focus_shortcuts();
853 }
854
855 _attach_inspector() {
856 const b = this.builder;
857 const attach = () => {
858 if ( typeof window.WPBC_BFB_Inspector === 'function' ) {
859 b.inspector = new WPBC_BFB_Inspector( document.getElementById( 'wpbc_bfb__inspector' ), b );
860 this._bind_id_sanitizer();
861 document.removeEventListener( 'wpbc_bfb_inspector_ready', attach );
862 }
863 };
864 // Ensure we bind after late ready as well.
865 if ( typeof window.WPBC_BFB_Inspector === 'function' ) {
866 attach();
867 } else {
868 b.inspector = {
869 bind_to_field() {
870 }, clear() {
871 }
872 };
873 document.addEventListener( 'wpbc_bfb_inspector_ready', attach );
874 setTimeout( attach, 0 );
875 }
876 }
877
878 /**
879 * Listen for "focus" hints from the canvas and open the right group/tab.
880 * - Supports: group === 'column_styles'
881 * - Also scrolls the group into view.
882 */
883 _bind_focus_shortcuts() {
884 /** @param {CustomEvent} e */
885 const on_focus = (e) => {
886 try {
887 const grp_key = e && e.detail && e.detail.group;
888 const tab_key = e && e.detail && e.detail.tab_key;
889 if ( !grp_key ) {
890 return;
891 }
892
893 const ins = document.getElementById( 'wpbc_bfb__inspector' ) || document.querySelector( '.wpbc_bfb__inspector' );
894 if ( !ins ) {
895 return;
896 }
897
898 if ( grp_key === 'column_styles' ) {
899 // Find the Column Styles slot/group.
900 const slot = ins.querySelector( '[data-bfb-slot="column_styles"]' ) || ins.querySelector( '[data-inspector-group-key="column_styles"]' );
901 if ( slot ) {
902 // Open collapsible container if present.
903 const group_wrap = slot.closest( '.inspector__group' ) || slot.closest( '[data-inspector-group]' );
904 if ( group_wrap && !group_wrap.classList.contains( 'is-open' ) ) {
905 group_wrap.classList.add( 'is-open' );
906 // Mirror ARIA state if your header uses aria-expanded.
907 const header_btn = group_wrap.querySelector( '[aria-expanded]' );
908 if ( header_btn ) {
909 header_btn.setAttribute( 'aria-expanded', 'true' );
910 }
911 }
912
913 // Optional: set the requested tab key if tabs exist in this group.
914 if ( tab_key ) {
915 const tabs = slot.querySelector( '[data-wpbc-tabs]' );
916 if ( tabs && window.wpbc_ui_tabs && typeof window.wpbc_ui_tabs.set_active === 'function' ) {
917 window.wpbc_ui_tabs.set_active( tabs, String( tab_key ) );
918 }
919 }
920
921 // Bring into view for convenience.
922 try {
923 // Uncomment (Only if needed) this to AUTO SCROLL to specific COLUMN in the section:.
924 // slot.scrollIntoView( { behavior: 'smooth', block: 'nearest' } );
925 } catch ( _e ) {}
926 }
927 }
928 } catch ( _e ) {}
929 };
930
931 this._on_inspector_focus = on_focus;
932 document.addEventListener( 'wpbc_bfb:inspector_focus', on_focus, true );
933 }
934
935 destroy() {
936 try {
937 if ( this._on_inspector_focus ) {
938 document.removeEventListener( 'wpbc_bfb:inspector_focus', this._on_inspector_focus, true );
939 this._on_inspector_focus = null;
940 }
941 } catch ( _e ) {
942 }
943 }
944
945
946 /**
947 * Hydrate inspector inputs for "special" keys that we handle explicitly.
948 * Works for both fields and sections.
949 * @param {WPBC_Form_Builder} builder
950 * @param {HTMLElement} sel
951 */
952 static _hydrate_special_controls(builder, sel) {
953 const ins = document.getElementById( 'wpbc_bfb__inspector' );
954 if ( !ins || !sel ) return;
955
956 const setVal = (key, val) => {
957 const ctrl = ins.querySelector( `[data-inspector-key="${key}"]` );
958 if ( ctrl && 'value' in ctrl ) ctrl.value = String( val ?? '' );
959 };
960
961 // Internal id / name / public html_id.
962 setVal( 'id', sel.getAttribute( 'data-id' ) || '' );
963 setVal( 'name', sel.getAttribute( 'data-name' ) || '' );
964 setVal( 'html_id', sel.getAttribute( 'data-html_id' ) || '' );
965
966 // Section-only extras are harmless to set for fields (controls may not exist).
967 setVal( 'cssclass', sel.getAttribute( 'data-cssclass' ) || '' );
968 setVal( 'label', sel.getAttribute( 'data-label' ) || '' );
969 }
970
971
972 /**
973 * Hydrate inspector inputs that declare a generic dataset mapping via
974 * [data-inspector-key] but do NOT declare a custom value_from adapter.
975 * This makes sections follow the same data flow as fields with almost no glue.
976 *
977 * @param {WPBC_Form_Builder} builder
978 * @param {HTMLElement} sel - currently selected field/section
979 */
980 static _generic_hydrate_controls(builder, sel) {
981 const ins = document.getElementById( 'wpbc_bfb__inspector' );
982 if ( !ins || !sel ) return;
983
984 const SKIP = /^(id|name|html_id|cssclass|label)$/; // handled by _hydrate_special_controls
985
986 // NEW: read schema for the selected element’s type.
987 const schemas = window.WPBC_BFB_Schemas || {};
988 const typeKey = (sel.dataset && sel.dataset.type) || '';
989 const schemaEntry = schemas[typeKey] || null;
990 const propsSchema = (schemaEntry && schemaEntry.schema && schemaEntry.schema.props) ? schemaEntry.schema.props : {};
991 const hasOwn = Function.prototype.call.bind( Object.prototype.hasOwnProperty );
992 const getDefault = (key) => {
993 const meta = propsSchema[key];
994 return (meta && hasOwn( meta, 'default' )) ? meta.default : undefined;
995 };
996
997 ins.querySelectorAll( '[data-inspector-key]' ).forEach( (ctrl) => {
998 const key = String( ctrl.dataset?.inspectorKey || '' ).toLowerCase();
999 if ( !key || SKIP.test( key ) ) return;
1000
1001 // Element-level lock.
1002 const dl = (ctrl.dataset?.locked || '').trim().toLowerCase();
1003 if ( dl === '1' || dl === 'true' || dl === 'yes' ) return;
1004
1005 // Respect explicit adapters.
1006 if ( ctrl.dataset?.value_from || ctrl.dataset?.valueFrom ) return;
1007
1008 const raw = sel.dataset ? sel.dataset[key] : undefined;
1009 const hasRaw = sel.dataset ? hasOwn( sel.dataset, key ) : false;
1010 const defValue = getDefault( key );
1011
1012 // Best-effort control typing with schema default fallback when value is absent.
1013
1014 if ( ctrl instanceof HTMLInputElement && (ctrl.type === 'checkbox' || ctrl.type === 'radio') ) {
1015 // If dataset is missing the key entirely -> use schema default (boolean).
1016 if ( !hasRaw ) {
1017 ctrl.checked = !!defValue;
1018 } else {
1019 ctrl.checked = Core.WPBC_BFB_Sanitize.coerce_boolean( raw, !!defValue );
1020 }
1021 } else if ( 'value' in ctrl ) {
1022 if ( hasRaw ) {
1023 ctrl.value = (raw != null) ? String( raw ) : '';
1024 } else {
1025 ctrl.value = (defValue == null) ? '' : String( defValue );
1026 }
1027 }
1028 } );
1029 }
1030
1031 _bind_id_sanitizer() {
1032 const b = this.builder;
1033 const ins = document.getElementById( 'wpbc_bfb__inspector' );
1034 if ( ! ins ) {
1035 return;
1036 }
1037 if ( ins.__wpbc_bfb_id_sanitizer_bound ) {
1038 return;
1039 }
1040 ins.__wpbc_bfb_id_sanitizer_bound = true;
1041
1042 const handler = (e) => {
1043
1044 const t = e.target;
1045 if ( !t || !('value' in t) ) {
1046 return;
1047 }
1048 const key = (t.dataset?.inspectorKey || '').toLowerCase();
1049 const sel = b.get_selected_field?.();
1050 const isSection = sel?.classList?.contains( 'wpbc_bfb__section' );
1051 if ( !sel ) return;
1052
1053 // Unified emitter that always includes the element reference.
1054 const EV = Core.WPBC_BFB_Events;
1055 // STRUCTURE_CHANGE can be "expensive" because other listeners may trigger full canvas refresh.
1056 // Debounce only continuous controls (e.g. value slider scrubbing) on the INPUT phase.
1057 const ensure_sc_debounce_state = () => {
1058 if ( b.__wpbc_bfb_sc_debounce_state ) {
1059 return b.__wpbc_bfb_sc_debounce_state;
1060 }
1061 b.__wpbc_bfb_sc_debounce_state = { timer_id: 0, pending_payload: null };
1062 return b.__wpbc_bfb_sc_debounce_state;
1063 };
1064
1065 const cancel_sc_debounced_emit = () => {
1066 const st = b.__wpbc_bfb_sc_debounce_state;
1067 if ( !st ) return;
1068 try { clearTimeout( st.timer_id ); } catch ( _ ) {}
1069 st.timer_id = 0;
1070 st.pending_payload = null;
1071 };
1072
1073 const bus_emit_change = (reason, extra = {}) => {
1074 // If we’re committing something (change/blur/etc), drop any pending "input" emit.
1075 cancel_sc_debounced_emit();
1076 b.bus?.emit?.( EV.STRUCTURE_CHANGE, { reason, el: sel, ...extra } );
1077 };
1078
1079 const bus_emit_change_debounced = (reason, extra = {}, wait_ms) => {
1080 const st = ensure_sc_debounce_state();
1081 const ms = Number.isFinite( wait_ms )
1082 ? wait_ms
1083 : (Number.isFinite( UI.STRUCTURE_CHANGE_DEBOUNCE_MS ) ? UI.STRUCTURE_CHANGE_DEBOUNCE_MS : 240);
1084
1085 // Capture the CURRENT selected element into the payload now (stable ref).
1086 st.pending_payload = { reason, el: sel, ...extra, debounced: true };
1087
1088 try { clearTimeout( st.timer_id ); } catch ( _ ) {}
1089 st.timer_id = setTimeout( function () {
1090 st.timer_id = 0;
1091 const payload = st.pending_payload;
1092 st.pending_payload = null;
1093 if ( payload ) {
1094 b.bus?.emit?.( EV.STRUCTURE_CHANGE, payload );
1095 }
1096 }, ms );
1097 };
1098
1099 // ---- FIELD/SECTION: internal id ----
1100 if ( key === 'id' ) {
1101 const unique = b.id.set_field_id( sel, t.value );
1102 if ( b.preview_mode && !isSection ) {
1103 b.render_preview( sel );
1104 }
1105 if ( t.value !== unique ) {
1106 t.value = unique;
1107 }
1108 bus_emit_change( 'id-change' );
1109 return;
1110 }
1111
1112 // ---- FIELD/SECTION: public HTML id ----
1113 if ( key === 'html_id' ) {
1114 const applied = b.id.set_field_html_id( sel, t.value );
1115 // For sections, also set the real DOM id so anchors/CSS can target it.
1116 if ( isSection ) {
1117 sel.id = applied || '';
1118 } else if ( b.preview_mode ) {
1119 b.render_preview( sel );
1120 }
1121 if ( t.value !== applied ) {
1122 t.value = applied;
1123 }
1124 bus_emit_change( 'html-id-change' );
1125 return;
1126 }
1127
1128 // ---- FIELDS ONLY: name ----
1129 if ( key === 'name' && !isSection ) {
1130
1131 // Live typing: sanitize only (NO uniqueness yet) to avoid "-2" spam
1132 if ( e.type === 'input' ) {
1133 const before = t.value;
1134 const sanitized = Core.WPBC_BFB_Sanitize.sanitize_html_name( before );
1135 if ( before !== sanitized ) {
1136 // optional: preserve caret to avoid jump
1137 const selStart = t.selectionStart, selEnd = t.selectionEnd;
1138 t.value = sanitized;
1139 try {
1140 t.setSelectionRange( selStart, selEnd );
1141 } catch ( _ ) {
1142 }
1143 }
1144 return; // uniqueness on change/blur
1145 }
1146
1147 // Commit (change/blur)
1148 const raw = String( t.value ?? '' ).trim();
1149
1150 if ( !raw ) {
1151 // RESEED: keep name non-empty and provisional (autoname stays ON)
1152 const S = Core.WPBC_BFB_Sanitize;
1153 const base = S.sanitize_html_name( sel.getAttribute( 'data-id' ) || sel.dataset.id || sel.dataset.type || 'field' );
1154 const uniq = b.id.ensure_unique_field_name( base, sel );
1155
1156 sel.setAttribute( 'data-name', uniq );
1157 sel.dataset.autoname = '1';
1158 sel.dataset.name_user_touched = '0';
1159
1160 // Keep DOM in sync if we’re not re-rendering
1161 if ( !b.preview_mode ) {
1162 const ctrl = sel.querySelector( 'input,textarea,select' );
1163 if ( ctrl ) ctrl.setAttribute( 'name', uniq );
1164 } else {
1165 b.render_preview( sel );
1166 }
1167
1168 if ( t.value !== uniq ) t.value = uniq;
1169 bus_emit_change( 'name-reseed' );
1170 return;
1171 }
1172
1173 // Non-empty commit: user takes control; disable autoname going forward
1174 sel.dataset.name_user_touched = '1';
1175 sel.dataset.autoname = '0';
1176
1177 const sanitized = Core.WPBC_BFB_Sanitize.sanitize_html_name( raw );
1178 const unique = b.id.set_field_name( sel, sanitized );
1179
1180 if ( !b.preview_mode ) {
1181 const ctrl = sel.querySelector( 'input,textarea,select' );
1182 if ( ctrl ) ctrl.setAttribute( 'name', unique );
1183 } else {
1184 b.render_preview( sel );
1185 }
1186
1187 if ( t.value !== unique ) t.value = unique;
1188 bus_emit_change( 'name-change' );
1189 return;
1190 }
1191
1192 // ---- SECTIONS & FIELDS: cssclass (live apply; no re-render) ----
1193 if ( key === 'cssclass' ) {
1194 const next = Core.WPBC_BFB_Sanitize.sanitize_css_classlist( t.value || '' );
1195 const desiredArr = next.split( /\s+/ ).filter( Boolean );
1196 const desiredSet = new Set( desiredArr );
1197
1198 // Core classes are never touched.
1199 const isCore = (cls) => cls === 'is-selected' || cls.startsWith( 'wpbc_' );
1200
1201 // Snapshot before mutating (DOMTokenList is live).
1202 const beforeClasses = Array.from( sel.classList );
1203 const customBefore = beforeClasses.filter( (c) => !isCore( c ) );
1204
1205 // Remove stray non-core classes not in desired.
1206 customBefore.forEach( (c) => {
1207 if ( !desiredSet.has( c ) ) sel.classList.remove( c );
1208 } );
1209
1210 // Add missing desired classes in one go.
1211 const missing = desiredArr.filter( (c) => !customBefore.includes( c ) );
1212 if ( missing.length ) sel.classList.add( ...missing );
1213
1214 // Keep dataset in sync (avoid useless attribute writes).
1215 if ( sel.getAttribute( 'data-cssclass' ) !== next ) {
1216 sel.setAttribute( 'data-cssclass', next );
1217 }
1218
1219 // Emit only if something actually changed.
1220 const afterClasses = Array.from( sel.classList );
1221 const changed = afterClasses.length !== beforeClasses.length || beforeClasses.some( (c, i) => c !== afterClasses[i] );
1222
1223 const detail = { key: 'cssclass', phase: e.type };
1224 if ( isSection ) {
1225 bus_emit_change( 'cssclass-change', detail );
1226 } else {
1227 bus_emit_change( 'prop-change', detail );
1228 }
1229 return;
1230 }
1231
1232
1233 // ---- SECTIONS: label ----
1234 if ( isSection && key === 'label' ) {
1235 const val = String( t.value ?? '' );
1236 sel.setAttribute( 'data-label', val );
1237 bus_emit_change( 'label-change' );
1238 return;
1239 }
1240
1241 // ---- FIELDS: label (auto-name while typing; freeze on commit) ----
1242 if ( !isSection && key === 'label' ) {
1243 const val = String( t.value ?? '' );
1244 sel.dataset.label = val;
1245
1246 // while typing, allow auto-name (if flags permit)
1247 try {
1248 Core.WPBC_BFB_Field_Base.maybe_autoname_from_label( b, sel, val );
1249 } catch ( _ ) {
1250 }
1251
1252 // if user committed the label (blur/change), freeze future auto-name
1253 if ( e.type !== 'input' ) {
1254 sel.dataset.autoname = '0'; // stop future label->name sync
1255 sel.dataset.fresh = '0'; // also kill the "fresh" escape hatch
1256 }
1257
1258 // Optional UI nicety: disable Name when auto is ON, enable when OFF
1259 const ins = document.getElementById( 'wpbc_bfb__inspector' );
1260 const nameCtrl = ins?.querySelector( '[data-inspector-key="name"]' );
1261 if ( nameCtrl ) {
1262 const autoActive =
1263 (sel.dataset.autoname ?? '1') !== '0' &&
1264 sel.dataset.name_user_touched !== '1' &&
1265 sel.dataset.was_loaded !== '1';
1266 nameCtrl.toggleAttribute( 'disabled', autoActive );
1267 if ( autoActive && !nameCtrl.placeholder ) {
1268 nameCtrl.placeholder = b?.i18n?.auto_from_label ?? 'auto — from label';
1269 }
1270 if ( !autoActive && nameCtrl.placeholder === (b?.i18n?.auto_from_label ?? 'auto — from label') ) {
1271 nameCtrl.placeholder = '';
1272 }
1273 }
1274
1275 // Always re-render the preview so label changes are visible immediately.
1276 b.render_preview( sel );
1277 bus_emit_change( 'label-change' );
1278 return;
1279 }
1280
1281
1282 // ---- DEFAULT (GENERIC): dataset writer for both fields & sections ----
1283 // Any inspector control with [data-inspector-key] that doesn't have a custom
1284 // adapter/value_from will simply read/write sel.dataset[key].
1285 if ( key ) {
1286
1287 const selfLocked = /^(1|true|yes)$/i.test( (t.dataset?.locked || '').trim() );
1288 if ( selfLocked ) {
1289 return;
1290 }
1291
1292 // Skip keys we handled above to avoid double work.
1293 if ( key === 'id' || key === 'name' || key === 'html_id' || key === 'cssclass' || key === 'label' ) {
1294 return;
1295 }
1296 let nextVal = '';
1297 if ( t instanceof HTMLInputElement && (t.type === 'checkbox' || t.type === 'radio') ) {
1298 nextVal = t.checked ? '1' : '';
1299 } else if ( 'value' in t ) {
1300 nextVal = String( t.value ?? '' );
1301 }
1302 // Persist to dataset.
1303 if ( sel?.dataset ) sel.dataset[key] = nextVal;
1304
1305 // Generator controls are "UI inputs" — avoid STRUCTURE_CHANGE spam while dragging/typing.
1306 const is_gen_key = (key.indexOf( 'gen_' ) === 0);
1307
1308 // Re-render on visual keys so preview stays in sync (calendar label/help, etc.).
1309 const visualKeys = new Set( [ 'help', 'placeholder', 'min_width', 'cssclass' ] );
1310 if ( !isSection && (visualKeys.has( key ) || key.startsWith( 'ui_' )) ) {
1311 // Light heuristic: only re-render on commit for heavy inputs; live for short ones is fine.
1312 if ( e.type === 'change' || key === 'help' || key === 'placeholder' ) {
1313 b.render_preview( sel );
1314 }
1315 }
1316
1317 if ( !(is_gen_key && e.type === 'input') ) {
1318 // Debounce continuous value slider input events to avoid full-canvas refresh spam.
1319 // We detect the slider group via [data-len-group] wrapper.
1320 const is_len_group_ctrl = !!(t && t.closest && t.closest( '[data-len-group]' ));
1321
1322 if ( is_len_group_ctrl && e.type === 'input' ) {
1323 bus_emit_change_debounced( 'prop-change', { key, phase: e.type } );
1324 } else {
1325 bus_emit_change( 'prop-change', { key, phase: e.type } );
1326 }
1327 }
1328 return;
1329 }
1330 };
1331
1332 ins.addEventListener( 'change', handler, true );
1333 // reflect instantly while typing as well.
1334 ins.addEventListener( 'input', handler, true );
1335 }
1336
1337 /**
1338 * Open Inspector after a field is added.
1339 * @private
1340 */
1341 _open_inspector_after_field_added() {
1342 const EV = Core.WPBC_BFB_Events;
1343 this.builder?.bus?.on?.( EV.FIELD_ADD, (e) => {
1344 const el = e?.detail?.el || null;
1345 if ( el && this.builder?.select_field ) {
1346 this.builder.select_field( el, { scrollIntoView: true } );
1347 }
1348 // Show Inspector Palette.
1349 wpbc_bfb__dispatch_event_safe(
1350 'wpbc_bfb:show_panel',
1351 {
1352 panel_id: 'wpbc_bfb__inspector',
1353 tab_id : 'wpbc_tab_inspector'
1354 }
1355 );
1356 } );
1357 }
1358 };
1359
1360 /**
1361 * Keyboard shortcuts for selection, deletion, and movement.
1362 */
1363 UI.WPBC_BFB_Keyboard_Controller = class extends UI.WPBC_BFB_Module {
1364 init() {
1365 this._on_key = this.on_key.bind( this );
1366 document.addEventListener( 'keydown', this._on_key, true );
1367 }
1368
1369 destroy() {
1370 document.removeEventListener( 'keydown', this._on_key, true );
1371 }
1372
1373 /** @param {KeyboardEvent} e */
1374 on_key(e) {
1375 const b = this.builder;
1376 const is_typing = this._is_typing_anywhere();
1377 if ( e.key === 'Escape' ) {
1378 if ( is_typing ) {
1379 return;
1380 }
1381 this.builder.bus.emit( Core.WPBC_BFB_Events.CLEAR_SELECTION, { source: 'esc' } );
1382 return;
1383 }
1384 const selected = b.get_selected_field?.();
1385 if ( !selected || is_typing ) {
1386 return;
1387 }
1388 if ( e.key === 'Delete' || e.key === 'Backspace' ) {
1389 e.preventDefault();
1390 b.delete_item?.( selected );
1391 return;
1392 }
1393 if ( (e.altKey || e.ctrlKey || e.metaKey) && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.shiftKey ) {
1394 e.preventDefault();
1395 const dir = (e.key === 'ArrowUp') ? 'up' : 'down';
1396 b.move_item?.( selected, dir );
1397 return;
1398 }
1399 if ( e.key === 'Enter' ) {
1400 e.preventDefault();
1401 b.select_field( selected, { scrollIntoView: true } );
1402 }
1403 }
1404
1405 /** @returns {boolean} */
1406 _is_typing_anywhere() {
1407 const a = document.activeElement;
1408 const tag = a?.tagName;
1409 if ( tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (a?.isContentEditable === true) ) {
1410 return true;
1411 }
1412 const ins = document.getElementById( 'wpbc_bfb__inspector' );
1413 return !!(ins && a && ins.contains( a ));
1414 }
1415 };
1416
1417 /**
1418 * Column resize logic for section rows.
1419 */
1420 UI.WPBC_BFB_Resize_Controller = class extends UI.WPBC_BFB_Module {
1421 init() {
1422 this.builder.init_resize_handler = this.handle_resize.bind( this );
1423 }
1424
1425 /**
1426 * read the CSS var (kept local so it doesn’t depend on the Min-Width module)
1427 *
1428 * @param col
1429 * @returns {number|number}
1430 * @private
1431 */
1432 _get_col_min_px(col) {
1433 const v = getComputedStyle( col ).getPropertyValue( '--wpbc-col-min' ) || '0';
1434 const n = parseFloat( v );
1435 return Number.isFinite( n ) ? Math.max( 0, n ) : 0;
1436 }
1437
1438 /** @param {MouseEvent} e */
1439 handle_resize(e) {
1440 const b = this.builder;
1441 e.preventDefault();
1442 if ( e.button !== 0 ) return;
1443
1444 const resizer = e.currentTarget;
1445 const row_el = resizer.parentElement;
1446 const cols = Array.from( row_el.querySelectorAll( ':scope > .wpbc_bfb__column' ) );
1447 const left_col = resizer?.previousElementSibling;
1448 const right_col = resizer?.nextElementSibling;
1449 if ( !left_col || !right_col || !left_col.classList.contains( 'wpbc_bfb__column' ) || !right_col.classList.contains( 'wpbc_bfb__column' ) ) return;
1450
1451 const left_index = cols.indexOf( left_col );
1452 const right_index = cols.indexOf( right_col );
1453 if ( left_index === -1 || right_index !== left_index + 1 ) return;
1454
1455 const start_x = e.clientX;
1456 const left_start_px = left_col.getBoundingClientRect().width;
1457 const right_start_px = right_col.getBoundingClientRect().width;
1458 const pair_px = Math.max( 0, left_start_px + right_start_px );
1459
1460 const gp = b.col_gap_percent;
1461 const computed = b.layout.compute_effective_bases_from_row( row_el, gp );
1462 const available = computed.available; // % of the “full 100” after gaps
1463 const bases = computed.bases.slice( 0 ); // current effective %
1464 const pair_avail = bases[left_index] + bases[right_index];
1465
1466 // Bail if we can’t compute sane deltas.
1467 if (!pair_px || !Number.isFinite(pair_avail) || pair_avail <= 0) return;
1468
1469 // --- MIN CLAMPS (pixels) -------------------------------------------------
1470 const pctToPx = (pct) => (pair_px * (pct / pair_avail)); // pair-local percent -> px
1471 const genericMinPct = Math.min( 0.1, available ); // original 0.1% floor (in “available %” space)
1472 const genericMinPx = pctToPx( genericMinPct );
1473
1474 const leftMinPx = Math.max( this._get_col_min_px( left_col ), genericMinPx );
1475 const rightMinPx = Math.max( this._get_col_min_px( right_col ), genericMinPx );
1476
1477 // freeze text selection + cursor
1478 const prev_user_select = document.body.style.userSelect;
1479 document.body.style.userSelect = 'none';
1480 row_el.style.cursor = 'col-resize';
1481
1482 const on_mouse_move = (ev) => {
1483 if ( !pair_px ) return;
1484
1485 // work in pixels, clamp by each side’s min
1486 const delta_px = ev.clientX - start_x;
1487 let newLeftPx = left_start_px + delta_px;
1488 newLeftPx = Math.max( leftMinPx, Math.min( pair_px - rightMinPx, newLeftPx ) );
1489 const newRightPx = pair_px - newLeftPx;
1490
1491 // translate back to pair-local percentages
1492 const newLeftPct = (newLeftPx / pair_px) * pair_avail;
1493 const newBases = bases.slice( 0 );
1494 newBases[left_index] = newLeftPct;
1495 newBases[right_index] = pair_avail - newLeftPct;
1496
1497 b.layout.apply_bases_to_row( row_el, newBases );
1498 };
1499
1500 const on_mouse_up = () => {
1501 document.removeEventListener( 'mousemove', on_mouse_move );
1502 document.removeEventListener( 'mouseup', on_mouse_up );
1503 window.removeEventListener( 'mouseup', on_mouse_up );
1504 document.removeEventListener( 'mouseleave', on_mouse_up );
1505 document.body.style.userSelect = prev_user_select || '';
1506 row_el.style.cursor = '';
1507
1508 // normalize to the row’s available % again
1509 const normalized = b.layout.compute_effective_bases_from_row( row_el, gp );
1510 b.layout.apply_bases_to_row( row_el, normalized.bases );
1511 };
1512
1513 document.addEventListener( 'mousemove', on_mouse_move );
1514 document.addEventListener( 'mouseup', on_mouse_up );
1515 window.addEventListener( 'mouseup', on_mouse_up );
1516 document.addEventListener( 'mouseleave', on_mouse_up );
1517 }
1518
1519 };
1520
1521 /**
1522 * Page and section creation, rebuilding, and nested Sortable setup.
1523 */
1524 UI.WPBC_BFB_Pages_Sections = class extends UI.WPBC_BFB_Module {
1525
1526 init() {
1527 this.builder.add_page = (opts) => this.add_page( opts );
1528 this.builder.add_section = (container, cols) => this.add_section( container, cols );
1529 this.builder.rebuild_section = (section_data, container) => this.rebuild_section( section_data, container );
1530 this.builder.init_all_nested_sortables = (el) => this.init_all_nested_sortables( el );
1531 this.builder.init_section_sortable = (el) => this.init_section_sortable( el );
1532 this.builder.pages_sections = this;
1533 }
1534
1535 /**
1536 * Give every field/section in a cloned subtree a fresh data-uid so
1537 * uniqueness checks don't exclude their originals.
1538 */
1539 _retag_uids_in_subtree(root) {
1540 const b = this.builder;
1541 if ( !root ) return;
1542 const nodes = [];
1543 if ( root.classList?.contains( 'wpbc_bfb__section' ) || root.classList?.contains( 'wpbc_bfb__field' ) ) {
1544 nodes.push( root );
1545 }
1546 nodes.push( ...root.querySelectorAll( '.wpbc_bfb__section, .wpbc_bfb__field' ) );
1547 nodes.forEach( (el) => {
1548 const prefix = el.classList.contains( 'wpbc_bfb__section' ) ? 's' : 'f';
1549 el.dataset.uid = `${prefix}-${++b._uid_counter}-${Date.now()}-${Math.random().toString( 36 ).slice( 2, 7 )}`;
1550 } );
1551 }
1552
1553 /**
1554 * Bump "foo", "foo-2", "foo-3", ...
1555 */
1556 _make_unique(base, taken) {
1557 const s = Core.WPBC_BFB_Sanitize;
1558 let v = String( base || '' );
1559 if ( !v ) v = 'field';
1560 const m = v.match( /-(\d+)$/ );
1561 let n = m ? (parseInt( m[1], 10 ) || 1) : 1;
1562 let stem = m ? v.replace( /-\d+$/, '' ) : v;
1563 while ( taken.has( v ) ) {
1564 n = Math.max( 2, n + 1 );
1565 v = `${stem}-${n}`;
1566 }
1567 taken.add( v );
1568 return v;
1569 }
1570
1571 /**
1572 * Strict, one-pass de-duplication for a newly-inserted subtree.
1573 * - Ensures unique data-id (internal), data-name (fields), data-html_id (public)
1574 * - Also updates DOM: <section id>, <input id>, <label for>, and input[name].
1575 */
1576 _dedupe_subtree_strict(root) {
1577 const b = this.builder;
1578 const s = Core.WPBC_BFB_Sanitize;
1579 if ( !root || !b?.pages_container ) return;
1580
1581 // 1) Build "taken" sets from outside the subtree.
1582 const takenDataId = new Set();
1583 const takenDataName = new Set();
1584 const takenHtmlId = new Set();
1585 const takenDomId = new Set();
1586
1587 // All fields/sections outside root
1588 b.pages_container.querySelectorAll( '.wpbc_bfb__field, .wpbc_bfb__section' ).forEach( el => {
1589 if ( root.contains( el ) ) return;
1590 const did = el.getAttribute( 'data-id' );
1591 const dnam = el.getAttribute( 'data-name' );
1592 const hid = el.getAttribute( 'data-html_id' );
1593 if ( did ) takenDataId.add( did );
1594 if ( dnam ) takenDataName.add( dnam );
1595 if ( hid ) takenHtmlId.add( hid );
1596 } );
1597
1598 // All DOM ids outside root (labels, inputs, anything)
1599 document.querySelectorAll( '[id]' ).forEach( el => {
1600 if ( root.contains( el ) ) return;
1601 if ( el.id ) takenDomId.add( el.id );
1602 } );
1603
1604 const nodes = [];
1605 if ( root.classList?.contains( 'wpbc_bfb__section' ) || root.classList?.contains( 'wpbc_bfb__field' ) ) {
1606 nodes.push( root );
1607 }
1608 nodes.push( ...root.querySelectorAll( '.wpbc_bfb__section, .wpbc_bfb__field' ) );
1609
1610 // 2) Walk the subtree and fix collisions deterministically.
1611 nodes.forEach( el => {
1612 const isField = el.classList.contains( 'wpbc_bfb__field' );
1613 const isSection = el.classList.contains( 'wpbc_bfb__section' );
1614
1615 // INTERNAL data-id
1616 {
1617 const raw = el.getAttribute( 'data-id' ) || '';
1618 const base = s.sanitize_html_id( raw ) || (isSection ? 'section' : 'field');
1619 const uniq = this._make_unique( base, takenDataId );
1620 if ( uniq !== raw ) el.setAttribute( 'data-id', uniq );
1621 }
1622
1623 // HTML name (fields only)
1624 if ( isField ) {
1625 const raw = el.getAttribute( 'data-name' ) || '';
1626 if ( raw ) {
1627 const base = s.sanitize_html_name( raw );
1628 const uniq = this._make_unique( base, takenDataName );
1629 if ( uniq !== raw ) {
1630 el.setAttribute( 'data-name', uniq );
1631 // Update inner control immediately
1632 const input = el.querySelector( 'input, textarea, select' );
1633 if ( input ) input.setAttribute( 'name', uniq );
1634 }
1635 }
1636 }
1637
1638 // Public HTML id (fields + sections)
1639 {
1640 const raw = el.getAttribute( 'data-html_id' ) || '';
1641 if ( raw ) {
1642 const base = s.sanitize_html_id( raw );
1643 // Reserve against BOTH known data-html_id and real DOM ids.
1644 const combinedTaken = new Set( [ ...takenHtmlId, ...takenDomId ] );
1645 let candidate = this._make_unique( base, combinedTaken );
1646 // Record into the real sets so future checks see the reservation.
1647 takenHtmlId.add( candidate );
1648 takenDomId.add( candidate );
1649
1650 if ( candidate !== raw ) el.setAttribute( 'data-html_id', candidate );
1651
1652 // Reflect to DOM immediately
1653 if ( isSection ) {
1654 el.id = candidate || '';
1655 } else {
1656 const input = el.querySelector( 'input, textarea, select' );
1657 const label = el.querySelector( 'label.wpbc_bfb__field-label' );
1658 if ( input ) input.id = candidate || '';
1659 if ( label ) label.htmlFor = candidate || '';
1660 }
1661 } else if ( isSection ) {
1662 // Ensure no stale DOM id if data-html_id was cleared
1663 el.removeAttribute( 'id' );
1664 }
1665 }
1666 } );
1667 }
1668
1669 _make_add_columns_control(page_el, section_container, insert_pos = 'bottom') {
1670
1671 // Accept insert_pos ('top'|'bottom'), default 'bottom'.
1672
1673 const tpl = document.getElementById( 'wpbc_bfb__add_columns_template' );
1674 if ( !tpl ) {
1675 return null;
1676 }
1677
1678 // Clone *contents* (not the id), unhide, and add a page-scoped class.
1679 const src = (tpl.content && tpl.content.firstElementChild) ? tpl.content.firstElementChild : tpl.firstElementChild;
1680 if ( !src ) {
1681 return null;
1682 }
1683
1684 const clone = src.cloneNode( true );
1685 clone.removeAttribute( 'hidden' );
1686 if ( clone.id ) {
1687 clone.removeAttribute( 'id' );
1688 }
1689 clone.querySelectorAll( '[id]' ).forEach( n => n.removeAttribute( 'id' ) );
1690
1691 // Mark where this control inserts sections.
1692 clone.dataset.insert = insert_pos; // 'top' | 'bottom'
1693
1694 // // Optional UI hint for users (keeps existing markup intact).
1695 // const hint = clone.querySelector( '.nav-tab-text .selected_value' );
1696 // if ( hint ) {
1697 // hint.textContent = (insert_pos === 'top') ? ' (add at top)' : ' (add at bottom)';
1698 // }
1699
1700 // Click on options - add section with N columns.
1701 clone.addEventListener( 'click', (e) => {
1702 const a = e.target.closest( '.ul_dropdown_menu_li_action_add_sections' );
1703 if ( !a ) {
1704 return;
1705 }
1706 e.preventDefault();
1707
1708 // Read N either from data-cols or fallback to parsing text like "3 Columns".
1709 let cols = parseInt( a.dataset.cols || (a.textContent.match( /\b(\d+)\s*Column/i )?.[1] ?? '1'), 10 );
1710 cols = Math.max( 1, Math.min( 4, cols ) );
1711
1712 // NEW: honor the control's insertion position
1713 this.add_section( section_container, cols, insert_pos );
1714
1715 // Reflect last choice (unchanged)
1716 const val = clone.querySelector( '.selected_value' );
1717 if ( val ) {
1718 val.textContent = ` (${cols})`;
1719 }
1720 } );
1721
1722 return clone;
1723 }
1724
1725 /**
1726 * @param {{scroll?: boolean}} [opts = {}]
1727 * @returns {HTMLElement}
1728 */
1729 add_page({ scroll = true } = {}) {
1730 const b = this.builder;
1731 const page_el = Core.WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__panel wpbc_bfb__panel--preview wpbc_bfb_form wpbc_container wpbc_form wpbc_container_booking_form' );
1732 page_el.setAttribute( 'data-page', ++b.page_counter );
1733
1734 // "Page 1 | X" - Render page Title with Remove X button.
1735 const controls_html = UI.render_wp_template( 'wpbc-bfb-tpl-page-remove', { page_number: b.page_counter } );
1736 page_el.innerHTML = controls_html + '<div class="wpbc_bfb__form_preview_section_container wpbc_wizard__border_container"></div>';
1737
1738 b.pages_container.appendChild( page_el );
1739 if ( scroll ) {
1740 page_el.scrollIntoView( { behavior: 'smooth', block: 'start' } );
1741 }
1742
1743 const section_container = page_el.querySelector( '.wpbc_bfb__form_preview_section_container' );
1744 const section_count_on_add_page = 2;
1745 this.init_section_sortable( section_container );
1746 this.add_section( section_container, section_count_on_add_page );
1747
1748 // Dropdown control cloned from the hidden template.
1749 const controls_host_top = page_el.querySelector( '.wpbc_bfb__controls' );
1750 const ctrl_top = this._make_add_columns_control( page_el, section_container, 'top' );
1751 if ( ctrl_top ) {
1752 controls_host_top.appendChild( ctrl_top );
1753 }
1754 // Bottom control bar after the section container.
1755 const controls_host_bottom = Core.WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__controls wpbc_bfb__controls--bottom' );
1756 section_container.after( controls_host_bottom );
1757 const ctrl_bottom = this._make_add_columns_control( page_el, section_container, 'bottom' );
1758 if ( ctrl_bottom ) {
1759 controls_host_bottom.appendChild( ctrl_bottom );
1760 }
1761
1762 return page_el;
1763 }
1764
1765 /**
1766 * @param {HTMLElement} container
1767 * @param {number} cols
1768 * @param {'top'|'bottom'} [insert_pos='bottom'] // NEW
1769 */
1770 add_section(container, cols, insert_pos = 'bottom') {
1771 const b = this.builder;
1772 cols = Math.max( 1, parseInt( cols, 10 ) || 1 );
1773
1774 const section = Core.WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__section' );
1775 section.setAttribute( 'data-id', `section-${++b.section_counter}-${Date.now()}` );
1776 section.setAttribute( 'data-uid', `s-${++b._uid_counter}-${Date.now()}-${Math.random().toString( 36 ).slice( 2, 7 )}` );
1777 section.setAttribute( 'data-type', 'section' );
1778 section.setAttribute( 'data-label', 'Section' );
1779 section.setAttribute( 'data-columns', String( cols ) );
1780 // Do not persist or seed per-column styles by default (opt-in via inspector).
1781
1782 const row = Core.WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__row wpbc__row' );
1783 for ( let i = 0; i < cols; i++ ) {
1784 const col = Core.WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__column wpbc__field' );
1785 col.style.flexBasis = (100 / cols) + '%';
1786 // No default CSS vars here; real columns remain unaffected until user activates styles.
1787 b.init_sortable?.( col );
1788 row.appendChild( col );
1789 if ( i < cols - 1 ) {
1790 const resizer = Core.WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__column-resizer' );
1791 resizer.addEventListener( 'mousedown', b.init_resize_handler );
1792 row.appendChild( resizer );
1793 }
1794 }
1795 section.appendChild( row );
1796 b.layout.set_equal_bases( row, b.col_gap_percent );
1797 b.add_overlay_toolbar( section );
1798 section.setAttribute( 'tabindex', '0' );
1799 this.init_all_nested_sortables( section );
1800
1801 // Insertion policy: top | bottom.
1802 if ( insert_pos === 'top' && container.firstElementChild ) {
1803 container.insertBefore( section, container.firstElementChild );
1804 } else {
1805 container.appendChild( section );
1806 }
1807 }
1808
1809 /**
1810 * @param {Object} section_data
1811 * @param {HTMLElement} container
1812 * @returns {HTMLElement} The rebuilt section element.
1813 */
1814 rebuild_section(section_data, container) {
1815 const b = this.builder;
1816 const cols_data = Array.isArray( section_data?.columns ) ? section_data.columns : [];
1817 this.add_section( container, cols_data.length || 1 );
1818 const section = container.lastElementChild;
1819 if ( !section.dataset.uid ) {
1820 section.setAttribute( 'data-uid', `s-${++b._uid_counter}-${Date.now()}-${Math.random().toString( 36 ).slice( 2, 7 )}` );
1821 }
1822 section.setAttribute( 'data-id', section_data?.id || `section-${++b.section_counter}-${Date.now()}` );
1823 section.setAttribute( 'data-type', 'section' );
1824 section.setAttribute( 'data-label', section_data?.label || 'Section' );
1825 section.setAttribute( 'data-columns', String( (section_data?.columns || []).length || 1 ) );
1826 // Persisted attributes
1827 if ( section_data?.html_id ) {
1828 section.setAttribute( 'data-html_id', String( section_data.html_id ) );
1829 // give the container a real id so anchors/CSS can target it
1830 section.id = String( section_data.html_id );
1831 }
1832
1833 // NEW: restore persisted per-column styles (raw JSON string).
1834 if ( section_data?.col_styles != null ) {
1835 const json = String( section_data.col_styles );
1836 section.setAttribute( 'data-col_styles', json );
1837 try {
1838 section.dataset.col_styles = json;
1839 } catch ( _e ) {
1840 }
1841 }
1842 // (No render_preview() call here on purpose: sections’ builder DOM uses .wpbc_bfb__row/.wpbc_bfb__column.)
1843
1844
1845 if ( section_data?.cssclass ) {
1846 section.setAttribute( 'data-cssclass', String( section_data.cssclass ) );
1847 // keep core classes, then add custom class(es)
1848 String( section_data.cssclass ).split( /\s+/ ).filter( Boolean ).forEach( cls => section.classList.add( cls ) );
1849 }
1850
1851 const row = section.querySelector( '.wpbc_bfb__row' );
1852 // Delegate parsing + activation + application to the Column Styles service.
1853 try {
1854 const json = section.getAttribute( 'data-col_styles' )
1855 || (section.dataset ? (section.dataset.col_styles || '') : '');
1856 const arr = UI.WPBC_BFB_Column_Styles.parse_col_styles( json );
1857 UI.WPBC_BFB_Column_Styles.apply( section, arr );
1858 } catch ( _e ) {
1859 }
1860
1861 cols_data.forEach( (col_data, index) => {
1862 const columns_only = row.querySelectorAll( ':scope > .wpbc_bfb__column' );
1863 const col = columns_only[index];
1864 col.style.flexBasis = col_data.width || '100%';
1865 (col_data.items || []).forEach( (item) => {
1866 if ( !item || !item.type ) {
1867 return;
1868 }
1869 if ( item.type === 'field' ) {
1870 const el = b.build_field( item.data );
1871 if ( el ) {
1872 col.appendChild( el );
1873 b.trigger_field_drop_callback( el, 'load' );
1874 }
1875 return;
1876 }
1877 if ( item.type === 'section' ) {
1878 this.rebuild_section( item.data, col );
1879 }
1880 } );
1881 } );
1882 const computed = b.layout.compute_effective_bases_from_row( row, b.col_gap_percent );
1883 b.layout.apply_bases_to_row( row, computed.bases );
1884 this.init_all_nested_sortables( section );
1885
1886 // NEW: retag UIDs first (so uniqueness checks don't exclude originals), then dedupe all keys.
1887 this._retag_uids_in_subtree( section );
1888 this._dedupe_subtree_strict( section );
1889 return section;
1890 }
1891
1892 /** @param {HTMLElement} container */
1893 init_all_nested_sortables(container) {
1894 const b = this.builder;
1895 if ( container.classList.contains( 'wpbc_bfb__form_preview_section_container' ) ) {
1896 this.init_section_sortable( container );
1897 }
1898 container.querySelectorAll( '.wpbc_bfb__section' ).forEach( (section) => {
1899 section.querySelectorAll( '.wpbc_bfb__column' ).forEach( (col) => {
1900 this.init_section_sortable( col );
1901 } );
1902 } );
1903 }
1904
1905 /** @param {HTMLElement} container */
1906 init_section_sortable(container) {
1907 const b = this.builder;
1908 if ( !container ) {
1909 return;
1910 }
1911 const is_column = container.classList.contains( 'wpbc_bfb__column' );
1912 const is_top_level = container.classList.contains( 'wpbc_bfb__form_preview_section_container' );
1913 if ( !is_column && !is_top_level ) {
1914 return;
1915 }
1916 b.init_sortable?.( container );
1917 }
1918 };
1919
1920 /**
1921 * Serialization and deserialization of pages/sections/fields.
1922 */
1923 UI.WPBC_BFB_Structure_IO = class extends UI.WPBC_BFB_Module {
1924 init() {
1925 this.builder.get_structure = () => this.serialize();
1926 this.builder.load_saved_structure = (s, opts) => this.deserialize( s, opts );
1927 }
1928
1929 /** @returns {Array} */
1930 serialize() {
1931 const b = this.builder;
1932 this._normalize_ids();
1933 this._normalize_names();
1934 const pages = [];
1935 b.pages_container.querySelectorAll( '.wpbc_bfb__panel--preview' ).forEach( (page_el, page_index) => {
1936 const container = page_el.querySelector( '.wpbc_bfb__form_preview_section_container' );
1937 const content = [];
1938 if ( !container ) {
1939 pages.push( { page: page_index + 1, content } );
1940 return;
1941 }
1942 container.querySelectorAll( ':scope > *' ).forEach( (child) => {
1943 if ( child.classList.contains( 'wpbc_bfb__section' ) ) {
1944 content.push( { type: 'section', data: this.serialize_section( child ) } );
1945 return;
1946 }
1947 if ( child.classList.contains( 'wpbc_bfb__field' ) ) {
1948 if ( child.classList.contains( 'is-invalid' ) ) {
1949 return;
1950 }
1951 const f_data = Core.WPBC_Form_Builder_Helper.get_all_data_attributes( child );
1952 // Drop ephemeral/editor-only flags
1953 [ 'uid', 'fresh', 'autoname', 'was_loaded', 'name_user_touched' ]
1954 .forEach( k => {
1955 if ( k in f_data ) delete f_data[k];
1956 } );
1957 content.push( { type: 'field', data: f_data } );
1958 }
1959 } );
1960 pages.push( { page: page_index + 1, content } );
1961 } );
1962 return pages;
1963 }
1964
1965 /**
1966 * @param {HTMLElement} section_el
1967 * @returns {{id:string,label:string,html_id:string,cssclass:string,col_styles:string,columns:Array}}
1968 */
1969 serialize_section(section_el) {
1970 const row = section_el.querySelector( ':scope > .wpbc_bfb__row' );
1971
1972 // NEW: read per-column styles from dataset/attributes (underscore & hyphen)
1973 var col_styles_raw =
1974 section_el.getAttribute( 'data-col_styles' ) ||
1975 (section_el.dataset ? (section_el.dataset.col_styles) : '') ||
1976 '';
1977
1978 const base = {
1979 id : section_el.dataset.id,
1980 label : section_el.dataset.label || '',
1981 html_id : section_el.dataset.html_id || '',
1982 cssclass : section_el.dataset.cssclass || '',
1983 col_styles: String( col_styles_raw ) // <-- NEW: keep as raw JSON string
1984 };
1985
1986 if ( !row ) {
1987 return Object.assign( {}, base, { columns: [] } );
1988 }
1989
1990 const columns = [];
1991 row.querySelectorAll( ':scope > .wpbc_bfb__column' ).forEach( function (col) {
1992 const width = col.style.flexBasis || '100%';
1993 const items = [];
1994 Array.prototype.forEach.call( col.children, function (child) {
1995 if ( child.classList.contains( 'wpbc_bfb__section' ) ) {
1996 items.push( { type: 'section', data: this.serialize_section( child ) } );
1997 return;
1998 }
1999 if ( child.classList.contains( 'wpbc_bfb__field' ) ) {
2000 if ( child.classList.contains( 'is-invalid' ) ) {
2001 return;
2002 }
2003 const f_data = Core.WPBC_Form_Builder_Helper.get_all_data_attributes( child );
2004 [ 'uid', 'fresh', 'autoname', 'was_loaded', 'name_user_touched' ].forEach( function (k) {
2005 if ( k in f_data ) {
2006 delete f_data[k];
2007 }
2008 } );
2009 items.push( { type: 'field', data: f_data } );
2010 }
2011 }.bind( this ) );
2012 columns.push( { width: width, items: items } );
2013 }.bind( this ) );
2014
2015 // Clamp persisted col_styles to the actual number of columns on Save.
2016 try {
2017 const colCount = columns.length;
2018 const raw = String( col_styles_raw || '' ).trim();
2019
2020 if ( raw ) {
2021 let arr = [];
2022 try {
2023 const parsed = JSON.parse( raw );
2024 arr = Array.isArray( parsed ) ? parsed : (parsed && Array.isArray( parsed.columns ) ? parsed.columns : []);
2025 } catch ( _e ) {
2026 arr = [];
2027 }
2028
2029 if ( colCount <= 0 ) {
2030 base.col_styles = '[]';
2031 } else {
2032 if ( arr.length > colCount ) arr.length = colCount;
2033 while ( arr.length < colCount ) arr.push( {} );
2034 base.col_styles = JSON.stringify( arr );
2035 }
2036 } else {
2037 base.col_styles = '';
2038 }
2039 } catch ( _e ) {
2040 }
2041
2042 return Object.assign( {}, base, { columns: columns } );
2043 }
2044
2045 /**
2046 * @param {Array} structure
2047 * @param {{deferIfTyping?: boolean}} [opts = {}]
2048 */
2049 deserialize(structure, { deferIfTyping = true } = {}) {
2050 const b = this.builder;
2051 if ( deferIfTyping && this._is_typing_in_inspector() ) {
2052 clearTimeout( this._defer_timer );
2053 this._defer_timer = setTimeout( () => {
2054 this.deserialize( structure, { deferIfTyping: false } );
2055 }, 150 );
2056 return;
2057 }
2058 b.pages_container.innerHTML = '';
2059 b.page_counter = 0;
2060 (structure || []).forEach( (page_data) => {
2061 const page_el = b.pages_sections.add_page( { scroll: false } );
2062 const section_container = page_el.querySelector( '.wpbc_bfb__form_preview_section_container' );
2063 section_container.innerHTML = '';
2064 b.init_section_sortable?.( section_container );
2065 (page_data.content || []).forEach( (item) => {
2066 if ( item.type === 'section' ) {
2067 // Now returns the element; attributes (incl. col_styles) are applied inside rebuild.
2068 b.pages_sections.rebuild_section( item.data, section_container );
2069 return;
2070 }
2071 if ( item.type === 'field' ) {
2072 const el = b.build_field( item.data );
2073 if ( el ) {
2074 section_container.appendChild( el );
2075 b.trigger_field_drop_callback( el, 'load' );
2076 }
2077 }
2078 } );
2079 } );
2080 b.usage?.update_palette_ui?.();
2081 b.bus.emit( Core.WPBC_BFB_Events.STRUCTURE_LOADED, { structure } );
2082 }
2083
2084 _normalize_ids() {
2085 const b = this.builder;
2086 b.pages_container.querySelectorAll( '.wpbc_bfb__panel--preview .wpbc_bfb__field:not(.is-invalid)' ).forEach( (el) => {
2087 const data = Core.WPBC_Form_Builder_Helper.get_all_data_attributes( el );
2088 const want = Core.WPBC_BFB_Sanitize.sanitize_html_id( data.id || '' ) || 'field';
2089 const uniq = b.id.ensure_unique_field_id( want, el );
2090 if ( data.id !== uniq ) {
2091 el.setAttribute( 'data-id', uniq );
2092 if ( b.preview_mode ) {
2093 b.render_preview( el );
2094 }
2095 }
2096 } );
2097 }
2098
2099 _normalize_names() {
2100 const b = this.builder;
2101 b.pages_container.querySelectorAll( '.wpbc_bfb__panel--preview .wpbc_bfb__field:not(.is-invalid)' ).forEach( (el) => {
2102 const data = Core.WPBC_Form_Builder_Helper.get_all_data_attributes( el );
2103 const base = Core.WPBC_BFB_Sanitize.sanitize_html_name( (data.name != null) ? data.name : data.id ) || 'field';
2104 const uniq = b.id.ensure_unique_field_name( base, el );
2105 if ( data.name !== uniq ) {
2106 el.setAttribute( 'data-name', uniq );
2107 if ( b.preview_mode ) {
2108 b.render_preview( el );
2109 }
2110 }
2111 } );
2112 }
2113
2114 /** @returns {boolean} */
2115 _is_typing_in_inspector() {
2116 const ins = document.getElementById( 'wpbc_bfb__inspector' );
2117 return !!(ins && document.activeElement && ins.contains( document.activeElement ));
2118 }
2119 };
2120
2121 /**
2122 * Minimal, standalone guard that enforces per-column min widths based on fields' data-min_width.
2123 *
2124 * @type {UI.WPBC_BFB_Min_Width_Guard}
2125 */
2126 UI.WPBC_BFB_Min_Width_Guard = class extends UI.WPBC_BFB_Module {
2127
2128 constructor(builder) {
2129 super( builder );
2130 this._on_field_add = this._on_field_add.bind( this );
2131 this._on_field_remove = this._on_field_remove.bind( this );
2132 this._on_structure_loaded = this._on_structure_loaded.bind( this );
2133 this._on_structure_change = this._on_structure_change.bind( this );
2134 this._on_window_resize = this._on_window_resize.bind( this );
2135
2136 this._pending_rows = new Set();
2137 this._pending_all = false;
2138 this._raf_id = 0;
2139 }
2140
2141 init() {
2142 const EV = Core.WPBC_BFB_Events;
2143 this.builder?.bus?.on?.( EV.FIELD_ADD, this._on_field_add );
2144 this.builder?.bus?.on?.( EV.FIELD_REMOVE, this._on_field_remove );
2145 this.builder?.bus?.on?.( EV.STRUCTURE_LOADED, this._on_structure_loaded );
2146 // Refresh selectively on structure change (NOT on every prop input).
2147 this.builder?.bus?.on?.( EV.STRUCTURE_CHANGE, this._on_structure_change );
2148
2149 window.addEventListener( 'resize', this._on_window_resize, { passive: true } );
2150 this._schedule_refresh_all();
2151 }
2152
2153 destroy() {
2154 const EV = Core.WPBC_BFB_Events;
2155 this.builder?.bus?.off?.( EV.FIELD_ADD, this._on_field_add );
2156 this.builder?.bus?.off?.( EV.FIELD_REMOVE, this._on_field_remove );
2157 this.builder?.bus?.off?.( EV.STRUCTURE_LOADED, this._on_structure_loaded );
2158 this.builder?.bus?.off?.( EV.STRUCTURE_CHANGE, this._on_structure_change );
2159 window.removeEventListener( 'resize', this._on_window_resize );
2160 }
2161
2162 _on_field_add(e) {
2163 this._schedule_refresh_all();
2164 // if you really want to be minimal work here, keep your row-only version.
2165 }
2166
2167 _on_field_remove(e) {
2168 const src_el = e?.detail?.el || null;
2169 const row = (src_el && src_el.closest) ? src_el.closest( '.wpbc_bfb__row' ) : null;
2170 if ( row ) {
2171 this._schedule_refresh_row( row );
2172 } else {
2173 this._schedule_refresh_all();
2174 }
2175 }
2176
2177 _on_structure_loaded() {
2178 this._schedule_refresh_all();
2179 }
2180
2181 _on_structure_change(e) {
2182 const reason = e?.detail?.reason || '';
2183 const key = e?.detail?.key || '';
2184
2185 // Ignore noisy prop changes that don't affect min widths.
2186 if ( reason === 'prop-change' && key !== 'min_width' ) {
2187 return;
2188 }
2189
2190 const el = e?.detail?.el || null;
2191 const row = el?.closest?.( '.wpbc_bfb__row' ) || null;
2192 if ( row ) {
2193 this._schedule_refresh_row( row );
2194 } else {
2195 this._schedule_refresh_all();
2196 }
2197 }
2198
2199 _on_window_resize() {
2200 this._schedule_refresh_all();
2201 }
2202
2203 _schedule_refresh_row(row_el) {
2204 if ( !row_el ) return;
2205 this._pending_rows.add( row_el );
2206 this._kick_raf();
2207 }
2208
2209 _schedule_refresh_all() {
2210 this._pending_all = true;
2211 this._pending_rows.clear();
2212 this._kick_raf();
2213 }
2214
2215 _kick_raf() {
2216 if ( this._raf_id ) return;
2217 this._raf_id = (window.requestAnimationFrame || setTimeout)( () => {
2218 this._raf_id = 0;
2219 if ( this._pending_all ) {
2220 this._pending_all = false;
2221 this.refresh_all();
2222 return;
2223 }
2224 const rows = Array.from( this._pending_rows );
2225 this._pending_rows.clear();
2226 rows.forEach( (r) => this.refresh_row( r ) );
2227 }, 0 );
2228 }
2229
2230
2231 refresh_all() {
2232 this.builder?.pages_container
2233 ?.querySelectorAll?.( '.wpbc_bfb__row' )
2234 ?.forEach?.( (row) => this.refresh_row( row ) );
2235 }
2236
2237 refresh_row(row_el) {
2238 if ( !row_el ) return;
2239
2240 const cols = row_el.querySelectorAll( ':scope > .wpbc_bfb__column' );
2241
2242 // 1) Recalculate each column’s required min px and write it to the CSS var.
2243 cols.forEach( (col) => this.apply_col_min( col ) );
2244
2245 // 2) Enforce it at the CSS level right away so layout can’t render narrower.
2246 cols.forEach( (col) => {
2247 const px = parseFloat( getComputedStyle( col ).getPropertyValue( '--wpbc-col-min' ) || '0' ) || 0;
2248 col.style.minWidth = px > 0 ? Math.round( px ) + 'px' : '';
2249 } );
2250
2251 // 3) Normalize current bases so the row respects all mins without overflow.
2252 try {
2253 const b = this.builder;
2254 const gp = b.col_gap_percent;
2255 const eff = b.layout.compute_effective_bases_from_row( row_el, gp ); // { bases, available }
2256 // Re-fit *current* bases against mins (same algorithm layout chips use).
2257 const fitted = UI.WPBC_BFB_Layout_Chips._fit_weights_respecting_min( b, row_el, eff.bases );
2258 if ( Array.isArray( fitted ) ) {
2259 const changed = fitted.some( (v, i) => Math.abs( v - eff.bases[i] ) > 0.01 );
2260 if ( changed ) {
2261 b.layout.apply_bases_to_row( row_el, fitted );
2262 }
2263 }
2264 } catch ( e ) {
2265 w._wpbc?.dev?.error?.( 'WPBC_BFB_Min_Width_Guard - refresh_row', e );
2266 }
2267 }
2268
2269 apply_col_min(col_el) {
2270 if ( !col_el ) return;
2271 let max_px = 0;
2272 const colRect = col_el.getBoundingClientRect();
2273 col_el.querySelectorAll( ':scope > .wpbc_bfb__field' ).forEach( (field) => {
2274 const raw = field.getAttribute( 'data-min_width' );
2275 let px = 0;
2276 if ( raw ) {
2277 const s = String( raw ).trim().toLowerCase();
2278 if ( s.endsWith( '%' ) ) {
2279 const n = parseFloat( s );
2280 if ( Number.isFinite( n ) && colRect.width > 0 ) {
2281 px = (n / 100) * colRect.width;
2282 } else {
2283 px = 0;
2284 }
2285 } else {
2286 px = this.parse_len_px( s );
2287 }
2288 } else {
2289 const cs = getComputedStyle( field );
2290 px = parseFloat( cs.minWidth || '0' ) || 0;
2291 }
2292 if ( px > max_px ) max_px = px;
2293 } );
2294 col_el.style.setProperty( '--wpbc-col-min', max_px > 0 ? Math.round( max_px ) + 'px' : '0px' );
2295 }
2296
2297 parse_len_px(value) {
2298 if ( value == null ) return 0;
2299 const s = String( value ).trim().toLowerCase();
2300 if ( s === '' ) return 0;
2301 if ( s.endsWith( 'px' ) ) {
2302 const n = parseFloat( s );
2303 return Number.isFinite( n ) ? n : 0;
2304 }
2305 if ( s.endsWith( 'rem' ) || s.endsWith( 'em' ) ) {
2306 const n = parseFloat( s );
2307 const base = parseFloat( getComputedStyle( document.documentElement ).fontSize ) || 16;
2308 return Number.isFinite( n ) ? n * base : 0;
2309 }
2310 const n = parseFloat( s );
2311 return Number.isFinite( n ) ? n : 0;
2312 }
2313 };
2314
2315 /**
2316 * WPBC_BFB_Toggle_Normalizer
2317 *
2318 * Converts plain checkboxes into toggle UI:
2319 * <div class="inspector__control wpbc_ui__toggle">
2320 * <input type="checkbox" id="{unique}" data-inspector-key="..." class="inspector__input" role="switch"
2321 * aria-checked="true|false">
2322 * <label class="wpbc_ui__toggle_icon" for="{unique}"></label>
2323 * <label class="wpbc_ui__toggle_label" for="{unique}">Label</label>
2324 * </div>
2325 *
2326 * - Skips inputs already inside `.wpbc_ui__toggle`.
2327 * - Reuses an existing <label for="..."> text if present; otherwise falls back to nearby labels or attributes.
2328 * - Auto-generates a unique id when absent.
2329 */
2330 UI.WPBC_BFB_Toggle_Normalizer = class {
2331
2332 /**
2333 * Upgrade all raw checkboxes in a container to toggles.
2334 * @param {HTMLElement} root_el
2335 */
2336 static upgrade_checkboxes_in(root_el) {
2337
2338 if ( !root_el || !root_el.querySelectorAll ) {
2339 return;
2340 }
2341
2342 var inputs = root_el.querySelectorAll( 'input[type="checkbox"]' );
2343 if ( !inputs.length ) {
2344 return;
2345 }
2346
2347 Array.prototype.forEach.call( inputs, function (input) {
2348
2349 // 1) Skip if already inside toggle wrapper.
2350 if ( input.closest( '.wpbc_ui__toggle' ) ) {
2351 return;
2352 }
2353 // Skip rows / where input checkbox explicitly marked with attribute 'data-wpbc-ui-no-toggle'.
2354 if ( input.hasAttribute( 'data-wpbc-ui-no-toggle' ) ) {
2355 return;
2356 }
2357
2358 // 2) Ensure unique id; prefer existing.
2359 var input_id = input.getAttribute( 'id' );
2360 if ( !input_id ) {
2361 var key = (input.dataset && input.dataset.inspectorKey) ? String( input.dataset.inspectorKey ) : 'opt';
2362 input_id = UI.WPBC_BFB_Toggle_Normalizer.generate_unique_id( 'wpbc_ins_auto_' + key + '_' );
2363 input.setAttribute( 'id', input_id );
2364 }
2365
2366 // 3) Find best label text.
2367 var label_text = UI.WPBC_BFB_Toggle_Normalizer.resolve_label_text( root_el, input, input_id );
2368
2369 // 4) Build the toggle wrapper.
2370 var wrapper = document.createElement( 'div' );
2371 wrapper.className = 'inspector__control wpbc_ui__toggle';
2372
2373 // Keep original input; just move it into wrapper.
2374 input.classList.add( 'inspector__input' );
2375 input.setAttribute( 'role', 'switch' );
2376 input.setAttribute( 'aria-checked', input.checked ? 'true' : 'false' );
2377
2378 var icon_label = document.createElement( 'label' );
2379 icon_label.className = 'wpbc_ui__toggle_icon';
2380 icon_label.setAttribute( 'for', input_id );
2381
2382 var text_label = document.createElement( 'label' );
2383 text_label.className = 'wpbc_ui__toggle_label';
2384 text_label.setAttribute( 'for', input_id );
2385 text_label.appendChild( document.createTextNode( label_text ) );
2386
2387 // 5) Insert wrapper into DOM near the input.
2388 // Preferred: replace the original labeled row if it matches typical inspector layout.
2389 var replaced = UI.WPBC_BFB_Toggle_Normalizer.try_replace_known_row( input, wrapper, label_text );
2390
2391 if ( !replaced ) {
2392 if ( !input.parentNode ) return; // NEW guard
2393 // Fallback: just wrap the input in place and append labels.
2394 input.parentNode.insertBefore( wrapper, input );
2395 wrapper.appendChild( input );
2396 wrapper.appendChild( icon_label );
2397 wrapper.appendChild( text_label );
2398 }
2399
2400 // 6) ARIA sync on change.
2401 input.addEventListener( 'change', function () {
2402 input.setAttribute( 'aria-checked', input.checked ? 'true' : 'false' );
2403 } );
2404 } );
2405 }
2406
2407 /**
2408 * Generate a unique id with a given prefix.
2409 * @param {string} prefix
2410 * @returns {string}
2411 */
2412 static generate_unique_id(prefix) {
2413 var base = String( prefix || 'wpbc_ins_auto_' );
2414 var uid = Math.random().toString( 36 ).slice( 2, 8 );
2415 var id = base + uid;
2416 // Minimal collision guard in the current document scope.
2417 while ( document.getElementById( id ) ) {
2418 uid = Math.random().toString( 36 ).slice( 2, 8 );
2419 id = base + uid;
2420 }
2421 return id;
2422 }
2423
2424 /**
2425 * Resolve the best human label for an input.
2426 * Priority:
2427 * 1) <label for="{id}">text</label>
2428 * 2) nearest sibling/parent .inspector__label text
2429 * 3) input.getAttribute('aria-label') || data-label || data-inspector-key || name || 'Option'
2430 * @param {HTMLElement} root_el
2431 * @param {HTMLInputElement} input
2432 * @param {string} input_id
2433 * @returns {string}
2434 */
2435 static resolve_label_text(root_el, input, input_id) {
2436 // for= association
2437 if ( input_id ) {
2438 var assoc = root_el.querySelector( 'label[for="' + UI.WPBC_BFB_Toggle_Normalizer.css_escape( input_id ) + '"]' );
2439 if ( assoc && assoc.textContent ) {
2440 var txt = assoc.textContent.trim();
2441 // Remove the old label from DOM; its text will be used by toggle.
2442 assoc.parentNode && assoc.parentNode.removeChild( assoc );
2443 if ( txt ) {
2444 return txt;
2445 }
2446 }
2447 }
2448
2449 // nearby inspector label
2450 var near_label = input.closest( '.inspector__row' );
2451 if ( near_label ) {
2452 var il = near_label.querySelector( '.inspector__label' );
2453 if ( il && il.textContent ) {
2454 var t2 = il.textContent.trim();
2455 // If this row had the standard label+control, drop the old text label to avoid duplicates.
2456 il.parentNode && il.parentNode.removeChild( il );
2457 if ( t2 ) {
2458 return t2;
2459 }
2460 }
2461 }
2462
2463 // fallbacks
2464 var aria = input.getAttribute( 'aria-label' );
2465 if ( aria ) {
2466 return aria;
2467 }
2468 if ( input.dataset && input.dataset.label ) {
2469 return String( input.dataset.label );
2470 }
2471 if ( input.dataset && input.dataset.inspectorKey ) {
2472 return String( input.dataset.inspectorKey );
2473 }
2474 if ( input.name ) {
2475 return String( input.name );
2476 }
2477 return 'Option';
2478 }
2479
2480 /**
2481 * Try to replace a known inspector row pattern with a toggle wrapper.
2482 * Patterns:
2483 * <div.inspector__row>
2484 * <label.inspector__label>Text</label>
2485 * <div.inspector__control> [input[type=checkbox]] </div>
2486 * </div>
2487 *
2488 * @param {HTMLInputElement} input
2489 * @param {HTMLElement} wrapper
2490 * @returns {boolean} replaced
2491 */
2492 static try_replace_known_row(input, wrapper, label_text) {
2493 var row = input.closest( '.inspector__row' );
2494 var ctrl_wrap = input.parentElement;
2495
2496 if ( row && ctrl_wrap && ctrl_wrap.classList.contains( 'inspector__control' ) ) {
2497 // Clear control wrap and reinsert toggle structure.
2498 while ( ctrl_wrap.firstChild ) {
2499 ctrl_wrap.removeChild( ctrl_wrap.firstChild );
2500 }
2501 row.classList.add( 'inspector__row--toggle' );
2502
2503 ctrl_wrap.classList.add( 'wpbc_ui__toggle' );
2504 ctrl_wrap.appendChild( input );
2505
2506 var input_id = input.getAttribute( 'id' );
2507 var icon_lbl = document.createElement( 'label' );
2508 icon_lbl.className = 'wpbc_ui__toggle_icon';
2509 icon_lbl.setAttribute( 'for', input_id );
2510
2511 var text_lbl = document.createElement( 'label' );
2512 text_lbl.className = 'wpbc_ui__toggle_label';
2513 text_lbl.setAttribute( 'for', input_id );
2514 if ( label_text ) {
2515 text_lbl.appendChild( document.createTextNode( label_text ) );
2516 }
2517 // If the row previously had a .inspector__label (we removed it in resolve_label_text),
2518 // we intentionally do NOT recreate it; the toggle text label becomes the visible one.
2519 // The text content is already resolved in resolve_label_text() and set below by caller.
2520
2521 ctrl_wrap.appendChild( icon_lbl );
2522 ctrl_wrap.appendChild( text_lbl );
2523 return true;
2524 }
2525
2526 // Not a known pattern; caller will wrap in place.
2527 return false;
2528 }
2529
2530 /**
2531 * CSS.escape polyfill for selectors.
2532 * @param {string} s
2533 * @returns {string}
2534 */
2535 static css_escape(s) {
2536 s = String( s );
2537 if ( window.CSS && typeof window.CSS.escape === 'function' ) {
2538 return window.CSS.escape( s );
2539 }
2540 return s.replace( /([^\w-])/g, '\\$1' );
2541 }
2542 };
2543
2544 /**
2545 * Apply all UI normalizers/enhancers to a container (post-render).
2546 * Keep this file small and add more normalizers later in one place.
2547 *
2548 * @param {HTMLElement} root
2549 */
2550 UI.apply_post_render = function (root) {
2551 if ( !root ) {
2552 return;
2553 }
2554 try {
2555 UI.WPBC_BFB_ValueSlider?.init_on?.( root );
2556 } catch ( e ) { /* noop */
2557 }
2558 try {
2559 var T = UI.WPBC_BFB_Toggle_Normalizer;
2560 if ( T && typeof T.upgrade_checkboxes_in === 'function' ) {
2561 T.upgrade_checkboxes_in( root );
2562 }
2563 } catch ( e ) {
2564 w._wpbc?.dev?.error?.( 'apply_post_render.toggle', e );
2565 }
2566
2567 // Accessibility: keep aria-checked in sync for all toggles inside root.
2568 try {
2569 root.querySelectorAll( '.wpbc_ui__toggle input[type="checkbox"]' ).forEach( function (cb) {
2570 if ( cb.__wpbc_aria_hooked ) {
2571 return;
2572 }
2573 cb.__wpbc_aria_hooked = true;
2574 cb.setAttribute( 'aria-checked', cb.checked ? 'true' : 'false' );
2575 // Delegate ‘change’ just once per render – native delegation still works fine for your logic.
2576 cb.addEventListener( 'change', () => {
2577 cb.setAttribute( 'aria-checked', cb.checked ? 'true' : 'false' );
2578 }, { passive: true } );
2579 } );
2580 } catch ( e ) {
2581 w._wpbc?.dev?.error?.( 'apply_post_render.aria', e );
2582 }
2583 };
2584
2585 UI.InspectorEnhancers = UI.InspectorEnhancers || (function () {
2586 var regs = [];
2587
2588 function register(name, selector, init, destroy) {
2589 regs.push( { name, selector, init, destroy } );
2590 }
2591
2592 function scan(root) {
2593 if ( !root ) return;
2594 regs.forEach( function (r) {
2595 root.querySelectorAll( r.selector ).forEach( function (node) {
2596 node.__wpbc_eh = node.__wpbc_eh || {};
2597 if ( node.__wpbc_eh[r.name] ) return;
2598 try {
2599 r.init && r.init( node, root );
2600 node.__wpbc_eh[r.name] = true;
2601 } catch ( _e ) {
2602 }
2603 } );
2604 } );
2605 }
2606
2607 function destroy(root) {
2608 if ( !root ) return;
2609 regs.forEach( function (r) {
2610 root.querySelectorAll( r.selector ).forEach( function (node) {
2611 try {
2612 r.destroy && r.destroy( node, root );
2613 } catch ( _e ) {
2614 }
2615 if ( node.__wpbc_eh ) delete node.__wpbc_eh[r.name];
2616 } );
2617 } );
2618 }
2619
2620 return { register, scan, destroy };
2621 })();
2622
2623 UI.WPBC_BFB_ValueSlider = {
2624 init_on(root) {
2625 var groups = (root.nodeType === 1 ? [ root ] : []).concat( [].slice.call( root.querySelectorAll?.( '[data-len-group]' ) || [] ) );
2626 groups.forEach( function (g) {
2627 if ( !g.matches || !g.matches( '[data-len-group]' ) ) return;
2628 if ( g.__wpbc_len_wired ) return;
2629
2630 var number = g.querySelector( '[data-len-value]' );
2631 var range = g.querySelector( '[data-len-range]' );
2632 var unit = g.querySelector( '[data-len-unit]' );
2633
2634 if ( !number || !range ) return;
2635
2636 // Mirror constraints if missing on the range.
2637 [ 'min', 'max', 'step' ].forEach( function (a) {
2638 if ( !range.hasAttribute( a ) && number.hasAttribute( a ) ) {
2639 range.setAttribute( a, number.getAttribute( a ) );
2640 }
2641 } );
2642
2643
2644 function sync_range_from_number() {
2645 if ( range.value !== number.value ) {
2646 range.value = number.value;
2647 }
2648 }
2649
2650 function dispatch_input(el) {
2651 try { el.dispatchEvent( new Event( 'input', { bubbles: true } ) ); } catch ( _e ) {}
2652 }
2653 function dispatch_change(el) {
2654 try { el.dispatchEvent( new Event( 'change', { bubbles: true } ) ); } catch ( _e ) {}
2655 }
2656
2657 // Throttle range->number syncing (time-based).
2658 var timer_id = 0;
2659 var pending_val = null;
2660 var pending_change = false;
2661 var last_flush_ts = 0;
2662
2663 // Change this to tune speed: 50..120 ms is a good range.
2664 var min_interval_ms = parseInt( g.dataset.lenThrottle || UI.VALUE_SLIDER_THROTTLE_MS, 10 );
2665 min_interval_ms = Number.isFinite( min_interval_ms ) ? Math.max( 0, min_interval_ms ) : 120;
2666
2667 function flush_range_to_number() {
2668 timer_id = 0;
2669
2670 if ( pending_val == null ) {
2671 return;
2672 }
2673
2674 var next = String( pending_val );
2675 pending_val = null;
2676
2677 if ( number.value !== next ) {
2678 number.value = next;
2679 // IMPORTANT: only 'input' while dragging.
2680 dispatch_input( number );
2681 }
2682
2683 if ( pending_change ) {
2684 pending_change = false;
2685 dispatch_change( number );
2686 }
2687
2688 last_flush_ts = Date.now();
2689 }
2690
2691 function schedule_range_to_number(val, emit_change) {
2692 pending_val = val;
2693 if ( emit_change ) {
2694 pending_change = true;
2695 }
2696
2697 // If commit requested, flush immediately.
2698 if ( pending_change ) {
2699 if ( timer_id ) {
2700 clearTimeout( timer_id );
2701 timer_id = 0;
2702 }
2703 flush_range_to_number();
2704 return;
2705 }
2706
2707 var now = Date.now();
2708 var delta = now - last_flush_ts;
2709
2710 // If enough time passed, flush immediately; else schedule.
2711 if ( delta >= min_interval_ms ) {
2712 flush_range_to_number();
2713 return;
2714 }
2715
2716 if ( timer_id ) {
2717 return;
2718 }
2719
2720 timer_id = setTimeout( flush_range_to_number, Math.max( 0, min_interval_ms - delta ) );
2721 }
2722
2723 function on_number_input() {
2724 sync_range_from_number();
2725 }
2726
2727 function on_number_change() {
2728 sync_range_from_number();
2729 }
2730
2731 function on_range_input() {
2732 schedule_range_to_number( range.value, false );
2733 }
2734
2735 function on_range_change() {
2736 schedule_range_to_number( range.value, true );
2737 }
2738
2739 number.addEventListener( 'input', on_number_input );
2740 number.addEventListener( 'change', on_number_change );
2741 range.addEventListener( 'input', on_range_input );
2742 range.addEventListener( 'change', on_range_change );
2743
2744 if ( unit ) {
2745 unit.addEventListener( 'change', function () {
2746 // We just nudge the number so upstream handlers re-run.
2747 try {
2748 number.dispatchEvent( new Event( 'input', { bubbles: true } ) );
2749 } catch ( _e ) {
2750 }
2751 } );
2752 }
2753
2754 // Initial sync
2755 sync_range_from_number();
2756
2757 g.__wpbc_len_wired = {
2758 destroy() {
2759 number.removeEventListener( 'input', on_number_input );
2760 number.removeEventListener( 'change', on_number_change );
2761 range.removeEventListener( 'input', on_range_input );
2762 range.removeEventListener( 'change', on_range_change );
2763 }
2764 };
2765 } );
2766 },
2767 destroy_on(root) {
2768 var groups = (root && root.nodeType === 1 ? [ root ] : []).concat(
2769 [].slice.call( root.querySelectorAll?.( '[data-len-group]' ) || [] )
2770 );
2771 groups.forEach( function (g) {
2772 if ( !g.matches || !g.matches( '[data-len-group]' ) ) return;
2773 try {
2774 g.__wpbc_len_wired && g.__wpbc_len_wired.destroy && g.__wpbc_len_wired.destroy();
2775 } catch ( _e ) {
2776 }
2777 delete g.__wpbc_len_wired;
2778 } );
2779 }
2780 };
2781
2782 // Register with the global enhancers hub.
2783 UI.InspectorEnhancers && UI.InspectorEnhancers.register(
2784 'value-slider',
2785 '[data-len-group]',
2786 function (el, _root) {
2787 UI.WPBC_BFB_ValueSlider.init_on( el );
2788 },
2789 function (el, _root) {
2790 UI.WPBC_BFB_ValueSlider.destroy_on( el );
2791 }
2792 );
2793
2794 // Single, load-order-safe patch so enhancers auto-run on every bind.
2795 (function patchInspectorEnhancers() {
2796 function applyPatch() {
2797 var Inspector = w.WPBC_BFB_Inspector;
2798 if ( !Inspector || Inspector.__wpbc_enhancers_patched ) return false;
2799 Inspector.__wpbc_enhancers_patched = true;
2800 var orig = Inspector.prototype.bind_to_field;
2801 Inspector.prototype.bind_to_field = function (el) {
2802 orig.call( this, el );
2803 try {
2804 var ins = this.panel
2805 || document.getElementById( 'wpbc_bfb__inspector' )
2806 || document.querySelector( '.wpbc_bfb__inspector' );
2807 UI.InspectorEnhancers && UI.InspectorEnhancers.scan( ins );
2808 } catch ( _e ) {
2809 }
2810 };
2811 // Initial scan if the DOM is already present.
2812 try {
2813 var insEl = document.getElementById( 'wpbc_bfb__inspector' )
2814 || document.querySelector( '.wpbc_bfb__inspector' );
2815 UI.InspectorEnhancers && UI.InspectorEnhancers.scan( insEl );
2816 } catch ( _e ) {
2817 }
2818 return true;
2819 }
2820
2821 // Try now; if Inspector isn’t defined yet, patch when it becomes ready.
2822 if ( !applyPatch() ) {
2823 document.addEventListener(
2824 'wpbc_bfb_inspector_ready',
2825 function () {
2826 applyPatch();
2827 },
2828 { once: true }
2829 );
2830 }
2831 })();
2832
2833 }( window, document ));
2834