Browse Source

Merge branch 'jlb/switch' into jlb/disclosure

Caleb Porzio 2 years ago
parent
commit
fd43c4333f

+ 1 - 0
packages/alpinejs/src/directives.js

@@ -193,6 +193,7 @@ let directiveOrder = [
     // that I don't have to manually add things like "tabs"
     // that I don't have to manually add things like "tabs"
     // to the order list...
     // to the order list...
     'tabs',
     'tabs',
+    'switch',
     'disclosure',
     'disclosure',
     'bind',
     'bind',
     'init',
     'init',

+ 3 - 1
packages/alpinejs/src/directives/x-bind.js

@@ -26,7 +26,9 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
 
 
     effect(() => evaluate(result => {
     effect(() => evaluate(result => {
         // If nested object key is undefined, set the default value to empty string.
         // If nested object key is undefined, set the default value to empty string.
-        if (result === undefined && expression.match(/\./)) result = ''
+        if (result === undefined && typeof expression === 'string' && expression.match(/\./)) {
+            result = ''
+        }
 
 
         mutateDom(() => bind(el, value, result, modifiers))
         mutateDom(() => bind(el, value, result, modifiers))
     }))
     }))

+ 2 - 0
packages/ui/src/index.js

@@ -1,11 +1,13 @@
 import dialog from './dialog'
 import dialog from './dialog'
 import disclosure from './disclosure'
 import disclosure from './disclosure'
 import popover from './popover'
 import popover from './popover'
+import notSwitch from './switch'
 import tabs from './tabs'
 import tabs from './tabs'
 
 
 export default function (Alpine) {
 export default function (Alpine) {
     dialog(Alpine)
     dialog(Alpine)
     disclosure(Alpine)
     disclosure(Alpine)
     popover(Alpine)
     popover(Alpine)
+    notSwitch(Alpine)
     tabs(Alpine)
     tabs(Alpine)
 }
 }

+ 116 - 0
packages/ui/src/switch.js

@@ -0,0 +1,116 @@
+
+export default function (Alpine) {
+    Alpine.directive('switch', (el, directive) => {
+        if      (directive.value === 'group')       handleGroup(el, Alpine)
+        else if (directive.value === 'label')       handleLabel(el, Alpine)
+        else if (directive.value === 'description') handleDescription(el, Alpine)
+        else                                        handleRoot(el, Alpine)
+    })
+
+    Alpine.magic('switch', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get isChecked() {
+                return $data.__value === true
+            },
+        }
+    })
+}
+
+function handleGroup(el, Alpine) {
+    Alpine.bind(el, {
+        'x-id'() { return ['alpine-switch-label', 'alpine-switch-description'] },
+        'x-data'() {
+            return {
+                __hasLabel: false,
+                __hasDescription: false,
+                __switchEl: undefined,
+            }
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-modelable': '__value',
+        'x-data'() {
+            return {
+                init() {
+                    queueMicrotask(() => {
+                        this.__value = Alpine.bound(this.$el, 'default-checked', false)
+                        this.__inputName = Alpine.bound(this.$el, 'name', false)
+                        this.__inputValue = Alpine.bound(this.$el, 'value', 'on')
+                        this.__inputId = Date.now()
+                    })
+                },
+                __value: undefined,
+                __inputName: undefined,
+                __inputValue: undefined,
+                __inputId: undefined,
+                __toggle() {
+                    this.__value = ! this.__value;
+                },
+            }
+        },
+        'x-effect'() {
+            let value = this.__value
+
+            // Only render a hidden input if the "name" prop is passed...
+            if (! this.__inputName) return
+
+            // First remove a previously appended hidden input (if it exists)...
+            let nextEl = this.$el.nextElementSibling
+            if (nextEl && String(nextEl.id) === String(this.__inputId)) {
+                nextEl.remove()
+            }
+
+            // If the value is true, create the input and append it, otherwise,
+            // we already removed it in the previous step...
+            if (value) {
+                let input = document.createElement('input')
+
+                input.type = 'hidden'
+                input.value = this.__inputValue
+                input.name = this.__inputName
+                input.id = this.__inputId
+
+                this.$el.after(input)
+            }
+        },
+        'x-init'() {
+            if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button'
+            this.$data.__switchEl = this.$el
+        },
+        'role': 'switch',
+        'tabindex': "0",
+        ':aria-checked'() { return !!this.__value },
+        ':aria-labelledby'() { return this.$data.__hasLabel && this.$id('alpine-switch-label') },
+        ':aria-describedby'() { return this.$data.__hasDescription && this.$id('alpine-switch-description') },
+        '@click.prevent'() { this.__toggle() },
+        '@keyup'(e) {
+            if (e.key !== 'Tab') e.preventDefault()
+            if (e.key === ' ') this.__toggle()
+        },
+        // This is needed so that we can "cancel" the click event when we use the `Enter` key on a button.
+        '@keypress.prevent'() { },
+    })
+}
+
+function handleLabel(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__hasLabel = true },
+        ':id'() { return this.$id('alpine-switch-label') },
+        '@click'() {
+            this.$data.__switchEl.click()
+            this.$data.__switchEl.focus({ preventScroll: true })
+        },
+    })
+}
+
+function handleDescription(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__hasDescription = true },
+        ':id'() { return this.$id('alpine-switch-description') },
+    })
+}

+ 151 - 0
tests/cypress/integration/plugins/ui/switch.spec.js

@@ -0,0 +1,151 @@
+import { beHidden, beVisible, haveAttribute, haveClasses, haveText, html, notBeVisible, notExist, test } from '../../../utils'
+
+test('has accessibility attributes',
+    [html`
+        <div x-data="{ checked: false }">
+            <article x-switch:group>
+                <label x-switch:label>Enable notifications</label>
+                <span description x-switch:description>A description of the switch.</span>
+
+                <button x-switch x-model="checked">Enable Notifications</button>
+            </article>
+        </div>
+    `],
+    ({ get }) => {
+        get('label').should(haveAttribute('id', 'alpine-switch-label-1'))
+        get('[description]').should(haveAttribute('id', 'alpine-switch-description-1'))
+        get('button').should(haveAttribute('type', 'button'))
+        get('button').should(haveAttribute('aria-labelledby', 'alpine-switch-label-1'))
+        get('button').should(haveAttribute('aria-describedby', 'alpine-switch-description-1'))
+        get('button').should(haveAttribute('role', 'switch'))
+        get('button').should(haveAttribute('tabindex', 0))
+        get('button').should(haveAttribute('aria-checked', 'false'))
+        get('button').click()
+        get('button').should(haveAttribute('aria-checked', 'true'))
+    },
+)
+
+test('works with x-model',
+    [html`
+        <div x-data="{ checked: false }">
+            <button x-switch x-model="checked">Enable notifications</button>
+
+            <article x-show="checked">
+                Notifications are enabled.
+            </article>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(notBeVisible())
+        get('button').click()
+        get('article').should(beVisible())
+        get('button').click()
+        get('article').should(notBeVisible())
+    },
+)
+
+test('works with internal state/$switch.isChecked',
+    [html`
+        <div x-data>
+            <button x-switch x-bind:class="$switch.isChecked ? 'foo' : 'bar'">
+                Enable notifications
+            </button>
+        </div>
+    `],
+    ({ get }) => {
+        get('button').should(haveClasses(['bar']))
+        get('button').click()
+        get('button').should(haveClasses(['foo']))
+        get('button').click()
+        get('button').should(haveClasses(['bar']))
+    },
+)
+
+test('pressing space toggles the switch',
+    [html`
+        <div x-data="{ checked: false }">
+            <div>
+                <button x-switch x-model="checked">Enable notifications</button>
+
+                <article x-show="checked">
+                    Notifications are enabled.
+                </article>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(notBeVisible())
+        get('button').focus()
+        get('button').type(' ')
+        get('article').should(beVisible())
+        get('button').type(' ')
+        get('article').should(notBeVisible())
+    },
+)
+
+test('default-checked',
+    [html`
+        <div x-data>
+            <div>
+                <button
+                    x-switch
+                    default-checked
+                    :class="$switch.isChecked ? 'checked' : 'not-checked'"
+                >Enable notifications</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('button').should(haveClasses(['checked']))
+        get('button').click()
+        get('button').should(haveClasses(['not-checked']))
+    },
+)
+
+test('name and value props',
+    [html`
+        <div x-data>
+            <div>
+                <button
+                    x-switch
+                    name="notifications"
+                    value="yes"
+                >Enable notifications</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('input').should(notExist())
+        get('button').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'notifications'))
+            .should(haveAttribute('value', 'yes'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').click()
+        get('input').should(notExist())
+    },
+)
+
+
+test('value defaults to "on"',
+    [html`
+        <div x-data>
+            <div>
+                <button
+                    x-switch
+                    name="notifications"
+                >Enable notifications</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('input').should(notExist())
+        get('button').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'notifications'))
+            .should(haveAttribute('value', 'on'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').click()
+        get('input').should(notExist())
+    },
+)