Переглянути джерело

Add $model magic property

Caleb Porzio 1 рік тому
батько
коміт
dabb72e429

+ 8 - 2
packages/alpinejs/src/directives/x-model.js

@@ -7,7 +7,7 @@ import on from '../utils/on'
 import { warn } from '../utils/warn'
 import { isCloning } from '../clone'
 
-directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
+function handler(el, { modifiers, expression }, { effect, cleanup }) {
     let scopeTarget = el
 
     if (modifiers.includes('parent')) {
@@ -126,7 +126,13 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
 
         el._x_forceModelUpdate(value)
     })
-})
+}
+
+handler.inline = (el, { expression }, { cleanup }) => {
+    el._x_model = { get() {}, set() {} }
+}
+
+directive('model', handler)
 
 function getInputValue(el, modifiers, event, currentValue) {
     return mutateDom(() => {

+ 45 - 0
packages/alpinejs/src/magics/$model.js

@@ -0,0 +1,45 @@
+import { findClosest } from '../lifecycle'
+import { magic } from '../magics'
+
+magic('model', (el, { cleanup }) => {
+    let accessor = generateModelAccessor(el.parentElement, cleanup)
+
+    Object.defineProperty(accessor, 'self', { get() {
+        return generateModelAccessor(el, cleanup)
+    }, })
+
+    return accessor
+})
+
+function generateModelAccessor(el, cleanup) {
+    // Find the closest element with x-model on it, NOT including the current element...
+    let closestModelEl = findClosest(el, i => {
+        if (i._x_model) return true
+    })
+
+    // Instead of simply returning the get/set object, we'll create a wrapping function
+    // so that we have the option to add additional APIs without breaking anything...
+    let accessor = function () {}
+
+    accessor.exists = () => {
+        return !! closestModelEl
+    }
+
+    accessor.get = () => {
+        return closestModelEl._x_model.get()
+    }
+
+    accessor.set = (value) => {
+        if (typeof value === 'function') {
+            closestModelEl._x_model.set(value(accessor.get()))
+        } else {
+            closestModelEl._x_model.set(value)
+        }
+    }
+
+    accessor.watch = (callback) => {
+        cleanup(Alpine.watch(() => accessor.get(), callback))
+    }
+
+    return accessor
+}

+ 1 - 0
packages/alpinejs/src/magics/index.js

@@ -4,6 +4,7 @@ import { magic } from '../magics'
 import './$nextTick'
 import './$dispatch'
 import './$watch'
+import './$model'
 import './$store'
 import './$data'
 import './$root'

+ 98 - 0
packages/docs/src/en/magics/model.md

@@ -0,0 +1,98 @@
+---
+order: 10
+prefix: $
+title: model
+---
+
+# $model
+
+`$model` is a magic property that can be used to interact with the closest `x-model` binding programmatically.
+
+Here's a simple example of using `$model` to access and control the `count` property bound using `x-model`.
+
+```alpine
+<div x-data="{ count: 0 }">
+    <div x-model="count">
+        <button @click="$model.set($model.get() + 1)">Increment</button>
+
+        Count: <span x-text="$model.get()"></span>
+    </div>
+</div>
+```
+
+<!-- START_VERBATIM -->
+<div class="demo">
+    <div x-data="{ count: 0 }">
+        <div x-model="count">
+            <button @click="$model.set($model.get() + 1)">Increment</button>
+
+            Count: <span x-text="$model.get()"></span>
+        </div>
+    </div>
+</div>
+<!-- END_VERBATIM -->
+
+As you can see, `$model.get()` and `$model.set()` can be used to programmatically control the `count` property bound using `x-model`.
+
+Typically this feature would be used in conjunction with a backend templating framework like Blade in Laravel. It's useful for abstracting away Alpine components into backend templates and exposing control to the outside scope through `x-model`.
+
+## Using $model and x-model on the same element.
+
+It's important to note that by default, `$model` can only be used within children of `x-model`, not on the `x-model` element itself.
+
+For example, the following code won't work because `x-model` and `$model` are both used on the same element:
+
+```alpine
+<!-- The following code will throw an error... -->
+<div x-data="{ count: 0 }">
+    Count: <span x-model="count" x-text="$model.get()"></span>
+</div>
+```
+
+To remedy this, ensure that `x-model` is declared on a parent element of `$model` in the HTML tree:
+
+```alpine
+<div x-data="{ count: 0 }" x-model="count">
+    Count: <span x-text="$model.get()"></span>
+</div>
+```
+
+Alternatively, you can use the `.self` modifier to reference the `x-model` directive declared on the same element:
+
+```alpine
+<div x-data="{ count: 0 }">
+    Count: <span x-model="count" x-text="$model.self.get()"></span>
+</div>
+```
+
+## Setting values using a callback
+
+If you prefer, `$model` offers an alternate syntax that allows you to pass a callback to `.set()` that receives the current value and returns the next value:
+
+```alpine
+<div x-model="count">
+    <button @click="$model.set(count => count + 1)">Increment</button>
+
+    Count: <span x-text="$model.get()"></span>
+</div>
+```
+
+## Registering watchers
+
+Although Alpine provides other methods to watch reactive values for changes, `$model.watch()` exposes a convenient way to register a watcher for the `x-model` property directly:
+
+```alpine
+<div x-model="count">
+    <button @click="$model.set(count => count + 1)">Increment</button>
+
+    <div x-init="
+        $model.watch(count => {
+            console.log('The new count is: ' + count)
+        })
+    "></div>
+</div>
+```
+
+Now everytime `count` changes, the newest count value will be logged to the console.
+
+Watchers registered using `$watch` will be automatically destroyed when the element they are declared on is removed from the DOM.

+ 213 - 0
tests/cypress/integration/magics/$model.spec.js

@@ -0,0 +1,213 @@
+import { beSelected, haveText, html, notBeSelected, test } from '../../utils'
+
+test('$model allows you to interact with parent x-model bindings explicitly',
+    html`
+        <div x-data="{ foo: 'bar' }" x-model="foo">
+            <button @click="$model.set('baz')">click me</button>
+            <h1 x-text="$model.get()"></h1>
+            <h2 x-text="foo"></h2>
+        </div>
+    `,
+    ({ get }) => {
+        get('h1').should(haveText('bar'))
+        get('h2').should(haveText('bar'))
+        get('button').click()
+        get('h1').should(haveText('baz'))
+        get('h2').should(haveText('baz'))
+    }
+)
+
+test('$model accepts a callback when setting a value',
+    html`
+        <div x-data="{ foo: 'bar' }" x-model="foo">
+            <button @click="$model.set(i => i + 'r')">click me</button>
+            <h1 x-text="$model.get()"></h1>
+            <h2 x-text="foo"></h2>
+        </div>
+    `,
+    ({ get }) => {
+        get('h1').should(haveText('bar'))
+        get('h2').should(haveText('bar'))
+        get('button').click()
+        get('h1').should(haveText('barr'))
+        get('h2').should(haveText('barr'))
+    }
+)
+
+test('$model can be used with a getter and setter',
+    html`
+        <div x-data="{ foo: 'bar' }" x-model="foo">
+            <div x-data="{
+                get value() {
+                    return this.$model.get()
+                },
+                set value(value) {
+                    this.$model.set(value)
+                }
+            }">
+                <button @click="value = 'baz'">click me</button>
+                <h1 x-text="foo"></h1>
+                <h2 x-text="value"></h2>
+                <h3 x-text="$model.get()"></h3>
+            </div>
+        </div>
+    `,
+    ({ get }) => {
+        get('h1').should(haveText('bar'))
+        get('h2').should(haveText('bar'))
+        get('h3').should(haveText('bar'))
+        get('button').click()
+        get('h1').should(haveText('baz'))
+        get('h2').should(haveText('baz'))
+        get('h3').should(haveText('baz'))
+    }
+)
+
+test('$model can be used with optional internal state: with outer',
+    html`
+        <div x-data="{ foo: 'bar' }" x-model="foo">
+            <button @click="foo = 'baz'">click me</button>
+
+            <div x-data="{
+                internalValue: 'bob',
+                get value() {
+                    if (this.$model.exists()) return this.$model.get()
+
+                    return this.internalValue
+                },
+                set value(value) {
+                    if (this.$model.exists()) {
+                        this.$model.set(value)
+                    } else {
+                        this.internalValue = value
+                    }
+                }
+            }">
+                <h1 x-text="value"></h1>
+            </div>
+        </div>
+    `,
+    ({ get }) => {
+        get('h1').should(haveText('bar'))
+        get('button').click()
+        get('h1').should(haveText('baz'))
+    }
+)
+
+test('$model can be used with optional internal state: without outer',
+    html`
+        <div x-data>
+            <div x-data="{
+                internalValue: 'bar',
+                get value() {
+                    if (this.$model.exists()) return this.$model.get()
+
+                    return this.internalValue
+                },
+                set value(value) {
+                    if (this.$model.exists()) {
+                        this.$model.set(value)
+                    } else {
+                        this.internalValue = value
+                    }
+                }
+            }">
+                <button @click="value = 'baz'">click me</button>
+
+                <h1 x-text="value"></h1>
+            </div>
+        </div>
+    `,
+    ({ get }) => {
+        get('h1').should(haveText('bar'))
+        get('button').click()
+        get('h1').should(haveText('baz'))
+    }
+)
+
+test('$model can be used with another x-model',
+    html`
+        <div x-data="{ foo: 'bar' }" x-model="foo">
+            <select x-model="$model">
+                <option>bar</option>
+                <option>baz</option>
+            </select>
+
+            <h1 x-text="foo"></h1>
+            <h2 x-text="$model.get()"></h2>
+        </div>
+    `,
+    ({ get }) => {
+        get('h1').should(haveText('bar'))
+        get('h2').should(haveText('bar'))
+        get('option:first').should(beSelected())
+        get('option:last').should(notBeSelected())
+        get('select').
+        get('select').select('baz')
+        get('h1').should(haveText('baz'))
+        get('h2').should(haveText('baz'))
+        get('option:first').should(notBeSelected())
+        get('option:last').should(beSelected())
+    }
+)
+
+test('$model can be used on the same element as the corresponding x-model',
+    [html`
+        <div x-data="{ foo: 'bar' }">
+            <button @click="foo = 'baz'">click me</button>
+
+            <div x-test x-model="foo">
+                <h1 x-text="value"></h1>
+            </div>
+        </div>
+    `,
+    `
+        Alpine.directive('test', el => {
+            Alpine.bind(el, {
+                'x-data'() {
+                    return {
+                        internalValue: 'bob',
+                        get value() {
+                            if (this.$model.self) return this.$model.self.get()
+
+                            return this.internalValue
+                        },
+                        set value(value) {
+                            if (this.$model.self) {
+                                this.$model.self.set(value)
+                            } else {
+                                this.internalValue = value
+                            }
+                        }
+                    }
+                }
+            })
+        })
+    `],
+    ({ get }) => {
+        get('h1').should(haveText('bar'))
+        get('button').click()
+        get('h1').should(haveText('baz'))
+    }
+)
+
+test('$model can watch for changing values and watcher gets cleaned up on element removal',
+    html`
+        <div x-data="{ foo: 'bar' }" x-model="foo">
+            <button @click="$model.set('baz')">click me</button>
+            <h1 x-text="$model.get()"></h1>
+            <h2 x-init="$model.watch(newValue => $el.textContent = newValue)" x-on:click="$el.remove()"></h2>
+            <h3 x-on:click="$model.set('bob')">click me</h3>
+        </div>
+    `,
+    ({ get }) => {
+        get('h1').should(haveText('bar'))
+        get('h2').should(haveText(''))
+        get('button').click()
+        get('h1').should(haveText('baz'))
+        get('h2').should(haveText('baz'))
+        get('h2').click()
+        get('h3').click()
+        get('h1').should(haveText('bob'))
+    }
+)

+ 3 - 1
tests/cypress/utils.js

@@ -109,7 +109,9 @@ export let notContain = text => el => expect(el).not.to.contain(text)
 
 export let haveHtml = html => el => expect(el).to.have.html(html)
 
-export let beChecked = () => el => expect(el).to.be.checked
+export let beSelected = () => el => expect(el).to.be.selected
+
+export let notBeSelected = () => el => expect(el).not.to.be.selected
 
 export let notBeChecked = () => el => expect(el).not.to.be.checked