1/* stimulusFetch: 'lazy' */ 2import { Controller } from '@hotwired/stimulus'; 3 4/** 5 * @interface OptionDisplayType 6 */ 7/** 8 * @function 9 * @name OptionDisplayType#getControls 10 * @returns {NodeListOf<HTMLElement>} 11 */ 12/** 13 * @function 14 * @name OptionDisplayType#getValue 15 * @returns {string|undefined|null} 16 */ 17/** 18 * @function 19 * @name OptionDisplayType#styleElm 20 * @param {HTMLElement} elm 21 * @returns {HTMLElement} 22 */ 23 24/** 25 * Radio option driver, radio options are displayed as a group of radio buttons enveloped in a label element which is 26 * used as the stylable element 27 * @implements {OptionDisplayType} 28 */ 29class RadioOption { 30 elm; 31 name; 32 constructor(attrElm) { 33 this.elm = attrElm; 34 this.name = attrElm.dataset.attr; 35 } 36 getControls() { 37 return this.elm.querySelectorAll('input[type="radio"][name="' + controlName(this.name) + '"]'); 38 } 39 styleElm(elm) { 40 return elm.closest('label'); 41 } 42 getValue() { 43 return this.elm.querySelector('input[type="radio"][name="' + controlName(this.name) + '"]:not([disabled]):checked')?.value; 44 } 45} 46 47/** 48 * Select option driver, select options are displayed as a select dropdown with the options themselves being the 49 * stylable elements 50 * @implements {OptionDisplayType} 51 */ 52class SelectOption { 53 elm; 54 name; 55 constructor(attrElm) { 56 this.elm = attrElm; 57 this.name = attrElm.dataset.attr; 58 } 59 getControls() { 60 return this.elm.querySelectorAll('select[name="' + controlName(this.name) + '"] option:not([disabled])'); 61 } 62 styleElm(elm) { 63 return elm; 64 } 65 getValue() { 66 return this.elm.querySelector('select[name="' + controlName(this.name) + '"]:not([disabled])')?.value; 67 } 68} 69 70class HiddenOption { 71 elm; 72 name; 73 constructor(attrElm) { 74 this.elm = attrElm; 75 this.name = attrElm.dataset.attr; 76 } 77 getControls() { 78 return [this.elm]; 79 } 80 styleElm(elm) { 81 return elm; 82 } 83 getValue() { 84 return this.elm.value; 85 } 86} 87 88/** 89 * Defines the control name, modify this if you change the form field names. 90 * @param name 91 * @returns {string} 92 * @constructor 93 */ 94const controlName = function (name) { 95 return 'attribute[' + name + ']'; 96} 97 98/** 99 * Option type driver mapping, maps to the "display" field of ProductDetailsComponent 100 */ 101const optionTypes = { 102 'I': RadioOption, 103 'B': RadioOption, 104 'S': SelectOption, 105 'H': HiddenOption 106}; 107 108// noinspection JSUnusedGlobalSymbols loaded via stimulus 109/** 110 * Provides a stock-aware product attributes output. 111 * Requires attribute as a DOM element with [data-attr="attribute id"] containing the attribute label and options. Each 112 * option value is the option id 113 * @property {{[string]: int}} stockValue JSON serialized product stock key/value store with normalized attribute 114 * combination stock levels @see ProductTemplateController 115 */ 116export default class extends Controller { 117 /** 118 * @type {SelectOption[]|RadioOption[]} 119 */ 120 #attrs = []; 121 122 /** 123 * @type {HTMLInputElement|HTMLButtonElement} 124 */ 125 #submitElm; 126 127 /** 128 * @type {HTMLInputElement|null} 129 */ 130 #qtyElm; 131 132 static values = { 133 'stock': Object, 134 } 135 connect() { 136 /** @type {HTMLElement} */ 137 const element = this.element; 138 this.#attrs = []; 139 this.#submitElm = element.querySelector('button[type="submit"],input[type="submit"]'); 140 this.#qtyElm = element.querySelector('[data-qty]'); 141 element.querySelectorAll('[data-attr]').forEach(attrElm => { 142 const type = attrElm.dataset.type ?? 'H'; 143 const attr = new optionTypes[type](attrElm); 144 this.#attrs.push(attr); 145 attr.getControls().forEach(input => { 146 input.addEventListener('change', () => this.onChange(input)); 147 }); 148 }); 149 this.applyAttrs(); 150 } 151 152 /** 153 * Converts an array of attribute option ids into a key for the stock value map. 154 * @param {(string|undefined)[]} optionSet 155 * @returns {string} 156 */ 157 getKey(optionSet) { 158 return '_' + optionSet.filter(v => v !== undefined).sort(this.naturalSort).join('_'); 159 } 160 161 /** 162 * Returns the current active options for a given attribute. 163 * @returns {(string|undefined)[]} 164 */ 165 getOptionSet() { 166 return this.#attrs.map((attr, index) => { 167 // a specific nuance, we show/hide available options based on configured override in the data model, so we 168 // make sure he last attr doesn't affect the display of preceding attrs 169 if (index === this.#attrs.length - 1) { 170 return undefined; 171 } 172 return attr.getValue() || undefined; 173 }); 174 } 175 176 onChange(input) { 177 const attrElm = input.closest('[data-attr]'); 178 if (!attrElm) return; 179 const optionSet = this.getOptionSet();
180 this.#attrs.forEach((attr, index) => { 181 attr.getControls().forEach(control => { 182 // apply current option value to active set and check if it's available/has stock 183 let currentSet = optionSet.slice(); 184 currentSet[index] = control.value; 185 const key = this.getKey(currentSet); 186 // availability check 187 if (this.stockValue[key] === undefined) { 188 attr.styleElm(control).classList.add('hidden'); 189 } else { 190 attr.styleElm(control).classList.remove('hidden'); 191 } 192 // stock check 193 if ((this.stockValue[key]||0) === 0) { 194 control.disabled = true; 195 attr.styleElm(control).classList.add('unavailable'); 196 } else { 197 control.disabled = false; 198 attr.styleElm(control).classList.remove('unavailable'); 199 } 200 }); 201 }); 202 this.applyAttrs(); 203 } 204 205 naturalSort(a, b) { 206 if (parseFloat(a) && parseFloat(b)) { 207 return parseFloat(a) - parseFloat(b); 208 } else { 209 return a < b ? -1 : a > b ? 1 : 0; 210 } 211 } 212 213 /** 214 * Enables/disables the qty field and submit button depending on the attr validity 215 */ 216 applyAttrs () { 217 if (!this.validAttrs()) { 218 this.#submitElm.disabled = true; 219 this.#qtyElm?.classList.add('hidden'); 220 } else { 221 this.#submitElm.disabled = false; 222 if (!this.#qtyElm) { 223 return; 224 } 225 const max = this.stockValue[this.getKey(this.#attrs.map(attr => attr.getValue()))]; 226 this.#qtyElm.querySelector('input')?.setAttribute('max', max); 227 this.#qtyElm.querySelector('[data-qty-max]').innerHTML = max; 228 this.#qtyElm.classList.remove('hidden'); 229 } 230 } 231 232 /** 233 * Check all attrs have valid values 234 * @returns {boolean} 235 */ 236 validAttrs() { 237 return this.#attrs.reduce((carry, attr) => carry && attr.getValue(), true); 238 } 239}
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.