PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
booking / includes / page-form-builder / __js / core / bfb-ui.js

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

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