1
0
Jason Beggs 2 жил өмнө
parent
commit
3fcafd571b

+ 1 - 1
packages/alpinejs/package.json

@@ -1,6 +1,6 @@
 {
     "name": "alpinejs",
-    "version": "3.10.3",
+    "version": "3.10.4",
     "description": "The rugged, minimal JavaScript framework",
     "author": "Caleb Porzio",
     "license": "MIT",

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

@@ -194,6 +194,8 @@ let directiveOrder = [
     // to the order list...
     'radio',
     'tabs',
+    'switch',
+    'disclosure',
     'bind',
     'init',
     'for',

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

@@ -26,7 +26,9 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
 
     effect(() => evaluate(result => {
         // 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))
     }))

+ 1 - 1
packages/collapse/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/collapse",
-    "version": "3.10.3",
+    "version": "3.10.4",
     "description": "Collapse and expand elements with robust animations",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 1 - 1
packages/docs/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/docs",
-    "version": "3.10.3-revision.1",
+    "version": "3.10.4-revision.1",
     "description": "The documentation for Alpine",
     "author": "Caleb Porzio",
     "license": "MIT"

+ 1 - 1
packages/docs/src/en/essentials/installation.md

@@ -33,7 +33,7 @@ This is by far the simplest way to get started with Alpine. Include the followin
 Notice the `@3.x.x` in the provided CDN link. This will pull the latest version of Alpine version 3. For stability in production, it's recommended that you hardcode the latest version in the CDN link.
 
 ```alpine
-<script defer src="https://unpkg.com/alpinejs@3.10.3/dist/cdn.min.js"></script>
+<script defer src="https://unpkg.com/alpinejs@3.10.4/dist/cdn.min.js"></script>
 ```
 
 That's it! Alpine is now available for use inside your page.

+ 1 - 1
packages/focus/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/focus",
-    "version": "3.10.3",
+    "version": "3.10.4",
     "description": "Manage focus within a page",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 1 - 1
packages/intersect/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/intersect",
-    "version": "3.10.3",
+    "version": "3.10.4",
     "description": "Trigger JavaScript when an element enters the viewport",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 1 - 1
packages/mask/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/mask",
-    "version": "3.10.3",
+    "version": "3.10.4",
     "description": "An Alpine plugin for input masking",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 1 - 1
packages/morph/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/morph",
-    "version": "3.10.3",
+    "version": "3.10.4",
     "description": "Diff and patch a block of HTML on a page with an HTML template",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 1 - 1
packages/persist/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/persist",
-    "version": "3.10.3",
+    "version": "3.10.4",
     "description": "Persist Alpine data across page loads",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/ui",
-    "version": "3.10.3-beta.3",
+    "version": "3.10.4-beta.4",
     "description": "Headless UI components for Alpine",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 80 - 0
packages/ui/src/disclosure.js

@@ -0,0 +1,80 @@
+
+export default function (Alpine) {
+    Alpine.directive('disclosure', (el, directive) => {
+        if      (! directive.value)            handleRoot(el, Alpine)
+        else if (directive.value === 'panel')  handlePanel(el, Alpine)
+        else if (directive.value === 'button') handleButton(el, Alpine)
+    })
+
+    Alpine.magic('disclosure', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get isOpen() {
+                return $data.__isOpen
+            },
+            close() {
+                $data.__close()
+            }
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-modelable': '__isOpen',
+        'x-data'() {
+            return {
+                init() {
+                    queueMicrotask(() => {
+                         let defaultIsOpen = Boolean(Alpine.bound(this.$el, 'default-open', false))
+
+                         if (defaultIsOpen) this.__isOpen = defaultIsOpen
+                    })
+                },
+                __isOpen: false,
+                __close() {
+                    this.__isOpen = false
+                },
+                __toggle() {
+                    this.__isOpen = ! this.__isOpen
+                },
+            }
+        },
+        'x-id'() { return ['alpine-disclosure-panel'] },
+    })
+}
+
+function handleButton(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() {
+            if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button'
+        },
+        '@click'() {
+            this.$data.__isOpen = ! this.$data.__isOpen
+        },
+        ':aria-expanded'() {
+            return this.$data.__isOpen
+        },
+        ':aria-controls'() {
+            return this.$data.$id('alpine-disclosure-panel')
+        },
+        '@keydown.space.prevent.stop'() { this.$data.__toggle() },
+        '@keydown.enter.prevent.stop'() { this.$data.__toggle() },
+        // Required for firefox, event.preventDefault() in handleKeyDown for
+        // the Space key doesn't cancel the handleKeyUp, which in turn
+        // triggers a *click*.
+        '@keyup.space.prevent'() { this.$data.__toggle() },
+    })
+}
+
+function handlePanel(el, Alpine) {
+    Alpine.bind(el, {
+        'x-show'() {
+            return this.$data.__isOpen
+        },
+        ':id'() {
+            return this.$data.$id('alpine-disclosure-panel')
+        },
+    })
+}

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

@@ -1,10 +1,14 @@
 import dialog from './dialog'
+import disclosure from './disclosure'
+import notSwitch from './switch'
 import popover from './popover'
 import radio from './radio'
 import tabs from './tabs'
 
 export default function (Alpine) {
     dialog(Alpine)
+    disclosure(Alpine)
+    notSwitch(Alpine)
     popover(Alpine)
     radio(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') },
+    })
+}

+ 79 - 0
tests/cypress/integration/plugins/ui/disclosure.spec.js

@@ -0,0 +1,79 @@
+import { beVisible, haveClasses, haveAttribute, html, notBeVisible, notHaveClasses, test } from '../../../utils'
+
+test('has accessibility attributes',
+    [html`
+        <div x-data x-disclosure>
+            <button trigger x-disclosure:button>Trigger</button>
+
+            <div x-disclosure:panel panel>
+                Content
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('button').should(haveAttribute('aria-expanded', 'false'))
+        get('button').should(haveAttribute('aria-controls', 'alpine-disclosure-panel-1'))
+        get('[panel]').should(haveAttribute('id', 'alpine-disclosure-panel-1'))
+    },
+)
+
+test('it toggles',
+    [html`
+        <div x-data x-disclosure>
+            <button trigger x-disclosure:button>Trigger</button>
+
+            <div x-disclosure:panel panel>
+                Content
+
+                <button close-button type="button" @click="$disclosure.close()">Close</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel]').should(notBeVisible())
+        get('[trigger]').click()
+        get('[panel]').should(beVisible())
+        get('[trigger]').click()
+        get('[panel]').should(notBeVisible())
+    },
+)
+
+test('$disclosure.isOpen and $disclosure.close() work',
+    [html`
+        <div x-data x-disclosure>
+            <button trigger x-disclosure:button>Trigger</button>
+
+            <div x-disclosure:panel panel :class="$disclosure.isOpen && 'open'">
+                Content
+
+                <button close-button type="button" @click="$disclosure.close()">Close</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel]').should(notHaveClasses(['open']))
+        get('[trigger]').click()
+        get('[panel]').should(haveClasses(['open']))
+        get('[close-button]').click()
+        get('[panel]').should(notBeVisible())
+    },
+)
+
+test('can set a default open state',
+    [html`
+        <div x-data x-disclosure :default-open="true">
+            <button trigger x-disclosure:button>Trigger</button>
+
+            <div x-disclosure:panel panel>
+                Content
+
+                <button close-button type="button" @click="$disclosure.close()">Close</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel]').should(beVisible())
+        get('[trigger]').click()
+        get('[panel]').should(notBeVisible())
+    },
+)

+ 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())
+    },
+)