浏览代码

Add x-transition API

Caleb Porzio 5 年之前
父节点
当前提交
f0050d47d1
共有 5 个文件被更改,包括 223 次插入13 次删除
  1. 0 0
      dist/alpine.js
  2. 0 0
      dist/alpine.js.map
  3. 19 10
      src/component.js
  4. 58 3
      src/utils.js
  5. 146 0
      test/transition.spec.js

文件差异内容过多而无法显示
+ 0 - 0
dist/alpine.js


文件差异内容过多而无法显示
+ 0 - 0
dist/alpine.js.map


+ 19 - 10
src/component.js

@@ -1,4 +1,4 @@
-import { walkSkippingNestedComponents, kebabCase, saferEval, saferEvalNoReturn, getXAttrs, debounce } from './utils'
+import { walkSkippingNestedComponents, kebabCase, saferEval, saferEvalNoReturn, getXAttrs, debounce, transitionIn, transitionOut } from './utils'
 
 export default class Component {
     constructor(el) {
@@ -92,7 +92,7 @@ export default class Component {
 
                 case 'show':
                     var { output } = this.evaluateReturnExpression(expression)
-                    this.updateVisibility(el, output)
+                    this.updateVisibility(el, output, true)
                     break;
 
                 case 'if':
@@ -301,16 +301,21 @@ export default class Component {
         el.innerText = value
     }
 
-    updateVisibility(el, value) {
+    updateVisibility(el, value, initialUpdate = false) {
         if (! value) {
-            el.style.display = 'none'
+            transitionOut(el, () => {
+                el.style.display = 'none'
+            }, initialUpdate)
         } else {
-            if (el.style.length === 1 && el.style.display !== '') {
-                el.removeAttribute('style')
-            } else {
-                el.style.removeProperty('display')
-            }
+            transitionIn(el, () => {
+                if (el.style.length === 1 && el.style.display !== '') {
+                    el.removeAttribute('style')
+                } else {
+                    el.style.removeProperty('display')
+                }
+            }, initialUpdate)
         }
+
     }
 
     updatePresence(el, expressionResult) {
@@ -324,8 +329,12 @@ export default class Component {
             el.parentElement.insertBefore(clone, el.nextElementSibling)
 
             el.nextElementSibling.__x_inserted_me = true
+
+            transitionIn(el.nextElementSibling, () => {})
         } else if (! expressionResult && elementHasAlreadyBeenAdded) {
-            el.nextElementSibling.remove()
+            transitionOut(el.nextElementSibling, () => {
+                el.nextElementSibling.remove()
+            })
         }
     }
 

+ 58 - 3
src/utils.js

@@ -66,7 +66,7 @@ export function saferEvalNoReturn(expression, dataContext, additionalHelperVaria
 }
 
 export function isXAttr(attr) {
-    const xAttrRE = /x-(on|bind|data|text|model|if|show|cloak|ref)/
+    const xAttrRE = /x-(on|bind|data|text|model|if|show|cloak|transition|ref)/
 
     return xAttrRE.test(attr.name)
 }
@@ -75,7 +75,7 @@ export function getXAttrs(el, type) {
     return Array.from(el.attributes)
         .filter(isXAttr)
         .map(attr => {
-            const typeMatch = attr.name.match(/x-(on|bind|data|text|model|if|show|cloak|ref)/)
+            const typeMatch = attr.name.match(/x-(on|bind|data|text|model|if|show|cloak|transition|ref)/)
             const valueMatch = attr.name.match(/:([a-zA-Z\-]+)/)
             const modifiers = attr.name.match(/\.[^.\]]+(?=[^\]]*$)/g) || []
 
@@ -90,6 +90,61 @@ export function getXAttrs(el, type) {
             // If no type is passed in for filtering, bypassfilter
             if (! type) return true
 
-            return i.type === name
+            return i.type === type
         })
 }
+
+export function transitionIn(el, callback, forceSkip = false) {
+    if (forceSkip) callback()
+
+    const attrs = getXAttrs(el, 'transition')
+
+    if (attrs.length < 1) callback()
+
+    const enter = (attrs.find(i => i.value === 'enter') || { expression: '' }).expression.split(' ').filter(i => i !== '')
+    const enterStart = (attrs.find(i => i.value === 'enter-start') || { expression: '' }).expression.split(' ').filter(i => i !== '')
+    const enterEnd = (attrs.find(i => i.value === 'enter-end') || { expression: '' }).expression.split(' ').filter(i => i !== '')
+
+    transition(el, enter, enterStart, enterEnd, callback, () => {})
+}
+
+export function transitionOut(el, callback, forceSkip = false) {
+    if (forceSkip) callback()
+
+    const attrs = getXAttrs(el, 'transition')
+
+    if (attrs.length < 1) callback()
+
+    const leave = (attrs.find(i => i.value === 'leave') || { expression: '' }).expression.split(' ').filter(i => i !== '')
+    const leaveStart = (attrs.find(i => i.value === 'leave-start') || { expression: '' }).expression.split(' ').filter(i => i !== '')
+    const leaveEnd = (attrs.find(i => i.value === 'leave-end') || { expression: '' }).expression.split(' ').filter(i => i !== '')
+
+    transition(el, leave, leaveStart, leaveEnd, () => {}, callback)
+}
+
+export function transition(el, classesDuring, classesStart, classesEnd, hook1, hook2) {
+    el.classList.add(...classesStart)
+    el.classList.add(...classesDuring)
+
+    requestAnimationFrame(() => {
+        const duration = Number(getComputedStyle(el).transitionDuration.replace('s', '')) * 1000
+
+        hook1()
+
+        requestAnimationFrame(() => {
+            el.classList.remove(...classesStart)
+            el.classList.add(...classesEnd)
+
+            setTimeout(() => {
+                hook2()
+
+                // Adding an "isConnected" check, in case the callback
+                // removed the element from the DOM.
+                if (el.isConnected) {
+                    el.classList.remove(...classesDuring)
+                    el.classList.remove(...classesEnd)
+                }
+            }, duration);
+        })
+    });
+}

+ 146 - 0
test/transition.spec.js

@@ -0,0 +1,146 @@
+import Alpine from 'alpinejs'
+import { wait } from '@testing-library/dom'
+
+global.MutationObserver = class {
+    observe() {}
+}
+
+test('transition in', async () => {
+    // Hijack "requestAnimationFrame" for finer-tuned control in this test.
+    var frameStack = []
+
+    jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
+        frameStack.push(callback)
+    });
+
+    // Hijack "getComputeStyle" because js-dom is weird with it.
+    // (hardcoding 10ms transition time for later assertions)
+    jest.spyOn(window, 'getComputedStyle').mockImplementation(el => {
+        return { transitionDuration: '.01s' }
+    });
+
+    document.body.innerHTML = `
+        <div x-data="{ show: false }">
+            <button x-on:click="show = ! show"></button>
+
+            <span
+                x-show="show"
+                x-transition:enter="enter"
+                x-transition:enter-start="enter-start"
+                x-transition:enter-end="enter-end"
+            ></span>
+        </div>
+    `
+
+    Alpine.start()
+
+    await wait(() => { expect(document.querySelector('span').getAttribute('style')).toEqual('display: none;') })
+
+    document.querySelector('button').click()
+
+    // Wait out the intial Alpine refresh debounce.
+    await new Promise((resolve) =>
+        setTimeout(() => {
+            resolve();
+        }, 5)
+    )
+
+    expect(document.querySelector('span').classList.contains('enter')).toEqual(true)
+    expect(document.querySelector('span').classList.contains('enter-start')).toEqual(true)
+    expect(document.querySelector('span').classList.contains('enter-end')).toEqual(false)
+    expect(document.querySelector('span').getAttribute('style')).toEqual('display: none;')
+
+    frameStack.pop()()
+
+    expect(document.querySelector('span').classList.contains('enter')).toEqual(true)
+    expect(document.querySelector('span').classList.contains('enter-start')).toEqual(true)
+    expect(document.querySelector('span').classList.contains('enter-end')).toEqual(false)
+    expect(document.querySelector('span').getAttribute('style')).toEqual(null)
+
+    frameStack.pop()()
+
+    expect(document.querySelector('span').classList.contains('enter')).toEqual(true)
+    expect(document.querySelector('span').classList.contains('enter-start')).toEqual(false)
+    expect(document.querySelector('span').classList.contains('enter-end')).toEqual(true)
+    expect(document.querySelector('span').getAttribute('style')).toEqual(null)
+
+    await new Promise((resolve) =>
+        setTimeout(() => {
+            expect(document.querySelector('span').classList.contains('enter')).toEqual(false)
+            expect(document.querySelector('span').classList.contains('enter-start')).toEqual(false)
+            expect(document.querySelector('span').classList.contains('enter-end')).toEqual(false)
+            expect(document.querySelector('span').getAttribute('style')).toEqual(null)
+            resolve();
+        }, 10)
+    )
+})
+
+test('transition out', async () => {
+    // Hijack "requestAnimationFrame" for finer-tuned control in this test.
+    var frameStack = []
+
+    jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
+        frameStack.push(callback)
+    });
+
+    // Hijack "getComputeStyle" because js-dom is weird with it.
+    // (hardcoding 10ms transition time for later assertions)
+    jest.spyOn(window, 'getComputedStyle').mockImplementation(el => {
+        return { transitionDuration: '.01s' }
+    });
+
+    document.body.innerHTML = `
+        <div x-data="{ show: true }">
+            <button x-on:click="show = ! show"></button>
+
+            <span
+                x-show="show"
+                x-transition:leave="leave"
+                x-transition:leave-start="leave-start"
+                x-transition:leave-end="leave-end"
+            ></span>
+        </div>
+    `
+
+    Alpine.start()
+
+    await wait(() => { expect(document.querySelector('span').getAttribute('style')).toEqual(null) })
+
+    document.querySelector('button').click()
+
+    // Wait out the intial Alpine refresh debounce.
+    await new Promise((resolve) =>
+        setTimeout(() => {
+            resolve();
+        }, 5)
+    )
+
+    expect(document.querySelector('span').classList.contains('leave')).toEqual(true)
+    expect(document.querySelector('span').classList.contains('leave-start')).toEqual(true)
+    expect(document.querySelector('span').classList.contains('leave-end')).toEqual(false)
+    expect(document.querySelector('span').getAttribute('style')).toEqual(null)
+
+    frameStack.pop()()
+
+    expect(document.querySelector('span').classList.contains('leave')).toEqual(true)
+    expect(document.querySelector('span').classList.contains('leave-start')).toEqual(true)
+    expect(document.querySelector('span').classList.contains('leave-end')).toEqual(false)
+    expect(document.querySelector('span').getAttribute('style')).toEqual(null)
+
+    frameStack.pop()()
+
+    expect(document.querySelector('span').classList.contains('leave')).toEqual(true)
+    expect(document.querySelector('span').classList.contains('leave-start')).toEqual(false)
+    expect(document.querySelector('span').classList.contains('leave-end')).toEqual(true)
+    expect(document.querySelector('span').getAttribute('style')).toEqual(null)
+
+    await new Promise((resolve) =>
+        setTimeout(() => {
+            expect(document.querySelector('span').classList.contains('leave')).toEqual(false)
+            expect(document.querySelector('span').classList.contains('leave-start')).toEqual(false)
+            expect(document.querySelector('span').classList.contains('leave-end')).toEqual(false)
+            expect(document.querySelector('span').getAttribute('style')).toEqual('display: none;')
+            resolve();
+        }, 10)
+    )
+})

部分文件因为文件数量过多而无法显示