Jelajahi Sumber

Merge branch 'main' into livewire3

Caleb Porzio 2 tahun lalu
induk
melakukan
ae686675fa
65 mengubah file dengan 1344 tambahan dan 485 penghapusan
  1. 3 3
      .github/workflows/run-tests.yml
  2. 1 1
      README.md
  3. 662 284
      package-lock.json
  4. 1 5
      package.json
  5. 1 1
      packages/alpinejs/package.json
  6. 0 2
      packages/alpinejs/src/alpine.js
  7. 1 1
      packages/alpinejs/src/clone.js
  8. 2 16
      packages/alpinejs/src/directives.js
  9. 1 1
      packages/alpinejs/src/directives/x-data.js
  10. 14 4
      packages/alpinejs/src/directives/x-for.js
  11. 13 6
      packages/alpinejs/src/directives/x-model.js
  12. 6 6
      packages/alpinejs/src/directives/x-transition.js
  13. 1 1
      packages/alpinejs/src/entangle.js
  14. 3 5
      packages/alpinejs/src/evaluator.js
  15. 6 0
      packages/alpinejs/src/lifecycle.js
  16. 13 6
      packages/alpinejs/src/magics.js
  17. 4 2
      packages/alpinejs/src/plugin.js
  18. 4 1
      packages/alpinejs/src/scheduler.js
  19. 2 10
      packages/alpinejs/src/scope.js
  20. 19 0
      packages/alpinejs/src/utils/bind.js
  21. 18 15
      packages/alpinejs/src/utils/on.js
  22. 1 1
      packages/collapse/package.json
  23. 1 1
      packages/docs/package.json
  24. 2 2
      packages/docs/src/en/advanced/csp.md
  25. 1 1
      packages/docs/src/en/advanced/extending.md
  26. 5 5
      packages/docs/src/en/directives/bind.md
  27. 3 3
      packages/docs/src/en/directives/data.md
  28. 11 0
      packages/docs/src/en/directives/model.md
  29. 15 2
      packages/docs/src/en/directives/on.md
  30. 6 6
      packages/docs/src/en/essentials/installation.md
  31. 1 1
      packages/docs/src/en/essentials/lifecycle.md
  32. 14 1
      packages/docs/src/en/plugins/mask.md
  33. 0 6
      packages/docs/src/en/ui.md
  34. 1 1
      packages/focus/package.json
  35. 8 3
      packages/focus/src/index.js
  36. 1 1
      packages/intersect/package.json
  37. 1 1
      packages/mask/package.json
  38. 48 38
      packages/mask/src/index.js
  39. 1 1
      packages/morph/package.json
  40. 31 0
      packages/morph/src/morph.js
  41. 4 1
      packages/navigate/package.json
  42. 1 1
      packages/persist/package.json
  43. 1 1
      packages/ui/package.json
  44. 1 1
      packages/ui/src/combobox.js
  45. 1 1
      packages/ui/src/disclosure.js
  46. 1 1
      packages/ui/src/listbox.js
  47. 1 1
      packages/ui/src/menu.js
  48. 1 1
      packages/ui/src/radio.js
  49. 1 1
      packages/ui/src/switch.js
  50. 1 1
      packages/ui/src/tabs.js
  51. 6 17
      scripts/build.js
  52. 31 0
      tests/cypress/integration/clone.spec.js
  53. 3 3
      tests/cypress/integration/directives/x-bind-style.spec.js
  54. 23 4
      tests/cypress/integration/directives/x-bind.spec.js
  55. 46 0
      tests/cypress/integration/directives/x-for.spec.js
  56. 37 0
      tests/cypress/integration/directives/x-if.spec.js
  57. 59 0
      tests/cypress/integration/directives/x-model.spec.js
  58. 68 2
      tests/cypress/integration/directives/x-on.spec.js
  59. 60 0
      tests/cypress/integration/directives/x-transition.spec.js
  60. 2 2
      tests/cypress/integration/entangle.spec.js
  61. 45 0
      tests/cypress/integration/plugins/mask.spec.js
  62. 20 0
      tests/cypress/integration/plugins/morph.spec.js
  63. 1 1
      tests/cypress/integration/plugins/navigate.spec.js
  64. 1 1
      tests/cypress/integration/plugins/ui/popover.spec.js
  65. 3 1
      tests/cypress/utils.js

+ 3 - 3
.github/workflows/run-tests.yml

@@ -4,10 +4,10 @@ jobs:
   build:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v1
-      - uses: actions/setup-node@v2
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
         with:
-          node-version: '15'
+          node-version: '18'
       - run: npm install
       - run: npm run build
       - run: npm run test

+ 1 - 1
README.md

@@ -8,7 +8,7 @@ Stay here for contribution-related information.
 
 > Looking for V2 docs? [here they are](https://github.com/alpinejs/alpine/tree/v2.8.2)
 
-<p align="center"><a href="https://alpinejs.dev/patterns"><img src="/hero.jpg" alt="Alpine Compoenent Patterns"></a></p>
+<p align="center"><a href="https://alpinejs.dev/patterns"><img src="/hero.jpg" alt="Alpine Component Patterns"></a></p>
 
 ## Contribution Guide:
 

File diff ditekan karena terlalu besar
+ 662 - 284
package-lock.json


+ 1 - 5
package.json

@@ -5,12 +5,11 @@
     ],
     "devDependencies": {
         "axios": "^0.21.1",
-        "brotli-size": "^4.0.0",
         "chalk": "^4.1.1",
         "cypress": "^7.0.0",
         "cypress-plugin-tab": "^1.0.5",
         "dot-json": "^1.2.2",
-        "esbuild": "^0.8.39",
+        "esbuild": "~0.16.17",
         "jest": "^26.6.3"
     },
     "scripts": {
@@ -21,8 +20,5 @@
         "jest": "jest test",
         "update-docs": "node ./scripts/update-docs.js",
         "release": "node ./scripts/release.js"
-    },
-    "dependencies": {
-        "nprogress": "^0.2.0"
     }
 }

+ 1 - 1
packages/alpinejs/package.json

@@ -1,6 +1,6 @@
 {
     "name": "alpinejs",
-    "version": "3.10.5",
+    "version": "3.12.2",
     "description": "The rugged, minimal JavaScript framework",
     "homepage": "https://alpinejs.dev",
     "repository": {

+ 0 - 2
packages/alpinejs/src/alpine.js

@@ -11,7 +11,6 @@ import { getBinding as bound } from './utils/bind'
 import { debounce } from './utils/debounce'
 import { throttle } from './utils/throttle'
 import { setStyles } from './utils/styles'
-import { entangle } from './entangle'
 import { nextTick } from './nextTick'
 import { walk } from './utils/walk'
 import { plugin } from './plugin'
@@ -53,7 +52,6 @@ let Alpine = {
     setStyles, // INTERNAL
     mutateDom,
     directive,
-    entangle,
     throttle,
     debounce,
     evaluate,

+ 1 - 1
packages/alpinejs/src/clone.js

@@ -2,7 +2,7 @@ import { effect, release, overrideEffect } from "./reactivity"
 import { initTree, isRoot } from "./lifecycle"
 import { walk } from "./utils/walk"
 
-let isCloning = false
+export let isCloning = false
 
 export function skipDuringClone(callback, fallback = () => {}) {
     return (...args) => isCloning ? fallback(...args) : callback(...args)

+ 2 - 16
packages/alpinejs/src/directives.js

@@ -27,11 +27,8 @@ export function directive(name, callback) {
                 );
                 return;
             }
-            const pos = directiveOrder.indexOf(directive)
-                ?? directiveOrder.indexOf('DEFAULT');
-            if (pos >= 0) {
-                directiveOrder.splice(pos, 0, name);
-            }
+            const pos = directiveOrder.indexOf(directive);
+            directiveOrder.splice(pos >= 0 ? pos : directiveOrder.indexOf('DEFAULT'), 0, name);
         }
     }
 }
@@ -206,20 +203,9 @@ let directiveOrder = [
     'ref',
     'data',
     'id',
-    // @todo: provide better directive ordering mechanisms so
-    // that I don't have to manually add things like "tabs"
-    // to the order list...
-    'radio',
-    'tabs',
-    'switch',
-    'disclosure',
-    'menu',
-    'listbox',
-    'combobox',
     'bind',
     'init',
     'for',
-    'mask',
     'model',
     'modelable',
     'transition',

+ 1 - 1
packages/alpinejs/src/directives/x-data.js

@@ -21,7 +21,7 @@ directive('data', skipDuringClone((el, { expression }, { cleanup }) => {
 
     let data = evaluate(el, expression, { scope: dataProviderContext })
 
-    if (data === undefined) data = {}
+    if (data === undefined || data === true) data = {}
 
     injectMagics(data, el)
 

+ 14 - 4
packages/alpinejs/src/directives/x-for.js

@@ -1,4 +1,4 @@
-import { addScopeToNode, refreshScope } from '../scope'
+import { addScopeToNode } from '../scope'
 import { evaluateLater } from '../evaluator'
 import { directive } from '../directives'
 import { reactive } from '../reactivity'
@@ -158,6 +158,8 @@ function loop(el, iteratorNames, evaluateItems, evaluateKey) {
             let marker = document.createElement('div')
 
             mutateDom(() => {
+                if (! elForSpot) warn(`x-for ":key" is undefined or invalid`, templateEl)
+
                 elForSpot.after(marker)
                 elInSpot.after(elForSpot)
                 elForSpot._x_currentIfEl && elForSpot.after(elForSpot._x_currentIfEl)
@@ -166,7 +168,7 @@ function loop(el, iteratorNames, evaluateItems, evaluateKey) {
                 marker.remove()
             })
 
-            refreshScope(elForSpot, scopes[keys.indexOf(keyForSpot)])
+            elForSpot._x_refreshXForScope(scopes[keys.indexOf(keyForSpot)])
         }
 
         // We can now create and add new elements.
@@ -183,7 +185,15 @@ function loop(el, iteratorNames, evaluateItems, evaluateKey) {
 
             let clone = document.importNode(templateEl.content, true).firstElementChild
 
-            addScopeToNode(clone, reactive(scope), templateEl)
+            let reactiveScope = reactive(scope)
+
+            addScopeToNode(clone, reactiveScope, templateEl)
+
+            clone._x_refreshXForScope = (newScope) => {
+                Object.entries(newScope).forEach(([key, value]) => {
+                    reactiveScope[key] = value
+                })
+            }
 
             mutateDom(() => {
                 lastEl.after(clone)
@@ -202,7 +212,7 @@ function loop(el, iteratorNames, evaluateItems, evaluateKey) {
         // data it depends on in case the data has changed in an
         // "unobservable" way.
         for (let i = 0; i < sames.length; i++) {
-            refreshScope(lookup[sames[i]], scopes[keys.indexOf(sames[i])])
+            lookup[sames[i]]._x_refreshXForScope(scopes[keys.indexOf(sames[i])])
         }
 
         // Now we'll log the keys (and the order they're in) for comparing

+ 13 - 6
packages/alpinejs/src/directives/x-model.js

@@ -5,6 +5,7 @@ import { nextTick } from '../nextTick'
 import bind from '../utils/bind'
 import on from '../utils/on'
 import { warn } from '../utils/warn'
+import { isCloning } from '../clone'
 
 directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
     let scopeTarget = el
@@ -45,7 +46,7 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
             })
         }
     }
-
+    
     if (typeof expression === 'string' && 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
@@ -62,10 +63,16 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
         || modifiers.includes('lazy')
             ? 'change' : 'input'
 
-    let removeListener = on(el, event, modifiers, (e) => {
+    // We only want to register the event listener when we're not cloning, since the
+    // mutation observer handles initializing the x-model directive already when
+    // the element is inserted into the DOM. Otherwise we register it twice.
+    let removeListener = isCloning ? () => {} : on(el, event, modifiers, (e) => {
         setValue(getInputValue(el, modifiers, e, getValue()))
     })
-
+    
+    if (modifiers.includes('fill') && [null, ''].includes(getValue())) {
+        el.dispatchEvent(new Event(event, {}));
+    }
     // Register the listener removal callback on the element, so that
     // in addition to the cleanup function, x-modelable may call it.
     // Also, make this a keyed object if we decide to reintroduce
@@ -124,9 +131,9 @@ function getInputValue(el, modifiers, event, currentValue) {
         // Check for event.detail due to an issue where IE11 handles other events as a CustomEvent.
         // Safari autofill triggers event as CustomEvent and assigns value to target
         // so we return event.target.value instead of event.detail
-        if (event instanceof CustomEvent && event.detail !== undefined) {
-            return typeof event.detail != 'undefined' ? event.detail : event.target.value
-        } else if (el.type === 'checkbox') {
+        if (event instanceof CustomEvent && event.detail !== undefined)
+            return event.detail ?? event.target.value
+        else if (el.type === 'checkbox') {
             // If the data we are binding to is an array, toggle its value inside the array.
             if (Array.isArray(currentValue)) {
                 let newValue = modifiers.includes('number') ? safeParseNumber(event.target.value) : event.target.value

+ 6 - 6
packages/alpinejs/src/directives/x-transition.js

@@ -7,8 +7,8 @@ import { once } from '../utils/once'
 
 directive('transition', (el, { value, modifiers, expression }, { evaluate }) => {
     if (typeof expression === 'function') expression = evaluate(expression)
-
-    if (! expression) {
+    if (expression === false) return
+    if (!expression || typeof expression === 'boolean') {
         registerTransitionsFromHelper(el, modifiers, value)
     } else {
         registerTransitionsFromClassString(el, expression, value)
@@ -50,7 +50,7 @@ function registerTransitionsFromHelper(el, modifiers, stage) {
     let wantsScale = wantsAll || modifiers.includes('scale')
     let opacityValue = wantsOpacity ? 0 : 1
     let scaleValue = wantsScale ? modifierValue(modifiers, 'scale', 95) / 100 : 1
-    let delay = modifierValue(modifiers, 'delay', 0)
+    let delay = modifierValue(modifiers, 'delay', 0) / 1000
     let origin = modifierValue(modifiers, 'origin', 'center')
     let property = 'opacity, transform'
     let durationIn = modifierValue(modifiers, 'duration', 150) / 1000
@@ -60,7 +60,7 @@ function registerTransitionsFromHelper(el, modifiers, stage) {
     if (transitioningIn) {
         el._x_transition.enter.during = {
             transformOrigin: origin,
-            transitionDelay: delay,
+            transitionDelay: `${delay}s`,
             transitionProperty: property,
             transitionDuration: `${durationIn}s`,
             transitionTimingFunction: easing,
@@ -80,7 +80,7 @@ function registerTransitionsFromHelper(el, modifiers, stage) {
     if (transitioningOut) {
         el._x_transition.leave.during = {
             transformOrigin: origin,
-            transitionDelay: delay,
+            transitionDelay: `${delay}s`,
             transitionProperty: property,
             transitionDuration: `${durationOut}s`,
             transitionTimingFunction: easing,
@@ -318,7 +318,7 @@ export function modifierValue(modifiers, key, fallback) {
         if (isNaN(rawValue)) return fallback
     }
 
-    if (key === 'duration') {
+    if (key === 'duration' || key === 'delay') {
         // Support x-transition.duration.500ms && duration.500
         let match = rawValue.match(/([0-9]+)ms/)
         if (match) return match[1]

+ 1 - 1
packages/alpinejs/src/entangle.js

@@ -2,7 +2,7 @@ import { effect, release } from './reactivity'
 
 export function entangle({ get: outerGet, set: outerSet }, { get: innerGet, set: innerSet }) {
     let firstRun = true
-    let outerHash, innerHash
+    let outerHash, innerHash, outerHashLatest, innerHashLatest
 
     let reference = effect(() => {
         let outer, inner

+ 3 - 5
packages/alpinejs/src/evaluator.js

@@ -39,11 +39,9 @@ export function normalEvaluator(el, expression) {
 
     let dataStack = [overriddenMagics, ...closestDataStack(el)]
 
-    if (typeof expression === 'function') {
-        return generateEvaluatorFromFunction(dataStack, expression)
-    }
-
-    let evaluator = generateEvaluatorFromString(dataStack, expression, el)
+    let evaluator = (typeof expression === 'function')
+        ? generateEvaluatorFromFunction(dataStack, expression)
+        : generateEvaluatorFromString(dataStack, expression, el)
 
     return tryCatch.bind(null, el, expression, evaluator)
 }

+ 6 - 0
packages/alpinejs/src/lifecycle.js

@@ -5,7 +5,13 @@ import { walk } from "./utils/walk"
 import { warn } from './utils/warn'
 import Alpine from "./alpine"
 
+let started = false
+
 export function start() {
+    if (started) warn('Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems.')
+
+    started = true
+
     if (! document.body) warn('Unable to initialize. Trying to load Alpine before `<body>` is available. Did you forget to add `defer` in Alpine\'s `<script>` tag?')
 
     dispatch(document, 'alpine:init')

+ 13 - 6
packages/alpinejs/src/magics.js

@@ -11,17 +11,24 @@ export function magic(name, callback) {
 
 export function injectMagics(obj, el) {
     Object.entries(magics).forEach(([name, callback]) => {
-        Object.defineProperty(obj, `$${name}`, {
-            get() {
+        let memoizedUtilities = null;
+        function getUtilities() {
+            if (memoizedUtilities) {
+                return memoizedUtilities;
+            } else {
                 let [utilities, cleanup] = getElementBoundUtilities(el)
                 
-                utilities = {interceptor, ...utilities}
+                memoizedUtilities = {interceptor, ...utilities}
                 
                 onElRemoved(el, cleanup)
-
-                return callback(el, utilities)
+                return memoizedUtilities;
+            }
+        }
+        
+        Object.defineProperty(obj, `$${name}`, {
+            get() {
+                return callback(el, getUtilities());
             },
-
             enumerable: false,
         })
     })

+ 4 - 2
packages/alpinejs/src/plugin.js

@@ -1,5 +1,7 @@
-import Alpine from './alpine'
+import Alpine from "./alpine";
 
 export function plugin(callback) {
-    callback(Alpine)
+    let callbacks = Array.isArray(callback) ? callback : [callback]
+
+    callbacks.forEach(i => i(Alpine))
 }

+ 4 - 1
packages/alpinejs/src/scheduler.js

@@ -2,6 +2,7 @@
 let flushPending = false
 let flushing = false
 let queue = []
+let lastFlushedIndex = -1
 
 export function scheduler (callback) { queueJob(callback) }
 
@@ -13,7 +14,7 @@ function queueJob(job) {
 export function dequeueJob(job) {
     let index = queue.indexOf(job)
 
-    if (index !== -1) queue.splice(index, 1)
+    if (index !== -1 && index > lastFlushedIndex) queue.splice(index, 1)
 }
 
 function queueFlush() {
@@ -30,9 +31,11 @@ export function flushJobs() {
 
     for (let i = 0; i < queue.length; i++) {
         queue[i]()
+        lastFlushedIndex = i
     }
 
     queue.length = 0
+    lastFlushedIndex = -1
 
     flushing = false
 }

+ 2 - 10
packages/alpinejs/src/scope.js

@@ -15,14 +15,6 @@ export function hasScope(node) {
     return !! node._x_dataStack
 }
 
-export function refreshScope(element, scope) {
-    let existingScope = element._x_dataStack[0]
-
-    Object.entries(scope).forEach(([key, value]) => {
-        existingScope[key] = value
-    })
-}
-
 export function closestDataStack(node) {
     if (node._x_dataStack) return node._x_dataStack
 
@@ -60,7 +52,7 @@ export function mergeProxies(objects) {
                     if ((descriptor.get && descriptor.get._x_alreadyBound) || (descriptor.set && descriptor.set._x_alreadyBound)) {
                         return true
                     }
-                    
+
                     // Properly bind getters and setters to this wrapper Proxy.
                     if ((descriptor.get || descriptor.set) && descriptor.enumerable) {
                         // Only bind user-defined getters, not our magic properties.
@@ -81,7 +73,7 @@ export function mergeProxies(objects) {
                         })
                     }
 
-                    return true 
+                    return true
                 }
 
                 return false

+ 19 - 0
packages/alpinejs/src/utils/bind.js

@@ -22,6 +22,14 @@ export default function bind(el, name, value, modifiers = []) {
         case 'class':
             bindClasses(el, value)
             break;
+        
+        // 'selected' and 'checked' are special attributes that aren't necessarily
+        // synced with their corresponding properties when updated, so both the 
+        // attribute and property need to be updated when bound.
+        case 'selected':
+        case 'checked':
+            bindAttributeAndProperty(el, name, value)
+            break;
 
         default:
             bindAttribute(el, name, value)
@@ -78,6 +86,11 @@ function bindStyles(el, value) {
     el._x_undoAddedStyles = setStyles(el, value)
 }
 
+function bindAttributeAndProperty(el, name, value) {
+    bindAttribute(el, name, value)
+    setPropertyIfChanged(el, name, value)
+}
+
 function bindAttribute(el, name, value) {
     if ([null, undefined, false].includes(value) && attributeShouldntBePreservedIfFalsy(name)) {
         el.removeAttribute(name)
@@ -94,6 +107,12 @@ function setIfChanged(el, attrName, value) {
     }
 }
 
+function setPropertyIfChanged(el, propName, value) {
+    if (el[propName] !== value) {
+        el[propName] = value
+    }
+}
+
 function updateSelect(el, value) {
     const arrayWrappedValue = [].concat(value).map(value => { return value + '' })
 

+ 18 - 15
packages/alpinejs/src/utils/on.js

@@ -18,6 +18,23 @@ export default function on (el, event, modifiers, callback) {
     if (modifiers.includes('capture')) options.capture = true
     if (modifiers.includes('window')) listenerTarget = window
     if (modifiers.includes('document')) listenerTarget = document
+
+    // By wrapping the handler with debounce & throttle first, we ensure that the wrapping logic itself is not
+    // throttled/debounced, only the user's callback is. This way, if the user expects
+    // `e.preventDefault()` to happen, it'll still happen even if their callback gets throttled.
+    if (modifiers.includes('debounce')) {
+        let nextModifier = modifiers[modifiers.indexOf('debounce')+1] || 'invalid-wait'
+        let wait = isNumeric(nextModifier.split('ms')[0]) ? Number(nextModifier.split('ms')[0]) : 250
+
+        handler = debounce(handler, wait)
+    }
+    if (modifiers.includes('throttle')) {
+        let nextModifier = modifiers[modifiers.indexOf('throttle')+1] || 'invalid-wait'
+        let wait = isNumeric(nextModifier.split('ms')[0]) ? Number(nextModifier.split('ms')[0]) : 250
+
+        handler = throttle(handler, wait)
+    }
+
     if (modifiers.includes('prevent')) handler = wrapHandler(handler, (next, e) => { e.preventDefault(); next(e) })
     if (modifiers.includes('stop')) handler = wrapHandler(handler, (next, e) => { e.stopPropagation(); next(e) })
     if (modifiers.includes('self')) handler = wrapHandler(handler, (next, e) => { e.target === el && next(e) })
@@ -59,20 +76,6 @@ export default function on (el, event, modifiers, callback) {
         next(e)
     })
 
-    if (modifiers.includes('debounce')) {
-        let nextModifier = modifiers[modifiers.indexOf('debounce')+1] || 'invalid-wait'
-        let wait = isNumeric(nextModifier.split('ms')[0]) ? Number(nextModifier.split('ms')[0]) : 250
-
-        handler = debounce(handler, wait)
-    }
-
-    if (modifiers.includes('throttle')) {
-        let nextModifier = modifiers[modifiers.indexOf('throttle')+1] || 'invalid-wait'
-        let wait = isNumeric(nextModifier.split('ms')[0]) ? Number(nextModifier.split('ms')[0]) : 250
-
-        handler = throttle(handler, wait)
-    }
-
     listenerTarget.addEventListener(event, handler, options)
 
     return () => {
@@ -104,7 +107,7 @@ function isKeyEvent(event) {
 
 function isListeningForASpecificKeyThatHasntBeenPressed(e, modifiers) {
     let keyModifiers = modifiers.filter(i => {
-        return ! ['window', 'document', 'prevent', 'stop', 'once'].includes(i)
+        return ! ['window', 'document', 'prevent', 'stop', 'once', 'capture'].includes(i)
     })
 
     if (keyModifiers.includes('debounce')) {

+ 1 - 1
packages/collapse/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/collapse",
-    "version": "3.10.5",
+    "version": "3.12.2",
     "description": "Collapse and expand elements with robust animations",
     "homepage": "https://alpinejs.dev/plugins/collapse",
     "repository": {

+ 1 - 1
packages/docs/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/docs",
-    "version": "3.10.5-revision.1",
+    "version": "3.12.2-revision.1",
     "description": "The documentation for Alpine",
     "author": "Caleb Porzio",
     "license": "MIT"

+ 2 - 2
packages/docs/src/en/advanced/csp.md

@@ -9,12 +9,12 @@ In order for Alpine to be able to execute plain strings from HTML attributes as
 
 > Under the hood, Alpine doesn't actually use eval() itself because it's slow and problematic. Instead it uses Function declarations, which are much better, but still violate "unsafe-eval".
 
-In order to accommodate environments where this CSP is necessary, Alpine offers an alternate build that doesn't violate "unsafe-eval", but has a more restrictive syntax.
+In order to accommodate environments where this CSP is necessary, Alpine will offer an alternate build that doesn't violate "unsafe-eval", but has a more restrictive syntax.
 
 <a name="installation"></a>
 ## Installation
 
-Like all Alpine extensions, you can include this either via `<script>` tag or module import:
+The CSP build hasn’t been officially released yet. In the meantime, you may [build it from source](https://github.com/alpinejs/alpine/tree/main/packages/csp). Once released, like all Alpine extensions, you will be able to include this either via `<script>` tag or module import:
 
 <a name="script-tag"></a>
 ### Script tag

+ 1 - 1
packages/docs/src/en/advanced/extending.md

@@ -228,7 +228,7 @@ Now if the directive is removed from this element or the element is removed itse
 ### Custom order
 
 By default, any new directive will run after the majority of the standard ones (with the exception of `x-teleport`). This is usually acceptable but some times you might need to run your custom directive before another specific one.
-This can be achieved by chaining the `.before() function to `Alpine.directive()` and specifing which directive needs to run after your custom one.
+This can be achieved by chaining the `.before() function to `Alpine.directive()` and specifying which directive needs to run after your custom one.
  
 ```js
 Alpine.directive('foo', (el, { value, modifiers, expression }) => {

+ 5 - 5
packages/docs/src/en/directives/bind.md

@@ -11,7 +11,7 @@ For example, here's a component where we will use `x-bind` to set the placeholde
 
 ```alpine
 <div x-data="{ placeholder: 'Type here...' }">
-  <input type="text" x-bind:placeholder="placeholder">
+    <input type="text" x-bind:placeholder="placeholder">
 </div>
 ```
 
@@ -33,11 +33,11 @@ Here's a simple example of a simple dropdown toggle, but instead of using `x-sho
 
 ```alpine
 <div x-data="{ open: false }">
-  <button x-on:click="open = ! open">Toggle Dropdown</button>
+    <button x-on:click="open = ! open">Toggle Dropdown</button>
 
-  <div :class="open ? '' : 'hidden'">
-    Dropdown Contents...
-  </div>
+    <div :class="open ? '' : 'hidden'">
+        Dropdown Contents...
+    </div>
 </div>
 ```
 

+ 3 - 3
packages/docs/src/en/directives/data.md

@@ -86,9 +86,9 @@ Let's refactor our component to use a getter called `isOpen` instead of accessin
 
 ```alpine
 <div x-data="{
-  open: false,
-  get isOpen() { return this.open },
-  toggle() { this.open = ! this.open },
+    open: false,
+    get isOpen() { return this.open },
+    toggle() { this.open = ! this.open },
 }">
     <button @click="toggle()">Toggle Content</button>
 

+ 11 - 0
packages/docs/src/en/directives/model.md

@@ -337,6 +337,17 @@ The default throttle interval is 250 milliseconds, you can easily customize this
 <input type="text" x-model.throttle.500ms="search">
 ```
 
+<a name="fill"></a>
+### `.fill`
+
+By default, if an input has a value attribute, it is ignored by Alpine and instead, the value of the input is set to the value of the property bound using `x-model`.
+
+But if a bound property is empty, then you can use an input's value attribute to populate the property by adding the `.fill` modifier.
+
+<div x-data="{ message: null }">
+  <input x-model.fill="message" value="This is the default message.">
+</div>
+
 <a name="programmatic access"></a>
 ## Programmatic access
 

+ 15 - 2
packages/docs/src/en/directives/on.md

@@ -100,7 +100,7 @@ Here's an example of a component that dispatches a custom DOM event and listens
 
 ```alpine
 <div x-data @foo="alert('Button Was Clicked!')">
-	<button @click="$event.target.dispatchEvent(new CustomEvent('foo', { bubbles: true }))">...</button>
+    <button @click="$event.target.dispatchEvent(new CustomEvent('foo', { bubbles: true }))">...</button>
 </div>
 ```
 
@@ -112,7 +112,7 @@ Here's the same component re-written with the `$dispatch` magic property.
 
 ```alpine
 <div x-data @foo="alert('Button Was Clicked!')">
-  <button @click="$dispatch('foo')">...</button>
+    <button @click="$dispatch('foo')">...</button>
 </div>
 ```
 
@@ -298,3 +298,16 @@ If you are listening for touch events, it's important to add `.passive` to your
 ```
 
 [→ Read more about passive listeners](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners)
+
+### .capture
+
+Add this modifier if you want to execute this listener in the event's capturing phase, e.g. before the event bubbles from the target element up the DOM.
+
+```
+<div @click.capture="console.log('I will log first')">
+    <button @click="console.log('I will log second')"></button>
+</div>
+```
+
+[→ Read more about the capturing and bubbling phase of events](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#usecapture)
+

+ 6 - 6
packages/docs/src/en/essentials/installation.md

@@ -19,12 +19,12 @@ This is by far the simplest way to get started with Alpine. Include the followin
 
 ```alpine
 <html>
-  <head>
-    ...
+    <head>
+        ...
 
-    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
-  </head>
-  ...
+        <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
+    </head>
+    ...
 </html>
 ```
 
@@ -33,7 +33,7 @@ This is by far the simplest way to get started with Alpine. Include the followin
 Notice the `@3.x.x` in the provided CDN link. This will pull the latest version of Alpine version 3. For stability in production, it's recommended that you hardcode the latest version in the CDN link.
 
 ```alpine
-<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.10.5/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.12.2/dist/cdn.min.js"></script>
 ```
 
 That's it! Alpine is now available for use inside your page.

+ 1 - 1
packages/docs/src/en/essentials/lifecycle.md

@@ -87,7 +87,7 @@ document.addEventListener('alpine:init', () => {
 <a name="alpine-initialized"></a>
 ### `alpine:initialized`
 
-Alpine also offers a hook that you can use to execute code After it's done initializing called `alpine:initialized`:
+Alpine also offers a hook that you can use to execute code AFTER it's done initializing called `alpine:initialized`:
 
 ```js
 document.addEventListener('alpine:initialized', () => {

+ 14 - 1
packages/docs/src/en/plugins/mask.md

@@ -58,7 +58,7 @@ Alpine.plugin(mask)
 <button :aria-expanded="expanded" @click="expanded = ! expanded" class="text-cyan-600 font-medium underline">
     <span x-text="expanded ? 'Hide' : 'Show more'">Show</span> <span x-text="expanded ? '↑' : '↓'">↓</span>
 </button>
- </div>
+</div>
 
 <a name="x-mask"></a>
 
@@ -171,3 +171,16 @@ You may also choose to override the thousands separator by supplying a third opt
     <input type="text" x-mask:dynamic="$money($input, '.', ' ')"  placeholder="3 000.00">
 </div>
 <!-- END_VERBATIM -->
+
+
+You can also override the default precision of 2 digits by using any desired number of digits as the fourth optional argument:
+
+```alpine
+<input x-mask:dynamic="$money($input, '.', ',', 4)">
+```
+
+<!-- START_VERBATIM -->
+<div class="demo" x-data>
+    <input type="text" x-mask:dynamic="$money($input, '.', ',', 4)"  placeholder="0.0001">
+</div>
+<!-- END_VERBATIM -->

+ 0 - 6
packages/docs/src/en/ui.md

@@ -1,6 +0,0 @@
----
-order: 5
-title: UI
-font-type: mono
-type: sub-directory
----

+ 1 - 1
packages/focus/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/focus",
-    "version": "3.10.5",
+    "version": "3.12.2",
     "description": "Manage focus within a page",
     "homepage": "https://alpinejs.dev/plugins/focus",
     "repository": {

+ 8 - 3
packages/focus/src/index.js

@@ -102,12 +102,17 @@ export default function (Alpine) {
 
             let oldValue = false
 
-            let trap = createFocusTrap(el, {
+            let options = {
                 escapeDeactivates: false,
                 allowOutsideClick: true,
                 fallbackFocus: () => el,
-                initialFocus: el.querySelector('[autofocus]')
-            })
+            }
+
+            let autofocusEl = el.querySelector('[autofocus]')
+
+            if (autofocusEl) options.initialFocus = autofocusEl
+
+            let trap = createFocusTrap(el, options)
 
             let undoInert = () => {}
             let undoDisableScrolling = () => {}

+ 1 - 1
packages/intersect/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/intersect",
-    "version": "3.10.5",
+    "version": "3.12.2",
     "description": "Trigger JavaScript when an element enters the viewport",
     "homepage": "https://alpinejs.dev/plugins/intersect",
     "repository": {

+ 1 - 1
packages/mask/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/mask",
-    "version": "3.10.5",
+    "version": "3.12.2",
     "description": "An Alpine plugin for input masking",
     "homepage": "https://alpinejs.dev/plugins/mask",
     "repository": {

+ 48 - 38
packages/mask/src/index.js

@@ -4,39 +4,44 @@ export default function (Alpine) {
         let templateFn = () => expression
         let lastInputValue = ''
 
-        if (['function', 'dynamic'].includes(value)) {
-            // This is an x-mask:function directive.
-
-            let evaluator = evaluateLater(expression)
-
-            effect(() => {
-                templateFn = input => {
-                    let result
-
-                    // We need to prevent "auto-evaluation" of functions like
-                    // x-on expressions do so that we can use them as mask functions.
-                    Alpine.dontAutoEvaluateFunctions(() => {
-                        evaluator(value => {
-                            result = typeof value === 'function' ? value(input) : value
-                        }, { scope: {
-                            // These are "magics" we'll make available to the x-mask:function:
-                            '$input': input,
-                            '$money': formatMoney.bind({ el }),
-                        }})
-                    })
-
-                    return result
-                }
-
-                // Run on initialize which serves a dual purpose:
-                // - Initializing the mask on the input if it has an initial value.
-                // - Running the template function to set up reactivity, so that
-                //   when a dependency inside it changes, the input re-masks.
-                processInputValue(el)
-            })
-        } else {
-            processInputValue(el)
-        }
+        queueMicrotask(() => {
+            if (['function', 'dynamic'].includes(value)) {
+                // This is an x-mask:function directive.
+
+                let evaluator = evaluateLater(expression)
+
+                effect(() => {
+                    templateFn = input => {
+                        let result
+
+                        // We need to prevent "auto-evaluation" of functions like
+                        // x-on expressions do so that we can use them as mask functions.
+                        Alpine.dontAutoEvaluateFunctions(() => {
+                            evaluator(value => {
+                                result = typeof value === 'function' ? value(input) : value
+                            }, { scope: {
+                                // These are "magics" we'll make available to the x-mask:function:
+                                '$input': input,
+                                '$money': formatMoney.bind({ el }),
+                            }})
+                        })
+
+                        return result
+                    }
+
+                    // Run on initialize which serves a dual purpose:
+                    // - Initializing the mask on the input if it has an initial value.
+                    // - Running the template function to set up reactivity, so that
+                    //   when a dependency inside it changes, the input re-masks.
+                    processInputValue(el, false)
+                })
+            } else {
+                processInputValue(el, false)
+            }
+
+            // Override x-model's initial value...
+            if (el._x_model) el._x_model.set(el.value)
+        })
 
         el.addEventListener('input', () => processInputValue(el))
         // Don't "restoreCursorPosition" on "blur", because Safari
@@ -56,7 +61,9 @@ export default function (Alpine) {
                 return lastInputValue = el.value
             }
 
-            let setInput = () => { lastInputValue = el.value = formatInput(input, template) }
+            let setInput = () => {
+                lastInputValue = el.value = formatInput(input, template)
+            }
 
             if (shouldRestoreCursor) {
                 // When an input element's value is set, it moves the cursor to the end
@@ -79,7 +86,7 @@ export default function (Alpine) {
 
             return rebuiltInput
         }
-    })
+    }).before('model')
 }
 
 export function restoreCursorPosition(el, template, callback) {
@@ -163,7 +170,8 @@ export function buildUp(template, input) {
     return output
 }
 
-export function formatMoney(input, delimiter = '.', thousands) {
+export function formatMoney(input, delimiter = '.', thousands, precision = 2) {
+    if (input === '-') return '-'
     if (/^\D+$/.test(input)) return '9'
 
     thousands = thousands ?? (delimiter === "," ? "." : ",")
@@ -187,12 +195,14 @@ export function formatMoney(input, delimiter = '.', thousands) {
         return output
     }
 
+    let minus = input.startsWith('-') ? '-' : ''
     let strippedInput = input.replaceAll(new RegExp(`[^0-9\\${delimiter}]`, 'g'), '')
     let template = Array.from({ length: strippedInput.split(delimiter)[0].length }).fill('9').join('')
 
-    template = addThousands(template, thousands)
+    template = `${minus}${addThousands(template, thousands)}`
 
-    if (input.includes(delimiter)) template += `${delimiter}99`
+    if (precision > 0 && input.includes(delimiter)) 
+        template += `${delimiter}` + '9'.repeat(precision)
 
     queueMicrotask(() => {
         if (this.el.value.endsWith(delimiter)) return

+ 1 - 1
packages/morph/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/morph",
-    "version": "3.10.5",
+    "version": "3.12.2",
     "description": "Diff and patch a block of HTML on a page with an HTML template",
     "homepage": "https://alpinejs.dev/plugins/morph",
     "repository": {

+ 31 - 0
packages/morph/src/morph.js

@@ -5,6 +5,8 @@ let resolveStep = () => {}
 let logger = () => {}
 
 export function morph(from, toHtml, options) {
+    monkeyPatchDomSetAttributeToAllowAtSymbols()
+
     // We're defining these globals and methods inside this function (instead of outside)
     // because it's an async function and if run twice, they would overwrite
     // each other.
@@ -386,3 +388,32 @@ function initializeAlpineOnTo(from, to, childrenOnly) {
         window.Alpine.clone(from, to)
     }
 }
+
+let patched = false
+
+function monkeyPatchDomSetAttributeToAllowAtSymbols() {
+    if (patched) return
+
+    patched = true
+
+    // Because morphdom may add attributes to elements containing "@" symbols
+    // like in the case of an Alpine `@click` directive, we have to patch
+    // the standard Element.setAttribute method to allow this to work.
+    let original = Element.prototype.setAttribute
+
+    let hostDiv = document.createElement('div')
+
+    Element.prototype.setAttribute = function newSetAttribute(name, value) {
+        if (! name.includes('@')) {
+            return original.call(this, name, value)
+        }
+
+        hostDiv.innerHTML = `<span ${name}="${value}"></span>`
+
+        let attr = hostDiv.firstElementChild.getAttributeNode(name)
+
+        hostDiv.firstElementChild.removeAttributeNode(attr)
+
+        this.setAttributeNode(attr)
+    }
+}

+ 4 - 1
packages/navigate/package.json

@@ -5,5 +5,8 @@
     "author": "Caleb Porzio",
     "license": "MIT",
     "main": "dist/module.cjs.js",
-    "module": "dist/module.esm.js"
+    "module": "dist/module.esm.js",
+    "dependencies": {
+        "nprogress": "^0.2.0"
+    }
 }

+ 1 - 1
packages/persist/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/persist",
-    "version": "3.10.5",
+    "version": "3.12.2",
     "description": "Persist Alpine data across page loads",
     "homepage": "https://alpinejs.dev/plugins/persist",
     "repository": {

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/ui",
-    "version": "3.10.5-beta.8",
+    "version": "3.12.1-beta.0",
     "description": "Headless UI components for Alpine",
     "homepage": "https://alpinejs.dev/components#headless",
     "repository": {

+ 1 - 1
packages/ui/src/combobox.js

@@ -7,7 +7,7 @@ export default function (Alpine) {
         else if (directive.value === 'options')      handleOptions(el, Alpine)
         else if (directive.value === 'option')       handleOption(el, Alpine, directive, evaluate)
         else                                         handleRoot(el, Alpine)
-    })
+    }).before('bind')
 
     Alpine.magic('comboboxOption', el => {
         let $data = Alpine.$data(el)

+ 1 - 1
packages/ui/src/disclosure.js

@@ -4,7 +4,7 @@ export default function (Alpine) {
         if      (! directive.value)            handleRoot(el, Alpine)
         else if (directive.value === 'panel')  handlePanel(el, Alpine)
         else if (directive.value === 'button') handleButton(el, Alpine)
-    })
+    }).before('bind')
 
     Alpine.magic('disclosure', el => {
         let $data = Alpine.$data(el)

+ 1 - 1
packages/ui/src/listbox.js

@@ -7,7 +7,7 @@ export default function (Alpine) {
         else if (directive.value === 'button') handleButton(el, Alpine)
         else if (directive.value === 'options') handleOptions(el, Alpine)
         else if (directive.value === 'option') handleOption(el, Alpine)
-    })
+    }).before('bind')
 
     Alpine.magic('listbox', (el) => {
         let data = Alpine.$data(el)

+ 1 - 1
packages/ui/src/menu.js

@@ -4,7 +4,7 @@ export default function (Alpine) {
         else if (directive.value === 'items') handleItems(el, Alpine)
         else if (directive.value === 'item') handleItem(el, Alpine)
         else if (directive.value === 'button') handleButton(el, Alpine)
-    });
+    }).before('bind')
 
     Alpine.magic('menuItem', el => {
         let $data = Alpine.$data(el)

+ 1 - 1
packages/ui/src/radio.js

@@ -5,7 +5,7 @@ export default function (Alpine) {
         else if (directive.value === 'option')      handleOption(el, Alpine)
         else if (directive.value === 'label')       handleLabel(el, Alpine)
         else if (directive.value === 'description') handleDescription(el, Alpine)
-    })
+    }).before('bind')
 
     Alpine.magic('radioOption', el => {
         let $data = Alpine.$data(el)

+ 1 - 1
packages/ui/src/switch.js

@@ -5,7 +5,7 @@ export default function (Alpine) {
         else if (directive.value === 'label')       handleLabel(el, Alpine)
         else if (directive.value === 'description') handleDescription(el, Alpine)
         else                                        handleRoot(el, Alpine)
-    })
+    }).before('bind')
 
     Alpine.magic('switch', el => {
         let $data = Alpine.$data(el)

+ 1 - 1
packages/ui/src/tabs.js

@@ -6,7 +6,7 @@ export default function (Alpine) {
         else if (directive.value === 'tab')        handleTab(el, Alpine)
         else if (directive.value === 'panels')     handlePanels(el, Alpine)
         else if (directive.value === 'panel')      handlePanel(el, Alpine)
-    })
+    }).before('bind')
 
     Alpine.magic('tab', el => {
         let $data = Alpine.$data(el)

+ 6 - 17
scripts/build.js

@@ -1,6 +1,6 @@
+let { writeToPackageDotJson, getFromPackageDotJson } = require('./utils');
 let fs = require('fs');
-let DotJson = require('dot-json');
-let brotliSize = require('brotli-size');
+let zlib = require('zlib');
 
 ([
     // Packages:
@@ -37,7 +37,7 @@ function bundleFile(package, file) {
                 outfile: `packages/${package}/dist/${file}`,
                 bundle: true,
                 platform: 'browser',
-                define: { CDN: true },
+                define: { CDN: 'true' },
             })
 
             // Build a minified version.
@@ -47,7 +47,7 @@ function bundleFile(package, file) {
                 bundle: true,
                 minify: true,
                 platform: 'browser',
-                define: { CDN: true },
+                define: { CDN: 'true' },
             }).then(() => {
                 outputSize(package, `packages/${package}/dist/${file.replace('.js', '.min.js')}`)
             })
@@ -86,26 +86,15 @@ function build(options) {
     options.define['process.env.NODE_ENV'] = process.argv.includes('--watch') ? `'production'` : `'development'`
 
     return require('esbuild').build({
+        logLevel: process.argv.includes('--watch') ? 'info' : 'warning',
         watch: process.argv.includes('--watch'),
         // external: ['alpinejs'],
         ...options,
     }).catch(() => process.exit(1))
 }
 
-function writeToPackageDotJson(package, key, value) {
-    let dotJson = new DotJson(`./packages/${package}/package.json`)
-
-    dotJson.set(key, value).save()
-}
-
-function getFromPackageDotJson(package, key) {
-    let dotJson = new DotJson(`./packages/${package}/package.json`)
-
-    return dotJson.get(key)
-}
-
 function outputSize(package, file) {
-    let size = bytesToSize(brotliSize.sync(fs.readFileSync(file)))
+    let size = bytesToSize(zlib.brotliCompressSync(fs.readFileSync(file)).length)
 
     console.log("\x1b[32m", `${package}: ${size}`)
 }

+ 31 - 0
tests/cypress/integration/clone.spec.js

@@ -97,3 +97,34 @@ test('wont register listeners on clone',
         get('#copy span').should(haveText('1'))
     }
 )
+
+test('wont register extra listeners on x-model on clone',
+    html`
+        <script>
+            document.addEventListener('alpine:initialized', () => {
+                window.original = document.getElementById('original')
+                window.copy = document.getElementById('copy')
+            })
+        </script>
+
+        <button x-data @click="Alpine.clone(original, copy)">click</button>
+
+        <div x-data="{ checks: [] }" id="original">
+            <input type="checkbox" x-model="checks" value="1">
+            <span x-text="checks"></span>
+        </div>
+
+        <div x-data="{ checks: [] }" id="copy">
+            <input type="checkbox" x-model="checks" value="1">
+            <span x-text="checks"></span>
+        </div>
+    `,
+    ({ get }) => {
+        get('#original span').should(haveText(''))
+        get('#copy span').should(haveText(''))
+        get('button').click()
+        get('#copy span').should(haveText(''))
+        get('#copy input').click()
+        get('#copy span').should(haveText('1'))
+    }
+)

+ 3 - 3
tests/cypress/integration/directives/x-bind-style.spec.js

@@ -40,7 +40,7 @@ test('style attribute object binding with CSS variable',
         </div>
     `,
     ({ get }) => {
-        get('div').should(haveAttribute('style', '--MyCSS-Variable:0.25;'))
+        get('div').should(haveAttribute('style', '--MyCSS-Variable: 0.25;'))
     }
 )
 
@@ -62,7 +62,7 @@ test('CSS custom properties are set',
         </div>
     `,
     ({ get }) => {
-        get('span').should(haveAttribute('style', 'color: var(--custom-prop); --custom-prop:#f00;'))
+        get('span').should(haveAttribute('style', 'color: var(--custom-prop); --custom-prop: #f00;'))
     }
 )
 
@@ -73,6 +73,6 @@ test('existing CSS custom properties are preserved',
         </div>
     `,
     ({ get }) => {
-        get('span').should(haveAttribute('style', 'color: var(--custom-prop-b); --custom-prop-a: red; --custom-prop-b:var(--custom-prop-a);'))
+        get('span').should(haveAttribute('style', 'color: var(--custom-prop-b); --custom-prop-a: red; --custom-prop-b: var(--custom-prop-a);'))
     }
 )

+ 23 - 4
tests/cypress/integration/directives/x-bind.spec.js

@@ -1,4 +1,4 @@
-import { beHidden, beVisible, haveText, beChecked, haveAttribute, haveClasses, haveValue, notBeChecked, notHaveAttribute, notHaveClasses, test, html } from '../../utils'
+import { beHidden, beVisible, haveText, beChecked, haveAttribute, haveClasses, haveProperty, haveValue, notBeChecked, notHaveAttribute, notHaveClasses, test, html } from '../../utils';
 
 test('sets attribute bindings on initialize',
     html`
@@ -391,8 +391,8 @@ test('x-bind object syntax event handlers defined as functions receive the event
         <script>
             window.data = () => { return {
                 button: {
-                    ['@click']() {
-                        this.$refs.span.innerText = this.$el.id
+                    ['@click'](event) {
+                        this.$refs.span.innerText = event.currentTarget.id
                     }
                 }
             }}
@@ -410,7 +410,7 @@ test('x-bind object syntax event handlers defined as functions receive the event
     }
 )
 
-test('x-bind object syntax event handlers defined as functions receive the event object as their first argument',
+test('x-bind object syntax event handlers defined as functions receive element bound magics',
     html`
         <script>
             window.data = () => { return {
@@ -452,3 +452,22 @@ test('Can retrieve Alpine bound data with global bound method',
         get('#6').should(haveText('bar'))
     }
 )
+
+test('x-bind updates checked attribute and property after user interaction',
+    html`
+        <div x-data="{ checked: true }">
+            <button @click="checked = !checked">toggle</button>
+            <input type="checkbox" x-bind:checked="checked" @change="checked = $event.target.checked" />
+        </div>
+    `,
+    ({ get }) => {
+        get('input').should(haveAttribute('checked', 'checked'))
+        get('input').should(haveProperty('checked', true))
+        get('input').click()
+        get('input').should(notHaveAttribute('checked'))
+        get('input').should(haveProperty('checked', false))
+        get('button').click()
+        get('input').should(haveAttribute('checked', 'checked'))
+        get('input').should(haveProperty('checked', true))
+    }
+)

+ 46 - 0
tests/cypress/integration/directives/x-for.spec.js

@@ -559,3 +559,49 @@ test('renders children using directives injected by x-html correctly',
         get('p:nth-of-type(2) span').should(haveText('bar'))
     }
 )
+
+test(
+    'handles x-data directly inside x-for',
+    html`
+        <div x-data="{ items: [{x:0, k:1},{x:1, k:2}] }">
+            <button x-on:click="items = [{x:3, k:1},{x:4, k:2}]">update</button>
+            <template x-for="item in items" :key="item.k">
+                <div :id="'item-' + item.k" x-data="{ inner: true }">
+                    <span x-text="item.x.toString()"></span>:
+                    <span x-text="item.k"></span>
+                </div>
+            </template>
+        </div>
+    `,
+    ({ get }) => {
+        get('#item-1 span:nth-of-type(1)').should(haveText('0'))
+        get('#item-2 span:nth-of-type(1)').should(haveText('1'))
+        get('button').click()
+        get('#item-1 span:nth-of-type(1)').should(haveText('3'))
+        get('#item-2 span:nth-of-type(1)').should(haveText('4'))
+})
+
+test('x-for throws descriptive error when key is undefined',
+    html`
+        <div x-data="{ items: [
+            {
+                id: 1,
+                name: 'foo',
+            },
+            {
+                id: 2,
+                name: 'bar',
+            },
+            {
+                id: 3,
+                name: 'baz',
+            },
+        ]}">
+            <template x-for="item in items" :key="item.doesntExist">
+                <span x-text="i"></span>
+            </template>
+        </div>
+    `,
+    ({ get }) => {},
+    true
+)

+ 37 - 0
tests/cypress/integration/directives/x-if.spec.js

@@ -73,3 +73,40 @@ test('x-if removed dom does not evaluate reactive expressions in dom tree',
         get('span').should(notExist())
     }
 )
+
+// Attempting to skip an already-flushed reactive effect would cause inconsistencies when updating other effects.
+// See https://github.com/alpinejs/alpine/issues/2803 for more details.
+test('x-if removed dom does not attempt skipping already-processed reactive effects in dom tree',
+    html`
+    <div x-data="{
+        isEditing: true,
+        foo: 'random text',
+        stopEditing() {
+          this.foo = '';
+          this.isEditing = false;
+        },
+    }">
+        <button @click="stopEditing">Stop editing</button>
+        <template x-if="isEditing">
+            <div id="div-editing">
+              <h2>Editing</h2>
+              <input id="foo" name="foo" type="text" x-model="foo" />
+            </div>
+        </template>
+
+        <template x-if="!isEditing">
+            <div id="div-not-editing"><h2>Not editing</h2></div>
+        </template>
+
+        <template x-if="!isEditing">
+            <div id="div-also-not-editing"><h2>Also not editing</h2></div>
+        </template>
+    </div>
+    `,
+    ({ get }) => {
+        get('button').click()
+        get('div#div-editing').should(notExist())
+        get('div#div-not-editing').should(exist())
+        get('div#div-also-not-editing').should(exist())
+    }
+)

+ 59 - 0
tests/cypress/integration/directives/x-model.spec.js

@@ -129,3 +129,62 @@ test('x-model updates value when the form is reset',
         get('span').should(haveText(''))
     }
 )
+
+test('x-model with fill modifier takes input value on null or empty string',
+    html`
+    <div x-data="{ a: 123, b: 0, c: '', d: null }">
+      <input x-model.fill="a" value="123456" />
+      <span id="a" x-text="a"></span>
+      <input x-model.fill="b" value="123456" />
+      <span id="b" x-text="b"></span>
+      <input x-model.fill="c" value="123456" />
+      <span id="c" x-text="c"></span>
+      <input x-model.fill="d" value="123456" />
+      <span id="d" x-text="d"></span>
+    </div>
+    `,
+    ({ get }) => {
+        get('#a').should(haveText('123'))
+        get('#b').should(haveText('0'))
+        get('#c').should(haveText('123456'))
+        get('#d').should(haveText('123456'))
+    }
+)
+
+test('x-model with fill modifier works with select/radio elements',
+    html`
+        <div x-data="{ a: null, b: null, c: null, d: null }">
+            <select x-model.fill="a">
+                <option value="123">123</option>
+                <option value="456" selected>456</option>
+            </select>
+            <select x-model.fill="b" multiple>
+                <option value="123" selected>123</option>
+                <option value="456" selected>456</option>
+            </select>
+        </div>
+    `,
+    ({ get }) => {
+        get('[x-data]').should(haveData('a', '456'));
+        get('[x-data]').should(haveData('b', ['123', '456']));
+    }
+);
+
+test('x-model with fill modifier respects number modifier',
+    html`
+        <div x-data="{ a: null, b: null, c: null, d: null }">
+            <input type="text" x-model.fill.number="a" value="456" / >
+            <select x-model.fill.number="b" multiple>
+                <option value="123" selected>123</option>
+                <option value="456" selected>456</option>
+            </select>
+        </div>
+    `,
+    ({ get }) => {
+        get('[x-data]').should(haveData('a', 456));
+        get('[x-data]').should(haveData('b', [123,456]));
+    }
+);
+
+
+

+ 68 - 2
tests/cypress/integration/directives/x-on.spec.js

@@ -97,10 +97,30 @@ test('.stop modifier',
     }
 )
 
-test('.capture modifier',
+
+test('.stop modifier with a .throttle',
     html`
         <div x-data="{ foo: 'bar' }">
-            <button @click.capture="foo = 'baz'">
+            <button x-on:click="foo = 'baz'">
+                <h1>h1</h1>
+                <h2 @click.stop.throttle>h2</h2>
+            </button>
+        </div>
+    `,
+    ({ get }) => {
+        get('div').should(haveData('foo', 'bar'))
+        get('h2').click()
+        get('h2').click()
+        get('div').should(haveData('foo', 'bar'))
+        get('h1').click()
+        get('div').should(haveData('foo', 'baz'))
+    }
+)
+
+test('.capture modifier',
+    html`
+        <div x-data="{ foo: 'bar', count: 0 }">
+            <button @click.capture="count = count + 1; foo = 'baz'">
                 <h1>h1</h1>
                 <h2 @click="foo = 'bob'">h2</h2>
             </button>
@@ -110,6 +130,39 @@ test('.capture modifier',
         get('div').should(haveData('foo', 'bar'))
         get('h2').click()
         get('div').should(haveData('foo', 'bob'))
+        get('div').should(haveData('count', 1))
+    }
+)
+
+test('.capture modifier with @keyup',
+    html`
+        <div x-data="{ foo: 'bar', count: 0 }">
+            <span @keyup.capture="count = count + 1; foo = 'span'">
+                <input type="text" @keyup="foo = 'input'">
+            </span>
+        </div>
+    `,
+    ({ get }) => {
+        get('div').should(haveData('foo', 'bar'))
+        get('input').type('f')
+        get('div').should(haveData('foo', 'input'))
+        get('div').should(haveData('count', 1))
+    }
+)
+
+test('.capture modifier with @keyup and specified key',
+    html`
+        <div x-data="{ foo: 'bar', count: 0 }">
+            <span @keyup.enter.capture="count = count + 1; foo = 'span'">
+                <input type="text" @keyup.enter="foo = 'input'">
+            </span>
+        </div>
+    `,
+    ({ get }) => {
+        get('div').should(haveData('foo', 'bar'))
+        get('input').type('{enter}')
+        get('div').should(haveData('foo', 'input'))
+        get('div').should(haveData('count', 1))
     }
 )
 
@@ -145,6 +198,19 @@ test('.prevent modifier',
     }
 )
 
+test('.prevent modifier with a .debounce',
+    html`
+        <div x-data="{}">
+            <input type="checkbox" x-on:click.prevent.debounce>
+        </div>
+    `,
+    ({ get }) => {
+        get('input').check()
+        get('input').check()
+        get('input').should(notBeChecked())
+    }
+)
+
 test('.window modifier',
     html`
         <div x-data="{ foo: 'bar' }">

+ 60 - 0
tests/cypress/integration/directives/x-transition.spec.js

@@ -80,3 +80,63 @@ test('transition:enter in nested x-show visually runs',
         get('h1').should(haveComputedStyle('opacity', '1')) // Eventually opacity will be 1
     }
 )
+
+test('transition duration and delay with and without ms syntax',
+    html`
+        <div x-data="{ showMs: false, showBlank: false }">
+
+            <span class="ms"
+                  x-show="showMs"
+                  x-transition.delay.80ms.duration.120ms>ms syntax</span>
+            <span class="blank"
+                  x-show="showBlank"
+                  x-transition.delay.80.duration.120>blank syntax</span>
+
+            <button class="ms"    x-on:click="showMs = true"></button>
+            <button class="blank" x-on:click="showBlank = true"></button>
+        </div>
+    `,
+    ({ get }) => {
+        get('span.ms').should(notBeVisible())
+        get('button.ms').click()
+        get('span.ms').should(notBeVisible()) // Not visible due to delay
+        get('span.ms').should(beVisible())
+        get('span.ms').should(notHaveComputedStyle('opacity', '1')) // We expect a value between 0 and 1
+        get('span.ms').should(haveComputedStyle('opacity', '1')) // Eventually opacity will be 1
+
+        get('span.blank').should(notBeVisible())
+        get('button.blank').click()
+        get('span.blank').should(notBeVisible()) // Not visible due to delay
+        get('span.blank').should(beVisible())
+        get('span.blank').should(notHaveComputedStyle('opacity', '1')) // We expect a value between 0 and 1
+        get('span.blank').should(haveComputedStyle('opacity', '1')) // Eventually opacity will be 1
+    }
+)
+
+test(
+    'bound x-transition can handle empty string and true values',
+    html`
+        <script>
+            window.transitions = () => {
+                return {
+                    withEmptyString: {
+                        ["x-transition.opacity"]: "",
+                    },
+                    withBoolean: {
+                        ["x-transition.opacity"]: true,
+                    },
+                };
+            };
+        </script>
+        <div x-data="transitions()">
+            <button x-bind="withEmptyString"></button>
+            <span x-bind="withBoolean">thing</span>
+        </div>
+    `,
+    ({ get }) => 
+        {
+            get('span').should(beVisible())
+            get('span').should(beVisible())
+        }
+    
+);

+ 2 - 2
tests/cypress/integration/entangle.spec.js

@@ -1,6 +1,6 @@
 import { haveValue, html, test } from '../utils'
 
-test('can entangle to getter/setter pairs',
+test.skip('can entangle to getter/setter pairs',
     [html`
     <div x-data="{ outer: 'foo' }">
         <input x-model="outer" outer>
@@ -33,7 +33,7 @@ test('can entangle to getter/setter pairs',
     }
 )
 
-test('can release entanglement',
+test.skip('can release entanglement',
     [html`
         <div x-data="{ outer: 'foo' }">
             <input x-model="outer" outer>

+ 45 - 0
tests/cypress/integration/plugins/mask.spec.js

@@ -60,6 +60,19 @@ test('x-mask with x-model',
     },
 )
 
+test('x-mask with x-model with initial value',
+    [html`
+        <div x-data="{ value: '1234567890' }">
+            <input x-mask="(999) 999-9999" x-model="value" id="1">
+            <input id="2" x-model="value">
+        </div>
+    `],
+    ({ get }) => {
+        get('#1').should(haveValue('(123) 456-7890'))
+        get('#2').should(haveValue('(123) 456-7890'))
+    },
+)
+
 test('x-mask with a falsy input',
     [html`<input x-data x-mask="">`],
     ({ get }) => {
@@ -178,3 +191,35 @@ test('$money mask should remove letters or non numeric characters',
         get('input').type('40').should(haveValue('40'))
     }
 )
+
+test('$money mask negative values',
+    [html`
+        <input id="1" x-data x-mask:dynamic="$money($input)" value="-1234.50" />
+        <input id="2" x-data x-mask:dynamic="$money($input)" />
+    `],
+    ({ get }) => {
+        get('#1').should(haveValue('-1,234.50'))
+        get('#2').type('-12.509').should(haveValue('-12.50'))
+        get('#2').type('{leftArrow}{leftArrow}{leftArrow}-').should(haveValue('-12.50'))
+        get('#2').type('{leftArrow}{leftArrow}{backspace}').should(haveValue('12.50'))
+        get('#2').type('{rightArrow}-').should(haveValue('12.50'))
+        get('#2').type('{rightArrow}-').should(haveValue('12.50'))
+        get('#2').type('{rightArrow}{rightArrow}{rightArrow}-').should(haveValue('12.50'))
+        get('#2').type('{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}-').should(haveValue('-12.50'))
+    }
+)
+
+test('$money with custom decimal precision',
+    [html`
+        <input id="0" x-data x-mask:dynamic="$money($input, '.', ',', 0)" />
+        <input id="1" x-data x-mask:dynamic="$money($input, '.', ',', 1)" />
+        <input id="2" x-data x-mask:dynamic="$money($input, '.', ',', 2)" />
+        <input id="3" x-data x-mask:dynamic="$money($input, '.', ',', 3)" />
+    `],
+    ({ get }) => {
+        get('#0').type('1234.5678').should(haveValue('12,345,678'))
+        get('#1').type('1234.5678').should(haveValue('1,234.5'))
+        get('#2').type('1234.5678').should(haveValue('1,234.56'))
+        get('#3').type('1234.5678').should(haveValue('1,234.567'))
+    }
+)

+ 20 - 0
tests/cypress/integration/plugins/morph.spec.js

@@ -421,3 +421,23 @@ test('can morph with flat-nested conditional markers',
         get('div:nth-of-type(3) input').should(haveValue('bar'))
     },
 )
+
+// '@event' handlers cannot be assigned directly on the element without Alpine's internl monkey patching...
+test('can morph @event handlers', [
+    html`
+        <div x-data="{ foo: 'bar' }">
+            <button x-text="foo"></button>
+        </div>
+    `],
+    ({ get, click }, reload, window, document) => {
+        let toHtml = html`
+            <button @click="foo = 'buzz'" x-text="foo"></button>
+        `;
+
+        get('button').should(haveText('bar'));
+
+        get('button').then(([el]) => window.Alpine.morph(el, toHtml));
+        get('button').click();
+        get('button').should(haveText('buzz'));
+    }
+);

+ 1 - 1
tests/cypress/integration/plugins/navigate.spec.js

@@ -1,6 +1,6 @@
 import { beEqualTo, beVisible, haveAttribute, haveFocus, haveText, html, notBeVisible, test } from '../../utils'
 
-// Test persistant peice of layout
+// Test persistent piece of layout
 // Handle non-origin links and such
 // Handle 404
 // Middle/command click link in new tab works?

+ 1 - 1
tests/cypress/integration/plugins/ui/popover.spec.js

@@ -156,7 +156,7 @@ test('focusing away doesnt close panel if focusing inside a group',
     },
 )
 
-test('focusing away still closes panel inside a group if the focus attribute is present',
+test.retry(5)('focusing away still closes panel inside a group if the focus attribute is present',
     [html`
         <div x-data>
             <div x-popover:group>

+ 3 - 1
tests/cypress/utils.js

@@ -87,7 +87,7 @@ function injectHtmlAndBootAlpine(cy, templateAndPotentiallyScripts, callback, pa
     })
 }
 
-export let haveData = (key, value) => ([ el ]) => expect(root(el)._x_dataStack[0][key]).to.equal(value)
+export let haveData = (key, value) => ([el]) => expect(root(el)._x_dataStack[0][key]).to.deep.equal(value);
 
 export let haveFocus = () => el => expect(el).to.have.focus
 
@@ -97,6 +97,8 @@ export let haveAttribute = (name, value) => el => expect(el).to.have.attr(name,
 
 export let notHaveAttribute = (name, value) => el => expect(el).not.to.have.attr(name, value)
 
+export let haveProperty = (name, value) => el => expect(el).to.have.prop(name, value)
+
 export let haveText = text => el => expect(el).to.have.text(text)
 
 export let notHaveText = text => el => expect(el).not.to.have.text(text)

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini