Skip to content

Commit

Permalink
Enable x-model CustomEvent listening on .detail
Browse files Browse the repository at this point in the history
  • Loading branch information
calebporzio committed Jan 23, 2020
1 parent d317832 commit c31add7
Show file tree
Hide file tree
Showing 8 changed files with 97 additions and 27 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ And 3 magic properties:
| --- |
| [`$el`](#el) |
| [`$refs`](#refs) |
| [`$dispatch`](#dispatch) |
| [`$nextTick`](#nexttick) |

### Directives
Expand Down Expand Up @@ -415,6 +416,32 @@ These behave exactly like VueJs's transition directives, except they have differ

---

### `$dispatch`
**Example:**
```html
<div @custom-event="console.log($event.detail.foo)">
<button @click="$dispatch('custom-event', { foo: 'bar' })">
<!-- When clicked, will console.log "bar" -->
</div>
```

`$dispatch` is a shortcut for creating a `CustomEvent` and dispatching it using `.dispatchEvent()` internally. There are lots of good use cases for passing data around and between components using custom events. [Read here](https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Creating_and_triggering_events) for more information on the underlying `CustomEvent` system in browsers.

You will notice that any data passed as the second parameter to `$dispatch('some-event', { some: 'data' })`, becomes available through the new events "detail" property: `$event.detail.some`. Attaching custom event data to the `.detail` property is standard practice for `CustomEvent`s in browsers. [Read here](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/detail) for more info.

You can also use `$dispatch()` to trigger data updates for `x-model` bindings. For example:

```html
<div x-data="{ foo: 'bar' }">
<span x-model="foo">
<button @click="$dispatch('input', 'baz')">
<!-- After the button is clicked, `x-model` will catch the bubbling "input" event, and update foo to "baz". -->
</span>
</div>
```

---

### `$nextTick`
**Example:**
```html
Expand Down
2 changes: 1 addition & 1 deletion dist/alpine.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/alpine.js.map

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default {
resolve(),
filesize(),
terser({
mangle: false,
compress: {
drop_debugger: false,
},
Expand Down
2 changes: 1 addition & 1 deletion src/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default class Component {
// We want to allow data manipulation, but not trigger DOM updates just yet.
// We haven't even initialized the elements with their Alpine bindings. I mean c'mon.
this.pauseReactivity = true
initReturnedCallback = saferEval(this.$el.getAttribute('x-init'), this.$data)
initReturnedCallback = this.evaluateReturnExpression(this.$el, initExpression)
this.pauseReactivity = false
}

Expand Down
54 changes: 30 additions & 24 deletions src/directives/model.js
Original file line number Diff line number Diff line change
@@ -1,43 +1,49 @@
import { registerListener } from './on'

export function registerModelListener(component, el, modifiers, expression, extraVars = {}) {
export function registerModelListener(component, el, modifiers, expression, extraVars) {
// If the element we are binding to is a select, a radio, or checkbox
// we'll listen for the change event instead of the "input" event.
var event = (el.tagName.toLowerCase() === 'select')
|| ['checkbox', 'radio'].includes(el.type)
|| modifiers.includes('lazy')
? 'change' : 'input'

const listenerExpression = modelListenerExpression(component, el, modifiers, expression)
const listenerExpression = `${expression} = rightSideOfExpression($event, ${expression})`

registerListener(component, el, event, modifiers, listenerExpression, extraVars)
}

function modelListenerExpression(component, el, modifiers, dataKey) {
var rightSideOfExpression = ''
if (el.type === 'checkbox') {
// If the data we are binding to is an array, toggle it's value inside the array.
if (Array.isArray(component.$data[dataKey])) {
rightSideOfExpression = `$event.target.checked ? ${dataKey}.concat([$event.target.value]) : ${dataKey}.filter(i => i !== $event.target.value)`
} else {
rightSideOfExpression = `$event.target.checked`
registerListener(component, el, event, modifiers, listenerExpression, () => {
return {
...extraVars(),
rightSideOfExpression: generateModelAssignmentFunction(el, modifiers, expression),
}
} else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
rightSideOfExpression = modifiers.includes('number')
? 'Array.from($event.target.selectedOptions).map(option => { return parseFloat(option.value || option.text) })'
: 'Array.from($event.target.selectedOptions).map(option => { return option.value || option.text })'
} else {
rightSideOfExpression = modifiers.includes('number')
? 'parseFloat($event.target.value)'
: (modifiers.includes('trim') ? '$event.target.value.trim()' : '$event.target.value')
}
})
}

function generateModelAssignmentFunction(el, modifiers, expression) {
if (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
// they already set a shared "x-model" attribute.
if (! el.hasAttribute('name')) el.setAttribute('name', dataKey)
if (! el.hasAttribute('name')) el.setAttribute('name', expression)
}

return `${dataKey} = ${rightSideOfExpression}`
return (event, currentValue) => {
if (event instanceof CustomEvent) {
return event.detail
} else if (el.type === 'checkbox') {
// If the data we are binding to is an array, toggle it's value inside the array.
if (Array.isArray(currentValue)) {
return event.target.checked ? currentValue.concat([event.target.value]) : currentValue.filter(i => i !== event.target.value)
} else {
return event.target.checked
}
} else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
return modifiers.includes('number')
? Array.from(event.target.selectedOptions).map(option => { return parseFloat(option.value || option.text) })
: Array.from(event.target.selectedOptions).map(option => { return option.value || option.text })
} else {
return modifiers.includes('number')
? parseFloat(event.target.value)
: (modifiers.includes('trim') ? event.target.value.trim() : event.target.value)
}
}
}
16 changes: 16 additions & 0 deletions test/lifecycle.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,19 @@ test('callbacks registered within x-mounted can affect reactive data changes', a

await wait(() => { expect(document.querySelector('span').innerText).toEqual('bob') })
})

test('x-init is capable of dispatching an event', async () => {
document.body.innerHTML = `
<div x-data="{ foo: 'bar' }" @update-foo="foo = $event.detail.foo">
<div x-data x-init="$dispatch('update-foo', { foo: 'baz' })"></div>
<span x-text="foo"></span>
</div>
`

Alpine.start()

await wait(() => {
expect(document.querySelector('span').innerText).toEqual('baz')
})
})
20 changes: 20 additions & 0 deletions test/model.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,23 @@ test('x-model undefined nested model key defaults to empty string', async () =>
expect(document.querySelector('span').innerText).toEqual('bar')
})
})

test('x-model can listen for custom input event dispatches', async () => {
document.body.innerHTML = `
<div x-data="{ foo: 'bar' }" x-model="foo">
<button @click="$dispatch('input', 'baz')"></button>
<span x-text="foo"></span>
</div>
`

Alpine.start()

expect(document.querySelector('span').innerText).toEqual('bar')

document.querySelector('button').click()

await wait(() => {
expect(document.querySelector('span').innerText).toEqual('baz')
})
})

0 comments on commit c31add7

Please sign in to comment.