Skip to content

Commit

Permalink
Make x-model="$model" work by default and tweak docs
Browse files Browse the repository at this point in the history
  • Loading branch information
calebporzio committed Dec 11, 2023
1 parent c1409a8 commit fa9b39a
Show file tree
Hide file tree
Showing 4 changed files with 66 additions and 139 deletions.
18 changes: 14 additions & 4 deletions packages/alpinejs/src/directives/x-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,28 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
evaluateSet = () => {}
}

let getValue = () => {
let getResult = () => {
let result

evaluateGet(value => result = value)

// The following code prevents an infinite loop when using:
// x-model="$model" by retreiving an x-model higher in the tree...
if (result._x_modelAccessor) {
return result._x_modelAccessor.closest
}

return result
}

let getValue = () => {
let result = getResult()

return isGetterSetter(result) ? result.get() : result
}

let setValue = value => {
let result

evaluateGet(value => result = value)
let result = getResult()

if (isGetterSetter(result)) {
result.set(value)
Expand Down
26 changes: 18 additions & 8 deletions packages/alpinejs/src/magics/$model.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ import { magic } from '../magics'
import { reactive } from '../reactivity'

magic('model', (el, { cleanup }) => {
let func = generateModelAccessor(el.parentElement, cleanup)
let func = generateModelAccessor(el, cleanup)

Object.defineProperty(func, 'self', { get() {
return accessor(generateModelAccessor(el, cleanup))
Object.defineProperty(func, 'closest', { get() {
let func = generateModelAccessor(el.parentElement, cleanup)

func._x_modelAccessor = true

return accessor(func)
}, })

func._x_modelAccessor = true

return accessor(func)
})

Expand All @@ -27,19 +33,23 @@ function generateModelAccessor(el, cleanup) {
return fallbackStateInitialValue
}

accessor.exists = () => {
return !! closestModelEl
let model = () => {
if (! closestModelEl) {
throw 'Cannot find an available x-model directive to reference from $model.'
}

return closestModelEl._x_model
}

accessor.get = () => {
return closestModelEl._x_model.get()
return model().get()
}

accessor.set = (value) => {
if (typeof value === 'function') {
closestModelEl._x_model.set(value(accessor.get()))
model().set(value(accessor.get()))
} else {
closestModelEl._x_model.set(value)
model().set(value)
}
}

Expand Down
91 changes: 30 additions & 61 deletions packages/docs/src/en/magics/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ title: 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`.
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 getters and setters

Here's a simple example of using `$model` to access and control the `count` property using `$model.get()` and `$model.set(...)`:

```alpine
<div x-data="{ count: 0 }">
Expand All @@ -34,70 +38,15 @@ Here's a simple example of using `$model` to access and control the `count` prop

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
### 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>
<button @click="$model.set(count => count + 1)">Increment</button>
```

## 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.

## Using $model within x-data
## Binding to x-data properties

Rather than manually controlling the `x-model` value using `$model.get()` and `$model.set()`, you can alternatively use `$model` as an entirely new value inside `x-data`.

Expand All @@ -115,6 +64,8 @@ For example:

This way you can freely use and modify the newly defined property `value` property within the nested component and `.get()` and `.set()` will be called internally.

> You may run into errors when using `$model` within `x-data` on the same element as the `x-model` you are trying to reference. This is because `x-data` is evaluated by Alpine before `x-model` is. In these cases, you must either ensure `x-model` is on a parent element of `x-data`, or you are deffering evaluation with `this.$nextTick` (or a similar strategy).
### Passing fallback state to $model

In scenarios where you aren't sure if a parent `x-model` exists or you want to make `x-model` optional, you can pass initial state to `$model` as a function parameter.
Expand All @@ -123,12 +74,30 @@ The following example will use the provided fallback value as the state if no `x

```alpine
<div>
<div x-data="{ value: $model(1) }">
<div x-data="{ value: $model(0) }">
<button @click="value = value + 1">Increment</button>
Count: <span x-text="value"></span>
</div>
</div>
```

In the above example you can see that there is no `x-model` defined in the parent HTML heirarchy. When `$model(1)` is called, it will recognize this and instead pass through the initial state as a reactive value.
In the above example you can see that there is no `x-model` defined in the parent HTML heirarchy. When `$model(0)` is called, it will recognize this and instead pass through the initial state as a reactive value.

## 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">
<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.
70 changes: 4 additions & 66 deletions tests/cypress/integration/magics/$model.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,68 +63,6 @@ test('$model can be used with a getter and setter',
}
)

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">
Expand Down Expand Up @@ -168,13 +106,13 @@ test('$model can be used on the same element as the corresponding x-model',
return {
internalValue: 'bob',
get value() {
if (this.$model.self) return this.$model.self.get()
if (this.$model) return this.$model.get()
return this.internalValue
},
set value(value) {
if (this.$model.self) {
this.$model.self.set(value)
if (this.$model) {
this.$model.set(value)
} else {
this.internalValue = value
}
Expand Down Expand Up @@ -258,7 +196,7 @@ test('$model can be used as a getter/setter pair in x-data on the same element w
Alpine.bind(el, {
'x-data'() {
return {
value: this.$model.self
value: this.$model
}
}
})
Expand Down

0 comments on commit fa9b39a

Please sign in to comment.