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

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

1,162 lines 40.2 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-fields.js == | 2025-09-10 15:47
3 // ---------------------------------------------------------------------------------------------------------------------
4 (function ( w ) {
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 /**
12 * Base class for field renderers (static-only contract).
13 * ================================================================================================================
14 * Contract exposed to the builder (static methods on the CLASS itself):
15 * - render(el, data, ctx) // REQUIRED
16 * - on_field_drop(data, el, meta) // OPTIONAL (default provided)
17 *
18 * Helpers for subclasses:
19 * - get_defaults() -> per-field defaults (MUST override in subclass to set type/label)
20 * - normalize_data(d) -> shallow merge with defaults
21 * - get_template(id) -> per-id cached wp.template compiler
22 *
23 * Subclass usage:
24 * class WPBC_BFB_Field_Text extends Core.WPBC_BFB_Field_Base { static get_defaults(){ ... } }
25 * WPBC_BFB_Field_Text.template_id = 'wpbc-bfb-field-text';
26 * ================================================================================================================
27 */
28 Core.WPBC_BFB_Field_Base = class {
29
30 /**
31 * Default field data (generic baseline).
32 * Subclasses MUST override to provide { type, label } appropriate for the field.
33 * @returns {Object}
34 */
35 static get_defaults() {
36 return {
37 type : 'field',
38 label : 'Field',
39 name : 'field',
40 html_id : '',
41 placeholder : '',
42 required : false,
43 minlength : '',
44 maxlength : '',
45 pattern : '',
46 cssclass : '',
47 help : ''
48 };
49 }
50
51 /**
52 * Shallow-merge incoming data with defaults.
53 * @param {Object} data
54 * @returns {Object}
55 */
56 static normalize_data( data ) {
57 var d = data || {};
58 var defaults = this.get_defaults();
59 var out = {};
60 var k;
61
62 for ( k in defaults ) {
63 if ( Object.prototype.hasOwnProperty.call( defaults, k ) ) {
64 out[k] = defaults[k];
65 }
66 }
67 for ( k in d ) {
68 if ( Object.prototype.hasOwnProperty.call( d, k ) ) {
69 out[k] = d[k];
70 }
71 }
72 return out;
73 }
74
75 /**
76 * Compile and cache a wp.template by id (per-id cache).
77 * @param {string} template_id
78 * @returns {Function|null}
79 */
80 static get_template(template_id) {
81
82 // Accept either "wpbc-bfb-field-text" or "tmpl-wpbc-bfb-field-text".
83 if ( ! template_id || ! window.wp || ! wp.template ) {
84 return null;
85 }
86 const domId = template_id.startsWith( 'tmpl-' ) ? template_id : ('tmpl-' + template_id);
87 if ( ! document.getElementById( domId ) ) {
88 return null;
89 }
90
91 if ( ! Core.__bfb_tpl_cache_map ) {
92 Core.__bfb_tpl_cache_map = {};
93 }
94
95 // Normalize id for the compiler & cache. // wp.template expects id WITHOUT the "tmpl-" prefix !
96 const key = template_id.replace( /^tmpl-/, '' );
97 if ( Core.__bfb_tpl_cache_map[key] ) {
98 return Core.__bfb_tpl_cache_map[key];
99 }
100
101 const compiler = wp.template( key ); // <-- normalized id here
102 if ( compiler ) {
103 Core.__bfb_tpl_cache_map[key] = compiler;
104 }
105
106 return compiler;
107 }
108
109 /**
110 * REQUIRED: render preview into host element (full redraw; idempotent).
111 * Subclasses should set static `template_id` to a valid wp.template id.
112 * @param {HTMLElement} el
113 * @param {Object} data
114 * @param {{mode?:string,builder?:any,tpl?:Function,sanit?:any}} ctx
115 * @returns {void}
116 */
117 static render( el, data, ctx ) {
118 if ( ! el ) {
119 return;
120 }
121
122 var compile = this.get_template( this.template_id );
123 var d = this.normalize_data( data );
124
125 var s = (ctx && ctx.sanit) ? ctx.sanit : Core.WPBC_BFB_Sanitize;
126
127 // Sanitize critical attributes before templating.
128 if ( s ) {
129 d.html_id = d.html_id ? s.sanitize_html_id( String( d.html_id ) ) : '';
130 d.name = s.sanitize_html_name( String( d.name || d.id || 'field' ) );
131 } else {
132 d.html_id = d.html_id ? String( d.html_id ) : '';
133 d.name = String( d.name || d.id || 'field' );
134 }
135
136 // Fall back to generic preview if template not available.
137 if ( compile ) {
138 el.innerHTML = compile( d );
139
140 // After render, set attribute values via DOM so quotes/newlines are handled correctly.
141 const input = el.querySelector( 'input, textarea, select' );
142 if ( input ) {
143 if ( d.placeholder != null ) input.setAttribute( 'placeholder', String( d.placeholder ) );
144 if ( d.title != null ) input.setAttribute( 'title', String( d.title ) );
145 }
146
147 } else {
148 el.innerHTML = Core.WPBC_Form_Builder_Helper.render_field_inner_html( d );
149 }
150
151 el.dataset.type = d.type || 'field';
152 el.setAttribute( 'data-label', (d.label != null ? String( d.label ) : '') ); // allow "".
153 }
154
155
156 /**
157 * OPTIONAL hook executed after field is dropped/loaded/preview.
158 * Default extended:
159 * - On first drop: stamp default label (existing behavior) and mark field as "fresh" for auto-name.
160 * - On load: mark as loaded so later label edits do not rename the saved name.
161 */
162 static on_field_drop(data, el, meta) {
163
164 const context = (meta && meta.context) ? String( meta.context ) : '';
165
166 // -----------------------------------------------------------------------------------------
167 // NEW: Seed default "help" (and keep it in Structure) for all field packs that define it.
168 // This fixes the mismatch where:
169 // - UI shows default help via normalize_data() / templates
170 // - but get_structure() / exporters see `help` as undefined/empty.
171 //
172 // Behavior:
173 // - Runs ONLY on initial drop (context === 'drop').
174 // - If get_defaults() exposes a non-empty "help", and data.help is
175 // missing / null / empty string -> we persist the default into `data`
176 // and notify Structure so exports see it.
177 // - On "load" we do nothing, so existing forms where user *cleared*
178 // help will not be overridden.
179 // -----------------------------------------------------------------------------------------
180 if ( context === 'drop' && data ) {
181 try {
182 const defs = (typeof this.get_defaults === 'function') ? this.get_defaults() : null;
183 if ( defs && Object.prototype.hasOwnProperty.call( defs, 'help' ) ) {
184 const current = Object.prototype.hasOwnProperty.call( data, 'help' ) ? data.help : undefined;
185 const hasValue = (current !== undefined && current !== null && String( current ) !== '');
186 const defaultVal = defs.help;
187
188 if ( ! hasValue && defaultVal != null && String( defaultVal ) !== '' ) {
189 // 1) persist into data object (used by Structure).
190 data.help = defaultVal;
191
192 // 2) mirror into dataset (for any DOM-based consumers).
193 if ( el ) {
194 el.dataset.help = String( defaultVal );
195
196 // 3) notify Structure / listeners (if available).
197 try {
198 Core.Structure?.update_field_prop?.( el, 'help', defaultVal );
199 el.dispatchEvent(
200 new CustomEvent( 'wpbc_bfb_field_data_changed', { bubbles: true, detail : { key: 'help', value: defaultVal } } )
201 );
202 } catch ( _inner ) {}
203 }
204 }
205 }
206 } catch ( _e ) {}
207 }
208 // -----------------------------------------------------------------------------------------
209
210 if ( context === 'drop' && !Object.prototype.hasOwnProperty.call( data, 'label' ) ) {
211 const defs = this.get_defaults();
212 data.label = defs.label || 'Field';
213 el.setAttribute( 'data-label', data.label );
214 }
215 // Mark provenance flags.
216 if ( context === 'drop' ) {
217 el.dataset.fresh = '1'; // can auto-name on first label edit.
218 el.dataset.autoname = '1';
219 el.dataset.was_loaded = '0';
220 // Seed a provisional unique name immediately.
221 try {
222 const b = meta?.builder;
223 if ( b?.id && (!el.hasAttribute( 'data-name' ) || !el.getAttribute( 'data-name' )) ) {
224 const S = Core.WPBC_BFB_Sanitize;
225 const base = S.sanitize_html_name( el.getAttribute( 'data-id' ) || data?.id || data?.type || 'field' );
226 const uniq = b.id.ensure_unique_field_name( base, el );
227 el.setAttribute( 'data-name', uniq );
228 el.dataset.name_user_touched = '0';
229 }
230 } catch ( _ ) {}
231
232 } else if ( context === 'load' ) {
233 el.dataset.fresh = '0';
234 el.dataset.autoname = '0';
235 el.dataset.was_loaded = '1'; // never rename names for loaded fields.
236 }
237 }
238
239 // --- Auto Rename "Fresh" field, on entering the new Label ---
240
241 /**
242 * Create a conservative field "name" from a human label.
243 * Uses the same constraints as sanitize_html_name (letters/digits/_- and leading letter).
244 */
245 static name_from_label(label) {
246 const s = Core.WPBC_BFB_Sanitize.sanitize_html_name( String( label ?? '' ) );
247 return s.toLowerCase() || 'field';
248 }
249
250 /**
251 * Auto-fill data-name from label ONLY for freshly dropped fields that were not edited yet.
252 * - Never runs for sections.
253 * - Never runs for loaded/existing fields.
254 * - Stops as soon as user edits the Name manually.
255 *
256 * @param {WPBC_Form_Builder} builder
257 * @param {HTMLElement} el - .wpbc_bfb__field element
258 * @param {string} labelVal
259 */
260 static maybe_autoname_from_label(builder, el, labelVal) {
261 if ( !builder || !el ) return;
262 if ( el.classList.contains( 'wpbc_bfb__section' ) ) return;
263
264 const allowAuto = el.dataset.autoname === '1';
265
266 const userTouched = el.dataset.name_user_touched === '1';
267 const isLoaded = el.dataset.was_loaded === '1';
268
269 if ( !allowAuto || userTouched || isLoaded ) return;
270
271 // Only override placeholder-y names
272 const S = Core.WPBC_BFB_Sanitize;
273
274 const base = this.name_from_label( labelVal );
275 const unique = builder.id.ensure_unique_field_name( base, el );
276 el.setAttribute( 'data-name', unique );
277
278 const ins = document.getElementById( 'wpbc_bfb__inspector' );
279 const nameCtrl = ins?.querySelector( '[data-inspector-key="name"]' );
280 if ( nameCtrl && 'value' in nameCtrl && nameCtrl.value !== unique ) nameCtrl.value = unique;
281 }
282
283
284 };
285
286 /**
287 * Select_Base (shared base for select-like packs)
288 *
289 * @type {Core.WPBC_BFB_Select_Base}
290 */
291 Core.WPBC_BFB_Select_Base = class extends Core.WPBC_BFB_Field_Base {
292
293 static template_id = null; // main preview template id
294 static option_row_template_id = 'wpbc-bfb-inspector-select-option-row'; // row tpl id
295 static kind = 'select';
296 static __root_wired = false;
297 static __root_node = null;
298
299 // Single source of selectors used by the inspector UI.
300 static ui = {
301 list : '.wpbc_bfb__options_list',
302 holder : '.wpbc_bfb__options_state[data-inspector-key="options"]',
303 row : '.wpbc_bfb__options_row',
304 label : '.wpbc_bfb__opt-label',
305 value : '.wpbc_bfb__opt-value',
306 toggle : '.wpbc_bfb__opt-selected-chk',
307 add_btn: '.js-add-option',
308
309 drag_handle : '.wpbc_bfb__drag-handle',
310 multiple_chk : '.js-opt-multiple[data-inspector-key="multiple"]',
311 default_text : '.js-default-value[data-inspector-key="default_value"]',
312 placeholder_input: '.js-placeholder[data-inspector-key="placeholder"]',
313 placeholder_note : '.js-placeholder-note',
314 size_input : '.inspector__input[data-inspector-key="size"]',
315
316 // Dropdown menu integration.
317 menu_root : '.wpbc_ui_el__dropdown',
318 menu_toggle: '[data-toggle="wpbc_dropdown"]',
319 menu_action: '.ul_dropdown_menu_li_action[data-action]',
320 // Value-differs toggle.
321 value_differs_chk: '.js-value-differs[data-inspector-key="value_differs"]',
322 };
323
324 /**
325 * Build option value from label.
326 * - If `differs === true` -> generate token (slug-like machine value).
327 * - If `differs === false` -> keep human text; escape only dangerous chars.
328 * @param {string} label
329 * @param {boolean} differs
330 * @returns {string}
331 */
332 static build_value_from_label(label, differs) {
333 const S = Core.WPBC_BFB_Sanitize;
334 if ( differs ) {
335 return (S && typeof S.to_token === 'function')
336 ? S.to_token( String( label || '' ) )
337 : String( label || '' ).trim().toLowerCase().replace( /\s+/g, '_' ).replace( /[^\w-]/g, '' );
338 }
339 // single-input mode: keep human text; template will escape safely.
340 return String( label == null ? '' : label );
341 }
342
343 /**
344 * Is the “value differs from label” toggle enabled?
345 * @param {HTMLElement} panel
346 * @returns {boolean}
347 */
348 static is_value_differs_enabled(panel) {
349 const chk = panel?.querySelector( this.ui.value_differs_chk );
350 return !!(chk && chk.checked);
351 }
352
353 /**
354 * Ensure visibility/enabled state of Value inputs based on the toggle.
355 * When disabled -> hide Value inputs and keep them mirrored from Label.
356 * @param {HTMLElement} panel
357 * @returns {void}
358 */
359 static sync_value_inputs_visibility(panel) {
360 const differs = this.is_value_differs_enabled( panel );
361 const rows = panel?.querySelectorAll( this.ui.row ) || [];
362
363 for ( let i = 0; i < rows.length; i++ ) {
364 const r = rows[i];
365 const lbl_in = r.querySelector( this.ui.label );
366 const val_in = r.querySelector( this.ui.value );
367 if ( !val_in ) continue;
368
369 if ( differs ) {
370 // Re-enable & show value input
371 val_in.removeAttribute( 'disabled' );
372 val_in.style.display = '';
373
374 // If we have a cached custom value and the row wasn't edited while OFF, restore it
375 const hasCache = !!val_in.dataset.cached_value;
376 const userEdited = r.dataset.value_user_touched === '1';
377
378 if ( hasCache && !userEdited ) {
379 val_in.value = val_in.dataset.cached_value;
380 } else if ( !hasCache ) {
381 // No cache: if value is just a mirrored label, offer a tokenized default
382 const lbl = lbl_in ? lbl_in.value : '';
383 const mirrored = this.build_value_from_label( lbl, /*differs=*/false );
384 if ( val_in.value === mirrored ) {
385 val_in.value = this.build_value_from_label( lbl, /*differs=*/true );
386 }
387 }
388 } else {
389 // ON -> OFF: cache once, then mirror
390 if ( !val_in.dataset.cached_value ) {
391 val_in.dataset.cached_value = val_in.value || '';
392 }
393 const lbl = lbl_in ? lbl_in.value : '';
394 val_in.value = this.build_value_from_label( lbl, /*differs=*/false );
395
396 val_in.setAttribute( 'disabled', 'disabled' );
397 val_in.style.display = 'none';
398 // NOTE: do NOT mark as user_touched here
399 }
400 }
401 }
402
403
404 /**
405 * Return whether this row’s value has been edited by user.
406 * @param {HTMLElement} row
407 * @returns {boolean}
408 */
409 static is_row_value_user_touched(row) {
410 return row?.dataset?.value_user_touched === '1';
411 }
412
413 /**
414 * Mark this row’s value as edited by user.
415 * @param {HTMLElement} row
416 */
417 static mark_row_value_user_touched(row) {
418 if ( row ) row.dataset.value_user_touched = '1';
419 }
420
421 /**
422 * Initialize “freshness” flags on a row (value untouched).
423 * Call on creation/append of rows.
424 * @param {HTMLElement} row
425 */
426 static init_row_fresh_flags(row) {
427 if ( row ) {
428 if ( !row.dataset.value_user_touched ) {
429 row.dataset.value_user_touched = '0';
430 }
431 }
432 }
433
434 // ---- defaults (packs can override) ----
435 static get_defaults() {
436 return {
437 type : this.kind,
438 label : 'Select',
439 name : '',
440 html_id : '',
441 placeholder : '--- Select ---',
442 required : false,
443 multiple : false,
444 size : null,
445 cssclass : '',
446 help : '',
447 default_value: '',
448 options : [
449 { label: 'Option 1', value: 'Option 1', selected: false },
450 { label: 'Option 2', value: 'Option 2', selected: false },
451 { label: 'Option 3', value: 'Option 3', selected: false },
452 { label: 'Option 4', value: 'Option 4', selected: false }
453 ],
454 min_width : '240px'
455 };
456 }
457
458 // ---- preview render (idempotent) ----
459 static render(el, data, ctx) {
460 if ( !el ) return;
461
462 const d = this.normalize_data( data );
463
464 if ( d.min_width != null ) {
465 el.dataset.min_width = String( d.min_width );
466 try {
467 el.style.setProperty( '--wpbc-col-min', String( d.min_width ) );
468 } catch ( _ ) {
469 }
470 }
471 if ( d.html_id != null ) el.dataset.html_id = String( d.html_id || '' );
472 if ( d.cssclass != null ) el.dataset.cssclass = String( d.cssclass || '' );
473 if ( d.placeholder != null ) el.dataset.placeholder = String( d.placeholder || '' );
474
475 const tpl = this.get_template( this.template_id );
476 if ( typeof tpl !== 'function' ) {
477 el.innerHTML = '<div class="wpbc_bfb__error" role="alert">Template not found: ' + this.template_id + '.</div>';
478 return;
479 }
480
481 try {
482 el.innerHTML = tpl( d );
483 } catch ( e ) {
484 window._wpbc?.dev?.error?.( 'Select_Base.render', e );
485 el.innerHTML = '<div class="wpbc_bfb__error" role="alert">Error rendering field preview.</div>';
486 return;
487 }
488
489 el.dataset.type = d.type || this.kind;
490 el.setAttribute( 'data-label', (d.label != null ? String( d.label ) : '') );
491
492 try {
493 Core.UI?.WPBC_BFB_Overlay?.ensure?.( ctx?.builder, el );
494 } catch ( _ ) {
495 }
496
497 if ( !el.dataset.options && Array.isArray( d.options ) && d.options.length ) {
498 try {
499 el.dataset.options = JSON.stringify( d.options );
500 } catch ( _ ) {
501 }
502 }
503 }
504
505 // ---- drop seeding (options + placeholder) ----
506 static on_field_drop(data, el, meta) {
507 try {
508 super.on_field_drop?.( data, el, meta );
509 } catch ( _ ) {
510 }
511
512 const is_drop = (meta && meta.context === 'drop');
513
514 if ( is_drop ) {
515 if ( !Array.isArray( data.options ) || !data.options.length ) {
516 const opts = (this.get_defaults().options || []).map( (o) => ({
517 label : o.label,
518 value : o.value,
519 selected: !!o.selected
520 }) );
521 data.options = opts;
522 try {
523 el.dataset.options = JSON.stringify( opts );
524 el.dispatchEvent( new CustomEvent( 'wpbc_bfb_field_data_changed', { bubbles: true,
525 detail : {
526 key : 'options',
527 value: opts
528 }
529 } ) );
530 Core.Structure?.update_field_prop?.( el, 'options', opts );
531 } catch ( _ ) {
532 }
533 }
534
535 const ph = (data.placeholder ?? '').toString().trim();
536 if ( !ph ) {
537 const dflt = this.get_defaults().placeholder || '--- Select ---';
538 data.placeholder = dflt;
539 try {
540 el.dataset.placeholder = String( dflt );
541 el.dispatchEvent( new CustomEvent( 'wpbc_bfb_field_data_changed', { bubbles: true,
542 detail : {
543 key : 'placeholder',
544 value: dflt
545 }
546 } ) );
547 Core.Structure?.update_field_prop?.( el, 'placeholder', dflt );
548 } catch ( _ ) {
549 }
550 }
551 }
552 }
553
554 // ==============================
555 // Inspector helpers (snake_case)
556 // ==============================
557 static get_panel_root(el) {
558 return el?.closest?.( '.wpbc_bfb__inspector__body' ) || el?.closest?.( '.wpbc_bfb__inspector' ) || null;
559 }
560
561 static get_list(panel) {
562 return panel ? panel.querySelector( this.ui.list ) : null;
563 }
564
565 static get_holder(panel) {
566 return panel ? panel.querySelector( this.ui.holder ) : null;
567 }
568
569 static make_uid() {
570 return 'wpbc_ins_auto_opt_' + Math.random().toString( 36 ).slice( 2, 10 );
571 }
572
573 static append_row(panel, data) {
574 const list = this.get_list( panel );
575 if ( !list ) return;
576
577 const idx = list.children.length;
578 const rowd = Object.assign( { label: '', value: '', selected: false, index: idx }, (data || {}) );
579 if ( !rowd.uid ) rowd.uid = this.make_uid();
580
581 const tpl_id = this.option_row_template_id;
582 const tpl = (window.wp && wp.template) ? wp.template( tpl_id ) : null;
583 const html = tpl ? tpl( rowd ) : null;
584
585 // In append_row() -> fallback HTML.
586 const wrap = document.createElement( 'div' );
587 wrap.innerHTML = html || (
588 '<div class="wpbc_bfb__options_row" data-index="' + (rowd.index || 0) + '">' +
589 '<span class="wpbc_bfb__drag-handle"><span class="wpbc_icn_drag_indicator"></span></span>' +
590 '<input type="text" class="wpbc_bfb__opt-label" placeholder="Label" value="' + (rowd.label || '') + '">' +
591 '<input type="text" class="wpbc_bfb__opt-value" placeholder="Value" value="' + (rowd.value || '') + '">' +
592 '<div class="wpbc_bfb__opt-selected">' +
593 '<div class="inspector__control wpbc_ui__toggle">' +
594 '<input type="checkbox" class="wpbc_bfb__opt-selected-chk inspector__input" id="' + rowd.uid + '" role="switch" ' + (rowd.selected ? 'checked aria-checked="true"' : 'aria-checked="false"') + '>' +
595 '<label class="wpbc_ui__toggle_icon_radio" for="' + rowd.uid + '"></label>' +
596 '<label class="wpbc_ui__toggle_label" for="' + rowd.uid + '">Default</label>' +
597 '</div>' +
598 '</div>' +
599 // 3-dot dropdown (uses existing plugin dropdown JS).
600 '<div class="wpbc_ui_el wpbc_ui_el_container wpbc_ui_el__dropdown">' +
601 '<a href="javascript:void(0)" data-toggle="wpbc_dropdown" aria-expanded="false" class="ul_dropdown_menu_toggle">' +
602 '<i class="menu_icon icon-1x wpbc_icn_more_vert"></i>' +
603 '</a>' +
604 '<ul class="ul_dropdown_menu" role="menu" style="right:0px; left:auto;">' +
605 '<li>' +
606 '<a class="ul_dropdown_menu_li_action" data-action="add_after" href="javascript:void(0)">' +
607 'Add New' +
608 '<i class="menu_icon icon-1x wpbc_icn_add_circle"></i>' +
609 '</a>' +
610 '</li>' +
611 '<li>' +
612 '<a class="ul_dropdown_menu_li_action" data-action="duplicate" href="javascript:void(0)">' +
613 'Duplicate' +
614 '<i class="menu_icon icon-1x wpbc_icn_content_copy"></i>' +
615 '</a>' +
616 '</li>' +
617 '<li class="divider"></li>' +
618 '<li>' +
619 '<a class="ul_dropdown_menu_li_action" data-action="remove" href="javascript:void(0)">' +
620 'Remove' +
621 '<i class="menu_icon icon-1x wpbc_icn_delete_outline"></i>' +
622 '</a>' +
623 '</li>' +
624 '</ul>' +
625 '</div>' +
626 '</div>'
627 );
628
629 const node = wrap.firstElementChild;
630 if (! node) {
631 return;
632 }
633 // pre-hide Value input if toggle is OFF **before** appending.
634 const differs = this.is_value_differs_enabled( panel );
635 const valIn = node.querySelector( this.ui.value );
636 const lblIn = node.querySelector( this.ui.label );
637
638 if ( !differs && valIn ) {
639 if ( !valIn.dataset.cached_value ) {
640 valIn.dataset.cached_value = valIn.value || '';
641 }
642 if ( lblIn ) valIn.value = this.build_value_from_label( lblIn.value, false );
643 valIn.setAttribute( 'disabled', 'disabled' );
644 valIn.style.display = 'none';
645 }
646
647
648 this.init_row_fresh_flags( node );
649 list.appendChild( node );
650
651 // Keep your existing post-append sync as a safety net
652 this.sync_value_inputs_visibility( panel );
653 }
654
655 static close_dropdown(anchor_el) {
656 try {
657 var root = anchor_el?.closest?.( this.ui.menu_root );
658 if ( root ) {
659 // If your dropdown toggler toggles a class like 'open', close it.
660 root.classList.remove( 'open' );
661 // Or if it relies on aria-expanded on the toggle.
662 var t = root.querySelector( this.ui.menu_toggle );
663 if ( t ) {
664 t.setAttribute( 'aria-expanded', 'false' );
665 }
666 }
667 } catch ( _ ) { }
668 }
669
670 static insert_after(new_node, ref_node) {
671 if ( ref_node?.parentNode ) {
672 if ( ref_node.nextSibling ) {
673 ref_node.parentNode.insertBefore( new_node, ref_node.nextSibling );
674 } else {
675 ref_node.parentNode.appendChild( new_node );
676 }
677 }
678 }
679
680 static commit_options(panel) {
681 const list = this.get_list( panel );
682 const holder = this.get_holder( panel );
683 if ( !list || !holder ) return;
684
685 const differs = this.is_value_differs_enabled( panel );
686
687 const rows = list.querySelectorAll( this.ui.row );
688 const options = [];
689 for ( let i = 0; i < rows.length; i++ ) {
690 const r = rows[i];
691 const lbl_in = r.querySelector( this.ui.label );
692 const val_in = r.querySelector( this.ui.value );
693 const chk = r.querySelector( this.ui.toggle );
694
695 const lbl = (lbl_in && lbl_in.value) || '';
696 let val = (val_in && val_in.value) || '';
697
698 // If single-input mode -> hard mirror to label.
699 if ( ! differs ) {
700 // single-input mode: mirror Label, minimal escaping (no slug).
701 val = this.build_value_from_label( lbl, /*differs=*/false );
702 if ( val_in ) {
703 val_in.value = val; // keep hidden input in sync for any previews/debug.
704 }
705 }
706
707 const sel = !!(chk && chk.checked);
708 options.push( { label: lbl, value: val, selected: sel } );
709 }
710
711 try {
712 holder.value = JSON.stringify( options );
713 holder.dispatchEvent( new Event( 'input', { bubbles: true } ) );
714 holder.dispatchEvent( new Event( 'change', { bubbles: true } ) );
715 panel.dispatchEvent( new CustomEvent( 'wpbc_bfb_field_data_changed', {
716 bubbles: true, detail: {
717 key: 'options', value: options
718 }
719 } ) );
720 } catch ( _ ) {
721 }
722
723 this.sync_default_value_lock( panel );
724 this.sync_placeholder_lock( panel );
725
726 // Mirror to the selected field element so canvas/export sees current options immediately.
727 const field = panel.__selectbase_field
728 || document.querySelector( '.wpbc_bfb__field.is-selected, .wpbc_bfb__field--selected' );
729 if ( field ) {
730 try {
731 field.dataset.options = JSON.stringify( options );
732 } catch ( _ ) {
733 }
734 Core.Structure?.update_field_prop?.( field, 'options', options );
735 field.dispatchEvent( new CustomEvent( 'wpbc_bfb_field_data_changed', {
736 bubbles: true, detail: { key: 'options', value: options }
737 } ) );
738 }
739 }
740
741
742 static ensure_sortable(panel) {
743
744 const list = this.get_list( panel );
745 if ( ! list ) {
746 return;
747 }
748
749 try {
750 const existing = window.Sortable?.get?.( list );
751 if ( existing ) {
752 return;
753 }
754
755 const builder = window.wpbc_bfb_api?.get_builder?.() || window.wpbc_bfb || null;
756
757 // Prefer the shared Sortable manager so the sidebar list uses
758 // the dedicated "simple_list" config instead of the canvas config.
759 if ( builder && builder.sortable && typeof builder.sortable.ensure === 'function' ) {
760
761 builder.sortable.ensure(
762 list,
763 'canvas',
764 {
765 sortable_kind : 'simple_list',
766 handle_selector : this.ui.drag_handle,
767 draggable_selector: this.ui.row,
768 onUpdate : () => {
769 this.commit_options( panel );
770 }
771 }
772 );
773
774 } else if ( window.Sortable?.create ) {
775 // Fallback if builder is not ready for some reason.
776 window.Sortable.create(
777 list,
778 {
779 handle : this.ui.drag_handle,
780 draggable : this.ui.row,
781 animation : 120,
782 forceFallback : true,
783 fallbackOnBody : false,
784 fallbackTolerance: 8,
785 removeCloneOnHide: true,
786 onUpdate : () => {
787 this.commit_options( panel );
788 }
789 }
790 );
791 }
792
793 list.dataset.sortable_init = '1';
794
795 } catch ( e ) {
796 window._wpbc?.dev?.error?.( 'Select_Base.ensure_sortable', e );
797 }
798 }
799
800 static rebuild_if_empty(panel) {
801 const list = this.get_list( panel );
802 const holder = this.get_holder( panel );
803 if ( !list || !holder || list.children.length ) return;
804
805 let data = [];
806 try {
807 data = JSON.parse( holder.value || '[]' );
808 } catch ( _ ) {
809 data = [];
810 }
811
812 if ( !Array.isArray( data ) || !data.length ) {
813 data = (this.get_defaults().options || []).slice( 0 );
814 try {
815 holder.value = JSON.stringify( data );
816 holder.dispatchEvent( new Event( 'input', { bubbles: true } ) );
817 holder.dispatchEvent( new Event( 'change', { bubbles: true } ) );
818 } catch ( _ ) {
819 }
820 }
821
822 for ( let i = 0; i < data.length; i++ ) {
823 this.append_row( panel, {
824 label : data[i]?.label || '',
825 value : data[i]?.value || '',
826 selected: !!data[i]?.selected,
827 index : i,
828 uid : this.make_uid()
829 } );
830 }
831
832 this.sync_default_value_lock( panel );
833 this.sync_placeholder_lock( panel );
834 this.sync_value_inputs_visibility( panel );
835 }
836
837 static has_row_defaults(panel) {
838 const checks = panel?.querySelectorAll( this.ui.toggle );
839 if ( !checks?.length ) return false;
840 for ( let i = 0; i < checks.length; i++ ) if ( checks[i].checked ) return true;
841 return false;
842 }
843
844 static is_multiple_enabled(panel) {
845 const chk = panel?.querySelector( this.ui.multiple_chk );
846 return !!(chk && chk.checked);
847 }
848
849 static has_text_default_value(panel) {
850 const dv = panel?.querySelector( this.ui.default_text );
851 return !!(dv && String( dv.value || '' ).trim().length);
852 }
853
854 static sync_default_value_lock(panel) {
855 const input = panel?.querySelector( this.ui.default_text );
856 const note = panel?.querySelector( '.js-default-value-note' );
857 if ( !input ) return;
858
859 const lock = this.has_row_defaults( panel );
860 input.disabled = !!lock;
861 if ( lock ) {
862 input.setAttribute( 'aria-disabled', 'true' );
863 if ( note ) note.style.display = '';
864 } else {
865 input.removeAttribute( 'aria-disabled' );
866 if ( note ) note.style.display = 'none';
867 }
868 }
869
870 static sync_placeholder_lock(panel) {
871 const input = panel?.querySelector( this.ui.placeholder_input );
872 const note = panel?.querySelector( this.ui.placeholder_note );
873
874 // NEW: compute multiple and toggle row visibility
875 const isMultiple = this.is_multiple_enabled( panel );
876 const placeholderRow = input?.closest( '.inspector__row' ) || null;
877 const sizeInput = panel?.querySelector( this.ui.size_input ) || null;
878 const sizeRow = sizeInput?.closest( '.inspector__row' ) || null;
879
880 // Show placeholder only for single-select; show size only for multiple
881 if ( placeholderRow ) placeholderRow.style.display = isMultiple ? 'none' : '';
882 if ( sizeRow ) sizeRow.style.display = isMultiple ? '' : 'none';
883
884 // Existing behavior (keep as-is)
885 if ( !input ) return;
886
887 const lock = isMultiple || this.has_row_defaults( panel ) || this.has_text_default_value( panel );
888 if ( note && !note.id ) note.id = 'wpbc_placeholder_note_' + Math.random().toString( 36 ).slice( 2, 10 );
889
890 input.disabled = !!lock;
891 if ( lock ) {
892 input.setAttribute( 'aria-disabled', 'true' );
893 if ( note ) {
894 note.style.display = '';
895 input.setAttribute( 'aria-describedby', note.id );
896 }
897 } else {
898 input.removeAttribute( 'aria-disabled' );
899 input.removeAttribute( 'aria-describedby' );
900 if ( note ) note.style.display = 'none';
901 }
902 }
903
904 static enforce_single_default(panel, clicked) {
905 if ( this.is_multiple_enabled( panel ) ) return;
906
907 const checks = panel?.querySelectorAll( this.ui.toggle );
908 if ( !checks?.length ) return;
909
910 if ( clicked && clicked.checked ) {
911 for ( let i = 0; i < checks.length; i++ ) if ( checks[i] !== clicked ) {
912 checks[i].checked = false;
913 checks[i].setAttribute( 'aria-checked', 'false' );
914 }
915 clicked.setAttribute( 'aria-checked', 'true' );
916 return;
917 }
918
919 let kept = false;
920 for ( let j = 0; j < checks.length; j++ ) if ( checks[j].checked ) {
921 if ( !kept ) {
922 kept = true;
923 } else {
924 checks[j].checked = false;
925 checks[j].setAttribute( 'aria-checked', 'false' );
926 }
927 }
928
929 this.sync_default_value_lock( panel );
930 this.sync_placeholder_lock( panel );
931 }
932
933 // ---- one-time bootstrap of a panel ----
934 static bootstrap_panel(panel) {
935 if ( !panel ) return;
936 if ( !panel.querySelector( '.wpbc_bfb__options_editor' ) ) return; // only select-like UIs
937 if ( panel.dataset.selectbase_bootstrapped === '1' ) {
938 this.ensure_sortable( panel );
939 return;
940 }
941
942 this.rebuild_if_empty( panel );
943 this.ensure_sortable( panel );
944 panel.dataset.selectbase_bootstrapped = '1';
945
946 this.sync_default_value_lock( panel );
947 this.sync_placeholder_lock( panel );
948 this.sync_value_inputs_visibility( panel );
949 }
950
951 // ---- hook into inspector lifecycle (fires ONCE) ----
952 static wire_once() {
953 if ( Core.__selectbase_wired ) return;
954 Core.__selectbase_wired = true;
955
956 const on_ready_or_render = (ev) => {
957 const panel = ev?.detail?.panel;
958 const field = ev?.detail?.el || ev?.detail?.field || null;
959 if ( !panel ) return;
960 if ( field ) panel.__selectbase_field = field;
961 this.bootstrap_panel( panel );
962 // If the inspector root was remounted, ensure root listeners are (re)bound.
963 this.wire_root_listeners();
964 };
965
966 document.addEventListener( 'wpbc_bfb_inspector_ready', on_ready_or_render );
967 document.addEventListener( 'wpbc_bfb_inspector_render', on_ready_or_render );
968
969 this.wire_root_listeners();
970 }
971
972 static wire_root_listeners() {
973
974 // If already wired AND the stored root is still in the DOM, bail out.
975 if ( this.__root_wired && this.__root_node?.isConnected ) return;
976
977 const root = document.getElementById( 'wpbc_bfb__inspector' );
978 if ( !root ) {
979 // Root missing (e.g., SPA re-render) — clear flags so we can wire later.
980 this.__root_wired = false;
981 this.__root_node = null;
982 return;
983 }
984
985 this.__root_node = root;
986 this.__root_wired = true;
987 root.dataset.selectbase_root_wired = '1';
988
989 const get_panel = (target) =>
990 target?.closest?.( '.wpbc_bfb__inspector__body' ) ||
991 root.querySelector( '.wpbc_bfb__inspector__body' ) || null;
992
993 // Click handlers: add / delete / duplicate
994 root.addEventListener( 'click', (e) => {
995 const panel = get_panel( e.target );
996 if ( !panel ) return;
997
998 this.bootstrap_panel( panel );
999
1000 const ui = this.ui;
1001
1002 // Existing "Add option" button (top toolbar)
1003 const add = e.target.closest?.( ui.add_btn );
1004 if ( add ) {
1005 this.append_row( panel, { label: '', value: '', selected: false } );
1006 this.commit_options( panel );
1007 this.sync_value_inputs_visibility( panel );
1008 return;
1009 }
1010
1011 // Dropdown menu actions.
1012 const menu_action = e.target.closest?.( ui.menu_action );
1013 if ( menu_action ) {
1014 e.preventDefault();
1015 e.stopPropagation();
1016
1017 const action = (menu_action.getAttribute( 'data-action' ) || '').toLowerCase();
1018 const row = menu_action.closest?.( ui.row );
1019
1020 if ( !row ) {
1021 this.close_dropdown( menu_action );
1022 return;
1023 }
1024
1025 if ( 'add_after' === action ) {
1026 // Add empty row after current
1027 const prev_count = this.get_list( panel )?.children.length || 0;
1028 this.append_row( panel, { label: '', value: '', selected: false } );
1029 // Move the newly added last row just after current row to preserve "add after"
1030 const list = this.get_list( panel );
1031 if ( list && list.lastElementChild && list.lastElementChild !== row ) {
1032 this.insert_after( list.lastElementChild, row );
1033 }
1034 this.commit_options( panel );
1035 this.sync_value_inputs_visibility( panel );
1036 } else if ( 'duplicate' === action ) {
1037 const lbl = (row.querySelector( ui.label ) || {}).value || '';
1038 const val = (row.querySelector( ui.value ) || {}).value || '';
1039 const sel = !!((row.querySelector( ui.toggle ) || {}).checked);
1040 this.append_row( panel, { label: lbl, value: val, selected: sel, uid: this.make_uid() } );
1041 // Place the new row right after the current.
1042 const list = this.get_list( panel );
1043
1044 if ( list && list.lastElementChild && list.lastElementChild !== row ) {
1045 this.insert_after( list.lastElementChild, row );
1046 }
1047 this.enforce_single_default( panel, null );
1048 this.commit_options( panel );
1049 this.sync_value_inputs_visibility( panel );
1050 } else if ( 'remove' === action ) {
1051 if ( row && row.parentNode ) row.parentNode.removeChild( row );
1052 this.commit_options( panel );
1053 this.sync_value_inputs_visibility( panel );
1054 }
1055
1056 this.close_dropdown( menu_action );
1057 return;
1058 }
1059
1060 }, true );
1061
1062
1063 // Input delegation.
1064 root.addEventListener( 'input', (e) => {
1065 const panel = get_panel( e.target );
1066 if ( ! panel ) {
1067 return;
1068 }
1069 const ui = this.ui;
1070 const is_label_or_value = e.target.classList?.contains( 'wpbc_bfb__opt-label' ) || e.target.classList?.contains( 'wpbc_bfb__opt-value' );
1071 const is_toggle = e.target.classList?.contains( 'wpbc_bfb__opt-selected-chk' );
1072 const is_multiple = e.target.matches?.( ui.multiple_chk );
1073 const is_default_text = e.target.matches?.( ui.default_text );
1074 const is_value_differs = e.target.matches?.( ui.value_differs_chk );
1075
1076 // Handle "value differs" toggle live
1077 if ( is_value_differs ) {
1078 this.sync_value_inputs_visibility( panel );
1079 this.commit_options( panel );
1080 return;
1081 }
1082
1083 // Track when the user edits VALUE explicitly
1084 if ( e.target.classList?.contains( 'wpbc_bfb__opt-value' ) ) {
1085 const row = e.target.closest( this.ui.row );
1086 this.mark_row_value_user_touched( row );
1087 // Keep the cache updated so toggling OFF/ON later restores the latest custom value
1088 e.target.dataset.cached_value = e.target.value || '';
1089 }
1090
1091 // Auto-fill VALUE from LABEL if value is fresh (and differs is ON); if differs is OFF, we mirror anyway in commit
1092 if ( e.target.classList?.contains( 'wpbc_bfb__opt-label' ) ) {
1093 const row = e.target.closest( ui.row );
1094 const val_in = row?.querySelector( ui.value );
1095 const differs = this.is_value_differs_enabled( panel );
1096
1097 if ( val_in ) {
1098 if ( !differs ) {
1099 // single-input mode: mirror human label with minimal escaping
1100 val_in.value = this.build_value_from_label( e.target.value, false );
1101 } else if ( !this.is_row_value_user_touched( row ) ) {
1102 // separate-value mode, only while fresh
1103 val_in.value = this.build_value_from_label( e.target.value, true );
1104 }
1105 }
1106 }
1107
1108
1109 if ( is_label_or_value || is_toggle || is_multiple ) {
1110 if ( is_toggle ) e.target.setAttribute( 'aria-checked', e.target.checked ? 'true' : 'false' );
1111 if ( is_toggle || is_multiple ) this.enforce_single_default( panel, is_toggle ? e.target : null );
1112 this.commit_options( panel );
1113 }
1114
1115 if ( is_default_text ) {
1116 this.sync_default_value_lock( panel );
1117 this.sync_placeholder_lock( panel );
1118 const holder = this.get_holder( panel );
1119 if ( holder ) {
1120 holder.dispatchEvent( new Event( 'input', { bubbles: true } ) );
1121 holder.dispatchEvent( new Event( 'change', { bubbles: true } ) );
1122 }
1123 }
1124 }, true );
1125
1126
1127 // Change delegation
1128 root.addEventListener( 'change', (e) => {
1129 const panel = get_panel( e.target );
1130 if ( !panel ) return;
1131
1132 const ui = this.ui;
1133 const is_toggle = e.target.classList?.contains( 'wpbc_bfb__opt-selected-chk' );
1134 const is_multi = e.target.matches?.( ui.multiple_chk );
1135 if ( !is_toggle && !is_multi ) return;
1136
1137 if ( is_toggle ) e.target.setAttribute( 'aria-checked', e.target.checked ? 'true' : 'false' );
1138 this.enforce_single_default( panel, is_toggle ? e.target : null );
1139 this.commit_options( panel );
1140 }, true );
1141
1142 // Lazy bootstrap
1143 root.addEventListener( 'mouseenter', (e) => {
1144 const panel = get_panel( e.target );
1145 if ( panel && e.target?.closest?.( this.ui.list ) ) this.bootstrap_panel( panel );
1146 }, true );
1147
1148 root.addEventListener( 'mousedown', (e) => {
1149 const panel = get_panel( e.target );
1150 if ( panel && e.target?.closest?.( this.ui.drag_handle ) ) this.bootstrap_panel( panel );
1151 }, true );
1152 }
1153
1154 };
1155
1156 try { Core.WPBC_BFB_Select_Base.wire_once(); } catch (_) {}
1157 // Try immediately (if root is already in DOM), then again on DOMContentLoaded.
1158 Core.WPBC_BFB_Select_Base.wire_root_listeners();
1159
1160 document.addEventListener('DOMContentLoaded', () => { Core.WPBC_BFB_Select_Base.wire_root_listeners(); });
1161
1162 }( window ));