PluginProbe
Booking Calendar / 11.7
Booking Calendar v11.7
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.7, at includes/page-form-builder/__js/core/bfb-ui.js

2,876 lines 99.4 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 // An explicit empty value is the legacy persisted representation of false.
1020 ctrl.checked = Core.WPBC_BFB_Sanitize.coerce_boolean( raw, false );
1021 }
1022 } else if ( 'value' in ctrl ) {
1023 if ( hasRaw ) {
1024 ctrl.value = (raw != null) ? String( raw ) : '';
1025 } else {
1026 ctrl.value = (defValue == null) ? '' : String( defValue );
1027 }
1028 }
1029 } );
1030 }
1031
1032 _bind_id_sanitizer() {
1033 const b = this.builder;
1034 const ins = document.getElementById( 'wpbc_bfb__inspector' );
1035 if ( ! ins ) {
1036 return;
1037 }
1038 if ( ins.__wpbc_bfb_id_sanitizer_bound ) {
1039 return;
1040 }
1041 ins.__wpbc_bfb_id_sanitizer_bound = true;
1042
1043 const handler = (e) => {
1044
1045 const t = e.target;
1046 if ( !t || !('value' in t) ) {
1047 return;
1048 }
1049 const key = (t.dataset?.inspectorKey || '').toLowerCase();
1050 const sel = b.get_selected_field?.();
1051 const isSection = sel?.classList?.contains( 'wpbc_bfb__section' );
1052 if ( !sel ) return;
1053
1054 // Unified emitter that always includes the element reference.
1055 const EV = Core.WPBC_BFB_Events;
1056 // STRUCTURE_CHANGE can be "expensive" because other listeners may trigger full canvas refresh.
1057 // Debounce only continuous controls (e.g. value slider scrubbing) on the INPUT phase.
1058 const ensure_sc_debounce_state = () => {
1059 if ( b.__wpbc_bfb_sc_debounce_state ) {
1060 return b.__wpbc_bfb_sc_debounce_state;
1061 }
1062 b.__wpbc_bfb_sc_debounce_state = { timer_id: 0, pending_payload: null };
1063 return b.__wpbc_bfb_sc_debounce_state;
1064 };
1065
1066 const cancel_sc_debounced_emit = () => {
1067 const st = b.__wpbc_bfb_sc_debounce_state;
1068 if ( !st ) return;
1069 try { clearTimeout( st.timer_id ); } catch ( _ ) {}
1070 st.timer_id = 0;
1071 st.pending_payload = null;
1072 };
1073
1074 const bus_emit_change = (reason, extra = {}) => {
1075 // If we’re committing something (change/blur/etc), drop any pending "input" emit.
1076 cancel_sc_debounced_emit();
1077 b.bus?.emit?.( EV.STRUCTURE_CHANGE, { reason, el: sel, ...extra } );
1078 };
1079
1080 const bus_emit_change_debounced = (reason, extra = {}, wait_ms) => {
1081 const st = ensure_sc_debounce_state();
1082 const ms = Number.isFinite( wait_ms )
1083 ? wait_ms
1084 : (Number.isFinite( UI.STRUCTURE_CHANGE_DEBOUNCE_MS ) ? UI.STRUCTURE_CHANGE_DEBOUNCE_MS : 240);
1085
1086 // Capture the CURRENT selected element into the payload now (stable ref).
1087 st.pending_payload = { reason, el: sel, ...extra, debounced: true };
1088
1089 try { clearTimeout( st.timer_id ); } catch ( _ ) {}
1090 st.timer_id = setTimeout( function () {
1091 st.timer_id = 0;
1092 const payload = st.pending_payload;
1093 st.pending_payload = null;
1094 if ( payload ) {
1095 b.bus?.emit?.( EV.STRUCTURE_CHANGE, payload );
1096 }
1097 }, ms );
1098 };
1099
1100 // ---- FIELD/SECTION: internal id ----
1101 if ( key === 'id' ) {
1102 const unique = b.id.set_field_id( sel, t.value );
1103 if ( b.preview_mode && !isSection ) {
1104 b.render_preview( sel );
1105 }
1106 if ( t.value !== unique ) {
1107 t.value = unique;
1108 }
1109 bus_emit_change( 'id-change' );
1110 return;
1111 }
1112
1113 // ---- FIELD/SECTION: public HTML id ----
1114 if ( key === 'html_id' ) {
1115 const applied = b.id.set_field_html_id( sel, t.value );
1116 // For sections, also set the real DOM id so anchors/CSS can target it.
1117 if ( isSection ) {
1118 sel.id = applied || '';
1119 } else if ( b.preview_mode ) {
1120 b.render_preview( sel );
1121 }
1122 if ( t.value !== applied ) {
1123 t.value = applied;
1124 }
1125 bus_emit_change( 'html-id-change' );
1126 return;
1127 }
1128
1129 // ---- FIELDS ONLY: name ----
1130 if ( key === 'name' && !isSection ) {
1131
1132 // Live typing: sanitize only (NO uniqueness yet) to avoid "-2" spam
1133 if ( e.type === 'input' ) {
1134 const before = t.value;
1135 const sanitized = Core.WPBC_BFB_Sanitize.sanitize_html_name( before );
1136 if ( before !== sanitized ) {
1137 // optional: preserve caret to avoid jump
1138 const selStart = t.selectionStart, selEnd = t.selectionEnd;
1139 t.value = sanitized;
1140 try {
1141 t.setSelectionRange( selStart, selEnd );
1142 } catch ( _ ) {
1143 }
1144 }
1145 return; // uniqueness on change/blur
1146 }
1147
1148 // Commit (change/blur)
1149 const raw = String( t.value ?? '' ).trim();
1150
1151 if ( !raw ) {
1152 // RESEED: keep name non-empty and provisional (autoname stays ON)
1153 const S = Core.WPBC_BFB_Sanitize;
1154 const base = S.sanitize_html_name( sel.getAttribute( 'data-id' ) || sel.dataset.id || sel.dataset.type || 'field' );
1155 const uniq = b.id.ensure_unique_field_name( base, sel );
1156
1157 sel.setAttribute( 'data-name', uniq );
1158 sel.dataset.autoname = '1';
1159 sel.dataset.name_user_touched = '0';
1160
1161 // Keep DOM in sync if we’re not re-rendering
1162 if ( !b.preview_mode ) {
1163 const ctrl = sel.querySelector( 'input,textarea,select' );
1164 if ( ctrl ) ctrl.setAttribute( 'name', uniq );
1165 } else {
1166 b.render_preview( sel );
1167 }
1168
1169 if ( t.value !== uniq ) t.value = uniq;
1170 bus_emit_change( 'name-reseed' );
1171 return;
1172 }
1173
1174 // Non-empty commit: user takes control; disable autoname going forward
1175 sel.dataset.name_user_touched = '1';
1176 sel.dataset.autoname = '0';
1177
1178 const sanitized = Core.WPBC_BFB_Sanitize.sanitize_html_name( raw );
1179 const unique = b.id.set_field_name( sel, sanitized );
1180
1181 if ( !b.preview_mode ) {
1182 const ctrl = sel.querySelector( 'input,textarea,select' );
1183 if ( ctrl ) ctrl.setAttribute( 'name', unique );
1184 } else {
1185 b.render_preview( sel );
1186 }
1187
1188 if ( t.value !== unique ) t.value = unique;
1189 bus_emit_change( 'name-change' );
1190 return;
1191 }
1192
1193 // ---- SECTIONS & FIELDS: cssclass (live apply; no re-render) ----
1194 if ( key === 'cssclass' ) {
1195 const next = Core.WPBC_BFB_Sanitize.sanitize_css_classlist( t.value || '' );
1196 const desiredArr = next.split( /\s+/ ).filter( Boolean );
1197 const desiredSet = new Set( desiredArr );
1198
1199 // Core classes are never touched.
1200 const isCore = (cls) => cls === 'is-selected' || cls.startsWith( 'wpbc_' );
1201
1202 // Snapshot before mutating (DOMTokenList is live).
1203 const beforeClasses = Array.from( sel.classList );
1204 const customBefore = beforeClasses.filter( (c) => !isCore( c ) );
1205
1206 // Remove stray non-core classes not in desired.
1207 customBefore.forEach( (c) => {
1208 if ( !desiredSet.has( c ) ) sel.classList.remove( c );
1209 } );
1210
1211 // Add missing desired classes in one go.
1212 const missing = desiredArr.filter( (c) => !customBefore.includes( c ) );
1213 if ( missing.length ) sel.classList.add( ...missing );
1214
1215 // Keep dataset in sync (avoid useless attribute writes).
1216 if ( sel.getAttribute( 'data-cssclass' ) !== next ) {
1217 sel.setAttribute( 'data-cssclass', next );
1218 }
1219
1220 // Emit only if something actually changed.
1221 const afterClasses = Array.from( sel.classList );
1222 const changed = afterClasses.length !== beforeClasses.length || beforeClasses.some( (c, i) => c !== afterClasses[i] );
1223
1224 const detail = { key: 'cssclass', phase: e.type };
1225 if ( isSection ) {
1226 bus_emit_change( 'cssclass-change', detail );
1227 } else {
1228 bus_emit_change( 'prop-change', detail );
1229 }
1230 return;
1231 }
1232
1233
1234 // ---- SECTIONS: label ----
1235 if ( isSection && key === 'label' ) {
1236 const val = String( t.value ?? '' );
1237 sel.setAttribute( 'data-label', val );
1238 bus_emit_change( 'label-change' );
1239 return;
1240 }
1241
1242 // ---- FIELDS: label (auto-name while typing; freeze on commit) ----
1243 if ( !isSection && key === 'label' ) {
1244 const val = String( t.value ?? '' );
1245 sel.dataset.label = val;
1246
1247 // while typing, allow auto-name (if flags permit)
1248 try {
1249 Core.WPBC_BFB_Field_Base.maybe_autoname_from_label( b, sel, val );
1250 } catch ( _ ) {
1251 }
1252
1253 // if user committed the label (blur/change), freeze future auto-name
1254 if ( e.type !== 'input' ) {
1255 sel.dataset.autoname = '0'; // stop future label->name sync
1256 sel.dataset.fresh = '0'; // also kill the "fresh" escape hatch
1257 }
1258
1259 // Optional UI nicety: disable Name when auto is ON, enable when OFF
1260 const ins = document.getElementById( 'wpbc_bfb__inspector' );
1261 const nameCtrl = ins?.querySelector( '[data-inspector-key="name"]' );
1262 if ( nameCtrl ) {
1263 const autoActive =
1264 (sel.dataset.autoname ?? '1') !== '0' &&
1265 sel.dataset.name_user_touched !== '1' &&
1266 sel.dataset.was_loaded !== '1';
1267 nameCtrl.toggleAttribute( 'disabled', autoActive );
1268 if ( autoActive && !nameCtrl.placeholder ) {
1269 nameCtrl.placeholder = b?.i18n?.auto_from_label ?? 'auto — from label';
1270 }
1271 if ( !autoActive && nameCtrl.placeholder === (b?.i18n?.auto_from_label ?? 'auto — from label') ) {
1272 nameCtrl.placeholder = '';
1273 }
1274 }
1275
1276 // Always re-render the preview so label changes are visible immediately.
1277 b.render_preview( sel );
1278 bus_emit_change( 'label-change' );
1279 return;
1280 }
1281
1282
1283 // ---- DEFAULT (GENERIC): dataset writer for both fields & sections ----
1284 // Any inspector control with [data-inspector-key] that doesn't have a custom
1285 // adapter/value_from will simply read/write sel.dataset[key].
1286 if ( key ) {
1287
1288 const selfLocked = /^(1|true|yes)$/i.test( (t.dataset?.locked || '').trim() );
1289 if ( selfLocked ) {
1290 return;
1291 }
1292
1293 // Skip keys we handled above to avoid double work.
1294 if ( key === 'id' || key === 'name' || key === 'html_id' || key === 'cssclass' || key === 'label' ) {
1295 return;
1296 }
1297 let nextVal = '';
1298 if ( t instanceof HTMLInputElement && (t.type === 'checkbox' || t.type === 'radio') ) {
1299 // Persist both boolean states explicitly so schema defaults cannot replace false.
1300 nextVal = t.checked ? 'true' : 'false';
1301 } else if ( 'value' in t ) {
1302 nextVal = String( t.value ?? '' );
1303 }
1304 // Persist to dataset.
1305 if ( sel?.dataset ) sel.dataset[key] = nextVal;
1306
1307 // Generator controls are "UI inputs" — avoid STRUCTURE_CHANGE spam while dragging/typing.
1308 const is_gen_key = (key.indexOf( 'gen_' ) === 0);
1309
1310 // Re-render on visual keys so preview stays in sync (calendar label/help, etc.).
1311 const visualKeys = new Set( [ 'help', 'placeholder', 'min_width', 'cssclass' ] );
1312 if ( !isSection && (visualKeys.has( key ) || key.startsWith( 'ui_' )) ) {
1313 // Light heuristic: only re-render on commit for heavy inputs; live for short ones is fine.
1314 if ( e.type === 'change' || key === 'help' || key === 'placeholder' ) {
1315 b.render_preview( sel );
1316 }
1317 }
1318
1319 if ( !(is_gen_key && e.type === 'input') ) {
1320 // Debounce continuous value slider input events to avoid full-canvas refresh spam.
1321 // We detect the slider group via [data-len-group] wrapper.
1322 const is_len_group_ctrl = !!(t && t.closest && t.closest( '[data-len-group]' ));
1323
1324 if ( is_len_group_ctrl && e.type === 'input' ) {
1325 bus_emit_change_debounced( 'prop-change', { key, phase: e.type } );
1326 } else {
1327 bus_emit_change( 'prop-change', { key, phase: e.type } );
1328 }
1329 }
1330 return;
1331 }
1332 };
1333
1334 ins.addEventListener( 'change', handler, true );
1335 // reflect instantly while typing as well.
1336 ins.addEventListener( 'input', handler, true );
1337 }
1338
1339 /**
1340 * Open Inspector after a field is added.
1341 * @private
1342 */
1343 _open_inspector_after_field_added() {
1344 const EV = Core.WPBC_BFB_Events;
1345 this.builder?.bus?.on?.( EV.FIELD_ADD, (e) => {
1346 const el = e?.detail?.el || null;
1347 if ( el && this.builder?.select_field ) {
1348 this.builder.select_field( el, { scrollIntoView: true } );
1349 }
1350 // Show Inspector Palette.
1351 wpbc_bfb__dispatch_event_safe(
1352 'wpbc_bfb:show_panel',
1353 {
1354 panel_id: 'wpbc_bfb__inspector',
1355 tab_id : 'wpbc_tab_inspector'
1356 }
1357 );
1358 } );
1359 }
1360 };
1361
1362 /**
1363 * Keyboard shortcuts for selection, deletion, and movement.
1364 */
1365 UI.WPBC_BFB_Keyboard_Controller = class extends UI.WPBC_BFB_Module {
1366 init() {
1367 this._on_key = this.on_key.bind( this );
1368 document.addEventListener( 'keydown', this._on_key, true );
1369 }
1370
1371 destroy() {
1372 document.removeEventListener( 'keydown', this._on_key, true );
1373 }
1374
1375 /** @param {KeyboardEvent} e */
1376 on_key(e) {
1377 const b = this.builder;
1378 const is_typing = this._is_typing_anywhere();
1379 if ( e.key === 'Escape' ) {
1380 if ( is_typing ) {
1381 return;
1382 }
1383 this.builder.bus.emit( Core.WPBC_BFB_Events.CLEAR_SELECTION, { source: 'esc' } );
1384 return;
1385 }
1386 const selected = b.get_selected_field?.();
1387 if ( !selected || is_typing ) {
1388 return;
1389 }
1390 if ( e.key === 'Delete' || e.key === 'Backspace' ) {
1391 e.preventDefault();
1392 b.delete_item?.( selected );
1393 return;
1394 }
1395 if ( (e.altKey || e.ctrlKey || e.metaKey) && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.shiftKey ) {
1396 e.preventDefault();
1397 const dir = (e.key === 'ArrowUp') ? 'up' : 'down';
1398 b.move_item?.( selected, dir );
1399 return;
1400 }
1401 if ( e.key === 'Enter' ) {
1402 e.preventDefault();
1403 b.select_field( selected, { scrollIntoView: true } );
1404 }
1405 }
1406
1407 /** @returns {boolean} */
1408 _is_typing_anywhere() {
1409 const a = document.activeElement;
1410 const tag = a?.tagName;
1411 if ( tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (a?.isContentEditable === true) ) {
1412 return true;
1413 }
1414 const ins = document.getElementById( 'wpbc_bfb__inspector' );
1415 return !!(ins && a && ins.contains( a ));
1416 }
1417 };
1418
1419 /**
1420 * Column resize logic for section rows.
1421 */
1422 UI.WPBC_BFB_Resize_Controller = class extends UI.WPBC_BFB_Module {
1423 init() {
1424 this.builder.init_resize_handler = this.handle_resize.bind( this );
1425 }
1426
1427 /**
1428 * read the CSS var (kept local so it doesn’t depend on the Min-Width module)
1429 *
1430 * @param col
1431 * @returns {number|number}
1432 * @private
1433 */
1434 _get_col_min_px(col) {
1435 const v = getComputedStyle( col ).getPropertyValue( '--wpbc-col-min' ) || '0';
1436 const n = parseFloat( v );
1437 return Number.isFinite( n ) ? Math.max( 0, n ) : 0;
1438 }
1439
1440 /** @param {MouseEvent} e */
1441 handle_resize(e) {
1442 const b = this.builder;
1443 e.preventDefault();
1444 if ( e.button !== 0 ) return;
1445
1446 const resizer = e.currentTarget;
1447 const row_el = resizer.parentElement;
1448 const cols = Array.from( row_el.querySelectorAll( ':scope > .wpbc_bfb__column' ) );
1449 const left_col = resizer?.previousElementSibling;
1450 const right_col = resizer?.nextElementSibling;
1451 if ( !left_col || !right_col || !left_col.classList.contains( 'wpbc_bfb__column' ) || !right_col.classList.contains( 'wpbc_bfb__column' ) ) return;
1452
1453 const left_index = cols.indexOf( left_col );
1454 const right_index = cols.indexOf( right_col );
1455 if ( left_index === -1 || right_index !== left_index + 1 ) return;
1456
1457 const start_x = e.clientX;
1458 const left_start_px = left_col.getBoundingClientRect().width;
1459 const right_start_px = right_col.getBoundingClientRect().width;
1460 const pair_px = Math.max( 0, left_start_px + right_start_px );
1461
1462 const gp = b.col_gap_percent;
1463 const computed = b.layout.compute_effective_bases_from_row( row_el, gp );
1464 const available = computed.available; // % of the “full 100” after gaps
1465 const bases = computed.bases.slice( 0 ); // current effective %
1466 const pair_avail = bases[left_index] + bases[right_index];
1467
1468 // Bail if we can’t compute sane deltas.
1469 if (!pair_px || !Number.isFinite(pair_avail) || pair_avail <= 0) return;
1470
1471 // --- MIN CLAMPS (pixels) -------------------------------------------------
1472 const pctToPx = (pct) => (pair_px * (pct / pair_avail)); // pair-local percent -> px
1473 const genericMinPct = Math.min( 0.1, available ); // original 0.1% floor (in “available %” space)
1474 const genericMinPx = pctToPx( genericMinPct );
1475
1476 const leftMinPx = Math.max( this._get_col_min_px( left_col ), genericMinPx );
1477 const rightMinPx = Math.max( this._get_col_min_px( right_col ), genericMinPx );
1478
1479 // freeze text selection + cursor
1480 const prev_user_select = document.body.style.userSelect;
1481 document.body.style.userSelect = 'none';
1482 row_el.style.cursor = 'col-resize';
1483
1484 const on_mouse_move = (ev) => {
1485 if ( !pair_px ) return;
1486
1487 // work in pixels, clamp by each side’s min
1488 const delta_px = ev.clientX - start_x;
1489 let newLeftPx = left_start_px + delta_px;
1490 newLeftPx = Math.max( leftMinPx, Math.min( pair_px - rightMinPx, newLeftPx ) );
1491 const newRightPx = pair_px - newLeftPx;
1492
1493 // translate back to pair-local percentages
1494 const newLeftPct = (newLeftPx / pair_px) * pair_avail;
1495 const newBases = bases.slice( 0 );
1496 newBases[left_index] = newLeftPct;
1497 newBases[right_index] = pair_avail - newLeftPct;
1498
1499 b.layout.apply_bases_to_row( row_el, newBases );
1500 };
1501
1502 const on_mouse_up = () => {
1503 document.removeEventListener( 'mousemove', on_mouse_move );
1504 document.removeEventListener( 'mouseup', on_mouse_up );
1505 window.removeEventListener( 'mouseup', on_mouse_up );
1506 document.removeEventListener( 'mouseleave', on_mouse_up );
1507 document.body.style.userSelect = prev_user_select || '';
1508 row_el.style.cursor = '';
1509
1510 // normalize to the row’s available % again
1511 const normalized = b.layout.compute_effective_bases_from_row( row_el, gp );
1512 b.layout.apply_bases_to_row( row_el, normalized.bases );
1513 };
1514
1515 document.addEventListener( 'mousemove', on_mouse_move );
1516 document.addEventListener( 'mouseup', on_mouse_up );
1517 window.addEventListener( 'mouseup', on_mouse_up );
1518 document.addEventListener( 'mouseleave', on_mouse_up );
1519 }
1520
1521 };
1522
1523 /**
1524 * Page and section creation, rebuilding, and nested Sortable setup.
1525 */
1526 UI.WPBC_BFB_Pages_Sections = class extends UI.WPBC_BFB_Module {
1527
1528 init() {
1529 this.builder.add_page = (opts) => this.add_page( opts );
1530 this.builder.add_section = (container, cols) => this.add_section( container, cols );
1531 this.builder.rebuild_section = (section_data, container) => this.rebuild_section( section_data, container );
1532 this.builder.init_all_nested_sortables = (el) => this.init_all_nested_sortables( el );
1533 this.builder.init_section_sortable = (el) => this.init_section_sortable( el );
1534 this.builder.pages_sections = this;
1535 }
1536
1537 /**
1538 * Give every field/section in a cloned subtree a fresh data-uid so
1539 * uniqueness checks don't exclude their originals.
1540 */
1541 _retag_uids_in_subtree(root) {
1542 const b = this.builder;
1543 if ( !root ) return;
1544 const nodes = [];
1545 if ( root.classList?.contains( 'wpbc_bfb__section' ) || root.classList?.contains( 'wpbc_bfb__field' ) ) {
1546 nodes.push( root );
1547 }
1548 nodes.push( ...root.querySelectorAll( '.wpbc_bfb__section, .wpbc_bfb__field' ) );
1549 nodes.forEach( (el) => {
1550 const prefix = el.classList.contains( 'wpbc_bfb__section' ) ? 's' : 'f';
1551 el.dataset.uid = `${prefix}-${++b._uid_counter}-${Date.now()}-${Math.random().toString( 36 ).slice( 2, 7 )}`;
1552 } );
1553 }
1554
1555 /**
1556 * Bump "foo", "foo-2", "foo-3", ...
1557 */
1558 _make_unique(base, taken) {
1559 const s = Core.WPBC_BFB_Sanitize;
1560 let v = String( base || '' );
1561 if ( !v ) v = 'field';
1562 const m = v.match( /-(\d+)$/ );
1563 let n = m ? (parseInt( m[1], 10 ) || 1) : 1;
1564 let stem = m ? v.replace( /-\d+$/, '' ) : v;
1565 while ( taken.has( v ) ) {
1566 n = Math.max( 2, n + 1 );
1567 v = `${stem}-${n}`;
1568 }
1569 taken.add( v );
1570 return v;
1571 }
1572
1573 /**
1574 * Strict, one-pass de-duplication for a newly-inserted subtree.
1575 * - Ensures unique data-id (internal), data-name (fields), data-html_id (public)
1576 * - Also updates DOM: <section id>, <input id>, <label for>, and input[name].
1577 */
1578 _dedupe_subtree_strict(root) {
1579 const b = this.builder;
1580 const s = Core.WPBC_BFB_Sanitize;
1581 if ( !root || !b?.pages_container ) return;
1582
1583 // 1) Build "taken" sets from outside the subtree.
1584 const takenDataId = new Set();
1585 const takenDataName = new Set();
1586 const takenHtmlId = new Set();
1587 const takenDomId = new Set();
1588
1589 // All fields/sections outside root
1590 b.pages_container.querySelectorAll( '.wpbc_bfb__field, .wpbc_bfb__section' ).forEach( el => {
1591 if ( root.contains( el ) ) return;
1592 const did = el.getAttribute( 'data-id' );
1593 const dnam = el.getAttribute( 'data-name' );
1594 const hid = el.getAttribute( 'data-html_id' );
1595 if ( did ) takenDataId.add( did );
1596 if ( dnam ) takenDataName.add( dnam );
1597 if ( hid ) takenHtmlId.add( hid );
1598 } );
1599
1600 // All DOM ids outside root (labels, inputs, anything)
1601 document.querySelectorAll( '[id]' ).forEach( el => {
1602 if ( root.contains( el ) ) return;
1603 if ( el.id ) takenDomId.add( el.id );
1604 } );
1605
1606 const nodes = [];
1607 if ( root.classList?.contains( 'wpbc_bfb__section' ) || root.classList?.contains( 'wpbc_bfb__field' ) ) {
1608 nodes.push( root );
1609 }
1610 nodes.push( ...root.querySelectorAll( '.wpbc_bfb__section, .wpbc_bfb__field' ) );
1611
1612 // 2) Walk the subtree and fix collisions deterministically.
1613 nodes.forEach( el => {
1614 const isField = el.classList.contains( 'wpbc_bfb__field' );
1615 const isSection = el.classList.contains( 'wpbc_bfb__section' );
1616
1617 // INTERNAL data-id
1618 {
1619 const raw = el.getAttribute( 'data-id' ) || '';
1620 const base = s.sanitize_html_id( raw ) || (isSection ? 'section' : 'field');
1621 const uniq = this._make_unique( base, takenDataId );
1622 if ( uniq !== raw ) el.setAttribute( 'data-id', uniq );
1623 }
1624
1625 // HTML name (fields only)
1626 if ( isField ) {
1627 const raw = el.getAttribute( 'data-name' ) || '';
1628 if ( raw ) {
1629 const base = s.sanitize_html_name( raw );
1630 const uniq = this._make_unique( base, takenDataName );
1631 if ( uniq !== raw ) {
1632 el.setAttribute( 'data-name', uniq );
1633 // Update inner control immediately
1634 const input = el.querySelector( 'input, textarea, select' );
1635 if ( input ) input.setAttribute( 'name', uniq );
1636 }
1637 }
1638 }
1639
1640 // Public HTML id (fields + sections)
1641 {
1642 const raw = el.getAttribute( 'data-html_id' ) || '';
1643 if ( raw ) {
1644 const base = s.sanitize_html_id( raw );
1645 // Reserve against BOTH known data-html_id and real DOM ids.
1646 const combinedTaken = new Set( [ ...takenHtmlId, ...takenDomId ] );
1647 let candidate = this._make_unique( base, combinedTaken );
1648 // Record into the real sets so future checks see the reservation.
1649 takenHtmlId.add( candidate );
1650 takenDomId.add( candidate );
1651
1652 if ( candidate !== raw ) el.setAttribute( 'data-html_id', candidate );
1653
1654 // Reflect to DOM immediately
1655 if ( isSection ) {
1656 el.id = candidate || '';
1657 } else {
1658 const input = el.querySelector( 'input, textarea, select' );
1659 const label = el.querySelector( 'label.wpbc_bfb__field-label' );
1660 if ( input ) input.id = candidate || '';
1661 if ( label ) label.htmlFor = candidate || '';
1662 }
1663 } else if ( isSection ) {
1664 // Ensure no stale DOM id if data-html_id was cleared
1665 el.removeAttribute( 'id' );
1666 }
1667 }
1668 } );
1669 }
1670
1671 _make_add_columns_control(page_el, section_container, insert_pos = 'bottom') {
1672
1673 // Accept insert_pos ('top'|'bottom'), default 'bottom'.
1674
1675 const tpl = document.getElementById( 'wpbc_bfb__add_columns_template' );
1676 if ( !tpl ) {
1677 return null;
1678 }
1679
1680 // Clone *contents* (not the id), unhide, and add a page-scoped class.
1681 const src = (tpl.content && tpl.content.firstElementChild) ? tpl.content.firstElementChild : tpl.firstElementChild;
1682 if ( !src ) {
1683 return null;
1684 }
1685
1686 const clone = src.cloneNode( true );
1687 clone.removeAttribute( 'hidden' );
1688 if ( clone.id ) {
1689 clone.removeAttribute( 'id' );
1690 }
1691 clone.querySelectorAll( '[id]' ).forEach( n => n.removeAttribute( 'id' ) );
1692
1693 // Mark where this control inserts sections.
1694 clone.dataset.insert = insert_pos; // 'top' | 'bottom'
1695
1696 // // Optional UI hint for users (keeps existing markup intact).
1697 // const hint = clone.querySelector( '.nav-tab-text .selected_value' );
1698 // if ( hint ) {
1699 // hint.textContent = (insert_pos === 'top') ? ' (add at top)' : ' (add at bottom)';
1700 // }
1701
1702 // Click on options - add section with N columns.
1703 clone.addEventListener( 'click', (e) => {
1704 const a = e.target.closest( '.ul_dropdown_menu_li_action_add_sections' );
1705 if ( !a ) {
1706 return;
1707 }
1708 e.preventDefault();
1709
1710 // Read N either from data-cols or fallback to parsing text like "3 Columns".
1711 let cols = parseInt( a.dataset.cols || (a.textContent.match( /\b(\d+)\s*Column/i )?.[1] ?? '1'), 10 );
1712 cols = Math.max( 1, Math.min( 4, cols ) );
1713
1714 // NEW: honor the control's insertion position
1715 this.add_section( section_container, cols, insert_pos );
1716
1717 // Reflect last choice (unchanged)
1718 const val = clone.querySelector( '.selected_value' );
1719 if ( val ) {
1720 val.textContent = ` (${cols})`;
1721 }
1722 } );
1723
1724 return clone;
1725 }
1726
1727 /**
1728 * @param {{scroll?: boolean}} [opts = {}]
1729 * @returns {HTMLElement}
1730 */
1731 add_page({ scroll = true } = {}) {
1732 const b = this.builder;
1733 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' );
1734 page_el.setAttribute( 'data-page', ++b.page_counter );
1735
1736 // "Page 1 | X" - Render page Title with Remove X button.
1737 const controls_html = UI.render_wp_template( 'wpbc-bfb-tpl-page-remove', { page_number: b.page_counter } );
1738 page_el.innerHTML = controls_html + '<div class="wpbc_bfb__form_preview_section_container wpbc_wizard__border_container"></div>';
1739
1740 b.pages_container.appendChild( page_el );
1741 if ( scroll ) {
1742 page_el.scrollIntoView( { behavior: 'smooth', block: 'start' } );
1743 }
1744
1745 const section_container = page_el.querySelector( '.wpbc_bfb__form_preview_section_container' );
1746 const section_count_on_add_page = 2;
1747 this.init_section_sortable( section_container );
1748 this.add_section( section_container, section_count_on_add_page );
1749
1750 // Dropdown control cloned from the hidden template.
1751 const controls_host_top = page_el.querySelector( '.wpbc_bfb__controls' );
1752 const ctrl_top = this._make_add_columns_control( page_el, section_container, 'top' );
1753 if ( ctrl_top ) {
1754 controls_host_top.appendChild( ctrl_top );
1755 }
1756 // Bottom control bar after the section container.
1757 const controls_host_bottom = Core.WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__controls wpbc_bfb__controls--bottom' );
1758 section_container.after( controls_host_bottom );
1759 const ctrl_bottom = this._make_add_columns_control( page_el, section_container, 'bottom' );
1760 if ( ctrl_bottom ) {
1761 controls_host_bottom.appendChild( ctrl_bottom );
1762 }
1763
1764 return page_el;
1765 }
1766
1767 /**
1768 * @param {HTMLElement} container
1769 * @param {number} cols
1770 * @param {'top'|'bottom'} [insert_pos='bottom'] // NEW
1771 */
1772 add_section(container, cols, insert_pos = 'bottom') {
1773 const b = this.builder;
1774 cols = Math.max( 1, parseInt( cols, 10 ) || 1 );
1775
1776 const section = Core.WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__section' );
1777 section.setAttribute( 'data-id', `section-${++b.section_counter}-${Date.now()}` );
1778 section.setAttribute( 'data-uid', `s-${++b._uid_counter}-${Date.now()}-${Math.random().toString( 36 ).slice( 2, 7 )}` );
1779 section.setAttribute( 'data-type', 'section' );
1780 section.setAttribute( 'data-label', 'Section' );
1781 section.setAttribute( 'data-columns', String( cols ) );
1782 // Do not persist or seed per-column styles by default (opt-in via inspector).
1783
1784 const row = Core.WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__row wpbc__row' );
1785 for ( let i = 0; i < cols; i++ ) {
1786 const col = Core.WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__column wpbc__field' );
1787 col.style.flexBasis = (100 / cols) + '%';
1788 // No default CSS vars here; real columns remain unaffected until user activates styles.
1789 b.init_sortable?.( col );
1790 row.appendChild( col );
1791 if ( i < cols - 1 ) {
1792 const resizer = Core.WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__column-resizer' );
1793 resizer.addEventListener( 'mousedown', b.init_resize_handler );
1794 row.appendChild( resizer );
1795 }
1796 }
1797 section.appendChild( row );
1798 b.layout.set_equal_bases( row, b.col_gap_percent );
1799 b.add_overlay_toolbar( section );
1800 section.setAttribute( 'tabindex', '0' );
1801 this.init_all_nested_sortables( section );
1802
1803 // Insertion policy: top | bottom.
1804 if ( insert_pos === 'top' && container.firstElementChild ) {
1805 container.insertBefore( section, container.firstElementChild );
1806 } else {
1807 container.appendChild( section );
1808 }
1809 }
1810
1811 /**
1812 * @param {Object} section_data
1813 * @param {HTMLElement} container
1814 * @returns {HTMLElement} The rebuilt section element.
1815 */
1816 rebuild_section(section_data, container) {
1817 const b = this.builder;
1818 const cols_data = Array.isArray( section_data?.columns ) ? section_data.columns : [];
1819 this.add_section( container, cols_data.length || 1 );
1820 const section = container.lastElementChild;
1821 if ( !section.dataset.uid ) {
1822 section.setAttribute( 'data-uid', `s-${++b._uid_counter}-${Date.now()}-${Math.random().toString( 36 ).slice( 2, 7 )}` );
1823 }
1824 section.setAttribute( 'data-id', section_data?.id || `section-${++b.section_counter}-${Date.now()}` );
1825 section.setAttribute( 'data-type', 'section' );
1826 section.setAttribute( 'data-label', section_data?.label || 'Section' );
1827 section.setAttribute( 'data-columns', String( (section_data?.columns || []).length || 1 ) );
1828 // Persisted attributes
1829 if ( section_data?.html_id ) {
1830 section.setAttribute( 'data-html_id', String( section_data.html_id ) );
1831 // give the container a real id so anchors/CSS can target it
1832 section.id = String( section_data.html_id );
1833 }
1834
1835 // NEW: restore persisted per-column styles (raw JSON string).
1836 if ( section_data?.col_styles != null ) {
1837 const json = String( section_data.col_styles );
1838 section.setAttribute( 'data-col_styles', json );
1839 try {
1840 section.dataset.col_styles = json;
1841 } catch ( _e ) {
1842 }
1843 }
1844 // (No render_preview() call here on purpose: sections’ builder DOM uses .wpbc_bfb__row/.wpbc_bfb__column.)
1845
1846
1847 if ( section_data?.cssclass ) {
1848 section.setAttribute( 'data-cssclass', String( section_data.cssclass ) );
1849 // keep core classes, then add custom class(es)
1850 String( section_data.cssclass ).split( /\s+/ ).filter( Boolean ).forEach( cls => section.classList.add( cls ) );
1851 }
1852
1853 const row = section.querySelector( '.wpbc_bfb__row' );
1854 // Delegate parsing + activation + application to the Column Styles service.
1855 try {
1856 const json = section.getAttribute( 'data-col_styles' )
1857 || (section.dataset ? (section.dataset.col_styles || '') : '');
1858 const arr = UI.WPBC_BFB_Column_Styles.parse_col_styles( json );
1859 UI.WPBC_BFB_Column_Styles.apply( section, arr );
1860 } catch ( _e ) {
1861 }
1862
1863 cols_data.forEach( (col_data, index) => {
1864 const columns_only = row.querySelectorAll( ':scope > .wpbc_bfb__column' );
1865 const col = columns_only[index];
1866 col.style.flexBasis = col_data.width || '100%';
1867 (col_data.items || []).forEach( (item) => {
1868 if ( !item || !item.type ) {
1869 return;
1870 }
1871 if ( item.type === 'field' ) {
1872 const el = b.build_field( item.data );
1873 if ( el ) {
1874 col.appendChild( el );
1875 b.trigger_field_drop_callback( el, 'load' );
1876 }
1877 return;
1878 }
1879 if ( item.type === 'section' ) {
1880 this.rebuild_section( item.data, col );
1881 }
1882 } );
1883 } );
1884 const computed = b.layout.compute_effective_bases_from_row( row, b.col_gap_percent );
1885 b.layout.apply_bases_to_row( row, computed.bases );
1886 this.init_all_nested_sortables( section );
1887
1888 // NEW: retag UIDs first (so uniqueness checks don't exclude originals), then dedupe all keys.
1889 this._retag_uids_in_subtree( section );
1890 this._dedupe_subtree_strict( section );
1891 return section;
1892 }
1893
1894 /** @param {HTMLElement} container */
1895 init_all_nested_sortables(container) {
1896 const b = this.builder;
1897 if ( container.classList.contains( 'wpbc_bfb__form_preview_section_container' ) ) {
1898 this.init_section_sortable( container );
1899 }
1900 container.querySelectorAll( '.wpbc_bfb__section' ).forEach( (section) => {
1901 section.querySelectorAll( '.wpbc_bfb__column' ).forEach( (col) => {
1902 this.init_section_sortable( col );
1903 } );
1904 } );
1905 }
1906
1907 /** @param {HTMLElement} container */
1908 init_section_sortable(container) {
1909 const b = this.builder;
1910 if ( !container ) {
1911 return;
1912 }
1913 const is_column = container.classList.contains( 'wpbc_bfb__column' );
1914 const is_top_level = container.classList.contains( 'wpbc_bfb__form_preview_section_container' );
1915 if ( !is_column && !is_top_level ) {
1916 return;
1917 }
1918 b.init_sortable?.( container );
1919 }
1920 };
1921
1922 /**
1923 * Serialization and deserialization of pages/sections/fields.
1924 */
1925 UI.WPBC_BFB_Structure_IO = class extends UI.WPBC_BFB_Module {
1926 init() {
1927 this.builder.get_structure = () => this.serialize();
1928 this.builder.load_saved_structure = (s, opts) => this.deserialize( s, opts );
1929 }
1930
1931 /**
1932 * Normalize option values when a field explicitly disables separate values.
1933 *
1934 * Older Builder versions persisted an unchecked value_differs toggle as an
1935 * empty string. Treat that explicit legacy value as false, while retaining
1936 * the schema default when the property is genuinely absent.
1937 *
1938 * @param {Object} field_data Serialized field data.
1939 * @returns {Object} Normalized serialized field data.
1940 */
1941 _normalize_field_option_values(field_data) {
1942 const has_value_differs = Object.prototype.hasOwnProperty.call( field_data, 'value_differs' );
1943 if ( !has_value_differs ) {
1944 return field_data;
1945 }
1946
1947 const value_differs = Core.WPBC_BFB_Sanitize.coerce_boolean( field_data.value_differs, false );
1948 field_data.value_differs = value_differs;
1949
1950 if ( !value_differs && Array.isArray( field_data.options ) ) {
1951 field_data.options = field_data.options.map( ( option_record ) => {
1952 if ( !option_record || typeof option_record !== 'object' ) {
1953 return option_record;
1954 }
1955
1956 return Object.assign(
1957 {},
1958 option_record,
1959 { value: String( option_record.label == null ? '' : option_record.label ) }
1960 );
1961 } );
1962 }
1963
1964 return field_data;
1965 }
1966
1967 /** @returns {Array} */
1968 serialize() {
1969 const b = this.builder;
1970 this._normalize_ids();
1971 this._normalize_names();
1972 const pages = [];
1973 b.pages_container.querySelectorAll( '.wpbc_bfb__panel--preview' ).forEach( (page_el, page_index) => {
1974 const container = page_el.querySelector( '.wpbc_bfb__form_preview_section_container' );
1975 const content = [];
1976 if ( !container ) {
1977 pages.push( { page: page_index + 1, content } );
1978 return;
1979 }
1980 container.querySelectorAll( ':scope > *' ).forEach( (child) => {
1981 if ( child.classList.contains( 'wpbc_bfb__section' ) ) {
1982 content.push( { type: 'section', data: this.serialize_section( child ) } );
1983 return;
1984 }
1985 if ( child.classList.contains( 'wpbc_bfb__field' ) ) {
1986 if ( child.classList.contains( 'is-invalid' ) ) {
1987 return;
1988 }
1989 const f_data = this._normalize_field_option_values(
1990 Core.WPBC_Form_Builder_Helper.get_all_data_attributes( child )
1991 );
1992 // Drop ephemeral/editor-only flags
1993 [ 'uid', 'fresh', 'autoname', 'was_loaded', 'name_user_touched' ]
1994 .forEach( k => {
1995 if ( k in f_data ) delete f_data[k];
1996 } );
1997 content.push( { type: 'field', data: f_data } );
1998 }
1999 } );
2000 pages.push( { page: page_index + 1, content } );
2001 } );
2002 return pages;
2003 }
2004
2005 /**
2006 * @param {HTMLElement} section_el
2007 * @returns {{id:string,label:string,html_id:string,cssclass:string,col_styles:string,columns:Array}}
2008 */
2009 serialize_section(section_el) {
2010 const row = section_el.querySelector( ':scope > .wpbc_bfb__row' );
2011
2012 // NEW: read per-column styles from dataset/attributes (underscore & hyphen)
2013 var col_styles_raw =
2014 section_el.getAttribute( 'data-col_styles' ) ||
2015 (section_el.dataset ? (section_el.dataset.col_styles) : '') ||
2016 '';
2017
2018 const base = {
2019 id : section_el.dataset.id,
2020 label : section_el.dataset.label || '',
2021 html_id : section_el.dataset.html_id || '',
2022 cssclass : section_el.dataset.cssclass || '',
2023 col_styles: String( col_styles_raw ) // <-- NEW: keep as raw JSON string
2024 };
2025
2026 if ( !row ) {
2027 return Object.assign( {}, base, { columns: [] } );
2028 }
2029
2030 const columns = [];
2031 row.querySelectorAll( ':scope > .wpbc_bfb__column' ).forEach( function (col) {
2032 const width = col.style.flexBasis || '100%';
2033 const items = [];
2034 Array.prototype.forEach.call( col.children, function (child) {
2035 if ( child.classList.contains( 'wpbc_bfb__section' ) ) {
2036 items.push( { type: 'section', data: this.serialize_section( child ) } );
2037 return;
2038 }
2039 if ( child.classList.contains( 'wpbc_bfb__field' ) ) {
2040 if ( child.classList.contains( 'is-invalid' ) ) {
2041 return;
2042 }
2043 const f_data = this._normalize_field_option_values(
2044 Core.WPBC_Form_Builder_Helper.get_all_data_attributes( child )
2045 );
2046 [ 'uid', 'fresh', 'autoname', 'was_loaded', 'name_user_touched' ].forEach( function (k) {
2047 if ( k in f_data ) {
2048 delete f_data[k];
2049 }
2050 } );
2051 items.push( { type: 'field', data: f_data } );
2052 }
2053 }.bind( this ) );
2054 columns.push( { width: width, items: items } );
2055 }.bind( this ) );
2056
2057 // Clamp persisted col_styles to the actual number of columns on Save.
2058 try {
2059 const colCount = columns.length;
2060 const raw = String( col_styles_raw || '' ).trim();
2061
2062 if ( raw ) {
2063 let arr = [];
2064 try {
2065 const parsed = JSON.parse( raw );
2066 arr = Array.isArray( parsed ) ? parsed : (parsed && Array.isArray( parsed.columns ) ? parsed.columns : []);
2067 } catch ( _e ) {
2068 arr = [];
2069 }
2070
2071 if ( colCount <= 0 ) {
2072 base.col_styles = '[]';
2073 } else {
2074 if ( arr.length > colCount ) arr.length = colCount;
2075 while ( arr.length < colCount ) arr.push( {} );
2076 base.col_styles = JSON.stringify( arr );
2077 }
2078 } else {
2079 base.col_styles = '';
2080 }
2081 } catch ( _e ) {
2082 }
2083
2084 return Object.assign( {}, base, { columns: columns } );
2085 }
2086
2087 /**
2088 * @param {Array} structure
2089 * @param {{deferIfTyping?: boolean}} [opts = {}]
2090 */
2091 deserialize(structure, { deferIfTyping = true } = {}) {
2092 const b = this.builder;
2093 if ( deferIfTyping && this._is_typing_in_inspector() ) {
2094 clearTimeout( this._defer_timer );
2095 this._defer_timer = setTimeout( () => {
2096 this.deserialize( structure, { deferIfTyping: false } );
2097 }, 150 );
2098 return;
2099 }
2100 b.pages_container.innerHTML = '';
2101 b.page_counter = 0;
2102 (structure || []).forEach( (page_data) => {
2103 const page_el = b.pages_sections.add_page( { scroll: false } );
2104 const section_container = page_el.querySelector( '.wpbc_bfb__form_preview_section_container' );
2105 section_container.innerHTML = '';
2106 b.init_section_sortable?.( section_container );
2107 (page_data.content || []).forEach( (item) => {
2108 if ( item.type === 'section' ) {
2109 // Now returns the element; attributes (incl. col_styles) are applied inside rebuild.
2110 b.pages_sections.rebuild_section( item.data, section_container );
2111 return;
2112 }
2113 if ( item.type === 'field' ) {
2114 const el = b.build_field( item.data );
2115 if ( el ) {
2116 section_container.appendChild( el );
2117 b.trigger_field_drop_callback( el, 'load' );
2118 }
2119 }
2120 } );
2121 } );
2122 b.usage?.update_palette_ui?.();
2123 b.bus.emit( Core.WPBC_BFB_Events.STRUCTURE_LOADED, { structure } );
2124 }
2125
2126 _normalize_ids() {
2127 const b = this.builder;
2128 b.pages_container.querySelectorAll( '.wpbc_bfb__panel--preview .wpbc_bfb__field:not(.is-invalid)' ).forEach( (el) => {
2129 const data = Core.WPBC_Form_Builder_Helper.get_all_data_attributes( el );
2130 const want = Core.WPBC_BFB_Sanitize.sanitize_html_id( data.id || '' ) || 'field';
2131 const uniq = b.id.ensure_unique_field_id( want, el );
2132 if ( data.id !== uniq ) {
2133 el.setAttribute( 'data-id', uniq );
2134 if ( b.preview_mode ) {
2135 b.render_preview( el );
2136 }
2137 }
2138 } );
2139 }
2140
2141 _normalize_names() {
2142 const b = this.builder;
2143 b.pages_container.querySelectorAll( '.wpbc_bfb__panel--preview .wpbc_bfb__field:not(.is-invalid)' ).forEach( (el) => {
2144 const data = Core.WPBC_Form_Builder_Helper.get_all_data_attributes( el );
2145 const base = Core.WPBC_BFB_Sanitize.sanitize_html_name( (data.name != null) ? data.name : data.id ) || 'field';
2146 const uniq = b.id.ensure_unique_field_name( base, el );
2147 if ( data.name !== uniq ) {
2148 el.setAttribute( 'data-name', uniq );
2149 if ( b.preview_mode ) {
2150 b.render_preview( el );
2151 }
2152 }
2153 } );
2154 }
2155
2156 /** @returns {boolean} */
2157 _is_typing_in_inspector() {
2158 const ins = document.getElementById( 'wpbc_bfb__inspector' );
2159 return !!(ins && document.activeElement && ins.contains( document.activeElement ));
2160 }
2161 };
2162
2163 /**
2164 * Minimal, standalone guard that enforces per-column min widths based on fields' data-min_width.
2165 *
2166 * @type {UI.WPBC_BFB_Min_Width_Guard}
2167 */
2168 UI.WPBC_BFB_Min_Width_Guard = class extends UI.WPBC_BFB_Module {
2169
2170 constructor(builder) {
2171 super( builder );
2172 this._on_field_add = this._on_field_add.bind( this );
2173 this._on_field_remove = this._on_field_remove.bind( this );
2174 this._on_structure_loaded = this._on_structure_loaded.bind( this );
2175 this._on_structure_change = this._on_structure_change.bind( this );
2176 this._on_window_resize = this._on_window_resize.bind( this );
2177
2178 this._pending_rows = new Set();
2179 this._pending_all = false;
2180 this._raf_id = 0;
2181 }
2182
2183 init() {
2184 const EV = Core.WPBC_BFB_Events;
2185 this.builder?.bus?.on?.( EV.FIELD_ADD, this._on_field_add );
2186 this.builder?.bus?.on?.( EV.FIELD_REMOVE, this._on_field_remove );
2187 this.builder?.bus?.on?.( EV.STRUCTURE_LOADED, this._on_structure_loaded );
2188 // Refresh selectively on structure change (NOT on every prop input).
2189 this.builder?.bus?.on?.( EV.STRUCTURE_CHANGE, this._on_structure_change );
2190
2191 window.addEventListener( 'resize', this._on_window_resize, { passive: true } );
2192 this._schedule_refresh_all();
2193 }
2194
2195 destroy() {
2196 const EV = Core.WPBC_BFB_Events;
2197 this.builder?.bus?.off?.( EV.FIELD_ADD, this._on_field_add );
2198 this.builder?.bus?.off?.( EV.FIELD_REMOVE, this._on_field_remove );
2199 this.builder?.bus?.off?.( EV.STRUCTURE_LOADED, this._on_structure_loaded );
2200 this.builder?.bus?.off?.( EV.STRUCTURE_CHANGE, this._on_structure_change );
2201 window.removeEventListener( 'resize', this._on_window_resize );
2202 }
2203
2204 _on_field_add(e) {
2205 this._schedule_refresh_all();
2206 // if you really want to be minimal work here, keep your row-only version.
2207 }
2208
2209 _on_field_remove(e) {
2210 const src_el = e?.detail?.el || null;
2211 const row = (src_el && src_el.closest) ? src_el.closest( '.wpbc_bfb__row' ) : null;
2212 if ( row ) {
2213 this._schedule_refresh_row( row );
2214 } else {
2215 this._schedule_refresh_all();
2216 }
2217 }
2218
2219 _on_structure_loaded() {
2220 this._schedule_refresh_all();
2221 }
2222
2223 _on_structure_change(e) {
2224 const reason = e?.detail?.reason || '';
2225 const key = e?.detail?.key || '';
2226
2227 // Ignore noisy prop changes that don't affect min widths.
2228 if ( reason === 'prop-change' && key !== 'min_width' ) {
2229 return;
2230 }
2231
2232 const el = e?.detail?.el || null;
2233 const row = el?.closest?.( '.wpbc_bfb__row' ) || null;
2234 if ( row ) {
2235 this._schedule_refresh_row( row );
2236 } else {
2237 this._schedule_refresh_all();
2238 }
2239 }
2240
2241 _on_window_resize() {
2242 this._schedule_refresh_all();
2243 }
2244
2245 _schedule_refresh_row(row_el) {
2246 if ( !row_el ) return;
2247 this._pending_rows.add( row_el );
2248 this._kick_raf();
2249 }
2250
2251 _schedule_refresh_all() {
2252 this._pending_all = true;
2253 this._pending_rows.clear();
2254 this._kick_raf();
2255 }
2256
2257 _kick_raf() {
2258 if ( this._raf_id ) return;
2259 this._raf_id = (window.requestAnimationFrame || setTimeout)( () => {
2260 this._raf_id = 0;
2261 if ( this._pending_all ) {
2262 this._pending_all = false;
2263 this.refresh_all();
2264 return;
2265 }
2266 const rows = Array.from( this._pending_rows );
2267 this._pending_rows.clear();
2268 rows.forEach( (r) => this.refresh_row( r ) );
2269 }, 0 );
2270 }
2271
2272
2273 refresh_all() {
2274 this.builder?.pages_container
2275 ?.querySelectorAll?.( '.wpbc_bfb__row' )
2276 ?.forEach?.( (row) => this.refresh_row( row ) );
2277 }
2278
2279 refresh_row(row_el) {
2280 if ( !row_el ) return;
2281
2282 const cols = row_el.querySelectorAll( ':scope > .wpbc_bfb__column' );
2283
2284 // 1) Recalculate each column’s required min px and write it to the CSS var.
2285 cols.forEach( (col) => this.apply_col_min( col ) );
2286
2287 // 2) Enforce it at the CSS level right away so layout can’t render narrower.
2288 cols.forEach( (col) => {
2289 const px = parseFloat( getComputedStyle( col ).getPropertyValue( '--wpbc-col-min' ) || '0' ) || 0;
2290 col.style.minWidth = px > 0 ? Math.round( px ) + 'px' : '';
2291 } );
2292
2293 // 3) Normalize current bases so the row respects all mins without overflow.
2294 try {
2295 const b = this.builder;
2296 const gp = b.col_gap_percent;
2297 const eff = b.layout.compute_effective_bases_from_row( row_el, gp ); // { bases, available }
2298 // Re-fit *current* bases against mins (same algorithm layout chips use).
2299 const fitted = UI.WPBC_BFB_Layout_Chips._fit_weights_respecting_min( b, row_el, eff.bases );
2300 if ( Array.isArray( fitted ) ) {
2301 const changed = fitted.some( (v, i) => Math.abs( v - eff.bases[i] ) > 0.01 );
2302 if ( changed ) {
2303 b.layout.apply_bases_to_row( row_el, fitted );
2304 }
2305 }
2306 } catch ( e ) {
2307 w._wpbc?.dev?.error?.( 'WPBC_BFB_Min_Width_Guard - refresh_row', e );
2308 }
2309 }
2310
2311 apply_col_min(col_el) {
2312 if ( !col_el ) return;
2313 let max_px = 0;
2314 const colRect = col_el.getBoundingClientRect();
2315 col_el.querySelectorAll( ':scope > .wpbc_bfb__field' ).forEach( (field) => {
2316 const raw = field.getAttribute( 'data-min_width' );
2317 let px = 0;
2318 if ( raw ) {
2319 const s = String( raw ).trim().toLowerCase();
2320 if ( s.endsWith( '%' ) ) {
2321 const n = parseFloat( s );
2322 if ( Number.isFinite( n ) && colRect.width > 0 ) {
2323 px = (n / 100) * colRect.width;
2324 } else {
2325 px = 0;
2326 }
2327 } else {
2328 px = this.parse_len_px( s );
2329 }
2330 } else {
2331 const cs = getComputedStyle( field );
2332 px = parseFloat( cs.minWidth || '0' ) || 0;
2333 }
2334 if ( px > max_px ) max_px = px;
2335 } );
2336 col_el.style.setProperty( '--wpbc-col-min', max_px > 0 ? Math.round( max_px ) + 'px' : '0px' );
2337 }
2338
2339 parse_len_px(value) {
2340 if ( value == null ) return 0;
2341 const s = String( value ).trim().toLowerCase();
2342 if ( s === '' ) return 0;
2343 if ( s.endsWith( 'px' ) ) {
2344 const n = parseFloat( s );
2345 return Number.isFinite( n ) ? n : 0;
2346 }
2347 if ( s.endsWith( 'rem' ) || s.endsWith( 'em' ) ) {
2348 const n = parseFloat( s );
2349 const base = parseFloat( getComputedStyle( document.documentElement ).fontSize ) || 16;
2350 return Number.isFinite( n ) ? n * base : 0;
2351 }
2352 const n = parseFloat( s );
2353 return Number.isFinite( n ) ? n : 0;
2354 }
2355 };
2356
2357 /**
2358 * WPBC_BFB_Toggle_Normalizer
2359 *
2360 * Converts plain checkboxes into toggle UI:
2361 * <div class="inspector__control wpbc_ui__toggle">
2362 * <input type="checkbox" id="{unique}" data-inspector-key="..." class="inspector__input" role="switch"
2363 * aria-checked="true|false">
2364 * <label class="wpbc_ui__toggle_icon" for="{unique}"></label>
2365 * <label class="wpbc_ui__toggle_label" for="{unique}">Label</label>
2366 * </div>
2367 *
2368 * - Skips inputs already inside `.wpbc_ui__toggle`.
2369 * - Reuses an existing <label for="..."> text if present; otherwise falls back to nearby labels or attributes.
2370 * - Auto-generates a unique id when absent.
2371 */
2372 UI.WPBC_BFB_Toggle_Normalizer = class {
2373
2374 /**
2375 * Upgrade all raw checkboxes in a container to toggles.
2376 * @param {HTMLElement} root_el
2377 */
2378 static upgrade_checkboxes_in(root_el) {
2379
2380 if ( !root_el || !root_el.querySelectorAll ) {
2381 return;
2382 }
2383
2384 var inputs = root_el.querySelectorAll( 'input[type="checkbox"]' );
2385 if ( !inputs.length ) {
2386 return;
2387 }
2388
2389 Array.prototype.forEach.call( inputs, function (input) {
2390
2391 // 1) Skip if already inside toggle wrapper.
2392 if ( input.closest( '.wpbc_ui__toggle' ) ) {
2393 return;
2394 }
2395 // Skip rows / where input checkbox explicitly marked with attribute 'data-wpbc-ui-no-toggle'.
2396 if ( input.hasAttribute( 'data-wpbc-ui-no-toggle' ) ) {
2397 return;
2398 }
2399
2400 // 2) Ensure unique id; prefer existing.
2401 var input_id = input.getAttribute( 'id' );
2402 if ( !input_id ) {
2403 var key = (input.dataset && input.dataset.inspectorKey) ? String( input.dataset.inspectorKey ) : 'opt';
2404 input_id = UI.WPBC_BFB_Toggle_Normalizer.generate_unique_id( 'wpbc_ins_auto_' + key + '_' );
2405 input.setAttribute( 'id', input_id );
2406 }
2407
2408 // 3) Find best label text.
2409 var label_text = UI.WPBC_BFB_Toggle_Normalizer.resolve_label_text( root_el, input, input_id );
2410
2411 // 4) Build the toggle wrapper.
2412 var wrapper = document.createElement( 'div' );
2413 wrapper.className = 'inspector__control wpbc_ui__toggle';
2414
2415 // Keep original input; just move it into wrapper.
2416 input.classList.add( 'inspector__input' );
2417 input.setAttribute( 'role', 'switch' );
2418 input.setAttribute( 'aria-checked', input.checked ? 'true' : 'false' );
2419
2420 var icon_label = document.createElement( 'label' );
2421 icon_label.className = 'wpbc_ui__toggle_icon';
2422 icon_label.setAttribute( 'for', input_id );
2423
2424 var text_label = document.createElement( 'label' );
2425 text_label.className = 'wpbc_ui__toggle_label';
2426 text_label.setAttribute( 'for', input_id );
2427 text_label.appendChild( document.createTextNode( label_text ) );
2428
2429 // 5) Insert wrapper into DOM near the input.
2430 // Preferred: replace the original labeled row if it matches typical inspector layout.
2431 var replaced = UI.WPBC_BFB_Toggle_Normalizer.try_replace_known_row( input, wrapper, label_text );
2432
2433 if ( !replaced ) {
2434 if ( !input.parentNode ) return; // NEW guard
2435 // Fallback: just wrap the input in place and append labels.
2436 input.parentNode.insertBefore( wrapper, input );
2437 wrapper.appendChild( input );
2438 wrapper.appendChild( icon_label );
2439 wrapper.appendChild( text_label );
2440 }
2441
2442 // 6) ARIA sync on change.
2443 input.addEventListener( 'change', function () {
2444 input.setAttribute( 'aria-checked', input.checked ? 'true' : 'false' );
2445 } );
2446 } );
2447 }
2448
2449 /**
2450 * Generate a unique id with a given prefix.
2451 * @param {string} prefix
2452 * @returns {string}
2453 */
2454 static generate_unique_id(prefix) {
2455 var base = String( prefix || 'wpbc_ins_auto_' );
2456 var uid = Math.random().toString( 36 ).slice( 2, 8 );
2457 var id = base + uid;
2458 // Minimal collision guard in the current document scope.
2459 while ( document.getElementById( id ) ) {
2460 uid = Math.random().toString( 36 ).slice( 2, 8 );
2461 id = base + uid;
2462 }
2463 return id;
2464 }
2465
2466 /**
2467 * Resolve the best human label for an input.
2468 * Priority:
2469 * 1) <label for="{id}">text</label>
2470 * 2) nearest sibling/parent .inspector__label text
2471 * 3) input.getAttribute('aria-label') || data-label || data-inspector-key || name || 'Option'
2472 * @param {HTMLElement} root_el
2473 * @param {HTMLInputElement} input
2474 * @param {string} input_id
2475 * @returns {string}
2476 */
2477 static resolve_label_text(root_el, input, input_id) {
2478 // for= association
2479 if ( input_id ) {
2480 var assoc = root_el.querySelector( 'label[for="' + UI.WPBC_BFB_Toggle_Normalizer.css_escape( input_id ) + '"]' );
2481 if ( assoc && assoc.textContent ) {
2482 var txt = assoc.textContent.trim();
2483 // Remove the old label from DOM; its text will be used by toggle.
2484 assoc.parentNode && assoc.parentNode.removeChild( assoc );
2485 if ( txt ) {
2486 return txt;
2487 }
2488 }
2489 }
2490
2491 // nearby inspector label
2492 var near_label = input.closest( '.inspector__row' );
2493 if ( near_label ) {
2494 var il = near_label.querySelector( '.inspector__label' );
2495 if ( il && il.textContent ) {
2496 var t2 = il.textContent.trim();
2497 // If this row had the standard label+control, drop the old text label to avoid duplicates.
2498 il.parentNode && il.parentNode.removeChild( il );
2499 if ( t2 ) {
2500 return t2;
2501 }
2502 }
2503 }
2504
2505 // fallbacks
2506 var aria = input.getAttribute( 'aria-label' );
2507 if ( aria ) {
2508 return aria;
2509 }
2510 if ( input.dataset && input.dataset.label ) {
2511 return String( input.dataset.label );
2512 }
2513 if ( input.dataset && input.dataset.inspectorKey ) {
2514 return String( input.dataset.inspectorKey );
2515 }
2516 if ( input.name ) {
2517 return String( input.name );
2518 }
2519 return 'Option';
2520 }
2521
2522 /**
2523 * Try to replace a known inspector row pattern with a toggle wrapper.
2524 * Patterns:
2525 * <div.inspector__row>
2526 * <label.inspector__label>Text</label>
2527 * <div.inspector__control> [input[type=checkbox]] </div>
2528 * </div>
2529 *
2530 * @param {HTMLInputElement} input
2531 * @param {HTMLElement} wrapper
2532 * @returns {boolean} replaced
2533 */
2534 static try_replace_known_row(input, wrapper, label_text) {
2535 var row = input.closest( '.inspector__row' );
2536 var ctrl_wrap = input.parentElement;
2537
2538 if ( row && ctrl_wrap && ctrl_wrap.classList.contains( 'inspector__control' ) ) {
2539 // Clear control wrap and reinsert toggle structure.
2540 while ( ctrl_wrap.firstChild ) {
2541 ctrl_wrap.removeChild( ctrl_wrap.firstChild );
2542 }
2543 row.classList.add( 'inspector__row--toggle' );
2544
2545 ctrl_wrap.classList.add( 'wpbc_ui__toggle' );
2546 ctrl_wrap.appendChild( input );
2547
2548 var input_id = input.getAttribute( 'id' );
2549 var icon_lbl = document.createElement( 'label' );
2550 icon_lbl.className = 'wpbc_ui__toggle_icon';
2551 icon_lbl.setAttribute( 'for', input_id );
2552
2553 var text_lbl = document.createElement( 'label' );
2554 text_lbl.className = 'wpbc_ui__toggle_label';
2555 text_lbl.setAttribute( 'for', input_id );
2556 if ( label_text ) {
2557 text_lbl.appendChild( document.createTextNode( label_text ) );
2558 }
2559 // If the row previously had a .inspector__label (we removed it in resolve_label_text),
2560 // we intentionally do NOT recreate it; the toggle text label becomes the visible one.
2561 // The text content is already resolved in resolve_label_text() and set below by caller.
2562
2563 ctrl_wrap.appendChild( icon_lbl );
2564 ctrl_wrap.appendChild( text_lbl );
2565 return true;
2566 }
2567
2568 // Not a known pattern; caller will wrap in place.
2569 return false;
2570 }
2571
2572 /**
2573 * CSS.escape polyfill for selectors.
2574 * @param {string} s
2575 * @returns {string}
2576 */
2577 static css_escape(s) {
2578 s = String( s );
2579 if ( window.CSS && typeof window.CSS.escape === 'function' ) {
2580 return window.CSS.escape( s );
2581 }
2582 return s.replace( /([^\w-])/g, '\\$1' );
2583 }
2584 };
2585
2586 /**
2587 * Apply all UI normalizers/enhancers to a container (post-render).
2588 * Keep this file small and add more normalizers later in one place.
2589 *
2590 * @param {HTMLElement} root
2591 */
2592 UI.apply_post_render = function (root) {
2593 if ( !root ) {
2594 return;
2595 }
2596 try {
2597 UI.WPBC_BFB_ValueSlider?.init_on?.( root );
2598 } catch ( e ) { /* noop */
2599 }
2600 try {
2601 var T = UI.WPBC_BFB_Toggle_Normalizer;
2602 if ( T && typeof T.upgrade_checkboxes_in === 'function' ) {
2603 T.upgrade_checkboxes_in( root );
2604 }
2605 } catch ( e ) {
2606 w._wpbc?.dev?.error?.( 'apply_post_render.toggle', e );
2607 }
2608
2609 // Accessibility: keep aria-checked in sync for all toggles inside root.
2610 try {
2611 root.querySelectorAll( '.wpbc_ui__toggle input[type="checkbox"]' ).forEach( function (cb) {
2612 if ( cb.__wpbc_aria_hooked ) {
2613 return;
2614 }
2615 cb.__wpbc_aria_hooked = true;
2616 cb.setAttribute( 'aria-checked', cb.checked ? 'true' : 'false' );
2617 // Delegate ‘change’ just once per render – native delegation still works fine for your logic.
2618 cb.addEventListener( 'change', () => {
2619 cb.setAttribute( 'aria-checked', cb.checked ? 'true' : 'false' );
2620 }, { passive: true } );
2621 } );
2622 } catch ( e ) {
2623 w._wpbc?.dev?.error?.( 'apply_post_render.aria', e );
2624 }
2625 };
2626
2627 UI.InspectorEnhancers = UI.InspectorEnhancers || (function () {
2628 var regs = [];
2629
2630 function register(name, selector, init, destroy) {
2631 regs.push( { name, selector, init, destroy } );
2632 }
2633
2634 function scan(root) {
2635 if ( !root ) return;
2636 regs.forEach( function (r) {
2637 root.querySelectorAll( r.selector ).forEach( function (node) {
2638 node.__wpbc_eh = node.__wpbc_eh || {};
2639 if ( node.__wpbc_eh[r.name] ) return;
2640 try {
2641 r.init && r.init( node, root );
2642 node.__wpbc_eh[r.name] = true;
2643 } catch ( _e ) {
2644 }
2645 } );
2646 } );
2647 }
2648
2649 function destroy(root) {
2650 if ( !root ) return;
2651 regs.forEach( function (r) {
2652 root.querySelectorAll( r.selector ).forEach( function (node) {
2653 try {
2654 r.destroy && r.destroy( node, root );
2655 } catch ( _e ) {
2656 }
2657 if ( node.__wpbc_eh ) delete node.__wpbc_eh[r.name];
2658 } );
2659 } );
2660 }
2661
2662 return { register, scan, destroy };
2663 })();
2664
2665 UI.WPBC_BFB_ValueSlider = {
2666 init_on(root) {
2667 var groups = (root.nodeType === 1 ? [ root ] : []).concat( [].slice.call( root.querySelectorAll?.( '[data-len-group]' ) || [] ) );
2668 groups.forEach( function (g) {
2669 if ( !g.matches || !g.matches( '[data-len-group]' ) ) return;
2670 if ( g.__wpbc_len_wired ) return;
2671
2672 var number = g.querySelector( '[data-len-value]' );
2673 var range = g.querySelector( '[data-len-range]' );
2674 var unit = g.querySelector( '[data-len-unit]' );
2675
2676 if ( !number || !range ) return;
2677
2678 // Mirror constraints if missing on the range.
2679 [ 'min', 'max', 'step' ].forEach( function (a) {
2680 if ( !range.hasAttribute( a ) && number.hasAttribute( a ) ) {
2681 range.setAttribute( a, number.getAttribute( a ) );
2682 }
2683 } );
2684
2685
2686 function sync_range_from_number() {
2687 if ( range.value !== number.value ) {
2688 range.value = number.value;
2689 }
2690 }
2691
2692 function dispatch_input(el) {
2693 try { el.dispatchEvent( new Event( 'input', { bubbles: true } ) ); } catch ( _e ) {}
2694 }
2695 function dispatch_change(el) {
2696 try { el.dispatchEvent( new Event( 'change', { bubbles: true } ) ); } catch ( _e ) {}
2697 }
2698
2699 // Throttle range->number syncing (time-based).
2700 var timer_id = 0;
2701 var pending_val = null;
2702 var pending_change = false;
2703 var last_flush_ts = 0;
2704
2705 // Change this to tune speed: 50..120 ms is a good range.
2706 var min_interval_ms = parseInt( g.dataset.lenThrottle || UI.VALUE_SLIDER_THROTTLE_MS, 10 );
2707 min_interval_ms = Number.isFinite( min_interval_ms ) ? Math.max( 0, min_interval_ms ) : 120;
2708
2709 function flush_range_to_number() {
2710 timer_id = 0;
2711
2712 if ( pending_val == null ) {
2713 return;
2714 }
2715
2716 var next = String( pending_val );
2717 pending_val = null;
2718
2719 if ( number.value !== next ) {
2720 number.value = next;
2721 // IMPORTANT: only 'input' while dragging.
2722 dispatch_input( number );
2723 }
2724
2725 if ( pending_change ) {
2726 pending_change = false;
2727 dispatch_change( number );
2728 }
2729
2730 last_flush_ts = Date.now();
2731 }
2732
2733 function schedule_range_to_number(val, emit_change) {
2734 pending_val = val;
2735 if ( emit_change ) {
2736 pending_change = true;
2737 }
2738
2739 // If commit requested, flush immediately.
2740 if ( pending_change ) {
2741 if ( timer_id ) {
2742 clearTimeout( timer_id );
2743 timer_id = 0;
2744 }
2745 flush_range_to_number();
2746 return;
2747 }
2748
2749 var now = Date.now();
2750 var delta = now - last_flush_ts;
2751
2752 // If enough time passed, flush immediately; else schedule.
2753 if ( delta >= min_interval_ms ) {
2754 flush_range_to_number();
2755 return;
2756 }
2757
2758 if ( timer_id ) {
2759 return;
2760 }
2761
2762 timer_id = setTimeout( flush_range_to_number, Math.max( 0, min_interval_ms - delta ) );
2763 }
2764
2765 function on_number_input() {
2766 sync_range_from_number();
2767 }
2768
2769 function on_number_change() {
2770 sync_range_from_number();
2771 }
2772
2773 function on_range_input() {
2774 schedule_range_to_number( range.value, false );
2775 }
2776
2777 function on_range_change() {
2778 schedule_range_to_number( range.value, true );
2779 }
2780
2781 number.addEventListener( 'input', on_number_input );
2782 number.addEventListener( 'change', on_number_change );
2783 range.addEventListener( 'input', on_range_input );
2784 range.addEventListener( 'change', on_range_change );
2785
2786 if ( unit ) {
2787 unit.addEventListener( 'change', function () {
2788 // We just nudge the number so upstream handlers re-run.
2789 try {
2790 number.dispatchEvent( new Event( 'input', { bubbles: true } ) );
2791 } catch ( _e ) {
2792 }
2793 } );
2794 }
2795
2796 // Initial sync
2797 sync_range_from_number();
2798
2799 g.__wpbc_len_wired = {
2800 destroy() {
2801 number.removeEventListener( 'input', on_number_input );
2802 number.removeEventListener( 'change', on_number_change );
2803 range.removeEventListener( 'input', on_range_input );
2804 range.removeEventListener( 'change', on_range_change );
2805 }
2806 };
2807 } );
2808 },
2809 destroy_on(root) {
2810 var groups = (root && root.nodeType === 1 ? [ root ] : []).concat(
2811 [].slice.call( root.querySelectorAll?.( '[data-len-group]' ) || [] )
2812 );
2813 groups.forEach( function (g) {
2814 if ( !g.matches || !g.matches( '[data-len-group]' ) ) return;
2815 try {
2816 g.__wpbc_len_wired && g.__wpbc_len_wired.destroy && g.__wpbc_len_wired.destroy();
2817 } catch ( _e ) {
2818 }
2819 delete g.__wpbc_len_wired;
2820 } );
2821 }
2822 };
2823
2824 // Register with the global enhancers hub.
2825 UI.InspectorEnhancers && UI.InspectorEnhancers.register(
2826 'value-slider',
2827 '[data-len-group]',
2828 function (el, _root) {
2829 UI.WPBC_BFB_ValueSlider.init_on( el );
2830 },
2831 function (el, _root) {
2832 UI.WPBC_BFB_ValueSlider.destroy_on( el );
2833 }
2834 );
2835
2836 // Single, load-order-safe patch so enhancers auto-run on every bind.
2837 (function patchInspectorEnhancers() {
2838 function applyPatch() {
2839 var Inspector = w.WPBC_BFB_Inspector;
2840 if ( !Inspector || Inspector.__wpbc_enhancers_patched ) return false;
2841 Inspector.__wpbc_enhancers_patched = true;
2842 var orig = Inspector.prototype.bind_to_field;
2843 Inspector.prototype.bind_to_field = function (el) {
2844 orig.call( this, el );
2845 try {
2846 var ins = this.panel
2847 || document.getElementById( 'wpbc_bfb__inspector' )
2848 || document.querySelector( '.wpbc_bfb__inspector' );
2849 UI.InspectorEnhancers && UI.InspectorEnhancers.scan( ins );
2850 } catch ( _e ) {
2851 }
2852 };
2853 // Initial scan if the DOM is already present.
2854 try {
2855 var insEl = document.getElementById( 'wpbc_bfb__inspector' )
2856 || document.querySelector( '.wpbc_bfb__inspector' );
2857 UI.InspectorEnhancers && UI.InspectorEnhancers.scan( insEl );
2858 } catch ( _e ) {
2859 }
2860 return true;
2861 }
2862
2863 // Try now; if Inspector isn’t defined yet, patch when it becomes ready.
2864 if ( !applyPatch() ) {
2865 document.addEventListener(
2866 'wpbc_bfb_inspector_ready',
2867 function () {
2868 applyPatch();
2869 },
2870 { once: true }
2871 );
2872 }
2873 })();
2874
2875 }( window, document ));
2876