Jason Beggs %!s(int64=2) %!d(string=hai) anos
pai
achega
3fcafd571b

+ 1 - 1
packages/alpinejs/package.json

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

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

@@ -194,6 +194,8 @@ let directiveOrder = [
     // to the order list...
     // to the order list...
     'radio',
     'radio',
     'tabs',
     'tabs',
+    'switch',
+    'disclosure',
     'bind',
     'bind',
     'init',
     'init',
     'for',
     '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 => {
     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))
     }))
     }))

+ 1 - 1
packages/collapse/package.json

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

+ 1 - 1
packages/docs/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/docs",
     "name": "@alpinejs/docs",
-    "version": "3.10.3-revision.1",
+    "version": "3.10.4-revision.1",
     "description": "The documentation for Alpine",
     "description": "The documentation for Alpine",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT"
     "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.
 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
 ```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.
 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",
     "name": "@alpinejs/focus",
-    "version": "3.10.3",
+    "version": "3.10.4",
     "description": "Manage focus within a page",
     "description": "Manage focus within a page",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",

+ 1 - 1
packages/intersect/package.json

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

+ 1 - 1
packages/mask/package.json

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

+ 1 - 1
packages/morph/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/morph",
     "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",
     "description": "Diff and patch a block of HTML on a page with an HTML template",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",

+ 1 - 1
packages/persist/package.json

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

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/ui",
     "name": "@alpinejs/ui",
-    "version": "3.10.3-beta.3",
+    "version": "3.10.4-beta.4",
     "description": "Headless UI components for Alpine",
     "description": "Headless UI components for Alpine",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT",
     "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 dialog from './dialog'
+import disclosure from './disclosure'
+import notSwitch from './switch'
 import popover from './popover'
 import popover from './popover'
 import radio from './radio'
 import radio from './radio'
 import tabs from './tabs'
 import tabs from './tabs'
 
 
 export default function (Alpine) {
 export default function (Alpine) {
     dialog(Alpine)
     dialog(Alpine)
+    disclosure(Alpine)
+    notSwitch(Alpine)
     popover(Alpine)
     popover(Alpine)
     radio(Alpine)
     radio(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') },
+    })
+}

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