PageSourceSearch

https://www.bergeng.com/mm5/json.mvc?Store_Code=BEOS&Function=Java…avaScriptResource_Code=mmx-forms&T=7d6cd504

js bergeng.com collected 2026-10-04 21:57:21 UTC 36,123 bytes, 1,585 lines download raw bytes

1class MMX_FormValidator {
2	// Properties
3	#forms;
4	#formSelector = '[data-mmx-form-validator]';
5	#fieldSelector = '.mmx-form-field';
6	#invalidClass = 'mmx-form-field--error';
7	#validClass = 'mmx-form-field--success';
8	#fieldDescriptionClass = 'mmx-form-field-description';
9	#fieldErrorMessageClass = 'mmx-form-field-error-message';
10	#validityStates = ['valueMissing', 'badInput', 'typeMismatch', 'tooShort', 'tooLong', 'rangeUnderflow', 'rangeOverflow', 'stepMismatch', 'patternMismatch', 'customError', 'valid'];
11
12	// Instance Methods
13	static autoInit() {
14		document.addEventListener('DOMContentLoaded', () => {
15			new MMX_FormValidator();
16		});
17	}
18
19	constructor(options = {}) {
20		this.#formSelector = typeof options?.formSelector === 'string' ? options.formSelector : this.#formSelector;
21		this.#fieldSelector = typeof options?.fieldSelector === 'string' ? options.fieldSelector : this.#fieldSelector;
22		this.#invalidClass = typeof options?.invalidClass === 'string' ? options.invalidClass : this.#invalidClass;
23		this.#validClass = typeof options?.validClass === 'string' ? options.validClass : this.#validClass;
24		this.#fieldDescriptionClass = typeof options?.fieldDescriptionClass === 'string' ? options.fieldDescriptionClass : this.#fieldDescriptionClass;
25		this.#validityStates = Array.isArray(options?.validityStates) ? options.validityStates : this.#validityStates;
26
27		this.#init();
28	}
29
30	#init() {
31		this.#forms = document.querySelectorAll(this.#formSelector);
32
33		this.#forms?.forEach(form => {
34			this.#bindEvents(form);
35			this.#displayInitialErrors(form);
36		});
37	}
38
39	// Events
40	#bindEvents(form) {
41		form.addEventListener('input', (e) => {
42			this.#onInput(e);
43		}, {capture: true});
44
45		form.addEventListener('change', (e) => {
46			this.#onChange(e);
47		}, {capture: true});
48
49		form.addEventListener('blur', (e) => {
50			this.#onBlur(e);
51		}, {capture: true});
52
53		form.addEventListener('submit', (e) => {
54			this.#onSubmit(e);
55		}, {capture: true});
56	}
57
58	#onInput(e) {
59		this.#setElementValidIfValid(e.target);
60	}
61
62	#onChange(e) {
63		this.#setElementValidIfValid(e.target);
64	}
65
66	#onBlur(e) {
67		this.#validateElement(e.target);
68	}
69
70	#onSubmit(e) {
71		const elementsAreValid = this.#validateElements(this.#getFormElements(e?.target), {reportValidity: true});
72		if (!elementsAreValid) {
73			e.preventDefault();
74		}
75	}
76
77	// Validation
78	#setElementValidIfValid(element) {
79		if (!this.#isElementValid(element)) {
80			return;
81		}
82
83		this.#setElementValid(element);
84	}
85
86	#getFormElements(form) {
87		return form.querySelectorAll('input, select, textarea');
88	}
89
90	#displayInitialErrors(form) {
91		Array.from(this.#getFormElements(form)).forEach(element => {
92			const field = this.#findElementField(element);
93
94			if (!field) {
95				return;
96			}
97
98			if (field?.classList.contains(this.#invalidClass)) {
99				this.#validateElement(element);
100			}
101		});
102	}
103
104	#validateElements(elements, options) {
105		return Array.from(elements).every(element => {
106			return this.#validateElement(element, options);
107		});
108	}
109
110	#validateElement(element, options) {
111		if (this.#isElementValid(element)) {
112			this.#setElementValid(element);
113			return true;
114		} else {
115			this.#setElementInvalid(element, options);
116			return false;
117		}
118	}
119
120	#isElementValid(element) {
121		return element?.validity?.valid === true;
122	}
123
124	#setElementValid(element) {
125		const field = this.#findElementField(element);
126
127		if (!field) {
128			return;
129		}
130
131		field.classList.remove(this.#invalidClass);
132		field.classList.add(this.#validClass);
133
134		this.#removeFieldErrorMessage(field);
135	}
136
137	#getFieldErrorMessage(field) {
138		return field.querySelector(`.${this.#fieldErrorMessageClass}`);
139	}
140
141	#removeFieldErrorMessage(field) {
142		this.#getFieldErrorMessage(field)?.remove?.();
143	}
144
145	#setElementInvalid(element, {reportValidity = false} = {}) {
146		const field = this.#findElementField(element);
147
148		if (!field) {
149			return;
150		}
151
152		field.classList.remove(this.#validClass);
153		field.classList.add(this.#invalidClass);
154
155		this.#addFieldErrorMessage({field, element});
156
157		if (reportValidity) {
158			element.reportValidity();
159		}
160	}
161
162	#addFieldErrorMessage({field, element}) {
163		const validationMessage = this.#getElementValidationMessage(element);
164		const fieldErrorMessage = this.#getFieldErrorMessage(field);
165
166		if (!fieldErrorMessage) {
167			this.#insertFieldErrorMessage(field, validationMessage);
168		} else {
169			fieldErrorMessage.innerHTML = validationMessage;
170		}
171	}
172
173	#insertFieldErrorMessage(field, message) {
174		const fieldErrorMessage = this.#createFieldErrorMessage(message);
175		const fieldDescription = this.#getFieldDescription(field);
176
177		if (fieldDescription) {
178			fieldDescription.insertAdjacentElement('beforebegin', fieldErrorMessage);
179		} else {
180			field.append(fieldErrorMessage);
181		}
182	}
183
184	#createFieldErrorMessage(message) {
185		return MMX.createElement({
186			type: 'div',
187			attributes: {
188				class: this.#fieldErrorMessageClass
189			},
190			content: message
191		});
192	}
193
194	#getFieldDescription(field) {
195		return field.querySelector(`.${this.#fieldDescriptionClass}`);
196	}
197
198	#getElementValidationMessage(element) {
199		const currentState = this.#validityStates.find(state => {
200			return element.validity?.[state];
201		});
202
203		return /*html*/`<mmx-icon data-name="error"></mmx-icon> ${element.dataset[currentState] ?? element.validationMessage}`;
204	}
205
206	#findElementField(element) {
207		return element?.closest?.(this.#fieldSelector);
208	}
209}
210
211MMX_FormValidator.autoInit();
212
213class MMX_FormInputAutoHeight {
214	#inputs;
215	#selector = '[data-mmx-form-input-auto-height]';
216
217	static autoInit() {
218		document.addEventListener('DOMContentLoaded', () => {
219			new MMX_FormInputAutoHeight();
220		});
221	}
222
223	constructor(options = {}) {
224		this.#selector = typeof options?.selector === 'string' ? options.selector : this.#selector;
225
226		this.#init();
227	}
228
229	#init() {
230		this.#inputs = document.querySelectorAll(this.#selector);
231
232		this.#inputs?.forEach(input => {
233			this.#updateInput(input);
234			this.#bindEvents(input);
235		});
236	}
237
238	// Events
239	#bindEvents(input) {
240		input.addEventListener('input', (e) => {
241			this.#onInput(e);
242		});
243
244		window.addEventListener('resize', () => {
245			this.#debouncedOnResize();
246		});
247	}
248
249	#onInput(e) {
250		this.#updateInput(e.target);
251	}
252
253	#debouncedOnResize = MMX.debounce(() => {
254		this.#updateInputs();
255	}, 100);
256
257	// Input Sizing
258	#updateInputs() {
259		this.#inputs.forEach(input => {
260			this.#updateInput(input);
261		});
262	}
263
264	#updateInput(input) {
265		input.style.height = null;
266		const inputStyle = getComputedStyle(input);
267		input.style.height = (input.scrollHeight + parseFloat(inputStyle.borderTopWidth) + parseFloat(inputStyle.borderBottomWidth))  + 'px';
268	}
269}
270
271MMX_FormInputAutoHeight.autoInit();
272
273class MMX_FormInputRange extends MMX_Element {
274	static formAssociated = true;
275
276	static get props () {
277		return {
278			name: {
279				allowAny: true,
280				default: ''
281			},
282			min: {
283				allowAny: true,
284				isNumeric: true,
285				default: 0
286			},
287			max: {
288				allowAny: true,
289				isNumeric: true,
290				default: 0
291			},
292			step: {
293				allowAny: true,
294				isNumeric: true,
295				default: 1
296			},
297			high: {
298				allowAny: true,
299				isNumeric: true,
300				default: 0
301			},
302			low: {
303				allowAny: true,
304				isNumeric: true,
305				default: 0
306			},
307			label: {
308				allowAny: true,
309				default: 'to'
310			},
311			formatter: {
312				allowAny: true
313			},
314			'format-rounding': {
315				isBoolean: true,
316				default: true
317			}
318		};
319	}
320
321	constructor() {
322		super();
323		this.makeComponent();
324		this.#setFormValue();
325		this.#bindComponentEvents();
326	}
327
328	styleResourceCodes = ['mmx-base', 'mmx-forms'];
329	renderUniquely = true;
330
331	render() {
332		const min = this.getPropValue('min');
333		const max = this.getPropValue('max');
334		const step = this.getPropValue('step');
335		const low = this.getPropValue('low') || min;
336		const high = this.getPropValue('high') || max;
337
338		return /*html*/`
339			<div
340				class="mmx-form-input-range"
341				part="wrapper"
342			>
343				<div part="slider" class="mmx-form-input-range__slider">
344					<div part="track" class="mmx-form-input-range__track">
345						<div part="slider-background" class="mmx-form-input-range__slider-background"></div>
346						<div part="slider-selection" class="mmx-form-input-range__slider-selection"></div>
347						<button id="slider-low" title="Low" part="slider-button slider-low" type="button" class="mmx-form-input-range__slider-button mmx-form-input-range__slider-low"></button>
348						<button id="slider-high" title="High" part="slider-button slider-high" type="button" class="mmx-form-input-range__slider-button mmx-form-input-range__slider-high"></button>
349					</div>
350				</div>
351				<div part="controls" class="mmx-form-input-range__controls">
352					<input
353						type="number"
354						id="input-low"
355						part="input input-low"
356						class="mmx-form-input-range__input mmx-form-input-range__input-low mmx-form-input"
357						step="${MMX.encodeEntities(step)}"
358						min="${MMX.encodeEntities(min)}"
359						max="${MMX.encodeEntities(max)}"
360						value="${MMX.encodeEntities(low)}"
361					/>
362					<label
363						for="input-low"
364						part="input-label input-label-low"
365						class="mmx-form-input-range__input-label mmx-form-input-range__input-label-low"
366					>
367						${MMX.encodeEntities(this.#formatValue(low))}
368					</label>
369					<div
370						part="input input-label"
371						class="mmx-form-input-range__label"
372					>
373						${MMX.encodeEntities(this.getPropValue('label'))}
374					</div>
375					<input
376						type="number"
377						id="input-high"
378						part="input input-high"
379						class="mmx-form-input-range__input mmx-form-input-range__input-high mmx-form-input"
380						step="${MMX.encodeEntities(step)}"
381						min="${MMX.encodeEntities(min)}"
382						max="${MMX.encodeEntities(max)}"
383						value="${MMX.encodeEntities(high)}"
384					/>
385					<label
386						for="input-high"
387						part="input-label input-label-high"
388						class="mmx-form-input-range__input-label mmx-form-input-range__input-label-high"
389					>
390						${MMX.encodeEntities(this.#formatValue(high))}
391					</label>
392				</div>
393			</div>
394		`;
395	}
396
397	afterRender() {
398		this.#setFormValue();
399		this.#updateSlider();
400		this.#bindEvents();
401	}
402
403	// Events
404	#bindComponentEvents() {
405		window.addEventListener('mousemove', (e) => this.#onPointerMoveWindow(e));
406		window.addEventListener('touchmove', (e) => this.#onPointerMoveWindow(e));
407		window.addEventListener('pointerup', () => this.#onPointerUpWindow());
408	}
409
410	#bindEvents() {
411		this.addEventListener('input', (e) => {
412			this.#onInput(e);
413		});
414
415		this.#sliderLow().addEventListener('pointerdown', (e) => this.#onPointerDownSliderButton(e));
416		this.#sliderHigh().addEventListener('pointerdown', (e) => this.#onPointerDownSliderButton(e));
417	}
418
419	// Events: Slider
420	#activeSlider;
421
422	#onPointerUpWindow() {
423		this.#activeSlider = undefined;
424	}
425
426	#onPointerMoveWindow(e) {
427		const isClicking = e instanceof MouseEvent ? e.buttons === 1 : true;
428
429		if (isClicking && this.#activeSlider instanceof HTMLElement) {
430			this.#onPointerMoveSliderButton(e);
431		}
432	}
433
434	#onPointerDownSliderButton(e) {
435		this.#activeSlider = e.target;
436	}
437
438	#onPointerMoveSliderButton(e) {
439		const sliderRect = this.#slider().getBoundingClientRect();
440		const pointerX = e.touches?.[0]?.clientX ?? e.clientX;
441
442		const state = this.#getStateOfRange({
443			min: sliderRect.left,
444			max: sliderRect.left + sliderRect.width,
445			low: pointerX,
446			high: pointerX
447		});
448
449		// Move Button
450		const button = this.#activeSlider;
451		button.style.left = this.#createPercent(state.relativeLow, state.range);
452
453		// Determine Value
454		const value = parseInt(((state.relativeLow / state.range) * (this.max - this.min) + this.min));
455
456		if (button.id === 'slider-low') {
457			this.low = value;
458		}
459		else if (button.id === 'slider-high') {
460			this.high = value;
461		}
462	}
463
464	#onInput() {
465		this.#setFormValue();
466		this.#updateSlider();
467	}
468
469	// Public Values
470	#setFormValue() {
471		this.#setValidity();
472		const formData = new FormData();
473		formData.append(this.name, this.value);
474		this.setFormValue(formData);
475	}
476
477	#setValidity() {
478		if (this.#inputLow()?.validity?.valid === false) {
479			this.setValidity(this.#inputLow().validity, this.#inputLow().validationMessage);
480			return;
481		}
482
483		if (this.#inputHigh()?.validity?.valid === false) {
484			this.setValidity(this.#inputHigh().validity, this.#inputHigh().validationMessage);
485			return;
486		}
487
488		this.setValidity({}, '');
489	}
490
491	get name() {
492		return this.getPropValue('name');
493	}
494
495	get value() {
496		const low = this.low > this.high ? this.high : this.low;
497		const high = this.high < this.low ? this.low : this.high;
498
499		return `${low}-${high}`;
500	}
501
502	get low() {
503		const value = this.#inputLow()?.valueAsNumber;
504		return isNaN(value) ? this.getPropValue('min') : value;
505	}
506
507	set low(value) {
508		this.#setInputValue(this.#inputLow(), value);
509	}
510
511	get high() {
512		const value = this.#inputHigh()?.valueAsNumber;
513		return isNaN(value) ? this.getPropValue('max') : value;
514	}
515
516	set high(value) {
517		this.#setInputValue(this.#inputHigh(), value);
518	}
519
520	get max() {
521		return this.getPropValue('max');
522	}
523
524	get min() {
525		return this.getPropValue('min');
526	}
527
528	#setInputValue(input, value) {
529		input.value = value;
530
531		if (input.validity.rangeOverflow || input.validity.stepMismatch){
532			input.stepDown();
533		}
534		else if (input.validity.rangeUnderflow){
535			input.stepUp();
536		}
537
538		const inputEvent = new Event('input', {
539			bubbles: true,
540			composed: true
541		});
542
543		input.dispatchEvent(inputEvent);
544
545		return input.value;
546	}
547
548	// Inputs
549	#inputLow() {
550		return this.shadowRoot.querySelector('[part~="input-low"]');
551	}
552
553	#inputHigh() {
554		return this.shadowRoot.querySelector('[part~="input-high"]');
555	}
556
557	// Input Label
558	#inputLabelLow() {
559		return this.shadowRoot.querySelector('[part~="input-label-low"]');
560	}
561
562	#inputLabelHigh() {
563		return this.shadowRoot.querySelector('[part~="input-label-high"]');
564	}
565
566	// Slider
567	#slider() {
568		return this.shadowRoot.querySelector('[part~="slider"]');
569	}
570
571	#sliderLow() {
572		return this.shadowRoot.querySelector('[part~="slider-low"]');
573	}
574
575	#sliderHigh() {
576		return this.shadowRoot.querySelector('[part~="slider-high"]');
577	}
578
579	#sliderSelection() {
580		return this.shadowRoot.querySelector('[part~="slider-selection"]');
581	}
582
583	#createPercent(numerator, denominator) {
584		return `${(numerator / denominator) * 100}%`;
585	}
586
587	#getStateOfRange ({low = 0, high = 0, min = 0, max = 0} = {}) {
588		let actualLow = low > high ? high : low;
589		let actualHigh = high < low ? low : high;
590
591		if (actualLow < min) {
592			actualLow = min;
593		}
594
595		if (actualHigh > max) {
596			actualHigh = max;
597		}
598
599		const range = max - min;
600		const relativeLow = actualLow - min;
601		const relativeHigh =  actualHigh - min;
602
603		return {
604			low: actualLow,
605			high: actualHigh,
606			min,
607			max,
608			range,
609			relativeLow,
610			relativeHigh,
611		};
612	}
613
614	#updateSlider() {
615		const state = this.#getStateOfRange(this);
616
617		// Slider Buttons
618		this.#sliderLow().style.left = this.#createPercent(state.relativeLow, state.range);
619		this.#sliderHigh().style.left = this.#createPercent(state.relativeHigh, state.range);
620
621		// Selection
622		this.#sliderSelection().style.left = this.#createPercent(state.relativeLow, state.range);
623		this.#sliderSelection().style.right = this.#createPercent(state.range - state.relativeHigh, state.range);
624
625		// Label Text
626		this.#inputLabelLow().innerHTML = this.#formatValue(this.low);
627		this.#inputLabelHigh().innerHTML = this.#formatValue(this.high);
628	}
629
630	#formatValue(value = '') {
631		return window[this.getPropValue('formatter')]?.(value, this.getPropValue('format-rounding')) ?? value;
632	}
633}
634
635if (!customElements.get('mmx-form-input-range')) {
636	customElements.define('mmx-form-input-range', MMX_FormInputRange);
637}
638
639class MMX_FormInputQuantity extends MMX_Element {
640	static formAssociated = true;
641
642	static get props () {
643		return {
644			name: {
645				allowAny: true,
646				default: ''
647			},
648			value: {
649				allowAny: true,
650				isNumeric: true,
651				default: 1
652			},
653			min: {
654				allowAny: true,
655				isNumeric: true,
656				default: 1
657			},
658			max: {
659				allowAny: true,
660				isNumeric: true
661			},
662			step: {
663				allowAny: true,
664				isNumeric: true,
665				default: 1
666			},
667			size: {
668				options: ['xs', 's', 'm', 'l'],
669				default: 'm'
670			}
671		};
672	}
673
674	constructor() {
675		super();
676		this.makeComponent();
677		this.#setFormValue();
678	}
679
680	styleResourceCodes = ['mmx-base', 'mmx-forms'];
681	renderUniquely = true;
682
683	render() {
684		const min = this.getPropValue('min');
685		const max = this.getPropValue('max');
686		const step = this.getPropValue('step');
687		const value = this.getPropValue('value');
688		const disabled = this.disabled ? 'disabled' : '';
689		const required = this.required ? 'required' : '';
690
691		return /*html*/`
692			<div
693				class="mmx-form-input-quantity mmx-form-input-quantity__size--${this.getPropValue('size')}"
694				part="wrapper"
695			>
696				<button
697					type="button"
698					id="decrease"
699					part="button decrease"
700					class="mmx-form-input-quantity__control mmx-form-input-quantity__button"
701					title="Decrease Quantity"
702					${disabled}
703				>
704					<mmx-icon data-name="subtract"></mmx-icon>
705				</button>
706				<input
707					type="number"
708					id="quantity"
709					part="input quantity"
710					class="mmx-form-input-quantity__control mmx-form-input-quantity__input"
711					aria-label="Quantity Amount"
712					step="${MMX.encodeEntities(step)}"
713					min="${MMX.encodeEntities(min)}"
714					max="${MMX.encodeEntities(max)}"
715					value="${MMX.encodeEntities(value)}"
716					${disabled}
717					${required}
718				/>
719				<button
720					type="button"
721					id="increase"
722					part="button increase"
723					class="mmx-form-input-quantity__control mmx-form-input-quantity__button"
724					title="Increase Quantity"
725					${disabled}
726				>
727					<mmx-icon data-name="add"></mmx-icon>
728				</button>
729			</div>
730		`;
731	}
732
733	afterRender() {
734		this.#handleChanges();
735		this.#bindEvents();
736	}
737
738	#handleChanges() {
739		this.#setFormValue();
740		this.#updateButtons();
741	}
742
743	// Elements
744	#inputQuantity() {
745		return this.shadowRoot.querySelector('[part~="quantity"]');
746	}
747
748	#increaseButton() {
749		return this.shadowRoot.querySelector('[part~="increase"]');
750	}
751
752	#decreaseButton() {
753		return this.shadowRoot.querySelector('[part~="decrease"]');
754	}
755
756	// Events
757	#bindEvents() {
758		this.addEventListener('input', (e) => {
759			this.#onInput(e);
760		});
761
762		this.#increaseButton().addEventListener('click', () => {
763			this.#increaseQuantity();
764		});
765
766		this.#decreaseButton().addEventListener('click', () => {
767			this.#decreaseQuantity();
768		});
769	}
770
771	#onInput() {
772		this.#handleChanges();
773	}
774
775	// Public Values
776	#setFormValue() {
777		this.#setValidity();
778		const formData = new FormData();
779		formData.append(this.name, this.value);
780		this.setFormValue(formData);
781	}
782
783	#setValidity() {
784		if (this.#inputQuantity()?.validity?.valid === false) {
785			this.setValidity(this.#inputQuantity().validity, this.#inputQuantity().validationMessage);
786			return;
787		}
788
789		this.setValidity({}, '');
790	}
791
792	get name() {
793		return this.getPropValue('name');
794	}
795
796	get disabled() {
797		return this.hasAttribute('disabled');
798	}
799
800	set disabled(disabled) {
801		disabled = Boolean(disabled);
802
803		if (disabled) {
804			this.setAttribute('disabled', '');
805		}
806		else {
807			this.removeAttribute('disabled');
808		}
809
810		this.#updateButtons();
811		this.#inputQuantity().disabled = disabled;
812
813		return disabled;
814	}
815
816	get required() {
817		return this.hasAttribute('required');
818	}
819
820	get max() {
821		return this.getPropValue('max');
822	}
823
824	get min() {
825		return this.getPropValue('min');
826	}
827
828	get value() {
829		return this.#inputQuantity()?.value ?? '';
830	}
831
832	get valueAsNumber() {
833		return this.#inputQuantity()?.valueAsNumber;
834	}
835
836	set value(value) {
837		const inputQuantity = this.#inputQuantity();
838
839		inputQuantity.value = value;
840
841		if (isNaN(inputQuantity.valueAsNumber) || inputQuantity.validity.quantityOverflow || inputQuantity.validity.stepMismatch){
842			inputQuantity.stepDown();
843		}
844		else if (inputQuantity.validity.quantityUnderflow){
845			inputQuantity.stepUp();
846		}
847
848		this.#dispatchInputEvent();
849	}
850
851	// Methods
852	#dispatchInputEvent() {
853		const inputEvent = new Event('input', {
854			bubbles: true,
855			composed: true
856		});
857
858		this.#inputQuantity().dispatchEvent(inputEvent);
859	}
860
861	#increaseQuantity() {
862		this.#inputQuantity().stepUp();
863		this.#dispatchInputEvent();
864	}
865
866	#decreaseQuantity() {
867		this.#inputQuantity().stepDown();
868		this.#dispatchInputEvent();
869	}
870
871	#updateButtons() {
872		this.#decreaseButton().disabled = this.disabled || Number(this.value) === Number(this.min);
873		this.#increaseButton().disabled = this.disabled || Number(this.value) === Number(this.max);
874	}
875}
876
877if (!customElements.get('mmx-form-input-quantity')) {
878	customElements.define('mmx-form-input-quantity', MMX_FormInputQuantity);
879}
880
881class MMX_Combobox extends MMX_Element {
882	#value = '';
883	#dispatchedValue = '';
884	#option = {};
885	#options = [];
886	static formAssociated = true;
887
888	static get props () {
889		return {
890			name: {
891				allowAny: true,
892				default: ''
893			},
894			label: {
895				allowAny: true,
896				default: 'Please select an option'
897			},
898			value: {
899				allowAny: true,
900				default: ''
901			},
902			size: {
903				options: ['xs', 's', 'm', 'l'],
904				default: 'm'
905			},
906			minimal: {
907				isBoolean: true,
908				default: false
909			},
910			options: {
911				isObject: true,
912				isJson: true,
913				options: [
914					'script',
915					'innerHTML'
916				],
917				default: null
918			}
919		};
920	}
921
922	constructor() {
923		super();
924		this.makeComponent();
925		this.#bindEvents();
926		this.#setOptions();
927		this.#setValue(this.getPropValue('value'));
928		this.#dispatchedValue = this.#value;
929	}
930
931	styleResourceCodes = ['mmx-base', 'mmx-forms'];
932	renderUniquely = true;
933
934	// Render Methods
935	render() {
936		const value = this.#value;
937		const label = this.getPropValue('label');
938		const disabled = this.disabled ? 'disabled' : '';
939		const minimal = this.getPropValue('minimal') === true ? 'minimal' : 'regular';
940
941		return /*html*/`
942			<div
943				id="wrapper"
944				part="wrapper"
945				class="mmx-combobox"
946			>
947				<div
948					id="select"
949					part="select"
950					class="mmx-combobox__select mmx-form-select mmx-form-input--${minimal}"
951					${disabled}
952				>
953					<button
954						id="select-button"
955						part="select-button input"
956						class="mmx-combobox__select-button mmx-form-select__dropdown"
957						popovertarget="popover"
958						type="button"
959						${disabled}
960					>
961						${MMX.encodeEntities(this.#getSelectButtonLabel())}
962					</button>
963				</div>
964				<div
965					id="popover"
966					part="popover"
967					class="mmx-combobox__popover"
968					popover
969				>
970					<input
971						id="search"
972						part="search input"
973						role="combobox"
974						class="mmx-combobox__search mmx-form-input"
975						type="search"
976						aria-controls="listbox"
977						aria-haspopup="listbox"
978						aria-autocomplete="list"
979						aria-expanded="true"
980						aria-label="${MMX.encodeEntities(label)}"
981						value="${MMX.encodeEntities(value)}"
982						autocomplete="off"
983						autofocus
984					/>
985					<button
986						id="hide-popover"
987						part="hide-popover input"
988						class="mmx-combobox__hide-popover mmx-form-select__picker-icon"
989						type="button"
990						popovertarget="popover"
991						popovertargetaction="hide"
992						title="Close options"
993						tabindex="-1"
994					>
995					</button>
996					<ul
997						id="listbox"
998						part="listbox"
999						role="listbox"
1000						class="mmx-combobox__listbox"
1001						tabindex="-1"
1002					>
1003						${this.#renderListBoxOptions()}
1004					</ul>
1005				</div>
1006			</div>
1007		`;
1008	}
1009
1010	#getSelectButtonLabel() {
1011		return this.#value.length ? this.#value : this.getPropValue('label');
1012	}
1013
1014	#renderListBoxOptions() {
1015		return this.#renderListBoxOptionLabel() + this.#options.map(option => {
1016			return this.#renderListBoxOption(option);
1017		}).join('');
1018	}
1019
1020	#renderListBoxOptionLabel() {
1021		if (this.#search()?.value?.length) {
1022			return '';
1023		}
1024
1025		return /*html*/`
1026			<li
1027				part="option"
1028				role="option"
1029				class="mmx-combobox__option mmx-combobox__option--label"
1030				aria-selected="false"
1031				disabled
1032			>
1033				${MMX.encodeEntities(this.getPropValue('label'))}
1034			</li>
1035		`;
1036	}
1037
1038	#renderListBoxOption(option = {}) {
1039		const match = this.#determineOptionMatch(option);
1040		const ariaSelected = match?.CEQ === true ? true : false;
1041		const searchIsActiveElement = this.shadowRoot.activeElement === this.#search();
1042		const hidden = searchIsActiveElement && match?.CIN === false ? 'hidden' : '';
1043
1044		return /*html*/`
1045			<li
1046				id="option-${MMX.encodeEntities(option.index)}"
1047				data-option-index="${MMX.encodeEntities(option.index)}"
1048				part="option"
1049				role="option"
1050				class="mmx-combobox__option"
1051				aria-selected="${ariaSelected}"
1052				${hidden}
1053			>
1054				${MMX.encodeEntities(option.prompt)}
1055			</li>
1056		`;
1057	}
1058
1059	afterRender() {
1060		this.#setFormValue();
1061		this.#bindRenderEvents();
1062		this.#updatePopoverLocation();
1063	}
1064
1065	#updateListBox() {
1066		if (!this.#listBox()) {
1067			return;
1068		}
1069
1070		this.#listBox().innerHTML = this.#renderListBoxOptions();
1071		this.#bindListBoxOptionEvents();
1072		this.#scrollOptionIntoView();
1073	}
1074
1075	// Attributes
1076	static get observedAttributes() {
1077		return ['disabled', 'required', ...this.propsToAttributeNames];
1078	}
1079
1080	// Elements
1081	#select() {
1082		return this.shadowRoot.getElementById('select');
1083	}
1084
1085	#selectButton() {
1086		return this.shadowRoot.getElementById('select-button');
1087	}
1088
1089	#popover() {
1090		return this.shadowRoot.getElementById('popover');
1091	}
1092
1093	#search() {
1094		return this.shadowRoot.getElementById('search');
1095	}
1096
1097	#listBox() {
1098		return this.shadowRoot.getElementById('listbox');
1099	}
1100
1101	#listBoxOptions() {
1102		return this.shadowRoot.querySelectorAll('[part~="option"]');
1103	}
1104
1105	#selectedListBoxOption() {
1106		return this.shadowRoot.querySelector('[part~="option"][aria-selected="true"]');
1107	}
1108
1109	#availableListBoxOptions() {
1110		return this.shadowRoot.querySelectorAll('[part~="option"]:not([hidden], [disabled])');
1111	}
1112
1113	// Events
1114	#bindEvents() {
1115		this.addEventListener('focus', e => {
1116			this.#onFocus(e);
1117		});
1118
1119		this.addEventListener('blur', e => {
1120			this.#onBlur(e);
1121		});
1122
1123		this.addEventListener('keydown', e => {
1124			this.#onKeyDown(e);
1125		});
1126	}
1127
1128	#bindRenderEvents() {
1129		this.#search().addEventListener('keyup', e => {
1130			this.#onSearchKeyUp(e);
1131		});
1132
1133		this.#search().addEventListener('input', e => {
1134			this.#onSearchInput(e);
1135		});
1136
1137		this.#bindListBoxOptionEvents();
1138		this.#bindAnchorPositionPolyfill();
1139	}
1140
1141	#bindListBoxOptionEvents() {
1142		this.#listBoxOptions()?.forEach(option => {
1143			option.addEventListener('click', e => {
1144				this.#onOptionClick({option, e});
1145			});
1146		});
1147	}
1148
1149	#onSearchKeyUp(e) {
1150		if (e.key === 'Enter') {
1151			this.#onSearchKeyUpEnter();
1152		}
1153	}
1154
1155	#onSearchKeyUpEnter() {
1156		const option = this.#selectedListBoxOption();
1157
1158		if (!(option instanceof HTMLElement)) {
1159			return;
1160		}
1161
1162		this.#setValueFromListBoxOption(option);
1163	}
1164
1165	#onSearchInput(e) {
1166		if (e.key === 'Enter') {
1167			return;
1168		}
1169
1170		this.#updateListBox();
1171		this.#setValue(this.#search().value);
1172	}
1173
1174	#onFocus(e) {
1175		this.showPopover();
1176	}
1177
1178	#onBlur(e) {
1179		this.hidePopover();
1180
1181		if (this.#search().value !== this.#dispatchedValue) {
1182			this.#dispatchEvents();
1183		}
1184	}
1185
1186	#onOptionClick({option, e}) {
1187		this.#setValueFromListBoxOption(option);
1188	}
1189
1190	#onKeyDown(e) {
1191		const activeElement = e.target.shadowRoot.activeElement;
1192		const isOption = activeElement.part.contains('option');
1193		const isSearch = activeElement.id === 'search';
1194		const isSelectButton = activeElement.id === 'select-button';
1195
1196		switch(e.key) {
1197			case 'ArrowDown':
1198				if (isOption || isSearch) {
1199					this.#onSearchKeyDownArrowDown(e);
1200				}
1201				else if (isSelectButton) {
1202					this.#onSelectButtonKeyDown(e);
1203				}
1204				break;
1205			case 'ArrowUp':
1206				if (isOption || isSearch) {
1207					this.#onSearchKeyDownArrowUp(e);
1208				}
1209				else if (isSelectButton) {
1210					this.#onSelectButtonKeyDown(e);
1211				}
1212				break;
1213			case 'Escape':
1214				if (isSearch) {
1215					this.#onSearchKeyDownEscape(e);
1216				}
1217				break;
1218		}
1219	}
1220
1221	#onSelectButtonKeyDown(e) {
1222		this.showPopover();
1223		this.#search().focus();
1224	}
1225
1226	#onSearchKeyDownArrowDown(e) {
1227		e.preventDefault();
1228		this.#selectNextListBoxOption();
1229	}
1230
1231	#onSearchKeyDownArrowUp(e) {
1232		e.preventDefault();
1233		this.#selectPreviousListBoxOption();
1234	}
1235
1236	#onSearchKeyDownEscape(e) {
1237		this.#setValue('');
1238		this.#selectButton().focus();
1239	}
1240
1241	// UI Interaction Methods
1242	#selectNextListBoxOption() {
1243		this.#moveSelectedListBoxOption(1);
1244	}
1245
1246	#selectPreviousListBoxOption() {
1247		this.#moveSelectedListBoxOption(-1);
1248	}
1249
1250	#moveSelectedListBoxOption(amount = 1) {
1251		const selectedOptionIndex = Array.from(this.#availableListBoxOptions()).indexOf(this.#selectedListBoxOption());
1252		const availableListBoxOptionsLength = this.#availableListBoxOptions()?.length ?? 0;
1253
1254		let newIndex = selectedOptionIndex + MMX.coerceNumber(amount, 1);
1255
1256		if (newIndex > availableListBoxOptionsLength - 1) {
1257			newIndex = 0;
1258		} else if (newIndex < 0) {
1259			newIndex = availableListBoxOptionsLength - 1;
1260		}
1261
1262		this.#selectListBoxOption(this.#availableListBoxOptions()?.[newIndex]);
1263	}
1264
1265	#selectListBoxOption(option) {
1266		if (!(option instanceof HTMLElement)) {
1267			this.#setActiveDescendant(null);
1268			return;
1269		}
1270
1271		const selectedOption = this.#selectedListBoxOption();
1272
1273		if (selectedOption) {
1274			selectedOption.ariaSelected = false;
1275		}
1276
1277		option.ariaSelected = true;
1278		this.#setActiveDescendant(option);
1279		this.#scrollOptionIntoView(option);
1280	}
1281
1282	#scrollOptionIntoView(option = this.#availableListBoxOptions()?.[0]) {
1283		option?.scrollIntoView?.({block: 'nearest'});
1284	}
1285
1286	#setActiveDescendant(option) {
1287		if (!(option instanceof HTMLElement)) {
1288			this.#search().removeAttribute('aria-activedescendant');
1289			return;
1290		}
1291
1292		this.#search().setAttribute('aria-activedescendant', option.id);
1293	}
1294
1295	#setValueFromListBoxOption(listBoxOption) {
1296		this.#selectListBoxOption(listBoxOption);
1297		const option = this.#getOptionFromListBoxOption(listBoxOption);
1298		this.#setValue(option?.prompt);
1299		this.hidePopover();
1300		this.#selectButton().focus();
1301		this.#dispatchEvents();
1302	}
1303
1304	#getOptionFromListBoxOption(listBoxOption) {
1305		const optionIndex = this.#getListBoxOptionIndex(listBoxOption);
1306		return this.#options[optionIndex];
1307	}
1308
1309	#getListBoxOptionIndex(option) {
1310		return MMX.coerceNumber(option?.dataset?.optionIndex, -1);
1311	}
1312
1313	// Value Methods
1314	get value() {
1315		return this.#value;
1316	}
1317
1318	set value(value) {
1319		this.#setValue(value);
1320	}
1321
1322	#setValue(value) {
1323		this.#value = MMX.coerceString(value);
1324		this.#option = this.#findOptionByPrompt(this.#value);
1325
1326		if (this.#search()) {
1327			this.#search().value = this.#value;
1328		}
1329
1330		if (this.#selectButton()) {
1331			this.#selectButton().textContent = this.#getSelectButtonLabel();
1332		}
1333
1334		this.#updateListBox();
1335		this.#setFormValue();
1336	}
1337
1338	#dispatchEvents() {
1339		this.#dispatchedValue = this.#value;
1340
1341		const eventOptions = {
1342			bubbles: true,
1343			composed: true
1344		};
1345
1346		this.dispatchEvent(new Event('input', eventOptions));
1347		this.dispatchEvent(new Event('change', eventOptions));
1348	}
1349
1350	// Option Methods
1351	get selectedOption() {
1352		return this.#option;
1353	}
1354
1355	setOptions(options = []) {
1356		this.#setOptions(options);
1357		this.#setValue(this.#value);
1358		this.#updateListBox();
1359	}
1360
1361	#setOptions(options = this.getPropValue('options')) {
1362		options = Array.isArray(options) ? options : [];
1363		let index = 0;
1364
1365		this.#options = options.reduce((options, option) => {
1366			if (typeof option === 'string') {
1367				options.push({
1368					prompt: option,
1369					value: option,
1370					index: index++
1371				});
1372			}
1373			else if (typeof option === 'object' && option !== null) {
1374				const prompt = MMX.coerceString(option.prompt);
1375				const value = MMX.coerceString(option.value ?? prompt);
1376
1377				options.push({
1378					prompt,
1379					value,
1380					index: index++
1381				});
1382			}
1383
1384			return options;
1385		}, []);
1386	}
1387
1388	#determineOptionMatch(option = {}) {
1389		if (!this.value) {
1390			return null;
1391		}
1392
1393		const result = {
1394			value: {
1395				CIN: this.#determineValueCIN(option.value, this.value),
1396				CEQ: this.#determineValueCEQ(option.value, this.value)
1397			},
1398			prompt: {
1399				CIN: this.#determineValueCIN(option.prompt, this.value),
1400				CEQ: this.#determineValueCEQ(option.prompt, this.value)
1401			}
1402		};
1403
1404		result.CIN = result.value.CIN || result.prompt.CIN;
1405		result.CEQ = result.value.CEQ || result.prompt.CEQ;
1406
1407		return result;
1408	}
1409
1410	#determineValueCIN(haystack = '', needle = '') {
1411		return haystack.toLowerCase().includes(needle.toLowerCase());
1412	}
1413
1414	#determineValueCEQ(a = '', b = '') {
1415		return a.toLowerCase() === b.toLowerCase();
1416	}
1417
1418	#findOptionByValue(value = '') {
1419		return this.#options.find(option => {
1420			return this.#determineValueCEQ(option.value, value);
1421		});
1422	}
1423
1424	#findOptionByPrompt(prompt = '') {
1425		return this.#options.find(option => {
1426			return this.#determineValueCEQ(option.prompt, prompt);
1427		});
1428	}
1429
1430	#setValueFromOption(option = {}) {
1431		const value = typeof option?.value === 'string' ? option.value : '';
1432		this.#setValue(value);
1433		this.#dispatchEvents();
1434	}
1435
1436	// Form Methods
1437	#setFormValue() {
1438		this.#setValidity();
1439		const formData = new FormData();
1440		formData.append(this.name, this.value);
1441		this.setFormValue(formData);
1442	}
1443
1444	#setValidity() {
1445		const hasValue = this.value.trim().length > 0;
1446		const hasOption = Boolean(this.#option);
1447		const isMissing = this.required && !hasValue;
1448		const isInvalidSelection = hasValue && !hasOption; // user typed something not in list
1449		const selectButton = this.#selectButton() ?? undefined;
1450
1451		if (isMissing) {
1452			this.setValidity({ valueMissing: true }, 'Please select an option.', selectButton);
1453		}
1454		else if (isInvalidSelection) {
1455			this.setValidity({ customError: true }, 'Please select a valid option.', selectButton);
1456		}
1457		else {
1458			this.setValidity({});
1459		}
1460	}
1461
1462	get name() {
1463		return this.getPropValue('name');
1464	}
1465
1466	get disabled() {
1467		return this.hasAttribute('disabled');
1468	}
1469
1470	set disabled(disabled) {
1471		disabled = Boolean(disabled);
1472
1473		if (disabled) {
1474			this.setAttribute('disabled', '');
1475		}
1476		else {
1477			this.removeAttribute('disabled');
1478		}
1479
1480		this.#select().disabled = disabled;
1481		this.#selectButton().disabled = disabled;
1482		this.#search().disabled = disabled;
1483	}
1484
1485	get required() {
1486		return this.hasAttribute('required');
1487	}
1488
1489	// Misc. Public Methods
1490	focus() {
1491		this.showPopover();
1492		this.#search().focus();
1493	}
1494
1495	get selectedIndex() {
1496		return this.#options.findIndex(option => {
1497			return this.#determineValueCEQ(option.prompt, this.#value);
1498		});
1499	}
1500
1501	set selectedIndex(index) {
1502		const option = this.#options[index];
1503		this.#setValueFromOption(option);
1504	}
1505
1506	selectValue(value) {
1507		const option = this.#findOptionByValue(value);
1508		this.#setValueFromOption(option);
1509	}
1510
1511	selectPrompt(prompt) {
1512		const option = this.#findOptionByPrompt(prompt);
1513		this.#setValueFromOption(option);
1514	}
1515
1516	showPopover() {
1517		this.#selectButton().inert = true;
1518		this.#updatePopoverLocation();
1519		this.#popover()?.showPopover?.({
1520			source: this.#selectButton()
1521		});
1522		this.#scrollOptionIntoView(this.#selectedListBoxOption());
1523	}
1524
1525	hidePopover() {
1526		this.#popover()?.hidePopover?.();
1527		this.#selectButton().inert = false;
1528	}
1529
1530	// Anchor Positioning Polyfill
1531	#bindAnchorPositionPolyfill() {
1532		if (this.#supportsAnchorPositioning()) {
1533			return;
1534		}
1535
1536		window.addEventListener('resize', () => {
1537			this.#updatePopoverLocation();
1538		});
1539
1540		window.addEventListener('scroll', () => {
1541			this.#updatePopoverLocation();
1542		});
1543	}
1544
1545	#supportsAnchorPositioning() {
1546		return [
1547			['anchor-name', '--mmx-combobox'],
1548			['justify-self', 'anchor-center'],
1549			['left', 'anchor(left)'],
1550			['position-anchor', '--mmx-combobox'],
1551			['top', 'anchor(top)'],
1552			['width', 'anchor-size(width)']
1553		].every(([property, value]) => {
1554			return CSS.supports(property, value);
1555		});
1556	}
1557
1558	#updatePopoverLocation() {
1559		if (this.#supportsAnchorPositioning()) {
1560			return;
1561		}
1562
1563		this.#positionElementToAnchor(this.#popover(), this);
1564	}
1565
1566	#positionElementToAnchor(element, anchor, root = document.body) {
1567		if (!(element instanceof HTMLElement) || !(anchor instanceof HTMLElement)) {
1568			return;
1569		}
1570
1571		const anchorRect = anchor.getBoundingClientRect();
1572		const rootRect = root.getBoundingClientRect();
1573		const overflowY = window.getComputedStyle(root)?.overflowY;
1574		const scrollFromTop = ((overflowY === 'scroll' || overflowY === 'auto') ? root.scrollTop : 0);
1575
1576		element.style.position = 'absolute';
1577		element.style.top = ((anchorRect.top - rootRect.top) - scrollFromTop) + 'px';
1578		element.style.left = (anchorRect.left - rootRect.left) + 'px';
1579		element.style.width = (anchorRect.width) + 'px';
1580	}
1581}
1582
1583if (!customElements.get('mmx-combobox')) {
1584	customElements.define('mmx-combobox', MMX_Combobox);
1585}

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.