import { walkSkippingNestedComponents, kebabCase, saferEval, saferEvalNoReturn, getXAttrs, debounce } from './utils' export default class Component { constructor(el) { this.el = el const rawData = saferEval(this.el.getAttribute('x-data'), {}) this.data = this.wrapDataInObservable(rawData) this.initialize() this.listenForNewElementsToInitialize() } wrapDataInObservable(data) { this.concernedData = [] var self = this const proxyHandler = keyPrefix => ({ set(obj, property, value) { const propertyName = keyPrefix + '.' + property const setWasSuccessful = Reflect.set(obj, property, value) if (self.concernedData.indexOf(propertyName) === -1) { self.concernedData.push(propertyName) } self.refresh() return setWasSuccessful }, get(target, key) { if (typeof target[key] === 'object' && target[key] !== null) { return new Proxy(target[key], proxyHandler(keyPrefix + '.' + key)) } return target[key] } }) return new Proxy(data, proxyHandler()) } initialize() { walkSkippingNestedComponents(this.el, el => { this.initializeElement(el) }) } initializeElement(el) { getXAttrs(el).forEach(({ type, value, modifiers, expression }) => { switch (type) { case 'on': var event = value this.registerListener(el, event, modifiers, expression) break; case 'model': // If the element we are binding to is a select, a radio, or checkbox // we'll listen for the change event instead of the "input" event. var event = (el.tagName.toLowerCase() === 'select') || ['checkbox', 'radio'].includes(el.type) || modifiers.includes('lazy') ? 'change' : 'input' const listenerExpression = this.generateExpressionForXModelListener(el, modifiers, expression) this.registerListener(el, event, modifiers, listenerExpression) var attrName = 'value' var { output } = this.evaluateReturnExpression(expression) this.updateAttributeValue(el, attrName, output) break; case 'bind': var attrName = value var { output } = this.evaluateReturnExpression(expression) this.updateAttributeValue(el, attrName, output) break; case 'text': var { output } = this.evaluateReturnExpression(expression) this.updateTextValue(el, output) break; case 'show': var { output } = this.evaluateReturnExpression(expression) this.updateVisibility(el, output) break; case 'if': var { output } = this.evaluateReturnExpression(expression) this.updatePresence(el, output) break; case 'cloak': el.removeAttribute('x-cloak') break; default: break; } }) } listenForNewElementsToInitialize() { const targetNode = this.el const observerOptions = { childList: true, attributes: false, subtree: true, } const observer = new MutationObserver((mutations) => { for (let i=0; i < mutations.length; i++){ if (mutations[i].addedNodes.length > 0) { mutations[i].addedNodes.forEach(node => { if (node.nodeType !== 1) return if (node.matches('[x-data]')) return if (getXAttrs(node).length > 0) { this.initializeElement(node) } }) } } }) observer.observe(targetNode, observerOptions); } refresh() { var self = this const walkThenClearDependancyTracker = (rootEl, callback) => { walkSkippingNestedComponents(rootEl, callback) self.concernedData = [] } debounce(walkThenClearDependancyTracker, 5)(this.el, function (el) { getXAttrs(el).forEach(({ type, value, modifiers, expression }) => { switch (type) { case 'model': var { output, deps } = self.evaluateReturnExpression(expression) if (self.concernedData.filter(i => deps.includes(i)).length > 0) { self.updateAttributeValue(el, 'value', output) } break; case 'bind': const attrName = value var { output, deps } = self.evaluateReturnExpression(expression) if (self.concernedData.filter(i => deps.includes(i)).length > 0) { self.updateAttributeValue(el, attrName, output) } break; case 'text': var { output, deps } = self.evaluateReturnExpression(expression) if (self.concernedData.filter(i => deps.includes(i)).length > 0) { self.updateTextValue(el, output) } break; case 'show': var { output, deps } = self.evaluateReturnExpression(expression) if (self.concernedData.filter(i => deps.includes(i)).length > 0) { self.updateVisibility(el, output) } break; case 'if': var { output, deps } = self.evaluateReturnExpression(expression) if (self.concernedData.filter(i => deps.includes(i)).length > 0) { self.updatePresence(el, output) } break; default: break; } }) }) } generateExpressionForXModelListener(el, modifiers, dataKey) { var rightSideOfExpression = '' if (el.type === 'checkbox') { // If the data we are binding to is an array, toggle it's value inside the array. if (Array.isArray(this.data[dataKey])) { rightSideOfExpression = `$event.target.checked ? ${dataKey}.concat([$event.target.value]) : [...${dataKey}.splice(0, ${dataKey}.indexOf($event.target.value)), ...${dataKey}.splice(${dataKey}.indexOf($event.target.value)+1)]` } else { rightSideOfExpression = `$event.target.checked` } } else if (el.tagName.toLowerCase() === 'select' && el.multiple) { rightSideOfExpression = modifiers.includes('number') ? 'Array.from($event.target.selectedOptions).map(option => { return parseFloat(option.value || option.text) })' : 'Array.from($event.target.selectedOptions).map(option => { return option.value || option.text })' } else { rightSideOfExpression = modifiers.includes('number') ? 'parseFloat($event.target.value)' : (modifiers.includes('trim') ? '$event.target.value.trim()' : '$event.target.value') } if (el.type === 'radio') { // Radio buttons only work properly when they share a name attribute. // People might assume we take care of that for them, because // they already set a shared "x-model" attribute. if (! el.hasAttribute('name')) el.setAttribute('name', dataKey) } return `${dataKey} = ${rightSideOfExpression}` } registerListener(el, event, modifiers, expression) { if (modifiers.includes('away')) { const handler = e => { // Don't do anything if the click came form the element or within it. if (el.contains(e.target)) return // Don't do anything if this element isn't currently visible. if (el.offsetWidth < 1 && el.offsetHeight < 1) return // Now that we are sure the element is visible, AND the click // is from outside it, let's run the expression. this.runListenerHandler(expression, e) if (modifiers.includes('once')) { document.removeEventListener(event, handler) } } // Listen for this event at the root level. document.addEventListener(event, handler) } else { const node = modifiers.includes('window') ? window : el const handler = e => { const modifiersWithoutWindow = modifiers.filter(i => i !== 'window') if (event === 'keydown' && modifiersWithoutWindow.length > 0 && ! modifiersWithoutWindow.includes(kebabCase(e.key))) return if (modifiers.includes('prevent')) e.preventDefault() if (modifiers.includes('stop')) e.stopPropagation() this.runListenerHandler(expression, e) if (modifiers.includes('once')) { node.removeEventListener(event, handler) } } node.addEventListener(event, handler) } } runListenerHandler(expression, e) { this.evaluateCommandExpression(expression, { '$event': e, '$refs': this.getRefsProxy() }) } evaluateReturnExpression(expression) { var affectedDataKeys = [] const proxyHandler = prefix => ({ get(object, prop) { if (typeof object[prop] === 'object' && object[prop] !== null && !Array.isArray(object[prop])) { return new Proxy(object[prop], proxyHandler(prefix + '.' + prop)) } if (typeof prop === 'string') { affectedDataKeys.push(prefix + '.' + prop) } else { affectedDataKeys.push(prop) } if (typeof object[prop] === 'object' && object[prop] !== null) { return new Proxy(object[prop], proxyHandler(prefix + '.' + prop)) } return object[prop] } }) const proxiedData = new Proxy(this.data, proxyHandler()) const result = saferEval(expression, proxiedData) return { output: result, deps: affectedDataKeys } } evaluateCommandExpression(expression, extraData) { saferEvalNoReturn(expression, this.data, extraData) } updateTextValue(el, value) { el.innerText = value } updateVisibility(el, value) { if (! value) { el.style.display = 'none' } else { if (el.style.length === 1 && el.style.display !== '') { el.removeAttribute('style') } else { el.style.removeProperty('display') } } } updatePresence(el, expressionResult) { if (el.nodeName.toLowerCase() !== 'template') console.warn(`Alpine: [x-if] directive should only be added to