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.