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, '&') 638 .replace(/</g, '<') 639 .replace(/>/g, '>') 640 .replace(/"/g, '"'); 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.