Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 40 additions & 10 deletions packages/alpinejs/src/directives/x-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
if (modifiers.includes('fill') && el.hasAttribute('value') && (getValue() === null || getValue() === '')) {
setValue(el.value)
}

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
Expand Down Expand Up @@ -139,26 +139,44 @@ function getInputValue(el, modifiers, event, currentValue) {
} 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
let newValue = null;

if (modifiers.includes('number')) {
newValue = safeParseNumber(event.target.value)
} else if (modifiers.includes('boolean')) {
newValue = safeParseBoolean(event.target.value)
} else {
newValue = event.target.value
}

return event.target.checked ? currentValue.concat([newValue]) : currentValue.filter(el => ! checkedAttrLooseCompare(el, newValue))
} else {
return event.target.checked
}
} else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
return modifiers.includes('number')
? Array.from(event.target.selectedOptions).map(option => {
if (modifiers.includes('number')) {
Array.from(event.target.selectedOptions).map(option => {
let rawValue = option.value || option.text
return safeParseNumber(rawValue)
})
: Array.from(event.target.selectedOptions).map(option => {
return option.value || option.text
} else if (modifiers.includes('boolean')) {
Array.from(event.target.selectedOptions).map(option => {
let rawValue = option.value || option.text
return safeParseBoolean(rawValue)
})
}

return Array.from(event.target.selectedOptions).map(option => {
return option.value || option.text
})
} else {
let rawValue = event.target.value
return modifiers.includes('number')
? safeParseNumber(rawValue)
: (modifiers.includes('trim') ? rawValue.trim() : rawValue)
if (modifiers.includes('number')) {
return safeParseNumber(event.target.value)
} else if (modifiers.includes('boolean')) {
return safeParseBoolean(event.target.value)
}

return modifiers.includes('trim') ? event.target.value.trim() : event.target.value
}
})
}
Expand All @@ -169,6 +187,18 @@ function safeParseNumber(rawValue) {
return isNumeric(number) ? number : rawValue
}

function safeParseBoolean(rawValue) {
if ([1, '1', 'true', true].includes(rawValue)) {
return true
}

if ([0, '0', 'false', false].includes(rawValue)) {
return false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking "How could we do this with only one comparison?" and my brain went to

Boolean(JSON.parse(rawValue.toString()))

But surely that would stupid right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that does not work, because if the rawValue is not 1/0/true/false, then we want to just return that raw value. With that code, I don't think that is possible 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I do see that. To me though, the use case of this modifier seems fundamentally contradictory to ever having it be any other values. I'd rather have it fail early (at a json parsing error) than continue to have the bad value end up somewhere else somehow.

But that's an opinionated design.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The x-model.number works like that as well so I thought it might be best to let it behave in the same way

function safeParseNumber(rawValue) {
let number = rawValue ? parseFloat(rawValue) : null
return isNumeric(number) ? number : rawValue
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense 👍

}

return rawValue ? rawValue : null
}

function checkedAttrLooseCompare(valueA, valueB) {
return valueA == valueB
}
Expand Down
13 changes: 13 additions & 0 deletions packages/docs/src/en/directives/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,19 @@ By default, any data stored in a property via `x-model` is stored as a string. T
<span x-text="typeof age"></span>
```

<a name="boolean"></a>
### `.boolean`

By default, any data stored in a property via `x-model` is stored as a string. To force Alpine to store the value as a JavaScript boolean, add the `.boolean` modifier. Both integers (1/0) and strings (true/false) are valid boolean values.

```alpine
<select x-model.boolean="isActive">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
<span x-text="typeof isActive"></span>
```

<a name="debounce"></a>
### `.debounce`

Expand Down
58 changes: 55 additions & 3 deletions tests/cypress/integration/directives/x-model.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,61 @@ test('x-model with number modifier returns: null if empty, original value if cas
}
)

test('x-model casts value to boolean if boolean modifier is present',
html`
<div x-data="{ foo: null, bar: null, baz: [] }">
<input type="text" x-model.boolean="foo"></input>
<select x-model.boolean="bar">
<option value="true">yes</option>
<option value="false">no</option>
</select>
</div>
`,
({ get }) => {
get('input[type=text]').type('1')
get('div').should(haveData('foo', true))

get('input[type=text]').clear().type('0')
get('div').should(haveData('foo', false))

get('input[type=text]').clear().type('true')
get('div').should(haveData('foo', true))

get('input[type=text]').clear().type('false')
get('div').should(haveData('foo', false))

get('select').select('no')
get('div').should(haveData('bar', false))

get('select').select('yes')
get('div').should(haveData('bar', true))
}
)

test('x-model with boolean modifier returns: null if empty, original value if casting fails, numeric value if casting passes',
html`
<div x-data="{ foo: 0, bar: '' }">
<input x-model.boolean="foo"></input>
</div>
`,
({ get }) => {
get('input').clear()
get('div').should(haveData('foo', null))
get('input').clear().type('bar')
get('div').should(haveData('foo', 'bar'))
get('input').clear().type('1')
get('div').should(haveData('foo', true))
get('input').clear().type('1').clear()
get('div').should(haveData('foo', null))
get('input').clear().type('0')
get('div').should(haveData('foo', false))
get('input').clear().type('bar')
get('div').should(haveData('foo', 'bar'))
get('input').clear().type('0').clear()
get('div').should(haveData('foo', null))
}
)

test('x-model trims value if trim modifier is present',
html`
<div x-data="{ foo: '' }">
Expand Down Expand Up @@ -150,6 +205,3 @@ test('x-model with fill modifier takes input value on null or empty string',
get('#d').should(haveText('123456'))
}
)