|
1 | | -//= require ../libraries/[email protected] |
| 1 | +import { Controller } from '@hotwired/stimulus' |
| 2 | + |
| 3 | +/** |
| 4 | + * One way data and visibility bindings for inputs |
| 5 | + * @extends Controller |
| 6 | + */ |
| 7 | +export default class DataBindingController extends Controller { |
| 8 | + /** |
| 9 | + * Initialize bindings on connection to the DOM |
| 10 | + */ |
| 11 | + connect() { |
| 12 | + if (this.element.dataset.bindingDebug === "true") { |
| 13 | + this.debugMode = true |
| 14 | + } |
| 15 | + |
| 16 | + this._debug("stimulus-data-binding: connecting to wrapper:", this.element) |
| 17 | + |
| 18 | + const sourceElements = Array.from(this.element.querySelectorAll('[data-binding-target]')) |
| 19 | + if (this.element.dataset.bindingTarget) sourceElements.unshift(this.element) |
| 20 | + |
| 21 | + if (sourceElements.length === 0) this._debug("No source elements found. Did you set data-binding-target on your source elements?") |
| 22 | + |
| 23 | + for (const sourceElement of sourceElements) { |
| 24 | + if (this.debugMode) console.group("stimulus-data-binding: Source element") |
| 25 | + this._debug("Source element found", sourceElement) |
| 26 | + |
| 27 | + if (sourceElement.dataset.bindingInitial !== 'false') { |
| 28 | + this._debug("Running initial binding on source element") |
| 29 | + this._runBindings(sourceElement) |
| 30 | + } else { |
| 31 | + this._debug("%cNot running initial binding on source element as binding-initial is set to false", "color: rgba(150,150,150,0.8);") |
| 32 | + } |
| 33 | + |
| 34 | + if (this.debugMode) console.groupEnd() |
| 35 | + } |
| 36 | + } |
| 37 | + |
| 38 | + /** |
| 39 | + * Updates bindings for the current element. |
| 40 | + * @param {Event} e - an event with a currentTarget DOMElement |
| 41 | + */ |
| 42 | + update(e) { |
| 43 | + this._runBindings(e.currentTarget) |
| 44 | + } |
| 45 | + |
| 46 | + /** |
| 47 | + * @private |
| 48 | + * @param {DOMElement} source |
| 49 | + */ |
| 50 | + _runBindings(source) { |
| 51 | + this._debug("Searching for targets for source: ", source) |
| 52 | + for (const targetRef of source.dataset.bindingTarget.split(' ')) { |
| 53 | + const targetElements = this._bindingElements(targetRef) |
| 54 | + |
| 55 | + if (targetElements.length === 0) this._debug(`Could not find any target elements for ref ${targetRef}. Have you set data-target-ref="${targetRef}" on your target elements?`) |
| 56 | + |
| 57 | + for (const target of targetElements) { |
| 58 | + if (this.debugMode) console.group("stimulus-data-binding: Target Element") |
| 59 | + this._debug("Target found. Running bindings for target: ", target) |
| 60 | + |
| 61 | + const bindingCondition = this._getDatum('bindingCondition', source, target) |
| 62 | + |
| 63 | + if (bindingCondition) { |
| 64 | + this._debug(`Evaluating binding condition: '${bindingCondition}'`) |
| 65 | + } else { |
| 66 | + this._debug(`%cNo binding condition set. Evaluating as true. To add a condition set 'data-binding-condition="..."'`, "color: rgba(150,150,150,0.8);") |
| 67 | + } |
| 68 | + |
| 69 | + const conditionPassed = this._evaluate( |
| 70 | + bindingCondition, |
| 71 | + { |
| 72 | + source, |
| 73 | + target |
| 74 | + } |
| 75 | + ) |
| 76 | + |
| 77 | + if (conditionPassed) { |
| 78 | + this._debug(`Condition evaluated to: `, conditionPassed) |
| 79 | + } else { |
| 80 | + this._debug(`Condition evaluated to: `, conditionPassed) |
| 81 | + } |
| 82 | + |
| 83 | + const bindingValue = this._getDatum('bindingValue', source, target) |
| 84 | + |
| 85 | + if (bindingValue) { |
| 86 | + this._debug(`Evaluating binding value: '${bindingValue}'`) |
| 87 | + } else { |
| 88 | + this._debug(`%cNo binding value set, evaluating as true. to set a value for the attribute / property on your target elements set 'data-binding-value="..."'`, "color: rgba(150,150,150,0.8);") |
| 89 | + } |
| 90 | + |
| 91 | + const value = this._evaluate( |
| 92 | + bindingValue, |
| 93 | + { |
| 94 | + source, |
| 95 | + target |
| 96 | + } |
| 97 | + ) |
| 98 | + |
| 99 | + this._debug(`Value evaluated to: '${value}'`) |
| 100 | + |
| 101 | + const bindingAttribute = this._getDatum( |
| 102 | + 'bindingAttribute', |
| 103 | + source, |
| 104 | + target |
| 105 | + ) |
| 106 | + |
| 107 | + if (!bindingAttribute) { |
| 108 | + this._debug(`%cNo binding attribute set. To add attributes to your target element set 'data-binding-attribute="..."'`, "color: rgba(150,150,150,0.8);") |
| 109 | + } |
| 110 | + |
| 111 | + if (bindingAttribute) { |
| 112 | + for (const attribute of bindingAttribute.split(' ')) { |
| 113 | + if (conditionPassed) { |
| 114 | + this._debug(`Condition passed so setting attribute '${attribute}' to '${value}'`) |
| 115 | + target.setAttribute(attribute, value) |
| 116 | + } else { |
| 117 | + this._debug(`Condition failed so removing attribute '${attribute}'`) |
| 118 | + target.removeAttribute(attribute) |
| 119 | + } |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + const bindingProperty = this._getDatum( |
| 124 | + 'bindingProperty', |
| 125 | + source, |
| 126 | + target |
| 127 | + ) |
| 128 | + |
| 129 | + if (!bindingProperty) { |
| 130 | + this._debug(`%cNo binding property set. To add properties to your target element set 'data-binding-property="..."'`, "color: rgba(150,150,150,0.8);") |
| 131 | + } |
| 132 | + |
| 133 | + if (bindingProperty) { |
| 134 | + for (const prop of bindingProperty.split(' ')) { |
| 135 | + const propertyValue = conditionPassed ? value : '' |
| 136 | + if (target[prop] != propertyValue) { target.dataset.hasChanged = true } |
| 137 | + |
| 138 | + if (conditionPassed) { |
| 139 | + this._debug(`Condition passed so setting property '${prop}' from ${target[prop]} to '${value}'`) |
| 140 | + } else { |
| 141 | + this._debug(`Condition failed so setting property '${prop}' from ${target[prop]} to '' (empty string)`) |
| 142 | + } |
| 143 | + |
| 144 | + target[prop] = propertyValue |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + const bindingClass = this._getDatum( |
| 149 | + 'bindingClass', |
| 150 | + source, |
| 151 | + target |
| 152 | + ) |
| 153 | + |
| 154 | + if (!bindingClass) { |
| 155 | + this._debug(`%cNo binding class set. To add classes to your target element set 'data-binding-class="..."'`, "color: rgba(150,150,150,0.8);") |
| 156 | + } |
| 157 | + |
| 158 | + if (bindingClass) { |
| 159 | + for (const klass of bindingClass.split(' ')) { |
| 160 | + if (conditionPassed) { |
| 161 | + this._debug(`Condition passed so adding class '${klass}'`) |
| 162 | + target.classList.add(klass) |
| 163 | + } else { |
| 164 | + this._debug(`Condition failed so removing class '${klass}'`) |
| 165 | + target.classList.remove(klass) |
| 166 | + } |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + const bindingEvent = this._getDatum('bindingEvent', source, target) |
| 171 | + |
| 172 | + if (!bindingEvent) { |
| 173 | + this._debug(`%cNo binding event set. To dispatch events on property change to your target element set 'data-binding-event="..."'`, "color: rgba(150,150,150,0.8);") |
| 174 | + } |
| 175 | + |
| 176 | + if (bindingEvent) { |
| 177 | + for (const event of bindingEvent.split(' ')) { |
| 178 | + if (target.dataset.hasChanged) { |
| 179 | + this._debug(`Target has changed so dispatching event ${event}`) |
| 180 | + target.dispatchEvent(new Event(event, { cancelable: true, bubbles: true })) |
| 181 | + delete target.dataset.hasChanged |
| 182 | + } else { |
| 183 | + this._debug(`No changes to target properties so not dispatching '${event}'`) |
| 184 | + } |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + if (this.debugMode) console.groupEnd() |
| 189 | + } |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + /** |
| 194 | + * @private |
| 195 | + * @param {String} name - the name of the binding reference |
| 196 | + */ |
| 197 | + _bindingElements(name) { |
| 198 | + return this.element.querySelectorAll(`[data-binding-ref="${name}"]`) |
| 199 | + } |
| 200 | + |
| 201 | + /** |
| 202 | + * @private |
| 203 | + * @param {String} attribute - the attribute to fetch from the source / target dataaset |
| 204 | + * @param {String} source - The source element to get the attribute from, only loads if target doesnt have it |
| 205 | + * @param {String} target - The target element to get the attribute from, has precedence over the source |
| 206 | + */ |
| 207 | + _getDatum(attribute, source, target) { |
| 208 | + return target.dataset[attribute] || source.dataset[attribute] |
| 209 | + } |
| 210 | + |
| 211 | + /** |
| 212 | + * @private |
| 213 | + * @param {String} expression - expression to safe eval |
| 214 | + * @param {Object} variables - variables to be present when evaluating the given expression |
| 215 | + */ |
| 216 | + _evaluate(expression, variables = {}) { |
| 217 | + if (!expression) return true |
| 218 | + return new Function( |
| 219 | + Object.keys(variables).map((v) => `$${v}`), |
| 220 | + `return ${expression.trim()}` |
| 221 | + )(...Object.values(variables)) |
| 222 | + } |
| 223 | + |
| 224 | + /** |
| 225 | + * @private |
| 226 | + * @param {String} expression - expression to safe eval |
| 227 | + * @param {Object} variables - variables to be present when evaluating the given expression |
| 228 | + */ |
| 229 | + _debug(...args) { |
| 230 | + if (this.debugMode) { |
| 231 | + console.log(...args) |
| 232 | + } |
| 233 | + } |
| 234 | +} |
0 commit comments