PageSourceSearch

https://www.eventeny.com/js/Components/FormField.js?v=1790795785

js eventeny.com collected 2026-10-01 10:25:52 UTC 65,125 bytes, 1,213 lines download raw bytes

1/**
2 * @file FormField.js
3 * @description A polymorphic form field web component. The `type` attribute determines
4 * which internal element is rendered. Label, error state, and description are always
5 * managed by the component. All styles are scoped inside the shadow DOM.
6 *
7 * @class FormField
8 * @extends WebComponent
9 *
10 * @example
11 * <!-- Text input -->
12 * <form-field-element label="Name" required></form-field-element>
13 *
14 * @example
15 * <!-- Number with prefix -->
16 * <form-field-element type="number" label="Price" prefix="$" description="Per ticket"></form-field-element>
17 *
18 * @example
19 * <!-- Dropdown -->
20 * <form-field-element type="dropdown" label="Status" description="Public visibility"></form-field-element>
21 *
22 * @example
23 * <!-- Multi-select -->
24 * <form-field-element type="multi-select" label="Categories" tag-variant="blue"></form-field-element>
25 *
26 * @example
27 * <!-- Date only, date + time, time only -->
28 * <form-field-element type="date"             label="Event date"  date-variant="events"></form-field-element>
29 * <form-field-element type="date-time"        label="Sale start"  date-variant="events" time-label="Start time"></form-field-element>
30 * <form-field-element type="datetime-compact" label="Event start" date-variant="events"></form-field-element>
31 * <form-field-element type="time"             label="Door open"></form-field-element>
32 *
33 * @example
34 * <!-- Toggle -->
35 * <form-field-element type="toggle" label="Waitlist enabled"></form-field-element>
36 *
37 * @example
38 * <!-- Checkbox group -->
39 * <form-field-element type="checkbox-group" label="Ticket pairing"></form-field-element>
40 *
41 * @example
42 * <!-- File uploader -->
43 * <form-field-element type="file" label="Ticket image" file-mode="single-image-inline"></form-field-element>
44 *
45 * @example
46 * <!-- Textarea -->
47 * <form-field-element type="textarea" label="Description" placeholder="Enter text..."></form-field-element>
48 *
49 * @example
50 * <!-- Read-only: renders the label/description chrome around a static formatted value
51 *      instead of the real control. Toggle the attribute in place to switch modes -->
52 * <form-field-element type="dropdown" label="Visibility" read-only></form-field-element>
53 *
54 * @attribute {string} type               - 'text' (default) | 'number' | 'dropdown' | 'multi-select' |
55 *                                          'date' | 'time' | 'date-time' | 'datetime-compact' | 'toggle' | 'file' | 'textarea'
56 * @attribute {string} label              - Label text shown above the field
57 * @attribute {string} description        - Subtext shown below the field
58 * @attribute required                    - Adds a required asterisk to the label (hidden when `read-only`)
59 * @attribute error                       - Puts the field into error state (red border + error message; ignored when `read-only`)
60 * @attribute {string} error-message      - Error text shown when `error` is set
61 * @attribute {string} validation-message - Default message used by `validate()` when none is passed
62 * @attribute {string} placeholder        - Passed through to the internal element
63 * @attribute disabled                    - Passed through to the internal element
64 * @attribute {string} prefix             - Prefix label for type="number" (e.g. "$")
65 * @attribute {string} suffix             - Suffix label for type="number" (e.g. "%")
66 * @attribute {string} date-variant       - Passed to date-picker-element (e.g. "events")
67 * @attribute {string} time-label         - Label for the time block in type="date-time"
68 * @attribute multi                       - Enables multi-select mode on date/date-time pickers (not supported by datetime-compact)
69 * @attribute {string} file-mode          - Passed to file-uploader (e.g. "single-image-inline")
70 * @attribute {string} tag-variant        - Passed to multi-select (e.g. "blue")
71 * @attribute {string} no-selected        - Passed to multi-select as empty-state text
72 * @attribute {string} name               - Creates a hidden `<input name="...">` in the light DOM
73 *                                          kept in sync with getValue(), so FormData works out of the box.
74 *                                          For type="checkbox-group" this becomes one `name[id]` hidden
75 *                                          input per currently-checked item instead of a single input.
76 * @attribute read-only                  - Renders a static formatted value instead of the interactive control, inside the same label/description chrome. `type` never changes —
77 *                                          toggling this attribute swaps what's rendered. Provide a `<span slot="display">` child to override the auto-formatted value
78 *                                          with custom markup (links, images, colored pills). Transitioning TO read-only reverts the value to the last setValue()/commitValue()
79 *                                          baseline by default (see `commit-on-read-only`), discarding any live edits made since; transitioning away from read-only rebuilds
80 *                                          the interactive control pre-filled with the current value.
81 * @attribute commit-on-read-only        - Opt-in: when present, transitioning TO read-only instead calls commitValue() (promotes current live edits to the new baseline,
82 *                                          rather than discarding them) — for an inline-edit-then-blur-commits pattern. Not needed for a Save-button flow, which can just call
83 *                                          commitValue() itself before re-adding `read-only`.
84 * @attribute {string} display-on/display-off - type="toggle" read-only text (falls back to `toggle-on`/`toggle-off`, then "Yes"/"No")
85 * @attribute {string} display-as        - Read-only rendering: 'text' (default) | 'pill' | 'tag'. Wraps the resolved value(s) in a `<pill-element>`/`<tag-element>` instead of
86 *                                     plain text. Not applicable to type="toggle" (use display-on/display-off, or a full `slot="display"` override for icon-based states). Exception:
87 *                                     type="checkbox-group"/"multi-select" default to tags (gray / teal respectively) even without this attribute set, since several values
88 *                                     joined as plain text reads poorly — set display-as/display-color explicitly to override either the shape or the color.
89 * @attribute {string} display-color - Color/variant used when `display-as` is set, applied to every value unless a matching item's own `displayColor` overrides it (see
90 *                                     setItems()). Must be the exact attribute pill-element expects for display-as="pill" (e.g. "purple-26", "gray-26" — it has no generic
91 *                                     variant prop, only one boolean attribute per exact color+era string), or a normal `variant` value for display-as="tag" (e.g. "purple").
92 *
93 * @method getValue()           - Current value. String for text/number/dropdown/textarea, bool for toggle,
94 *                                `{date,hour,ampm}` for date-time, `"11:30 am"` for time, array of ids for
95 *                                checkbox-group, null if incomplete. Falls back to the cached value when
96 *                                `read-only`.
97 * @method setValue(value)      - Sets the field value. Accepts the same shape returned by getValue().
98 *                                Treated as an authoritative baseline: also updates what a later
99 *                                read-only revert (Cancel) falls back to. Live user edits do NOT go
100 *                                through this method, so they don't affect that baseline on their own.
101 * @method commitValue()        - No-arg. Promotes the current (live-edited) value to be the new
102 *                                read-only-revert baseline, without changing the value itself. Use from
103 *                                a Save action, right before re-adding `read-only`.
104 * @method setItems(items)      - Populates dropdown / multi-select / radio / checkbox-group options.
105 *                                items: `[{id, value, displayColor?}]` — `displayColor` is optional, used
106 *                                only when `display-as` is set (see above); everything works the same
107 *                                without it. This is the preferred way to populate multi-select too now
108 *                                (routes to its addItems(), which accepts this same array shape).
109 * @method setSelected(id)      - Sets the selected item by id (dropdown / multi-select); accepts an array of ids for checkbox-group
110 * @method getSelected()        - Returns the selected id (dropdown / multi-select), or array of ids (checkbox-group)
111 * @method addItems(obj)        - Direct pass-through to multi-select's addItems() — accepts either its
112 *                                legacy id-keyed object shape or the standard array shape used by setItems()
113 * @method setEvents(events)    - Sets event-date markers on the internal date-picker
114 * @method setViewDate(str)     - Sets the month shown when the internal date-picker opens, until a date is selected
115 * @method setFiles(files)      - Sets files on the internal file-uploader
116 * @method getFiles()           - Returns files from the internal file-uploader
117 * @method setError(message)    - Puts the field into error state with the given message
118 * @method clearError()         - Clears error state and hides the error message
119 * @method setLoading(bool)     - Shows/hides a loading spinner in place of the field (for async fetch)
120 * @method focus()              - Delegates focus to the internal element
121 * @method validate(message)    - Checks if the field has a value; on failure sets error using:
122 *                                message -> validation-message attr -> 'This field is required'.
123 *                                Returns true if valid, false otherwise.
124 * @method getField()           - Returns the internal element (escape hatch for unsupported cases)
125 *
126 * @event field-change - Fired on every value change. `event.detail = { value: getValue() }`
127 * @event change       - Re-dispatched from the internal element for legacy listener compatibility
128 */
129import { WebComponent } from './Base/WebComponent.js';
130import './TagElement.js';
131import './BasicCheckbox.js';
132import './BasicPill.js';
133
134/* Focus-ring modality: native inputs match :focus-visible on click too, so the ring is class-driven off the last input device. */
135if (!window.__ffFocusModalityBound) {
136    window.__ffFocusModalityBound = true;
137    window.__ffKeyboardFocus = false;
138    document.addEventListener('keydown', (e) => {
139        if (e.key === 'Tab' || e.key === 'Enter' || (e.key && e.key.indexOf('Arrow') === 0)) window.__ffKeyboardFocus = true;
140    }, true);
141    ['mousedown', 'pointerdown', 'touchstart'].forEach((evt) => {
142        document.addEventListener(evt, () => { window.__ffKeyboardFocus = false; }, true);
143    });
144}
145
146class FormField extends WebComponent {
147    static get observedAttributes() {
148        return [
149            'label', 'description', 'required', 'error', 'error-message',
150            'type', 'prefix', 'suffix', 'placeholder', 'disabled',
151            'date-variant', 'time-label', 'file-mode', 'tag-variant', 'no-selected',
152            'validation-message', 'multi',
153        ];
154    }
155
156    // Simple format checks used by validate() for type="email"/"phone" — not full RFC compliance.
157    static EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
158    static PHONE_PATTERN = /^\+?\d[\d\s().-]{6,19}$/;
159
160    _type = 'text';
161    _field = null;
162    _hourDropdown = null;
163    _ampmControl = null;
164    _toggleLabelEl = null;
165    _numWrap = null;
166    _prefixEl = null;
167    _suffixEl = null;
168    _hiddenInput = null;
169    _cbHiddenInputs = null; // Map<id, hiddenInputEl> — checkbox-group only
170    _labelTxtEl = null;
171    _errorEl = null;
172    _descEl = null;
173    _fieldArea = null;
174    _loadingEl = null;
175    _built = false;
176    _lastValue = null;     // current value, kept even when read-only (no live _field to read from)
177    _lastItems = null;     // last setItems() payload, kept even when read-only
178    _readOnlyValue = null; // snapshot restored on revert (kept in sync by setValue(); see commitValue())
179
180    constructor() {
181        super();
182        this.loadBaseCSS();
183        this.loadBaseJS();
184        this.loadIcons();
185    }
186
187    connectedCallback() {
188        if (this._built) return;
189        this._built = true;
190        this._type = (this.getAttribute('type') || 'text').toLowerCase();
191        this.render();
192    }
193
194    attributeChangedCallback(name, oldVal, newVal) {
195        if (oldVal === newVal || !this._built) return;
196        switch (name) {
197            case 'label':
198                if (this._labelTxtEl) {
199                    this._labelTxtEl.textContent = newVal || '';
200                    this._labelTxtEl.title = newVal || '';	// Keep the truncation tooltip in step
201                }
202                if (this._field) this._field.setAttribute('aria-label', newVal || '');
203                break;
204            case 'description':
205                if (this._descEl) {
206                    this._descEl.textContent = newVal || '';
207                    this._descEl.classList.toggle('hide', !newVal);
208                }
209                break;
210            case 'error':
211            case 'error-message':
212                this._syncError();
213                break;
214            case 'disabled':
215                if (this._field) {
216                    if (newVal !== null) this._field.setAttribute('disabled', '');
217                    else                this._field.removeAttribute('disabled');
218                }
219                break;
220            case 'small':
221                if (this._type === 'date-time') {
222                    const on = newVal !== null;
223                    if (this._field) {
224                        if (on) {
225                            this._field.style.setProperty('--dp-height', '32px');
226                            this._field.style.setProperty('--dp-font-size', '14px');
227                        } else {
228                            this._field.style.removeProperty('--dp-height');
229                            this._field.style.removeProperty('--dp-font-size');
230                        }
231                    }
232                    if (this._hourDropdown) {
233                        if (on) this._hourDropdown.setAttribute('size', 'small');
234                        else    this._hourDropdown.removeAttribute('size');
235                    }
236                    if (this._ampmControl) {
237                        if (on) this._ampmControl.style.setProperty('--seg-control-size', '32px');
238                        else    this._ampmControl.style.removeProperty('--seg-control-size');
239                    }
240                }
241                break;
242            case 'prefix':
243                if (this._prefixEl && this._numWrap) {
244                    const has = newVal !== null && newVal !== '';
245                    this._prefixEl.textContent = has ? newVal : '';
246                    this._prefixEl.classList.toggle('hide', !has);
247                    if (has) {
248                        this._numWrap.classList.add('input-wrapper-left');
249                        this._numWrap.classList.remove('input-wrapper-right');
250                    } else {
251                        this._numWrap.classList.remove('input-wrapper-left');
252                        if (this.getAttribute('suffix')) this._numWrap.classList.add('input-wrapper-right');
253                    }
254                }
255                break;
256            case 'suffix':
257                if (this._suffixEl && this._numWrap) {
258                    const has = newVal !== null && newVal !== '';
259                    this._suffixEl.textContent = has ? newVal : '';
260                    this._suffixEl.classList.toggle('hide', !has);
261                    if (has && !this.getAttribute('prefix')) {
262                        this._numWrap.classList.add('input-wrapper-right');
263                        this._numWrap.classList.remove('input-wrapper-left');
264                    } else if (!has && !this.getAttribute('prefix')) {
265                        this._numWrap.classList.remove('input-wrapper-right');
266                    }
267                }
268                break;
269            case 'read-only': {
270                const goingReadOnly = newVal !== null;
271                if (goingReadOnly) {
272                    if (this.hasAttribute('commit-on-read-only')) {
273                        this.commitValue(); // promote live value to new snapshot
274                    } else {
275                        this._lastValue = this._readOnlyValue; // discard live edits, revert to snapshot
276                    }
277                    this._syncHiddenInputs(this._lastValue);
278                }
279                this.render(); // rebuilds _fieldHtml() synchronously (loadTemplate -> setEventListeners)
280                if (!goingReadOnly) this._restoreField();
281                break;
282            }
283        }
284    }
285
286    render() {
287        const label    = this.getAttribute('label') || '';
288        const desc     = this.getAttribute('description') || '';
289        const readOnly = this.hasAttribute('read-only');
290        const required = this.hasAttribute('required') && !readOnly;
291        const errorMsg = this.getAttribute('error-message') || '';
292        const hasError = this.hasAttribute('error') && !readOnly;
293
294        this.loadTemplate(`
295            <style>${this._styles()}</style>
296            <div class="flex flex-column gap-4">
297                <div id="ff-label" class="flex items-center justify-between ff-font-14">
298                    <div id="ff-label-wrap" class="flex items-center">
299                        ${label
300                            ? `<span id="ff-label-txt" title="${this._esc(label)}">${this._esc(label)}</span>`
301                            : `<slot name="label"></slot>`
302                        }
303                        ${required ? '<span class="ff-req" aria-hidden="true"> *</span>' : ''}
304                    </div>
305                    <slot name="label-action"></slot>
306                </div>
307                <div id="ff-fa">
308                    ${this._fieldHtml()}
309                    <div id="ff-loading" class="ff-ld hide"><div class="ff-sp"></div></div>
310                </div>
311                <div class="flex ff-footer">
312                    <span id="ff-error" class="ff-em${hasError ? '' : ' hide'}">${this._esc(errorMsg)}</span>
313                    <span id="ff-desc" class="bulk-field-desc${desc ? '' : ' hide'}${hasError ? ' ff-desc-err' : ''}">${this._esc(desc)}</span>
314                </div>
315            </div>
316        `);
317    }
318
319    // Called by loadTemplate() after shadow DOM is ready
320    setEventListeners() {
321        this._labelTxtEl    = this.shadowRoot.getElementById('ff-label-txt');
322        this._errorEl       = this.shadowRoot.getElementById('ff-error');
323        this._descEl        = this.shadowRoot.getElementById('ff-desc');
324        this._fieldArea     = this.shadowRoot.getElementById('ff-fa');
325        this._loadingEl     = this.shadowRoot.getElementById('ff-loading');
326        this._field         = this.shadowRoot.getElementById('ff-field');
327        this._numWrap       = this.shadowRoot.getElementById('ff-nw');
328        this._prefixEl      = this.shadowRoot.getElementById('ff-prefix');
329        this._suffixEl      = this.shadowRoot.getElementById('ff-suffix');
330        this._hourDropdown  = this.shadowRoot.getElementById('ff-hour');
331        this._ampmControl   = this.shadowRoot.getElementById('ff-ampm');
332        this._toggleLabelEl = this.shadowRoot.getElementById('ff-tl');
333
334        const type = this._type;
335
336        // time has no #ff-field — wire sub-components before the _field guard
337        if (type === 'time') {
338            const onChange = () => this._onFieldChange(this.getValue());
339            if (this._hourDropdown) this._hourDropdown.addEventListener('change', onChange);
340            if (this._ampmControl)  this._ampmControl.addEventListener('change', onChange);
341            this._initDateTimeSubFields();
342        }
343
344        // checkbox-group has no #ff-field either — its own container/checkboxes instead
345        if (type === 'checkbox-group') {
346            const container = this.shadowRoot.getElementById('ff-cbgroup');
347            if (container) {
348                container.querySelectorAll('.ff-cb-input').forEach((cb) => {
349                    cb.addEventListener('change', () => {
350                        const selected = Array.from(container.querySelectorAll('.ff-cb-input'))
351                            .filter((el) => el.isChecked())
352                            .map((el) => el.dataset.id);
353                        this._onFieldChange(selected);
354                    });
355                });
356            }
357        }
358
359        if (!this._field) return;
360
361        if (type === 'text' || type === 'textarea' || type === 'number' || type === 'email' || type === 'phone') {
362            this._field.addEventListener('input', (e) => this._onFieldChange(e.target.value));
363            this._bindKeyboardFocusRing();
364        } else if (type === 'dropdown') {
365            this._field.addEventListener('change', () => {
366                this._onFieldChange(typeof this._field.getValue === 'function' ? this._field.getValue() : null);
367            });
368        } else if (type === 'multi-select') {
369            this._field.addEventListener('selectionchange', (e) => {
370                this._onFieldChange(e.detail && e.detail.selected ? e.detail.selected : []);
371            });
372        } else if (type === 'date' || type === 'datetime-compact') {
373            this._field.addEventListener('change', () => {
374                this._onFieldChange(typeof this._field.getValue === 'function' ? this._field.getValue() : null);
375            });
376        } else if (type === 'date-time') {
377            const onChange = () => this._onFieldChange(this._getDateTimeVal());
378            this._field.addEventListener('change', onChange);
379            if (this._hourDropdown) this._hourDropdown.addEventListener('change', onChange);
380            if (this._ampmControl)  this._ampmControl.addEventListener('change', onChange);
381            this._initDateTimeSubFields();
382        } else if (type === 'toggle') {
383            this._field.addEventListener('change', (e) => {
384                const checked = !!(e.detail && e.detail.checked);
385                this._updateToggleLabel(checked);
386                this._onFieldChange(checked);
387            });
388        } else if (type === 'file') {
389            this._field.addEventListener('change', () => this._onFieldChange(this._field.files || []));
390        } else if (type === 'radio') {
391            this._field.addEventListener('change', () => this._onFieldChange(this._field.value));
392        }
393
394        // Hidden input for form compatibility (checkbox-group manages its own set of
395        // per-id hidden inputs instead — see _syncCheckboxGroupHiddenInputs)
396        const nameAttr = this.getAttribute('name');
397        if (nameAttr && !this._hiddenInput && type !== 'checkbox-group') {
398            this._hiddenInput = document.createElement('input');
399            this._hiddenInput.type = 'hidden';
400            this._hiddenInput.name = nameAttr;
401            if (type === 'toggle') {
402                // Mirror a native checkbox: excluded from FormData while unchecked.
403                this._hiddenInput.disabled = !(this._field && this._field.checked);
404                this._hiddenInput.value = '1';
405            }
406            this.after(this._hiddenInput);
407        }
408    }
409
410    _updateToggleLabel(checked) {
411        if (!this._toggleLabelEl) return;
412        this._toggleLabelEl.textContent = checked
413            ? (this.getAttribute('toggle-on')  || '')
414            : (this.getAttribute('toggle-off') || '');
415    }
416
417    _initDateTimeSubFields() {
418        const initHours = () => {
419            if (this._hourDropdown && typeof this._hourDropdown.setItems === 'function') {
420                const items = [];
421                for (let h = 1; h <= 12; h++) {
422                    for (const m of ['00', '15', '30', '45']) {
423                        items.push({ id: h * 60 + parseInt(m, 10), value: h + ':' + m });
424                    }
425                }
426                this._hourDropdown.setItems(items);
427            }
428        };
429        const initAmPm = () => {
430            if (this._ampmControl && typeof this._ampmControl.setOptions === 'function') {
431                this._ampmControl.setOptions([{ value: 'am', label: 'AM' }, { value: 'pm', label: 'PM' }]);
432            }
433        };
434        customElements.get('dropdown-element')  ? initHours() : customElements.whenDefined('dropdown-element').then(initHours);
435        customElements.get('segmented-control') ? initAmPm()  : customElements.whenDefined('segmented-control').then(initAmPm);
436    }
437
438    _fieldHtml() {
439        if (this.hasAttribute('read-only')) {
440            // Falls back to the slot's own content when no light-DOM <span slot="display">
441            // is provided by the consumer — native slot behavior, no JS branching needed.
442            return `<div id="ff-ro" class="ff-ro-value"><slot name="display">${this._readOnlyContent()}</slot></div>`;
443        }
444
445        const type     = this._type;
446        const label    = this._esc(this.getAttribute('label') || '');
447        const ph       = this._esc(this.getAttribute('placeholder') || '');
448        const dis      = this.hasAttribute('disabled') ? ' disabled' : '';
449        const required = this.hasAttribute('required');
450        const ariaReq  = required ? ' aria-required="true"' : '';
451        const ariaLbl  = label ? ` aria-label="${label}"` : '';
452
453        switch (type) {
454            case 'text':
455                return `<input type="text" id="ff-field" class="double-columns-form-input-field"${ariaLbl}${ariaReq}${ph ? ` placeholder="${ph}"` : ''}${dis}>`;
456
457            case 'email':
458                return `<input type="email" id="ff-field" class="double-columns-form-input-field"${ariaLbl}${ariaReq}${ph ? ` placeholder="${ph}"` : ''}${dis}>`;
459
460            case 'phone':
461                return `<input type="tel" id="ff-field" class="double-columns-form-input-field"${ariaLbl}${ariaReq}${ph ? ` placeholder="${ph}"` : ''}${dis}>`;
462
463            case 'textarea':
464                return `<textarea id="ff-field" class="double-columns-form-input-field"${ariaLbl}${ariaReq}${ph ? ` placeholder="${ph}"` : ''}${dis}></textarea>`;
465
466            case 'number': {
467                const prefix = this._esc(this.getAttribute('prefix') || '');
468                const suffix = this._esc(this.getAttribute('suffix') || '');
469                const cls = prefix ? ' input-wrapper-left' : suffix ? ' input-wrapper-right' : '';
470                return `<div id="ff-nw" class="double-columns-form-input-field${cls}">
471                    <span id="ff-prefix" class="ff-affix${prefix ? '' : ' hide'}" aria-hidden="true">${prefix}</span>
472                    <input type="number" id="ff-field" class="no-border-input"${ariaLbl}${ariaReq}${ph ? ` placeholder="${ph}"` : ''}${dis}>
473                    <span id="ff-suffix" class="ff-affix${suffix ? '' : ' hide'}" aria-hidden="true">${suffix}</span>
474                </div>`;
475            }
476
477            case 'radio':
478                return `<radio-group id="ff-field"></radio-group>`;
479
480            case 'dropdown':
481                return `<dropdown-element id="ff-field" label="${ph || '—'}"${ariaLbl}${dis ? ' disabled' : ''}></dropdown-element>`;
482
483            case 'multi-select': {
484                const tv = this.hasAttribute('tag-variant') ? ' tag-variant' : '';
485                const ns = this.hasAttribute('no-selected') ? ' no-selected' : '';
486                const ol = this.hasAttribute('one-line-tags') ? ' one-line-tags' : '';
487                return `<multi-select id="ff-field"${tv}${ns}${ol} label="${ph || '—'}"${ariaLbl}></multi-select>`;
488            }
489
490            case 'date': {
491                const variant = this.getAttribute('date-variant') || '';
492                const multi   = this.hasAttribute('multi') ? ' mode="multi"' : '';
493                return `<date-picker-element id="ff-field"${variant ? ` variant="${variant}"` : ''}${multi} placeholder="${ph || '-'}"${ariaLbl}></date-picker-element>`;
494            }
495
496            // Single field combining date + time (no separate hour/AM-PM sub-widgets,
497            // unlike type="date-time" below) — datetime-compact doesn't support `multi`.
498            case 'datetime-compact': {
499                const variant = this.getAttribute('date-variant') || '';
500                return `<date-picker-element id="ff-field" datetime-compact${variant ? ` variant="${variant}"` : ''} placeholder="${ph || '-'}"${ariaLbl}></date-picker-element>`;
501            }
502
503            // The date picker takes its own line with the hour dropdown and AM/PM paired
504            // beneath it, so the full date stays readable in a narrow (half-width) column.
505            case 'date-time': {
506                const variant   = this.getAttribute('date-variant') || '';
507                const timeLabel = this._esc(this.getAttribute('time-label') || '');
508                const multi     = this.hasAttribute('multi') ? ' mode="multi"' : '';
509                const small     = this.hasAttribute('small');
510                const dpStyle   = small ? ' style="--dp-height:32px;--dp-font-size:14px"' : '';
511                const ddSize    = small ? ' size="small"' : '';
512                // date-variant segmented control needs its full color/border override regardless of size —
513                // omitting it falls back to SegmentedControl's default variant, which has no selected-color set
514                const scStyle   = `--seg-control-size:${small ? '30px' : '36px'};--seg-border-radius:4px;--selected-color:#E6F6F5;--selected-text-color:#038580;--border-color:#9DA6A5;--selected-border-color:#0389A0;`;
515                return `<div class="flex flex-column gap-8 bulk-date-time-row">
516                    <date-picker-element id="ff-field"${variant ? ` variant="${variant}"` : ''}${multi} placeholder="${ph || '-'}"${ariaLbl}${dpStyle}></date-picker-element>
517                    <div class="flex gap-8 items-center bulk-time-group">
518                        ${timeLabel ? `<div id="ff-time-label" class="ff-font-14 bulk-time-label">${timeLabel}</div>` : ''}
519                        <dropdown-element id="ff-hour" label="—" class="bulk-time-dropdown"${ddSize} aria-label="Hour"></dropdown-element>
520                        <segmented-control id="ff-ampm" date deselectable aria-label="AM/PM" style="${scStyle}"></segmented-control>
521                    </div>
522                </div>`;
523            }
524
525            case 'time':
526                return `<div class="flex gap-8 bulk-time-group items-center">
527                    <dropdown-element id="ff-hour" label="—" class="bulk-time-dropdown" aria-label="Hour"></dropdown-element>
528                    <segmented-control id="ff-ampm" date deselectable aria-label="AM/PM" style="--seg-control-size:36px;--seg-border-radius:4px;--selected-color:#E6F6F5;--selected-text-color:#038580;--border-color:#9DA6A5;--selected-border-color:#0389A0;"></segmented-control>
529                </div>`;
530
531            case 'toggle': {
532                const _ton   = this.getAttribute('toggle-on')  || '';
533                const _toff  = this.getAttribute('toggle-off') || '';
534                const _split = this.hasAttribute('toggle-split');
535                if (_split) {
536                    return `<div class="flex items-center gap-8">
537                        ${_toff ? `<span class="ff-font-14" aria-hidden="true">${this._esc(_toff)}</span>` : ''}
538                        <toggle-element id="ff-field"${ariaLbl}${dis ? ' disabled' : ''}></toggle-element>
539                        ${_ton  ? `<span class="ff-font-14" aria-hidden="true">${this._esc(_ton)}</span>`  : ''}
540                    </div>`;
541                }
542                const _tlbl = (_ton || _toff)
543                    ? `<span id="ff-tl" class="ff-font-14" aria-hidden="true">${this._esc(_toff)}</span>`
544                    : '';
545                return `<div class="flex items-center gap-8">
546                    <toggle-element id="ff-field"${ariaLbl}${dis ? ' disabled' : ''}></toggle-element>
547                    ${_tlbl}
548                </div>`;
549            }
550
551            case 'checkbox-group': {
552                const items    = this._lastItems || [];
553                const selected = new Set((this._lastValue || []).map(String));
554                return `<div id="ff-cbgroup" class="flex flex-column gap-8">
555                    ${items.map((it) => `
556                        <checkbox-element class="ff-cb-input" data-id="${this._esc(String(it.id))}" label="${this._esc(it.value)}"${selected.has(String(it.id)) ? ' checked' : ''}${dis}></checkbox-element>
557                    `).join('')}
558                </div>`;
559            }
560
561            case 'file': {
562                const placeholderSrc = this._esc(this.getAttribute('placeholder-src') || '');
563                return `<file-uploader id="ff-field" mode="${this.getAttribute('file-mode') || 'basic'}"${placeholderSrc ? ` placeholder-src="${placeholderSrc}"` : ''}${ariaLbl}></file-uploader>`;
564            }
565
566            default:
567                return `<input type="text" id="ff-field" class="double-columns-form-input-field"${ariaLbl}${ariaReq}${ph ? ` placeholder="${ph}"` : ''}${dis}>`;
568        }
569    }
570
571    // Default (no slot="display" override) static formatting for read-only mode,
572    // keyed off `_type`. Reads from _lastValue/_lastItems since there's no live
573    // interactive control to query while read-only. Every branch's plain text runs
574    // through _wrapDisplay() so display-as/display-color apply uniformly.
575    _readOnlyContent() {
576        const type = this._type;
577
578        if (type === 'toggle') {
579            const on  = this.getAttribute('display-on')  || this.getAttribute('toggle-on')  || 'Yes';
580            const off = this.getAttribute('display-off') || this.getAttribute('toggle-off') || '
580No';
581            return this._esc(this._lastValue ? on : off);
582        }
583
584        // checkbox-group and multi-select are both "multiple ids selected from an items
585        // array" — same lookup, same optional per-item displayColor.
586        // checkbox-group and multi-select are both "multiple ids selected from an items
587        // array" — same lookup, same optional per-item displayColor. Unlike the single-value
588        // types below, these default to tags even without display-as/display-color set (a
589        // plain comma/space-joined list of several values reads poorly) — gray for
590        // checkbox-group, teal for multi-select (matching the teal chips DatePickerElement's
591        // own multi mode already uses for "several selected things").
592        if (type === 'checkbox-group' || type === 'multi-select') {
593            const items    = this._lastItems || [];
594            const selected = new Set((this._lastValue || []).map(String));
595            const chosen   = items.filter((it) => selected.has(String(it.id)));
596            const defaults = { as: 'tag', color: type === 'multi-select' ? 'teal' : 'gray' };
597            return chosen
598                .map((it) => this._wrapDisplay(this._esc(it.value), it.displayColor, defaults))
599                .join(' ');
600        }
601
602        if (type === 'dropdown' || type === 'radio') {
603            const items = this._lastItems || [];
604            const match = items.find((it) => String(it.id) === String(this._lastValue));
605            const text  = this._esc(match ? match.value : (this._lastValue ?? ''));
606            return this._wrapDisplay(text, match && match.displayColor);
607        }
608
609        if (type === 'number') {
610            const prefix = this._esc(this.getAttribute('prefix') || '');
611            const suffix = this._esc(this.getAttribute('suffix') || '');
612            return this._wrapDisplay(`${prefix}${this._esc(this._lastValue ?? '')}${suffix}`);
613        }
614
615        // text, textarea, date, datetime-compact, date-time, time, file — plain value
616        const val  = this._lastValue;
617        const text = val == null ? '' : this._esc(typeof val === 'object' ? JSON.stringify(val) : String(val));
618        return this._wrapDisplay(text);
619    }
620
621    // Wraps already-escaped `text` in a pill/tag per the `display-as`/`display-color`
622    // attributes — a no-op (plain text passthrough) when neither `display-as` nor `defaults.as`
623    // apply. `itemColor` (an item's optional `displayColor`) wins over `display-color`, which
624    // wins over `defaults` — used by checkbox-group/multi-select to default to tags even when
625    // the field itself doesn't set display-as/display-color (see call site).
626    _wrapDisplay(text, itemColor, defaults = {}) {
627        if (!text) return text;
628        const as = (this.getAttribute('display-as') || defaults.as || '').toLowerCase();
629        if (as !== 'pill' && as !== 'tag') return text;
630        const color = itemColor || this.getAttribute('display-color') || defaults.color || (as === 'pill' ? 'gray-26' : 'gray');
631        if (as === 'pill') return `<pill-element ${this._esc(color)}>${text}</pill-element>`;
632        return `<tag-element variant="${this._esc(color)}">${text}</tag-element>`;
633    }
634
635    _esc(str) {
636        return String(str || '')
637            .replace(/&/g, '&amp;')
638            .replace(/</g, '&lt;')
639            .replace(/>/g, '&gt;')
640            .replace(/"/g, '&quot;');
641    }
642
643    // Focus ring is added only when focus arrives from the keyboard
644    _bindKeyboardFocusRing() {
645        const ringEl = this._numWrap || this._field;
646        if (!ringEl) return;
647        this._field.addEventListener('focus', () => {
648            ringEl.classList.toggle('ff-kb-focus', !!window.__ffKeyboardFocus);
649        });
650        this._field.addEventListener('blur', () => ringEl.classList.remove('ff-kb-focus'));
651    }
652
653    _onFieldChange(value) {
654        this._lastValue = value;
655        this._syncHiddenInputs(value);
656        this.dispatchEvent(new CustomEvent('field-change', { detail: { value }, bubbles: true, composed: true }));
657        this.dispatchEvent(new CustomEvent('change',       { detail: { value }, bubbles: true, composed: true }));
658    }
659
660    // Shared by _onFieldChange (live edits) and setValue (programmatic) so both paths
661    // keep form-submission state consistent regardless of which one fires.
662    _syncHiddenInputs(value) {
663        if (this._type === 'toggle') {
664            // Mirror a native checkbox: excluded from FormData while unchecked.
665            if (this._hiddenInput) {
666                this._hiddenInput.disabled = !value;
667                this._hiddenInput.value = value ? '1' : '';
668            }
669        } else if (this._type === 'checkbox-group') {
670            this._syncCheckboxGroupHiddenInputs(value);
671        } else if (this._hiddenInput && this._type !== 'file' && this._type !== 'date-time') {
672            this._hiddenInput.value = value == null
673                ? '' : (typeof value === 'object' ? JSON.stringify(value) : String(value));
674        }
675    }
676
677    // checkbox-group has no single value, so it can't reuse the generic `name` hidden
678    // input — instead it maintains one `name[id]` hidden input per checked item,
679    // matching how a plain HTML checkbox list already behaves (present only when checked).
680    _syncCheckboxGroupHiddenInputs(selectedIds) {
681        const name = this.getAttribute('name');
682        if (!name) return;
683        if (!this._cbHiddenInputs) this._cbHiddenInputs = new Map();
684
685        const selected = new Set((selectedIds || []).map(String));
686
687        for (const [id, el] of this._cbHiddenInputs) {
688            if (!selected.has(id)) {
689                el.remove();
690                this._cbHiddenInputs.delete(id);
691            }
692        }
693
694        selected.forEach((id) => {
695            if (this._cbHiddenInputs.has(id)) return;
696            const input = document.createElement('input');
697            input.type = 'hidden';
698            input.name = `${name}[${id}]`;
699            input.value = '1';
700            this.after(input);
701            this._cbHiddenInputs.set(id, input);
702        });
703    }
704
705    _syncError() {
706        const on  = this.hasAttribute('error');
707        const msg = this.getAttribute('error-message') || '';
708        if (this._errorEl) {
709            this._errorEl.textContent = msg;
710            this._errorEl.classList.toggle('hide', !on);
711        }
712        if (this._descEl) {
713            const hasDesc = !!this.getAttribute('description');
714            this._descEl.classList.toggle('ff-desc-err', on);
715            this._descEl.classList.toggle('hide', on || !hasDesc);
716        }
717
718        const type = this._type;
719        const setAttr = (el) => {
720            if (!el) return;
721            if (on) el.setAttribute('error', '');
722            else    el.removeAttribute('error');
723        };
724
725        if (type === 'text' || type === 'textarea' || type === 'email' || type === 'phone') {
726            if (this._field) {
727                this._field.classList.toggle('ff-input-error', on);
728                this._field.setAttribute('aria-invalid', on ? 'true' : 'false');
729            }
730        } else if (type === 'number') {
731            const target = this._numWrap || this._field;
732            if (target) {
733                target.classList.toggle('ff-input-error', on);
734                if (this._field) this._field.setAttribute('aria-invalid', on ? 'true' : 'false');
735            }
736        } else if (type === 'dropdown' || type === 'multi-select' || type === 'date' || type === 'datetime-compact') {
737            setAttr(this._field);
738        } else if (type === 'date-time') {
739            setAttr(this._field);
740            setAttr(this._hourDropdown);
741            setAttr(this._ampmControl);
742        } else if (type === 'time') {
743            setAttr(this._hourDropdown);
744            setAttr(this._ampmControl);
745        }
746    }
747
748    // segmented-control extends SingleSelectCore, which exposes `value` as a
749    // property accessor rather than getValue()/setValue()/deselect() methods.
750    _getAmPm() {
751        return this._ampmControl ? (this._ampmControl.value ?? null) : null;
752    }
753
754    // Deferred like _initDateTimeSubFields(): assigning .value before segmented-control
755    // upgrades permanently shadows its value accessor, so clicks stop updating it.
756    _setAmPm(value) {
757        if (!this._ampmControl) return;
758        const apply = () => { this._ampmControl.value = value ?? null; };
759        customElements.get('segmented-control') ? apply() : customElements.whenDefined('segmented-control').then(apply);
760    }
761
762    _getDateTimeVal() {
763        return {
764            date: this._field        && typeof this._field.getValue        === 'function' ? this._field.getValue()       : null,
765            hour: this._hourDropdown && typeof this._hourDropdown.getValue  === 'function' ? this._hourDropdown.getValue() : null,
766            ampm: this._getAmPm(),
767        };
768    }
769
770    _getTimeVal() {
771        return {
772            hour: this._hourDropdown && typeof this._hourDropdown.getValue === 'function' ? this._hourDropdown.getValue() : null,
773            ampm: this._getAmPm(),
774        };
775    }
776
777    // Formats {date, hour, ampm} — hour is total minutes (e.g. 255 = 4:15 pm)
778    _fmtDateTime(val, weekDay) {
779        if (!val) return null;
780        const parts = [];
781
782        if (val.date) {
783            parts.push((weekDay && !Array.isArray(val.date)) ? date_format(new Date(val.date), 'l, F d, Y') : (Array.isArray(val.date) ? val.date.join(', ') : val.date));
784        }
785        if (val.hour != null && val.hour !== 'none') {
786            const h = Math.floor(val.hour / 60) % 12 || 12;
787            const m = val.hour % 60;
788            parts.push(h + ':' + String(m).padStart(2, '0'));
789        }
790        if (val.ampm) {
791            parts.push(val.ampm.toLowerCase());
792        }
793
794        return parts.length ? parts.join(' ') : null;
795    }
796
797    // ── Public API ────────────────────────────────────────────────────────────
798
799    getValue() {
800        if (this._type === 'checkbox-group') return this._lastValue || [];
801        if (this._type === 'time') {
802            if (!this._hourDropdown && !this._ampmControl) return this._lastValue ?? null; // read-only: no sub-widgets built
803            const tv = this._getTimeVal();
804            return tv.hour && tv.ampm ? `${tv.hour} ${tv.ampm.toLowerCase()}` : null;
805        }
806        if (!this._field) return this._lastValue ?? null; // read-only: no live control to read from
807        switch (this._type) {
808            case 'text': case 'textarea': case 'number': case 'email': case 'phone': return this._field.value;
809            case 'dropdown':     return typeof this._field.getSelected    === 'function' ? this._field.getSelected()    : null;
810            case 'multi-select': return typeof this._field.getSelected === 'function' ? this._field.getSelected() : [];
811            case 'date':         return typeof this._field.getValue    === 'function' ? this._field.getValue()    : null;
812            case 'datetime-compact': return typeof this._field.getValue === 'function' ? this._field.getValue()   : null;
813            case 'date-time':    return this._getDateTimeVal();
814            case 'time':         return this._getTimeVal();
815            case 'toggle':       return !!this._field.checked;
816            case 'file':         return this._field.files || [];
817            case 'radio':        return typeof this._field.getSelectedValue    === 'function' ? this._field.getSelectedValue()    : null;
818        }
819        return this._lastValue ?? null;
820    }
821
822    setValue(value) {
823        // time has no #ff-field — handle it before the _field guard, mirroring getValue()
824        if (this._type === 'time') {
825            if (value) {
826                if (value.hour && this._hourDropdown && typeof this._hourDropdown.setSelected === 'function') this._hourDropdown.setSelected(String(value.hour));
827                if (value.ampm) this._setAmPm(value.ampm);
828            } else {
829                if (this._hourDropdown && typeof this._hourDropdown.setSelected === 'function') this._hourDropdown.setSelected('none');
830                this._setAmPm(null);
831            }
832            return;
833        }
834        this._lastValue = value;
835        // A programmatic setValue() is treated as an authoritative new baseline — unlike
836        // live edits (_onFieldChange, from the user typing/picking in the control), which
837        // deliberately do NOT touch this, so a read-only revert has something to discard back to.
838        this._readOnlyValue = value;
839
840        if (this._type === 'checkbox-group') {
841            const container = this.shadowRoot?.getElementById('ff-cbgroup');
842            if (container) {
843                const selected = new Set((value || []).map(String));
844                container.querySelectorAll('.ff-cb-input').forEach((cb) => {
845                    // checkbox-element's `checked` setter just syncs its internal state/attribute —
846                    // it doesn't dispatch 'change', so this can't recurse into _onFieldChange.
847                    cb.checked = selected.has(cb.dataset.id);
848                });
849            } else if (this.hasAttribute('read-only') && this._built) {
850                this.render();
851            }
852            return;
853        }
854        if (!this._field) return;
855        switch (this._type) {
856            case 'text': case 'textarea': case 'number': case 'email': case 'phone':
857                this._field.value = value ?? '';
858                break;
859            case 'dropdown':
860                if (typeof this._field.setSelected === 'function') this._field.setSelected(value);
861                break;
862            case 'multi-select':
863                if (typeof this._field.setSelected === 'function') this._field.setSelected(value);
864                break;
865            case 'date':
866            case 'datetime-compact':
867                if (typeof this._field.setValue === 'function') this._field.setValue(value);
868                break;
869            case 'date-time':
870                if (value) {
871                    if (value.date && typeof this._field.setValue                === 'function') this._field.setValue(value.date);
872                    if (value.hour && typeof this._hourDropdown.setSelected      === 'function') this._hourDropdown.setSelected(String(value.hour));
873                    if (value.ampm) this._setAmPm(value.ampm);
874                } else {
875                    if (this._field        && typeof this._field.clear               === 'function') this._field.clear();
876                    if (this._hourDropdown && typeof this._hourDropdown.setSelected  === 'function') this._hourDropdown.setSelected('none');
877                    this._setAmPm(null);
878                }
879                break;
880            case 'time':
881                if (value) {
882                    if (value.hour && this._hourDropdown && typeof this._hourDropdown.setSelected === 'function') this._hourDropdown.setSelected(String(value.hour));
883                    if (value.ampm && this._ampmControl) this._ampmControl.value = value.ampm;
884                } else {
885                    if (this._hourDropdown && typeof this._hourDropdown.setSelected === 'function') this._hourDropdown.setSelected('none');
886                    if (this._ampmControl) this._ampmControl.value = null;
887                }
888                break;
889            case 'toggle':
890                if (value) this._field.setAttribute('checked', '');
891                else       this._field.removeAttribute('checked');
892                this._updateToggleLabel(value ? true : false);
893                break;
894            case 'file':
895                this._field.files = value || [];
896                break;
897            case 'radio':
898                this._field.value = value !== null && value !== undefined ? String(value) : '';
899                break;
900        }
901        this._syncHiddenInputs(value);
902    }
903
904    // Promotes the current live value (already updated via user edits, which don't touch
905    // the snapshot on their own) to be the new read-only-revert baseline. Used by a Save
906    // button after editing — the "commit" counterpart to a plain read-only-attribute
907    // revert. See also the `commit-on-read-only` attribute for an auto-commit variant.
908    commitValue() {
909        this._readOnlyValue = this.getValue();
910    }
911
912    /**
913     * Reset the field to its empty/default state and clear any error. Mirrors
914     * native form.reset() for a component that has no server-rendered default,
915     * so closing and reopening a modal never surfaces stale input.
916     */
917    reset() {
918        if (!this._field) return;
919        switch (this._type) {
920            case 'toggle':       this.setValue(false); break;
921            case 'multi-select': this.setValue([]);    break;
922            case 'file':         this.setValue([]);    break;
923            // text/textarea/number/dropdown/date/date-time/time/radio all clear on null
924            default:             this.setValue(null);  break;
925        }
926        this.clearError();
927    }
928
929    // Re-applies cached items/value to a freshly (re)built interactive control —
930    // called after leaving read-only mode, since render() always creates a brand
931    // new internal element with no memory of prior state.
932    _restoreField() {
933        // checkbox-group is excluded here: its checkbox list is already baked from
934        // _lastItems/_lastValue by the render() that just ran, so re-calling setItems()
935        // would just trigger a redundant second render.
936        if (this._lastItems && (this._type === 'dropdown' || this._type === 'multi-select' || this._type === 'radio')) {
937            this.setItems(this._lastItems);
938        }
939        this.setValue(this._lastValue);
940    }
941
942    // Pass-through — [{id, value}] for dropdown/multi-select/checkbox-group; [{value, label}] for radio
943    setItems(items) {
944        this._lastItems = items;
945        if (this._type === 'checkbox-group') {
946            // checkbox-group has no imperative setItems() API of its own — the whole
947            // list is baked from _lastItems at render time, in both modes.
948            if (this._built) this.render();
949            return;
950        }
951        if (!this._field) {
952            if (this.hasAttribute('read-only') && this._built) this.render();
953            return;
954        }
955        if (this._type === 'radio') {
956            if (typeof this._field.setOptions === 'function') this._field.setOptions(items);
957        } else if (this._type === 'multi-select') {
958            // multi-select's own method is addItems(), not setItems() — it now accepts
959            // this same [{id, value, ...}] array shape directly (js/Components/MultiSelect.js).
960            if (typeof this._field.addItems === 'function') this._field.addItems(items);
961        } else if (typeof this._field.setItems === 'function') {
962            this._field.setItems(items);
963        }
964    }
965    setSelected(id) {
966        if (this._type === 'checkbox-group') { this.setValue(Array.isArray(id) ? id : [id]); return; }
967        if (this._field && typeof this._field.setSelected === 'function') this._field.setSelected(id);
968    }
969    getSelectedValue(){ return this._field && typeof this._field.getSelectedValue === 'function' ? this._field.getSelectedValue() : null; }
970    getSelectedNames(){ return this._field && typeof this._field.getSelectedNames === 'function' ? this._field.getSelectedNames() : null; }
971    getSelected() {
972        if (this._type === 'checkbox-group') return this._lastValue || [];
973        return this._field && typeof this._field.getSelected === 'function' ? this._field.getSelected() : null;
974    }
975    addItems(obj)     { if (this._field && typeof this._field.addItems    === 'function') this._field.addItems(obj); }
976    setEvents(events) { if (this._field && typeof this._field.setEventDates === 'function') this._field.setEventDates(events); }
977    setViewDate(str)  { if (this._field && typeof this._field.setViewDate  === 'function') this._field.setViewDate(str); }
978    getDateTimeValue(weekDay) { return (this._type === 'date-time') ? this._fmtDateTime(this._getDateTimeVal(), weekDay) : null; }
979    // this._field.files = ... (not a setFiles() method -- FileUploader only exposes the
980    // property setter; calling a nonexistent this._field.setFiles() here would throw).
981    setFiles(files)   { if (this._field) this._field.files = files || []; }
982    getFiles()        { return this._field ? (this._field.files || []) : []; }
983
984    setError(message) {
985        if (message) this.setAttribute('error-message', message);
986        this.setAttribute('error', '');
987    }
988
989    clearError() {
990        this.removeAttribute('error');
991        this.removeAttribute('error-message');
992    }
993
994    setLoading(active) {
995        if (!this._built) return;
996        if (this._loadingEl) this._loadingEl.classList.toggle('hide', !active);
997        if (this._field)     this._field.classList.toggle('hide', !!active);
998    }
999
1000    focus() {
1001        if (this._field && typeof this._field.focus === 'function') this._field.focus();
1002    }
1003
1004    validate(message) {
1005        if (!this.hasAttribute('required')) { this.clearError(); return true; }
1006        const val  = this.getValue();
1007        const type = this._type;
1008        const hasValue = !(val === null || val === undefined || String(val).trim() === '');
1009
1010        if (this.hasAttribute('required')) {
1011            let empty;
1012            if (type === 'toggle') {
1013                empty = false;
1014            } else if (type === 'multi-select') {
1015                empty = !Array.isArray(val) || val.length === 0;
1016            } else if (type === 'date-time') {
1017                const dt = val || {};
1018                const filled = (v) => v !== null && v !== undefined && String(v).trim() !== '';
1019                empty = !filled(dt.date) || !filled(dt.hour) || !filled(dt.ampm);
1020            } else if (type === 'time') {
1021                const tv = this._getTimeVal();
1022                const filled = (v) => v !== null && v !== undefined && String(v).trim() !== '';
1023                empty = !filled(tv.hour) || !filled(tv.ampm);
1024            } else if (type === 'file') {
1025                empty = !(this._field && this._field.hasFile);
1026            } else {
1027                empty = !hasValue;
1028            }
1029            if (empty) {
1030                this.setError(message || this.getAttribute('validation-message') || 'This field is required.');
1031                return false;
1032            }
1033        }
1034
1035        // Format check applies whenever a value is present, required or not (e.g. an
1036        // optional agent email that was typed wrong should still flag as invalid).
1037        if (hasValue && (type === 'email' || type === 'phone')) {
1038            const pattern = type === 'email' ? FormField.EMAIL_PATTERN : FormField.PHONE_PATTERN;
1039            if (!pattern.test(String(val).trim())) {
1040                this.setError(message || (type === 'email' ? 'Enter a valid email address.' : 'Enter a valid phone number.'));
1041                return false;
1042            }
1043        }
1044
1045        this.clearError();
1046        return true;
1047    }
1048
1049    // Escape hatch for cases not covered by the standard API
1050    getField() { return this._field; }
1051
1052    // ── Styles ────────────────────────────────────────────────────────────────
1053    // Base utility classes (flex, gap-*, font-14, double-columns-form-input-field,
1054    // input-wrapper-*, no-border-input, etc.) come from the base CSS loaded via
1055    // loadBaseCSS() + loadTemplate(). Only page-specific and component-specific
1056    // rules are defined here. Once HTML is refactored to use this component, the
1057    // matching rules can be pruned from index.php's <style> block.
1058
1059    _styles() {
1060        return `
1061            :host { display: block; }
1062            /* When used as a top-level bulk editor field, hidden until activated */
1063            :host([data-field])           { display: none; }
1064            :host([data-field].is-active) { display: block; }
1065            /* Full-width span in the values grid */
1066            :host([data-size="full"]), :host(.is-col-full) { grid-column: 1 / -1; }
1067            .hide { display: none !important; }
1068
1069            /* 14px font-size (not in base CSS) */
1070            .ff-font-14 { font-size: 14px; }
1071
1072            /* Labels stay on a single line — a long one truncates and reveals on hover.
1073               min-width:0 on both flex ancestors is what lets the span shrink far enough
1074               for the ellipsis to engage. */
1075            #ff-label      { min-width: 0; }
1076            #ff-label-wrap { min-width: 0; }
1077            #ff-label-txt {
1078                min-width: 0;
1079                overflow: hidden;
1080                text-overflow: ellipsis;
1081                white-space: nowrap;
1082            }
1083
1084            /* Required asterisk */
1085            .ff-req { color: #D9534F; font-size: 14px; flex-shrink: 0; }
1086
1087            /* Prefix / suffix symbol (e.g. $ or %) inside number wrapper */
1088            .ff-affix { color: #5E6463; font-size: 14px; white-space: nowrap; padding: 0 4px; }
1089
1090            /* read-only: static formatted value, in place of the interactive control */
1091            .ff-ro-value {
1092                min-height: 40px;
1093                display: flex;
1094                align-items: center;
1095                flex-wrap: wrap;
1096                gap: 4px;
1097                font-size: 1rem;
1098                color: #222222;
1099                word-break: break-word;
1100            }
1101
1102            /* read-only: stylistic-only differences from the editable state — wider
1103               label/field gap, bold label, no help text (checkbox-element's own
1104               left-aligned/full-width row layout covers the checkbox-group case) */
1105            :host([read-only]) #ff-root      { gap: 8px; }
1106            :host([read-only]) #ff-label-txt { font-weight: 600; }
1107            :host([read-only]) #ff-desc      { display: none !important; }
1108
1109            /* checkbox-group */
1110            #ff-cbgroup { display: flex; flex-direction: column; gap: 8px; }
1111
1112            /* ── Input field styles (from events-event-base-2018-09-27.css) ── */
1113            .double-columns-form-input-field {
1114                box-sizing: border-box;
1115                width: 100%;
1116                border-radius: 4px;
1117                height: 40px;
1118                padding: 0 12px;
1119                border: 1px solid #9DA6A5 !important;
1120                color: #54555A;
1121                font-size: 1rem;
1122                font-weight: 400;
1123                line-height: 22px;
1124                outline: none;
1125            }
1126            textarea.double-columns-form-input-field {
1127                height: auto;
1128                /* Consumers raise this per form; 80px stays the component default */
1129                min-height: var(--ff-textarea-min-height, 80px);
1130                padding: 8px 12px;
1131                resize: vertical;
1132            }
1133            .double-columns-form-input-field:hover  { border-color: #3F4242 !important; }
1134            /* :focus-within so the number wrapper picks up the border from its inner input */
1135            .double-columns-form-input-field:focus-within { border-color: #0389A0 !important; }
1136            /* Keyboard-only focus ring, matching the design-system --border-focused treatment */
1137            .double-columns-form-input-field.ff-kb-focus { outline: 2px solid var(--border-focused, #0389A0); outline-offset: 0; }
1138
1139            .input-wrapper-left {
1140                display: grid;
1141                grid-template-columns: auto 1fr;
1142                align-items: center;
1143            }
1144            .input-wrapper-right {
1145                display: grid;
1146                grid-template-columns: 1fr auto;
1147                align-items: center;
1148            }
1149
1150            /* Number wrapper — always flex for vertical centering; grid overrides for prefix/suffix */
1151            #ff-nw { display: flex; align-items: center; }
1152            #ff-nw.input-wrapper-left, #ff-nw.input-wrapper-right { display: grid; align-items: center; }
1153            /* Without prefix or suffix — inner input fills wrapper (no extra padding) */
1154            #ff-nw:not(.input-wrapper-left):not(.input-wrapper-right) > #ff-field { padding: 0; }
1155
1156            /* Input inside a prefix/suffix wrapper — no own border */
1157            .no-border-input {
1158                border: none !important;
1159                outline: none;
1160                background: transparent;
1161                width: 100%;
1162                font-size: 1rem;
1163                color: #54555A;
1164                padding: 0 6px;
1165                height: auto;
1166            }
1167            /* ──────────────────────────────────────────────────────────────── */
1168
1169            /* Error border on native inputs and number wrappers */
1170            .ff-input-error { border-color: #D9534F !important; }
1171
1172            /* Inline error message (shown above description when error is set) */
1173            .ff-em { display: block; font-size: 12px; color: #D9534F; margin-right: 4px; }
1174
1175            /* Description — mirrors .bulk-field-desc from index.php */
1176            .bulk-field-desc { display: block; font-size: 12px; color: #5E6463; }
1177            .ff-desc-err { color: #D9534F !important; }
1178
1179            /* Hide the footer row entirely when both children are hidden */
1180            .ff-footer:has(.ff-em.hide):has(.bulk-field-desc.hide) { display: none !important; }
1181
1182            /* Date-time — date picker on its own line, hour + AM/PM paired beneath it
1183               (keeps both sub-fields usable when the field sits in a narrow/half-width
1184               column instead of splitting the row 50/50). */
1185            .bulk-date-time-row { min-width: 0; }
1186            /* 176px = narrowest trigger that still fits a full MM/DD/YYYY plus icon and caret */
1187            .bulk-date-time-row > date-picker-element { min-width: 176px; }
1188            .bulk-time-group    { align-items: center; flex-wrap: wrap; min-width: 0; }
1189            .bulk-time-dropdown { flex: 0 1 auto; min-width: 92px; }
1190            .bulk-time-label    { display: none; }
1191
1192            /* Loading spinner */
1193            .ff-ld { display: flex; align-items: center; justify-content: center; padding: 8px; }
1194            .ff-sp {
1195                width: 16px; height: 16px;
1196                border: 2px solid #D1D5DB;
1197                border-top-color: #038580;
1198                border-radius: 50%;
1199                animation: ff-spin 0.6s linear infinite;
1200            }
1201            @keyframes ff-spin { to { transform: rotate(360deg); } }
1202
1203            @media (max-width: 600px) {
1204                .bulk-time-label    { display: block; }
1205                .bulk-time-dropdown { width: auto !important; }
1206            }
1207        `;
1208    }
1209}
1210
1211if (!customElements.get('form-field-element')) {
1212    customElements.define('form-field-element', FormField);
1213}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.