component.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. import { walkSkippingNestedComponents, kebabCase, saferEval, saferEvalNoReturn, getXAttrs, debounce } from './utils'
  2. export default class Component {
  3. constructor(el) {
  4. this.el = el
  5. const rawData = saferEval(this.el.getAttribute('x-data'), {})
  6. this.data = this.wrapDataInObservable(rawData)
  7. this.initialize()
  8. this.listenForNewElementsToInitialize()
  9. }
  10. wrapDataInObservable(data) {
  11. this.concernedData = []
  12. var self = this
  13. const proxyHandler = keyPrefix => ({
  14. set(obj, property, value) {
  15. const propertyName = keyPrefix + '.' + property
  16. const setWasSuccessful = Reflect.set(obj, property, value)
  17. if (self.concernedData.indexOf(propertyName) === -1) {
  18. self.concernedData.push(propertyName)
  19. }
  20. self.refresh()
  21. return setWasSuccessful
  22. },
  23. get(target, key) {
  24. if (typeof target[key] === 'object' && target[key] !== null) {
  25. return new Proxy(target[key], proxyHandler(keyPrefix + '.' + key))
  26. }
  27. return target[key]
  28. }
  29. })
  30. return new Proxy(data, proxyHandler())
  31. }
  32. initialize() {
  33. walkSkippingNestedComponents(this.el, el => {
  34. this.initializeElement(el)
  35. })
  36. }
  37. initializeElement(el) {
  38. getXAttrs(el).forEach(({ type, value, modifiers, expression }) => {
  39. switch (type) {
  40. case 'on':
  41. var event = value
  42. this.registerListener(el, event, modifiers, expression)
  43. break;
  44. case 'model':
  45. // If the element we are binding to is a select, a radio, or checkbox
  46. // we'll listen for the change event instead of the "input" event.
  47. var event = (el.tagName.toLowerCase() === 'select')
  48. || ['checkbox', 'radio'].includes(el.type)
  49. || modifiers.includes('lazy')
  50. ? 'change' : 'input'
  51. const listenerExpression = this.generateExpressionForXModelListener(el, modifiers, expression)
  52. this.registerListener(el, event, modifiers, listenerExpression)
  53. var attrName = 'value'
  54. var { output } = this.evaluateReturnExpression(expression)
  55. this.updateAttributeValue(el, attrName, output)
  56. break;
  57. case 'bind':
  58. var attrName = value
  59. var { output } = this.evaluateReturnExpression(expression)
  60. this.updateAttributeValue(el, attrName, output)
  61. break;
  62. case 'text':
  63. var { output } = this.evaluateReturnExpression(expression)
  64. this.updateTextValue(el, output)
  65. break;
  66. case 'show':
  67. var { output } = this.evaluateReturnExpression(expression)
  68. this.updateVisibility(el, output)
  69. break;
  70. case 'if':
  71. var { output } = this.evaluateReturnExpression(expression)
  72. this.updatePresence(el, output)
  73. break;
  74. case 'cloak':
  75. el.removeAttribute('x-cloak')
  76. break;
  77. default:
  78. break;
  79. }
  80. })
  81. }
  82. listenForNewElementsToInitialize() {
  83. const targetNode = this.el
  84. const observerOptions = {
  85. childList: true,
  86. attributes: false,
  87. subtree: true,
  88. }
  89. const observer = new MutationObserver((mutations) => {
  90. for (let i=0; i < mutations.length; i++){
  91. if (mutations[i].addedNodes.length > 0) {
  92. mutations[i].addedNodes.forEach(node => {
  93. if (node.nodeType !== 1) return
  94. if (node.matches('[x-data]')) return
  95. if (getXAttrs(node).length > 0) {
  96. this.initializeElement(node)
  97. }
  98. })
  99. }
  100. }
  101. })
  102. observer.observe(targetNode, observerOptions);
  103. }
  104. refresh() {
  105. var self = this
  106. const walkThenClearDependancyTracker = (rootEl, callback) => {
  107. walkSkippingNestedComponents(rootEl, callback)
  108. self.concernedData = []
  109. }
  110. debounce(walkThenClearDependancyTracker, 5)(this.el, function (el) {
  111. getXAttrs(el).forEach(({ type, value, modifiers, expression }) => {
  112. switch (type) {
  113. case 'model':
  114. var { output, deps } = self.evaluateReturnExpression(expression)
  115. if (self.concernedData.filter(i => deps.includes(i)).length > 0) {
  116. self.updateAttributeValue(el, 'value', output)
  117. }
  118. break;
  119. case 'bind':
  120. const attrName = value
  121. var { output, deps } = self.evaluateReturnExpression(expression)
  122. if (self.concernedData.filter(i => deps.includes(i)).length > 0) {
  123. self.updateAttributeValue(el, attrName, output)
  124. }
  125. break;
  126. case 'text':
  127. var { output, deps } = self.evaluateReturnExpression(expression)
  128. if (self.concernedData.filter(i => deps.includes(i)).length > 0) {
  129. self.updateTextValue(el, output)
  130. }
  131. break;
  132. case 'show':
  133. var { output, deps } = self.evaluateReturnExpression(expression)
  134. if (self.concernedData.filter(i => deps.includes(i)).length > 0) {
  135. self.updateVisibility(el, output)
  136. }
  137. break;
  138. case 'if':
  139. var { output, deps } = self.evaluateReturnExpression(expression)
  140. if (self.concernedData.filter(i => deps.includes(i)).length > 0) {
  141. self.updatePresence(el, output)
  142. }
  143. break;
  144. default:
  145. break;
  146. }
  147. })
  148. })
  149. }
  150. generateExpressionForXModelListener(el, modifiers, dataKey) {
  151. var rightSideOfExpression = ''
  152. if (el.type === 'checkbox') {
  153. // If the data we are binding to is an array, toggle it's value inside the array.
  154. if (Array.isArray(this.data[dataKey])) {
  155. 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)]`
  156. } else {
  157. rightSideOfExpression = `$event.target.checked`
  158. }
  159. } else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
  160. rightSideOfExpression = modifiers.includes('number')
  161. ? 'Array.from($event.target.selectedOptions).map(option => { return parseFloat(option.value || option.text) })'
  162. : 'Array.from($event.target.selectedOptions).map(option => { return option.value || option.text })'
  163. } else {
  164. rightSideOfExpression = modifiers.includes('number')
  165. ? 'parseFloat($event.target.value)'
  166. : (modifiers.includes('trim') ? '$event.target.value.trim()' : '$event.target.value')
  167. }
  168. if (el.type === 'radio') {
  169. // Radio buttons only work properly when they share a name attribute.
  170. // People might assume we take care of that for them, because
  171. // they already set a shared "x-model" attribute.
  172. if (! el.hasAttribute('name')) el.setAttribute('name', dataKey)
  173. }
  174. return `${dataKey} = ${rightSideOfExpression}`
  175. }
  176. registerListener(el, event, modifiers, expression) {
  177. if (modifiers.includes('away')) {
  178. const handler = e => {
  179. // Don't do anything if the click came form the element or within it.
  180. if (el.contains(e.target)) return
  181. // Don't do anything if this element isn't currently visible.
  182. if (el.offsetWidth < 1 && el.offsetHeight < 1) return
  183. // Now that we are sure the element is visible, AND the click
  184. // is from outside it, let's run the expression.
  185. this.runListenerHandler(expression, e)
  186. if (modifiers.includes('once')) {
  187. document.removeEventListener(event, handler)
  188. }
  189. }
  190. // Listen for this event at the root level.
  191. document.addEventListener(event, handler)
  192. } else {
  193. const node = modifiers.includes('window') ? window : el
  194. const handler = e => {
  195. const modifiersWithoutWindow = modifiers.filter(i => i !== 'window')
  196. if (event === 'keydown' && modifiersWithoutWindow.length > 0 && ! modifiersWithoutWindow.includes(kebabCase(e.key))) return
  197. if (modifiers.includes('prevent')) e.preventDefault()
  198. if (modifiers.includes('stop')) e.stopPropagation()
  199. this.runListenerHandler(expression, e)
  200. if (modifiers.includes('once')) {
  201. node.removeEventListener(event, handler)
  202. }
  203. }
  204. node.addEventListener(event, handler)
  205. }
  206. }
  207. runListenerHandler(expression, e) {
  208. this.evaluateCommandExpression(expression, {
  209. '$event': e,
  210. '$refs': this.getRefsProxy()
  211. })
  212. }
  213. evaluateReturnExpression(expression) {
  214. var affectedDataKeys = []
  215. const proxyHandler = prefix => ({
  216. get(object, prop) {
  217. if (typeof object[prop] === 'object' && object[prop] !== null && !Array.isArray(object[prop])) {
  218. return new Proxy(object[prop], proxyHandler(prefix + '.' + prop))
  219. }
  220. if (typeof prop === 'string') {
  221. affectedDataKeys.push(prefix + '.' + prop)
  222. } else {
  223. affectedDataKeys.push(prop)
  224. }
  225. if (typeof object[prop] === 'object' && object[prop] !== null) {
  226. return new Proxy(object[prop], proxyHandler(prefix + '.' + prop))
  227. }
  228. return object[prop]
  229. }
  230. })
  231. const proxiedData = new Proxy(this.data, proxyHandler())
  232. const result = saferEval(expression, proxiedData)
  233. return {
  234. output: result,
  235. deps: affectedDataKeys
  236. }
  237. }
  238. evaluateCommandExpression(expression, extraData) {
  239. saferEvalNoReturn(expression, this.data, extraData)
  240. }
  241. updateTextValue(el, value) {
  242. el.innerText = value
  243. }
  244. updateVisibility(el, value) {
  245. if (! value) {
  246. el.style.display = 'none'
  247. } else {
  248. if (el.style.length === 1 && el.style.display !== '') {
  249. el.removeAttribute('style')
  250. } else {
  251. el.style.removeProperty('display')
  252. }
  253. }
  254. }
  255. updatePresence(el, expressionResult) {
  256. if (el.nodeName.toLowerCase() !== 'template') console.warn(`Alpine: [x-if] directive should only be added to <template> tags.`)
  257. const elementHasAlreadyBeenAdded = el.nextElementSibling && el.nextElementSibling.__x_inserted_me === true
  258. if (expressionResult && ! elementHasAlreadyBeenAdded) {
  259. const clone = document.importNode(el.content, true);
  260. el.parentElement.insertBefore(clone, el.nextElementSibling)
  261. el.nextElementSibling.__x_inserted_me = true
  262. } else if (! expressionResult && elementHasAlreadyBeenAdded) {
  263. el.nextElementSibling.remove()
  264. }
  265. }
  266. updateAttributeValue(el, attrName, value) {
  267. if (attrName === 'value') {
  268. if (el.type === 'radio') {
  269. el.checked = el.value == value
  270. } else if (el.type === 'checkbox') {
  271. if (Array.isArray(value)) {
  272. // I'm purposely not using Array.includes here because it's
  273. // strict, and because of Numeric/String mis-casting, I
  274. // want the "includes" to be "fuzzy".
  275. let valueFound = false
  276. value.forEach(val => {
  277. if (val == el.value) {
  278. valueFound = true
  279. }
  280. })
  281. el.checked = valueFound
  282. } else {
  283. el.checked = !! value
  284. }
  285. } else if (el.tagName === 'SELECT') {
  286. this.updateSelect(el, value)
  287. } else {
  288. el.value = value
  289. }
  290. } else if (attrName === 'class') {
  291. if (Array.isArray(value)) {
  292. el.setAttribute('class', value.join(' '))
  293. } else {
  294. // Use the class object syntax that vue uses to toggle them.
  295. Object.keys(value).forEach(classNames => {
  296. if (value[classNames]) {
  297. classNames.split(' ').forEach(className => el.classList.add(className))
  298. } else {
  299. classNames.split(' ').forEach(className => el.classList.remove(className))
  300. }
  301. })
  302. }
  303. } else if (['disabled', 'readonly', 'required', 'checked', 'hidden'].includes(attrName)) {
  304. // Boolean attributes have to be explicitly added and removed, not just set.
  305. if (!! value) {
  306. el.setAttribute(attrName, '')
  307. } else {
  308. el.removeAttribute(attrName)
  309. }
  310. } else {
  311. el.setAttribute(attrName, value)
  312. }
  313. }
  314. updateSelect(el, value) {
  315. const arrayWrappedValue = [].concat(value).map(value => { return value + '' })
  316. Array.from(el.options).forEach(option => {
  317. option.selected = arrayWrappedValue.includes(option.value || option.text)
  318. })
  319. }
  320. getRefsProxy() {
  321. var self = this
  322. // One of the goals of this is to not hold elements in memory, but rather re-evaluate
  323. // the DOM when the system needs something from it. This way, the framework is flexible and
  324. // friendly to outside DOM changes from libraries like Vue/Livewire.
  325. // For this reason, I'm using an "on-demand" proxy to fake a "$refs" object.
  326. return new Proxy({}, {
  327. get(object, property) {
  328. var ref
  329. // We can't just query the DOM because it's hard to filter out refs in
  330. // nested components.
  331. walkSkippingNestedComponents(self.el, el => {
  332. if (el.hasAttribute('x-ref') && el.getAttribute('x-ref') === property) {
  333. ref = el
  334. }
  335. })
  336. return ref
  337. }
  338. })
  339. }
  340. }