Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove attribute when set to null/undefined/false #486

Merged
merged 6 commits into from May 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Expand Up @@ -252,14 +252,23 @@ In this example, the "hidden" class will only be applied when the value of the `

**`x-bind` for boolean attributes**

`x-bind` supports boolean attributes in the same way that value attributes, using a variable as the condition or any JavaScript expression that resolves to `true` or `false`.
`x-bind` supports boolean attributes in the same way as value attributes, using a variable as the condition or any JavaScript expression that resolves to `true` or `false`.

For example:
`<button x-bind:disabled="myVar">Click me</button>`
```html
<!-- Given: -->
<button x-bind:disabled="myVar">Click me</button>

<!-- When myVar == true: -->
<button disabled="disabled">Click me</button>

<!-- When myVar == false: -->
<button>Click me</button>
```

This will add or remove the `disabled` attribute when `myVar` is true or false respectively.

Boolean attributes are supported as per the [HTML specification](https://html.spec.whatwg.org/multipage/indices.html#attributes-3:boolean-attribute), for example `disabled`, `readonly`, `required`, `checked`, `hidden`, `selected`, `open` etc.
Boolean attributes are supported as per the [HTML specification](https://html.spec.whatwg.org/multipage/indices.html#attributes-3:boolean-attribute), for example `disabled`, `readonly`, `required`, `checked`, `hidden`, `selected`, `open`, etc.

---

Expand Down
12 changes: 5 additions & 7 deletions dist/alpine-ie11.js
Expand Up @@ -6029,15 +6029,13 @@
var newClasses = value.split(' ').filter(Boolean);
el.setAttribute('class', arrayUnique(_originalClasses.concat(newClasses)).join(' '));
}
} else if (isBooleanAttr(attrName)) {
// Boolean attributes have to be explicitly added and removed, not just set.
if (!!value) {
el.setAttribute(attrName, '');
} else {
} else {
// If an attribute's bound value is null, undefined or false, remove the attribute
if ([null, undefined, false].includes(value)) {
el.removeAttribute(attrName);
} else {
isBooleanAttr(attrName) ? el.setAttribute(attrName, attrName) : el.setAttribute(attrName, value);
}
} else {
el.setAttribute(attrName, value);
}
}

Expand Down
12 changes: 5 additions & 7 deletions dist/alpine.js
Expand Up @@ -575,15 +575,13 @@
const newClasses = value.split(' ').filter(Boolean);
el.setAttribute('class', arrayUnique(originalClasses.concat(newClasses)).join(' '));
}
} else if (isBooleanAttr(attrName)) {
// Boolean attributes have to be explicitly added and removed, not just set.
if (!!value) {
el.setAttribute(attrName, '');
} else {
} else {
// If an attribute's bound value is null, undefined or false, remove the attribute
if ([null, undefined, false].includes(value)) {
el.removeAttribute(attrName);
} else {
isBooleanAttr(attrName) ? el.setAttribute(attrName, attrName) : el.setAttribute(attrName, value);
}
} else {
el.setAttribute(attrName, value);
}
}

Expand Down
12 changes: 5 additions & 7 deletions src/directives/bind.js
Expand Up @@ -68,15 +68,13 @@ export function handleAttributeBindingDirective(component, el, attrName, express
const newClasses = value.split(' ').filter(Boolean)
el.setAttribute('class', arrayUnique(originalClasses.concat(newClasses)).join(' '))
}
} else if (isBooleanAttr(attrName)) {
// Boolean attributes have to be explicitly added and removed, not just set.
if (!! value) {
el.setAttribute(attrName, '')
} else {
} else {
// If an attribute's bound value is null, undefined or false, remove the attribute
if ([null, undefined, false].includes(value)) {
el.removeAttribute(attrName)
} else {
isBooleanAttr(attrName) ? el.setAttribute(attrName, attrName) : el.setAttribute(attrName, value)
}
} else {
el.setAttribute(attrName, value)
}
}

Expand Down
152 changes: 98 additions & 54 deletions test/bind.spec.js
Expand Up @@ -175,14 +175,46 @@ test('class attribute bindings are synced by string syntax', async () => {
expect(document.querySelector('span').classList.contains('baz')).toBeTruthy()
})

test('boolean attributes set to false are removed from element', async () => {
test('non-boolean attributes set to null/undefined/false are removed from the element', async () => {
document.body.innerHTML = `
<div x-data="{ isSet: false }">
<div x-data="{}">
<a href="#hello" x-bind:href="null"></a>
<a href="#hello" x-bind:href="false"></a>
<a href="#hello" x-bind:href="undefined"></a>
<!-- custom attribute see https://github.com/alpinejs/alpine/issues/280 -->
<span visible="true" x-bind:visible="null"></span>
<span visible="true" x-bind:visible="false"></span>
<span visible="true" x-bind:visible="undefined"></span>
</div>
`
Alpine.start()

expect(document.querySelectorAll('a')[0].getAttribute('href')).toBeNull()
expect(document.querySelectorAll('a')[1].getAttribute('href')).toBeNull()
expect(document.querySelectorAll('a')[2].getAttribute('href')).toBeNull()
expect(document.querySelectorAll('span')[0].getAttribute('visible')).toBeNull()
expect(document.querySelectorAll('span')[1].getAttribute('visible')).toBeNull()
expect(document.querySelectorAll('span')[2].getAttribute('visible')).toBeNull()
})

test('non-boolean empty string attributes are not removed', async () => {
document.body.innerHTML = `
<div x-data="{}">
<a href="#hello" x-bind:href="''"></a>
</div>
`
Alpine.start()

expect(document.querySelectorAll('a')[0].getAttribute('href')).toEqual('')
})

test('truthy boolean attribute values are set to their attribute name', async () => {
document.body.innerHTML = `
<div x-data="{ isSet: true }">
<input x-bind:disabled="isSet"></input>
<input x-bind:checked="isSet"></input>
<input x-bind:required="isSet"></input>
<input x-bind:readonly="isSet"></input>
<input x-bind:hidden="isSet"></input>
<details x-bind:open="isSet"></details>
<select x-bind:multiple="isSet"></select>
<option x-bind:selected="isSet"></option>
Expand Down Expand Up @@ -211,42 +243,43 @@ test('boolean attributes set to false are removed from element', async () => {
></script>
</div>
`

Alpine.start()

expect(document.querySelectorAll('input')[0].disabled).toBeFalsy()
expect(document.querySelectorAll('input')[1].checked).toBeFalsy()
expect(document.querySelectorAll('input')[2].required).toBeFalsy()
expect(document.querySelectorAll('input')[3].readOnly).toBeFalsy()
expect(document.querySelectorAll('input')[4].hidden).toBeFalsy()
expect(document.querySelectorAll('details')[0].open).toBeFalsy()
expect(document.querySelectorAll('option')[0].selected).toBeFalsy()
expect(document.querySelectorAll('select')[0].multiple).toBeFalsy()
expect(document.querySelectorAll('textarea')[0].autofocus).toBeFalsy()
expect(document.querySelectorAll('dl')[0].attributes.itemscope).toBeFalsy()
expect(document.querySelectorAll('form')[0].attributes.novalidate).toBeFalsy()
expect(document.querySelectorAll('iframe')[0].attributes.allowfullscreen).toBeFalsy()
expect(document.querySelectorAll('iframe')[0].attributes.allowpaymentrequest).toBeFalsy()
expect(document.querySelectorAll('button')[0].attributes.formnovalidate).toBeFalsy()
expect(document.querySelectorAll('audio')[0].attributes.autoplay).toBeFalsy()
expect(document.querySelectorAll('audio')[0].attributes.controls).toBeFalsy()
expect(document.querySelectorAll('audio')[0].attributes.loop).toBeFalsy()
expect(document.querySelectorAll('audio')[0].attributes.muted).toBeFalsy()
expect(document.querySelectorAll('video')[0].attributes.playsinline).toBeFalsy()
expect(document.querySelectorAll('track')[0].attributes.default).toBeFalsy()
expect(document.querySelectorAll('img')[0].attributes.ismap).toBeFalsy()
expect(document.querySelectorAll('ol')[0].attributes.reversed).toBeFalsy()
expect(document.querySelectorAll('script')[0].attributes.async).toBeFalsy()
expect(document.querySelectorAll('script')[0].attributes.defer).toBeFalsy()
expect(document.querySelectorAll('script')[0].attributes.nomodule).toBeFalsy()
expect(document.querySelectorAll('input')[0].disabled).toBeTruthy()
expect(document.querySelectorAll('input')[1].checked).toBeTruthy()
expect(document.querySelectorAll('input')[2].required).toBeTruthy()
expect(document.querySelectorAll('input')[3].readOnly).toBeTruthy()
expect(document.querySelectorAll('details')[0].open).toBeTruthy()
expect(document.querySelectorAll('option')[0].selected).toBeTruthy()
expect(document.querySelectorAll('select')[0].multiple).toBeTruthy()
expect(document.querySelectorAll('textarea')[0].autofocus).toBeTruthy()
expect(document.querySelectorAll('dl')[0].attributes.itemscope).toBeTruthy()
expect(document.querySelectorAll('form')[0].attributes.novalidate).toBeTruthy()
expect(document.querySelectorAll('iframe')[0].attributes.allowfullscreen).toBeTruthy()
expect(document.querySelectorAll('iframe')[0].attributes.allowpaymentrequest).toBeTruthy()
expect(document.querySelectorAll('button')[0].attributes.formnovalidate).toBeTruthy()
expect(document.querySelectorAll('audio')[0].attributes.autoplay).toBeTruthy()
expect(document.querySelectorAll('audio')[0].attributes.controls).toBeTruthy()
expect(document.querySelectorAll('audio')[0].attributes.loop).toBeTruthy()
expect(document.querySelectorAll('audio')[0].attributes.muted).toBeTruthy()
expect(document.querySelectorAll('video')[0].attributes.playsinline).toBeTruthy()
expect(document.querySelectorAll('track')[0].attributes.default).toBeTruthy()
expect(document.querySelectorAll('img')[0].attributes.ismap).toBeTruthy()
expect(document.querySelectorAll('ol')[0].attributes.reversed).toBeTruthy()
expect(document.querySelectorAll('script')[0].attributes.async).toBeTruthy()
expect(document.querySelectorAll('script')[0].attributes.defer).toBeTruthy()
expect(document.querySelectorAll('script')[0].attributes.nomodule).toBeTruthy()
})

test('boolean attributes set to true are added to element', async () => {
test('null, undefined, or false boolean attribute values are removed', async () => {
document.body.innerHTML = `
<div x-data="{ isSet: true }">
<div x-data="{ isSet: false }">
<input x-bind:disabled="isSet"></input>
<input x-bind:checked="isSet"></input>
<input x-bind:required="isSet"></input>
<input x-bind:readonly="isSet"></input>
<input x-bind:hidden="isSet"></input>
<details x-bind:open="isSet"></details>
<select x-bind:multiple="isSet"></select>
<option x-bind:selected="isSet"></option>
Expand Down Expand Up @@ -275,33 +308,44 @@ test('boolean attributes set to true are added to element', async () => {
></script>
</div>
`
Alpine.start()

expect(document.querySelectorAll('input')[0].getAttribute('disabled')).toBeNull()
expect(document.querySelectorAll('input')[1].getAttribute('checked')).toBeNull()
expect(document.querySelectorAll('input')[2].getAttribute('required')).toBeNull()
expect(document.querySelectorAll('input')[3].getAttribute('readOnly')).toBeNull()
expect(document.querySelectorAll('input')[4].getAttribute('hidden')).toBeNull()
expect(document.querySelectorAll('details')[0].getAttribute('open')).toBeNull()
expect(document.querySelectorAll('option')[0].getAttribute('selected')).toBeNull()
expect(document.querySelectorAll('select')[0].getAttribute('multiple')).toBeNull()
expect(document.querySelectorAll('textarea')[0].getAttribute('autofocus')).toBeNull()
expect(document.querySelectorAll('dl')[0].getAttribute('itemscope')).toBeNull()
expect(document.querySelectorAll('form')[0].getAttribute('novalidate')).toBeNull()
expect(document.querySelectorAll('iframe')[0].getAttribute('allowfullscreen')).toBeNull()
expect(document.querySelectorAll('iframe')[0].getAttribute('allowpaymentrequest')).toBeNull()
expect(document.querySelectorAll('button')[0].getAttribute('formnovalidate')).toBeNull()
expect(document.querySelectorAll('audio')[0].getAttribute('autoplay')).toBeNull()
expect(document.querySelectorAll('audio')[0].getAttribute('controls')).toBeNull()
expect(document.querySelectorAll('audio')[0].getAttribute('loop')).toBeNull()
expect(document.querySelectorAll('audio')[0].getAttribute('muted')).toBeNull()
expect(document.querySelectorAll('video')[0].getAttribute('playsinline')).toBeNull()
expect(document.querySelectorAll('track')[0].getAttribute('default')).toBeNull()
expect(document.querySelectorAll('img')[0].getAttribute('ismap')).toBeNull()
expect(document.querySelectorAll('ol')[0].getAttribute('reversed')).toBeNull()
expect(document.querySelectorAll('script')[0].getAttribute('async')).toBeNull()
expect(document.querySelectorAll('script')[0].getAttribute('defer')).toBeNull()
expect(document.querySelectorAll('script')[0].getAttribute('nomodule')).toBeNull()
})

test('boolean empty string attributes are not removed', async () => {
document.body.innerHTML = `
<div x-data="{}">
<input x-bind:disabled="''">
</div>
`
Alpine.start()

expect(document.querySelectorAll('input')[0].disabled).toBeTruthy()
expect(document.querySelectorAll('input')[1].checked).toBeTruthy()
expect(document.querySelectorAll('input')[2].required).toBeTruthy()
expect(document.querySelectorAll('input')[3].readOnly).toBeTruthy()
expect(document.querySelectorAll('details')[0].open).toBeTruthy()
expect(document.querySelectorAll('option')[0].selected).toBeTruthy()
expect(document.querySelectorAll('select')[0].multiple).toBeTruthy()
expect(document.querySelectorAll('textarea')[0].autofocus).toBeTruthy()
expect(document.querySelectorAll('dl')[0].attributes.itemscope).toBeTruthy()
expect(document.querySelectorAll('form')[0].attributes.novalidate).toBeTruthy()
expect(document.querySelectorAll('iframe')[0].attributes.allowfullscreen).toBeTruthy()
expect(document.querySelectorAll('iframe')[0].attributes.allowpaymentrequest).toBeTruthy()
expect(document.querySelectorAll('button')[0].attributes.formnovalidate).toBeTruthy()
expect(document.querySelectorAll('audio')[0].attributes.autoplay).toBeTruthy()
expect(document.querySelectorAll('audio')[0].attributes.controls).toBeTruthy()
expect(document.querySelectorAll('audio')[0].attributes.loop).toBeTruthy()
expect(document.querySelectorAll('audio')[0].attributes.muted).toBeTruthy()
expect(document.querySelectorAll('video')[0].attributes.playsinline).toBeTruthy()
expect(document.querySelectorAll('track')[0].attributes.default).toBeTruthy()
expect(document.querySelectorAll('img')[0].attributes.ismap).toBeTruthy()
expect(document.querySelectorAll('ol')[0].attributes.reversed).toBeTruthy()
expect(document.querySelectorAll('script')[0].attributes.async).toBeTruthy()
expect(document.querySelectorAll('script')[0].attributes.defer).toBeTruthy()
expect(document.querySelectorAll('script')[0].attributes.nomodule).toBeTruthy()
expect(document.querySelectorAll('input')[0].disabled).toEqual(true)
})

test('binding supports short syntax', async () => {
Expand Down
2 changes: 1 addition & 1 deletion test/model.spec.js
Expand Up @@ -131,7 +131,7 @@ test('x-model binds checkbox value', async () => {
<div x-data="{ foo: true }">
<input type="checkbox" x-model="foo"></input>

<span x-bind:bar="foo"></span>
<span x-bind:bar="JSON.stringify(foo)"></span>
</div>
`

Expand Down