فهرست منبع

Merge branch 'main' into jlb/listbox

Jason Beggs 2 سال پیش
والد
کامیت
8b809b4079

+ 28 - 6
index.html

@@ -7,14 +7,36 @@
     <script src="./packages/mask/dist/cdn.js"></script>
     <script src="./packages/mask/dist/cdn.js"></script>
     <script src="./packages/ui/dist/cdn.js" defer></script>
     <script src="./packages/ui/dist/cdn.js" defer></script>
     <script src="./packages/alpinejs/dist/cdn.js" defer></script>
     <script src="./packages/alpinejs/dist/cdn.js" defer></script>
+    <script src="//cdn.tailwindcss.com"></script>
     <!-- <script src="https://unpkg.com/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
     <!-- <script src="https://unpkg.com/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
 
 
     <!-- Play around. -->
     <!-- Play around. -->
-    <div x-data="{ open: false }">
-        <button @click="open = !open">Toggle</button>
+    <main x-data="{ active: null, access: [
+        {
+            id: 'access-1',
+            name: 'Public access',
+            description: 'This project would be available to anyone who has the link',
+            disabled: false,
+        },
+    ]}">
+        <div x-radio group x-model="active">
+            <fieldset>
+                <legend>
+                    <h2 x-radio:label>Privacy setting</h2>
+                    <p x-radio:description>Some description</p>
+                </legend>
 
 
-        <span x-show="open">
-            Content...
-        </span>
-    </div>
+                <div>
+                    <template x-for="(item, i) in access" :key="item.id">
+                        <div :option="item.id" x-radio:option :value="item" :disabled="item.disabled">
+                            <span :label="item.id" x-radio:label x-text="item.name"></span>
+                            <span :description="item.id" x-radio:description x-text="item.description"></span>
+                        </div>
+                    </template>
+                </div>
+            </fieldset>
+        </div>
+
+        <span x-text="JSON.stringify(active)"></span>
+    </main>
 </html>
 </html>

+ 1 - 1
packages/alpinejs/package.json

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

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

@@ -192,11 +192,15 @@ let directiveOrder = [
     // @todo: provide better directive ordering mechanisms so
     // @todo: provide better directive ordering mechanisms so
     // 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',
     'radio',
     'radio',
+    'tabs',
     'switch',
     'switch',
     'disclosure',
     'disclosure',
+    'menu',
     'listbox',
     'listbox',
+    'list',
+    'item',
+    'combobox',
     'bind',
     'bind',
     'init',
     'init',
     'for',
     'for',

+ 1 - 1
packages/collapse/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/collapse",
     "name": "@alpinejs/collapse",
-    "version": "3.10.4",
+    "version": "3.10.5",
     "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.4-revision.1",
+    "version": "3.10.5-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.4/dist/cdn.min.js"></script>
+<script defer src="https://unpkg.com/alpinejs@3.10.5/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.4",
+    "version": "3.10.5",
     "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.4",
+    "version": "3.10.5",
     "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.4",
+    "version": "3.10.5",
     "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.4",
+    "version": "3.10.5",
     "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.4",
+    "version": "3.10.5",
     "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.4-beta.4",
+    "version": "3.10.5-beta.7",
     "description": "Headless UI components for Alpine",
     "description": "Headless UI components for Alpine",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",

+ 1 - 1
packages/ui/src/disclosure.js

@@ -64,7 +64,7 @@ function handleButton(el, Alpine) {
         // Required for firefox, event.preventDefault() in handleKeyDown for
         // Required for firefox, event.preventDefault() in handleKeyDown for
         // the Space key doesn't cancel the handleKeyUp, which in turn
         // the Space key doesn't cancel the handleKeyUp, which in turn
         // triggers a *click*.
         // triggers a *click*.
-        '@keyup.space.prevent'() { this.$data.__toggle() },
+        '@keyup.space.prevent'() {},
     })
     })
 }
 }
 
 

+ 5 - 1
packages/ui/src/index.js

@@ -4,7 +4,9 @@ import dialog from './dialog'
 import disclosure from './disclosure'
 import disclosure from './disclosure'
 import listbox from './listbox'
 import listbox from './listbox'
 import popover from './popover'
 import popover from './popover'
+import menu from './menu'
 import notSwitch from './switch'
 import notSwitch from './switch'
+import radio from './radio'
 import tabs from './tabs'
 import tabs from './tabs'
 
 
 export default function (Alpine) {
 export default function (Alpine) {
@@ -12,7 +14,9 @@ export default function (Alpine) {
     dialog(Alpine)
     dialog(Alpine)
     disclosure(Alpine)
     disclosure(Alpine)
     listbox(Alpine)
     listbox(Alpine)
-    popover(Alpine)
+    menu(Alpine)
     notSwitch(Alpine)
     notSwitch(Alpine)
+    popover(Alpine)
+    radio(Alpine)
     tabs(Alpine)
     tabs(Alpine)
 }
 }

+ 229 - 0
packages/ui/src/menu.js

@@ -0,0 +1,229 @@
+export default function (Alpine) {
+    Alpine.directive('menu', (el, directive) => {
+        if (! directive.value) handleRoot(el, Alpine)
+        else if (directive.value === 'items') handleItems(el, Alpine)
+        else if (directive.value === 'item') handleItem(el, Alpine)
+        else if (directive.value === 'button') handleButton(el, Alpine)
+    });
+
+    Alpine.magic('menuItem', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get isActive() {
+                return $data.__activeEl == $data.__itemEl
+            },
+            get isDisabled() {
+                return el.__isDisabled.value
+            },
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-id'() { return ['alpine-menu-button', 'alpine-menu-items'] },
+        'x-modelable': '__isOpen',
+        'x-data'() {
+            return {
+                __itemEls: [],
+                __activeEl: null,
+                __isOpen: false,
+                __open() {
+                    this.__isOpen = true
+
+                    // Safari needs more of a "tick" for focusing after x-show for some reason.
+                    // Probably because Alpine adds an extra tick when x-showing for @click.outside
+                    let nextTick = callback => requestAnimationFrame(() => requestAnimationFrame(callback))
+
+                    nextTick(() => this.$refs.__items.focus({ preventScroll: true }))
+                },
+                __close(focusAfter = true) {
+                    this.__isOpen = false
+
+                    focusAfter && this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
+                },
+                __contains(outer, inner) {
+                    return !! Alpine.findClosest(inner, el => el.isSameNode(outer))
+                }
+            }
+        },
+        '@focusin.window'() {
+            if (! this.$data.__contains(this.$el, document.activeElement)) {
+                this.$data.__close(false)
+            }
+        },
+    })
+}
+
+function handleButton(el, Alpine) {
+    Alpine.bind(el, {
+        'x-ref': '__button',
+        'aria-haspopup': 'true',
+        ':aria-labelledby'() { return this.$id('alpine-menu-label') },
+        ':id'() { return this.$id('alpine-menu-button') },
+        ':aria-expanded'() { return this.$data.__isOpen },
+        ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-menu-items') },
+        'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
+        '@click'() { this.$data.__open() },
+        '@keydown.down.stop.prevent'() { this.$data.__open() },
+        '@keydown.up.stop.prevent'() { this.$data.__open(dom.Alpine, last) },
+        '@keydown.space.stop.prevent'() { this.$data.__open() },
+        '@keydown.enter.stop.prevent'() { this.$data.__open() },
+    })
+}
+
+function handleItems(el, Alpine) {
+    Alpine.bind(el, {
+        'x-ref': '__items',
+        'aria-orientation': 'vertical',
+        'role': 'menu',
+        ':id'() { return this.$id('alpine-menu-items') },
+        ':aria-labelledby'() { return this.$id('alpine-menu-button') },
+        ':aria-activedescendant'() { return this.$data.__activeEl && this.$data.__activeEl.id },
+        'x-show'() { return this.$data.__isOpen },
+        'tabindex': '0',
+        '@click.outside'() { this.$data.__close() },
+        '@keydown'(e) { dom.search(Alpine, this.$refs.__items, e.key, el => el.__activate()) },
+        '@keydown.down.stop.prevent'() {
+            if (this.$data.__activeEl) dom.next(Alpine, this.$data.__activeEl, el => el.__activate())
+            else dom.first(Alpine, this.$refs.__items, el => el.__activate())
+        },
+        '@keydown.up.stop.prevent'() {
+            if (this.$data.__activeEl) dom.previous(Alpine, this.$data.__activeEl, el => el.__activate())
+            else dom.last(Alpine, this.$refs.__items, el => el.__activate())
+        },
+        '@keydown.home.stop.prevent'() { dom.first(Alpine, this.$refs.__items, el => el.__activate()) },
+        '@keydown.end.stop.prevent'() { dom.last(Alpine, this.$refs.__items, el => el.__activate()) },
+        '@keydown.page-up.stop.prevent'() { dom.first(Alpine, this.$refs.__items, el => el.__activate()) },
+        '@keydown.page-down.stop.prevent'() { dom.last(Alpine, this.$refs.__items, el => el.__activate()) },
+        '@keydown.escape.stop.prevent'() { this.$data.__close() },
+        '@keydown.space.stop.prevent'() { this.$data.__activeEl && this.$data.__activeEl.click() },
+        '@keydown.enter.stop.prevent'() { this.$data.__activeEl && this.$data.__activeEl.click() },
+        // 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'() { },
+    })
+}
+
+function handleItem(el, Alpine) {
+    Alpine.bind(el, () => {
+        return {
+            'x-data'() {
+                return {
+                    __itemEl: this.$el,
+                    init() {
+                        // Add current element to element list for navigating.
+                        let els = Alpine.raw(this.$data.__itemEls)
+                        let inserted = false
+
+                        for (let i = 0; i < els.length; i++) {
+                            if (els[i].compareDocumentPosition(this.$el) & Node.DOCUMENT_POSITION_PRECEDING) {
+                                els.splice(i, 0, this.$el)
+                                inserted = true
+                                break
+                            }
+                        }
+
+                        if (! inserted) els.push(this.$el)
+
+                        this.$el.__activate = () => {
+                            this.$data.__activeEl = this.$el
+                            this.$el.scrollIntoView({ block: 'nearest' })
+                        }
+
+                        this.$el.__deactivate = () => {
+                            this.$data.__activeEl = null
+                        }
+
+
+                        this.$el.__isDisabled = Alpine.reactive({ value: false })
+
+                        queueMicrotask(() => {
+                            this.$el.__isDisabled.value = Alpine.bound(this.$el, 'disabled', false)
+                        })
+                    },
+                    destroy() {
+                        // Remove this element from the elements list.
+                        let els = this.$data.__itemEls
+                        els.splice(els.indexOf(this.$el), 1)
+                    },
+                }
+            },
+            'x-id'() { return ['alpine-menu-item'] },
+            ':id'() { return this.$id('alpine-menu-item') },
+            ':tabindex'() { return this.$el.__isDisabled.value ? false : '-1' },
+            'role': 'menuitem',
+            '@mousemove'() { this.$el.__isDisabled.value || this.$menuItem.isActive || this.$el.__activate() },
+            '@mouseleave'() { this.$el.__isDisabled.value || ! this.$menuItem.isActive || this.$el.__deactivate() },
+        }
+    })
+}
+
+let dom = {
+    first(Alpine, parent, receive = i => i, fallback = () => { }) {
+        let first = Alpine.$data(parent).__itemEls[0]
+
+        if (! first) return fallback()
+
+        if (first.tagName.toLowerCase() === 'template') {
+            return this.next(Alpine, first, receive)
+        }
+
+        if (first.__isDisabled.value) return this.next(Alpine, first, receive)
+
+        return receive(first)
+    },
+    last(Alpine, parent, receive = i => i, fallback = () => { }) {
+        let last = Alpine.$data(parent).__itemEls.slice(-1)[0]
+
+        if (! last) return fallback()
+        if (last.__isDisabled.value) return this.previous(Alpine, last, receive)
+        return receive(last)
+    },
+    next(Alpine, el, receive = i => i, fallback = () => { }) {
+        if (! el) return fallback()
+
+        let els = Alpine.$data(el).__itemEls
+        let next = els[els.indexOf(el) + 1]
+
+        if (! next) return fallback()
+        if (next.__isDisabled.value || next.tagName.toLowerCase() === 'template') return this.next(Alpine, next, receive, fallback)
+        return receive(next)
+    },
+    previous(Alpine, el, receive = i => i, fallback = () => { }) {
+        if (! el) return fallback()
+
+        let els = Alpine.$data(el).__itemEls
+        let prev = els[els.indexOf(el) - 1]
+
+        if (! prev) return fallback()
+        if (prev.__isDisabled.value || prev.tagName.toLowerCase() === 'template') return this.previous(Alpine, prev, receive, fallback)
+        return receive(prev)
+    },
+    searchQuery: '',
+    debouncedClearSearch: undefined,
+    clearSearch(Alpine) {
+        if (! this.debouncedClearSearch) {
+            this.debouncedClearSearch = Alpine.debounce(function () { this.searchQuery = '' }, 350)
+        }
+
+        this.debouncedClearSearch()
+    },
+    search(Alpine, parent, key, receiver) {
+        if (key.length > 1) return
+
+        this.searchQuery += key
+
+        let els = Alpine.raw(Alpine.$data(parent).__itemEls)
+
+        let el = els.find(el => {
+            return el.textContent.trim().toLowerCase().startsWith(this.searchQuery)
+        })
+
+        el && ! el.__isDisabled.value && receiver(el)
+
+        this.clearSearch(Alpine)
+    },
+}

+ 220 - 0
packages/ui/src/radio.js

@@ -0,0 +1,220 @@
+
+export default function (Alpine) {
+    Alpine.directive('radio', (el, directive) => {
+        if      (! directive.value)                 handleRoot(el, Alpine)
+        else if (directive.value === 'option')      handleOption(el, Alpine)
+        else if (directive.value === 'label')       handleLabel(el, Alpine)
+        else if (directive.value === 'description') handleDescription(el, Alpine)
+    })
+
+    Alpine.magic('radioOption', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get isActive() {
+                return $data.__option === $data.__active
+            },
+            get isChecked() {
+                return $data.__option === $data.__value
+            },
+            get isDisabled() {
+                let disabled = $data.__disabled
+
+                if ($data.__rootDisabled) return true
+
+                return disabled
+            },
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-modelable': '__value',
+        'x-data'() {
+            return {
+                init() {
+                    queueMicrotask(() => {
+                        this.__rootDisabled = Alpine.bound(el, 'disabled', false);
+                        this.__value = Alpine.bound(this.$el, 'default-value', false)
+                        this.__inputName = Alpine.bound(this.$el, 'name', false)
+                        this.__inputId = 'alpine-radio-'+Date.now()
+                    })
+
+                    // Add `role="none"` to all non role elements.
+                    this.$nextTick(() => {
+                        let walker = document.createTreeWalker(
+                            this.$el,
+                            NodeFilter.SHOW_ELEMENT,
+                            {
+                                acceptNode: node => {
+                                    if (node.getAttribute('role') === 'radio') return NodeFilter.FILTER_REJECT
+                                    if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
+                                    return NodeFilter.FILTER_ACCEPT
+                                }
+                            },
+                            false
+                        )
+
+                        while (walker.nextNode()) walker.currentNode.setAttribute('role', 'none')
+                    })
+                },
+                __value: undefined,
+                __active: undefined,
+                __rootEl: this.$el,
+                __optionValues: [],
+                __disabledOptions: new Set,
+                __optionElsByValue: new Map,
+                __hasLabel: false,
+                __hasDescription: false,
+                __rootDisabled: false,
+                __inputName: undefined,
+                __inputId: undefined,
+                __change(value) {
+                    if (this.__rootDisabled) return
+
+                    this.__value = value
+                },
+                __addOption(option, el, disabled) {
+                    // Add current element to element list for navigating.
+                    let options = Alpine.raw(this.__optionValues)
+                    let els = options.map(i => this.__optionElsByValue.get(i))
+                    let inserted = false
+
+                    for (let i = 0; i < els.length; i++) {
+                        if (els[i].compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING) {
+                            options.splice(i, 0, option)
+                            this.__optionElsByValue.set(option, el)
+                            inserted = true
+                            break
+                        }
+                    }
+
+                    if (!inserted) {
+                        options.push(option)
+                        this.__optionElsByValue.set(option, el)
+                    }
+
+                    disabled && this.__disabledOptions.add(option)
+                },
+                __isFirstOption(option) {
+                    return this.__optionValues.indexOf(option) === 0
+                },
+                __setActive(option) {
+                    this.__active = option
+                },
+                __focusOptionNext() {
+                    let option = this.__active
+                    let all = this.__optionValues.filter(i => !this.__disabledOptions.has(i))
+                    let next = all[this.__optionValues.indexOf(option) + 1]
+                    next = next || all[0]
+
+                    this.__optionElsByValue.get(next).focus()
+                    this.__change(next)
+                },
+                __focusOptionPrev() {
+                    let option = this.__active
+                    let all = this.__optionValues.filter(i => !this.__disabledOptions.has(i))
+                    let prev = all[all.indexOf(option) - 1]
+                    prev = prev || all.slice(-1)[0]
+
+                    this.__optionElsByValue.get(prev).focus()
+                    this.__change(prev)
+                },
+            }
+        },
+        '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 = value
+                input.name = this.__inputName
+                input.id = this.__inputId
+
+                this.$el.after(input)
+            }
+        },
+        'role': 'radiogroup',
+        'x-id'() { return ['alpine-radio-label', 'alpine-radio-description'] },
+        ':aria-labelledby'() { return this.__hasLabel && this.$id('alpine-radio-label') },
+        ':aria-describedby'() { return this.__hasDescription && this.$id('alpine-radio-description') },
+        '@keydown.up.prevent.stop'() { this.__focusOptionPrev() },
+        '@keydown.left.prevent.stop'() { this.__focusOptionPrev() },
+        '@keydown.down.prevent.stop'() { this.__focusOptionNext() },
+        '@keydown.right.prevent.stop'() { this.__focusOptionNext() },
+    })
+}
+
+function handleOption(el, Alpine) {
+    Alpine.bind(el, {
+        'x-data'() {
+            return {
+                init() {
+                    queueMicrotask(() => {
+                        this.__disabled = Alpine.bound(el, 'disabled', false)
+                        this.__option = Alpine.bound(el, 'value')
+                        this.$data.__addOption(this.__option, this.$el, this.__disabled)
+                    })
+                },
+                __option: undefined,
+                __disabled: false,
+                __hasLabel: false,
+                __hasDescription: false,
+            }
+        },
+        'x-id'() { return ['alpine-radio-label', 'alpine-radio-description'] },
+        'role': 'radio',
+        ':aria-checked'() { return this.$radioOption.isChecked },
+        ':aria-disabled'() { return this.$radioOption.isDisabled },
+        ':aria-labelledby'() { return this.__hasLabel && this.$id('alpine-radio-label') },
+        ':aria-describedby'() { return this.__hasDescription && this.$id('alpine-radio-description') },
+        ':tabindex'()   {
+            if (this.$radioOption.isDisabled) return -1
+            if (this.$radioOption.isChecked) return 0
+            if (! this.$data.__value && this.$data.__isFirstOption(this.$data.__option)) return 0
+
+            return -1
+        },
+        '@click'() {
+            if (this.$radioOption.isDisabled) return
+            this.$data.__change(this.$data.__option)
+            this.$el.focus()
+        },
+        '@focus'() {
+            if (this.$radioOption.isDisabled) return
+            this.$data.__setActive(this.$data.__option)
+        },
+        '@blur'() {
+            if (this.$data.__active === this.$data.__option) this.$data.__setActive(undefined)
+        },
+        '@keydown.space.stop.prevent'() { this.$data.__change(this.$data.__option) },
+    })
+}
+
+function handleLabel(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__hasLabel = true },
+        ':id'() { return this.$id('alpine-radio-label') },
+    })
+}
+
+function handleDescription(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__hasDescription = true },
+        ':id'() { return this.$id('alpine-radio-description') },
+    })
+}

+ 1 - 1
packages/ui/src/switch.js

@@ -41,7 +41,7 @@ function handleRoot(el, Alpine) {
                         this.__value = Alpine.bound(this.$el, 'default-checked', false)
                         this.__value = Alpine.bound(this.$el, 'default-checked', false)
                         this.__inputName = Alpine.bound(this.$el, 'name', false)
                         this.__inputName = Alpine.bound(this.$el, 'name', false)
                         this.__inputValue = Alpine.bound(this.$el, 'value', 'on')
                         this.__inputValue = Alpine.bound(this.$el, 'value', 'on')
-                        this.__inputId = Date.now()
+                        this.__inputId = 'alpine-switch-'+Date.now()
                     })
                     })
                 },
                 },
                 __value: undefined,
                 __value: undefined,

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

@@ -77,3 +77,26 @@ test('can set a default open state',
         get('[panel]').should(notBeVisible())
         get('[panel]').should(notBeVisible())
     },
     },
 )
 )
+
+test('it toggles using the space key',
+    [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]').type(' ')
+        get('[panel]').should(notBeVisible())
+        get('[trigger]').type(' ')
+        get('[panel]').should(beVisible())
+    },
+)

+ 392 - 0
tests/cypress/integration/plugins/ui/menu.spec.js

@@ -0,0 +1,392 @@
+import { haveClasses, beVisible, haveAttribute, haveText, html, notBeVisible, notExist, test, haveFocus, notHaveClasses, notHaveAttribute } from '../../../utils'
+
+test('it works',
+    [html`
+        <div x-data x-menu>
+            <span>
+                <button x-menu:button trigger>
+                    <span>Options</span>
+                </button>
+            </span>
+
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+
+                <div>
+                    <a x-menu:item href="#account-settings">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support">
+                        Support
+                    </a>
+                    <a x-menu:item disabled href="#new-feature">
+                        New feature (soon)
+                    </a>
+                    <a x-menu:item href="#license">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>`],
+    ({ get }) => {
+        get('[items]').should(notBeVisible())
+        get('[trigger]').click()
+        get('[items]').should(beVisible())
+    },
+)
+
+test('focusing away closes menu',
+    [html`
+    <div>
+        <div x-data x-menu>
+            <span>
+                <button x-menu:button trigger>
+                    <span>Options</span>
+                </button>
+            </span>
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+                <div>
+                    <a x-menu:item href="#account-settings">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support">
+                        Support
+                    </a>
+                    <a x-menu:item href="#license">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>
+
+        <button>Focus away</button>
+    </div>
+    `],
+    ({ get }) => {
+        get('[items]').should(notBeVisible())
+        get('[trigger]').click()
+        get('[items]').should(beVisible())
+        cy.focused().tab()
+        get('[items]').should(notBeVisible())
+    },
+)
+
+test('it works with x-model',
+    [html`
+        <div x-data="{ open: false }" x-menu x-model="open">
+            <button trigger @click="open = !open">
+                <span>Options</span>
+            </button>
+
+            <button x-menu:button>
+                <span>Options</span>
+            </button>
+
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+
+                <div>
+                    <a x-menu:item href="#account-settings">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support">
+                        Support
+                    </a>
+                    <a x-menu:item disabled href="#new-feature">
+                        New feature (soon)
+                    </a>
+                    <a x-menu:item href="#license">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>`],
+    ({ get }) => {
+        get('[items]').should(notBeVisible())
+        get('[trigger]').click()
+        get('[items]').should(beVisible())
+        get('[trigger]').click()
+        get('[items]').should(notBeVisible())
+    },
+)
+
+test('keyboard controls',
+    [html`
+        <div x-data x-menu>
+            <span>
+                <button x-menu:button trigger>
+                    <span>Options</span>
+                </button>
+            </span>
+
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+
+                <div>
+                    <a x-menu:item href="#account-settings" :class="$menuItem.isActive && 'active'">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support" :class="$menuItem.isActive && 'active'">
+                        Support
+                    </a>
+                    <a x-menu:item disabled href="#new-feature" :class="$menuItem.isActive && 'active'">
+                        New feature (soon)
+                    </a>
+                    <a x-menu:item href="#license" :class="$menuItem.isActive && 'active'">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out" :class="$menuItem.isActive && 'active'">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>`],
+    ({ get }) => {
+        get('.active').should(notExist())
+        get('[trigger]').type(' ')
+        get('[items]')
+            .should(beVisible())
+            .should(haveFocus())
+            .type('{downarrow}')
+        get('[href="#account-settings"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .type('{downarrow}')
+        get('[href="#support"]')
+            .should(haveClasses(['active']))
+            .type('{downarrow}')
+        get('[href="#license"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .type('{uparrow}')
+        get('[href="#support"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .type('{home}')
+        get('[href="#account-settings"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .type('{end}')
+        get('[href="#sign-out"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .type('{pageUp}')
+        get('[href="#account-settings"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .type('{pageDown}')
+        get('[href="#sign-out"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .tab()
+            .should(haveFocus())
+            .should(beVisible())
+            .tab({ shift: true})
+            .should(haveFocus())
+            .should(beVisible())
+            .type('{esc}')
+            .should(notBeVisible())
+    },
+)
+
+test('search',
+    [html`
+        <div x-data x-menu>
+            <span>
+                <button x-menu:button trigger>
+                    <span>Options</span>
+                </button>
+            </span>
+
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+
+                <div>
+                    <a x-menu:item href="#account-settings" :class="$menuItem.isActive && 'active'">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support" :class="$menuItem.isActive && 'active'">
+                        Support
+                    </a>
+                    <a x-menu:item disabled href="#new-feature" :class="$menuItem.isActive && 'active'">
+                        New feature (soon)
+                    </a>
+                    <a x-menu:item href="#license" :class="$menuItem.isActive && 'active'">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out" :class="$menuItem.isActive && 'active'">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>`],
+    ({ get, wait }) => {
+        get('.active').should(notExist())
+        get('[trigger]').click()
+        get('[items]')
+            .type('ac')
+        get('[href="#account-settings"]')
+            .should(haveClasses(['active']))
+        wait(500)
+        get('[items]')
+            .type('si')
+        get('[href="#sign-out"]')
+            .should(haveClasses(['active']))
+    },
+)
+
+test('has accessibility attributes',
+    [html`
+        <div x-data x-menu>
+            <label x-menu:label>Options label</label>
+
+            <span>
+                <button x-menu:button trigger>
+                    <span>Options</span>
+                </button>
+            </span>
+
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+
+                <div>
+                    <a x-menu:item href="#account-settings" :class="$menuItem.isActive && 'active'">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support" :class="$menuItem.isActive && 'active'">
+                        Support
+                    </a>
+                    <a x-menu:item disabled href="#new-feature" :class="$menuItem.isActive && 'active'">
+                        New feature (soon)
+                    </a>
+                    <a x-menu:item href="#license" :class="$menuItem.isActive && 'active'">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out" :class="$menuItem.isActive && 'active'">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>`],
+    ({ get }) => {
+        get('[trigger]')
+            .should(haveAttribute('aria-haspopup', 'true'))
+            .should(haveAttribute('aria-labelledby', 'alpine-menu-label-1'))
+            .should(haveAttribute('aria-expanded', 'false'))
+            .should(notHaveAttribute('aria-controls'))
+            .should(haveAttribute('id', 'alpine-menu-button-1'))
+            .click()
+            .should(haveAttribute('aria-expanded', 'true'))
+            .should(haveAttribute('aria-controls', 'alpine-menu-items-1'))
+
+        get('[items]')
+            .should(haveAttribute('aria-orientation', 'vertical'))
+            .should(haveAttribute('role', 'menu'))
+            .should(haveAttribute('id', 'alpine-menu-items-1'))
+            .should(haveAttribute('aria-labelledby', 'alpine-menu-button-1'))
+            .should(notHaveAttribute('aria-activedescendant'))
+            .should(haveAttribute('tabindex', '0'))
+            .type('{downarrow}')
+            .should(haveAttribute('aria-activedescendant', 'alpine-menu-item-1'))
+
+        get('[href="#account-settings"]')
+            .should(haveAttribute('role', 'menuitem'))
+            .should(haveAttribute('id', 'alpine-menu-item-1'))
+            .should(haveAttribute('tabindex', '-1'))
+
+        get('[href="#support"]')
+            .should(haveAttribute('role', 'menuitem'))
+            .should(haveAttribute('id', 'alpine-menu-item-2'))
+            .should(haveAttribute('tabindex', '-1'))
+
+        get('[items]')
+            .type('{downarrow}')
+            .should(haveAttribute('aria-activedescendant', 'alpine-menu-item-2'))
+    },
+)
+
+test('$menuItem.isDisabled',
+    [html`
+        <div x-data x-menu>
+            <label x-menu:label>Options label</label>
+
+            <span>
+                <button x-menu:button trigger>
+                    <span>Options</span>
+                </button>
+            </span>
+
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+
+                <div>
+                    <a x-menu:item href="#account-settings" :class="{ 'active': $menuItem.isActive, 'disabled': $menuItem.isDisabled }">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support" :class="{ 'active': $menuItem.isActive, 'disabled': $menuItem.isDisabled }">
+                        Support
+                    </a>
+                    <a x-menu:item disabled href="#new-feature" :class="{ 'active': $menuItem.isActive, 'disabled': $menuItem.isDisabled }">
+                        New feature (soon)
+                    </a>
+                    <a x-menu:item href="#license" :class="{ 'active': $menuItem.isActive, 'disabled': $menuItem.isDisabled }">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out" :class="{ 'active': $menuItem.isActive, 'disabled': $menuItem.isDisabled }">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>`],
+    ({ get }) => {
+        get('[trigger]').click()
+        get('[href="#account-settings"]').should(notHaveClasses(['disabled']))
+        get('[href="#support"]').should(notHaveClasses(['disabled']))
+        get('[href="#new-feature"]').should(haveClasses(['disabled']))
+    },
+)

+ 513 - 0
tests/cypress/integration/plugins/ui/radio.spec.js

@@ -0,0 +1,513 @@
+import { haveAttribute, haveFocus, html, haveClasses, notHaveClasses, test, haveText, notExist, beHidden, } from '../../../utils'
+
+test('it works using x-model',
+    [html`
+        <main x-data="{ active: null, access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio x-model="active">
+                <fieldset>
+                    <legend>
+                        <h2 x-radio:label>Privacy setting</h2>
+                    </legend>
+
+                    <div>
+                        <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                            <div :option="id" x-radio:option :value="id" :disabled="disabled">
+                                <span x-radio:label x-text="name"></span>
+                                <span x-radio:description x-text="description"></span>
+                            </div>
+                        </template>
+                    </div>
+                </fieldset>
+            </div>
+
+            <input x-model="active" type="hidden">
+        </main>
+    `],
+    ({ get }) => {
+        get('input').should(haveAttribute('value', ''))
+        get('[option="access-2"]').click()
+        get('input').should(haveAttribute('value', 'access-2'))
+        get('[option="access-4"]').click()
+        get('input').should(haveAttribute('value', 'access-4'))
+    },
+)
+
+test('it works without x-model/with default-value',
+    [html`
+        <main x-data="{ access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio default-value="access-4">
+                <fieldset>
+                    <legend>
+                        <h2 x-radio:label>Privacy setting</h2>
+                    </legend>
+
+                    <div>
+                        <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                            <div :option="id" x-radio:option :value="id" :disabled="disabled">
+                                <span x-radio:label x-text="name"></span>
+                                <span x-radio:description x-text="description"></span>
+                            </div>
+                        </template>
+                    </div>
+                </fieldset>
+            </div>
+        </main>
+    `],
+    ({ get }) => {
+        get('[option="access-4"]').should(haveAttribute('aria-checked', 'true'))
+        get('[option="access-2"]').click()
+        get('[option="access-2"]').should(haveAttribute('aria-checked', 'true'))
+    },
+)
+
+test('cannot select any option when the group is disabled',
+    [html`
+        <main x-data="{ active: null, access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio x-model="active" disabled>
+                <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                    <div :option="id" x-radio:option :value="id" :disabled="disabled">
+                        <span x-radio:label x-text="name"></span>
+                        <span x-radio:description x-text="description"></span>
+                    </div>
+                </template>
+            </div>
+
+            <input x-model="active" type="hidden">
+        </main>
+    `],
+    ({ get }) => {
+        get('input').should(haveAttribute('value', ''))
+        get('[option="access-1"]').click()
+        get('input').should(haveAttribute('value', ''))
+    },
+)
+
+test('cannot select a disabled option',
+    [html`
+        <main x-data="{ active: null, access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio x-model="active">
+                <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                    <div :option="id" x-radio:option :value="id" :disabled="disabled">
+                        <span x-radio:label x-text="name"></span>
+                        <span x-radio:description x-text="description"></span>
+                    </div>
+                </template>
+            </div>
+
+            <input x-model="active" type="hidden">
+        </main>
+    `],
+    ({ get }) => {
+        get('input').should(haveAttribute('value', ''))
+        get('[option="access-3"]').click()
+        get('input').should(haveAttribute('value', ''))
+    },
+)
+
+test('keyboard navigation works',
+    [html`
+        <main x-data="{ active: null, access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio x-model="active">
+                <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                    <div :option="id" x-radio:option :value="id" :disabled="disabled">
+                        <span x-radio:label x-text="name"></span>
+                        <span x-radio:description x-text="description"></span>
+                    </div>
+                </template>
+            </div>
+
+            <input x-model="active" type="hidden">
+        </main>
+    `],
+    ({ get }) => {
+        get('input').should(haveAttribute('value', ''))
+        get('[option="access-1"]').focus().type('{downarrow}')
+        get('[option="access-2"]').should(haveFocus()).type('{downarrow}')
+        get('[option="access-4"]').should(haveFocus()).type('{downarrow}')
+        get('[option="access-1"]').should(haveFocus())
+    },
+)
+
+test('has accessibility attributes',
+    [html`
+        <main x-data="{ active: null, access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio group x-model="active">
+                <fieldset>
+                    <legend>
+                        <h2 x-radio:label>Privacy setting</h2>
+                        <p x-radio:description>Some description</p>
+                    </legend>
+
+                    <div>
+                        <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                            <div :option="id" x-radio:option :value="id" :disabled="disabled">
+                                <span :label="id" x-radio:label x-text="name"></span>
+                                <span :description="id" x-radio:description x-text="description"></span>
+                            </div>
+                        </template>
+                    </div>
+                </fieldset>
+            </div>
+        </main>
+    `],
+    ({ get }) => {
+        get('[group]').should(haveAttribute('role', 'radiogroup'))
+            .should(haveAttribute('aria-labelledby', 'alpine-radio-label-1'))
+            .should(haveAttribute('aria-describedby', 'alpine-radio-description-1'))
+        get('h2').should(haveAttribute('id', 'alpine-radio-label-1'))
+        get('p').should(haveAttribute('id', 'alpine-radio-description-1'))
+
+        get('[option="access-1"]')
+            .should(haveAttribute('tabindex', 0))
+
+        for (i in 4) {
+            get(`[option="access-${i}"]`)
+                .should(haveAttribute('role', 'radio'))
+                .should(haveAttribute('aria-disabled', 'false'))
+                .should(haveAttribute('aria-labelledby', `alpine-radio-label-${i + 1}`))
+                .should(haveAttribute('aria-describedby', `alpine-radio-description-${i + 1}`))
+            get(`[label="access-${i}"]`)
+                .should(haveAttribute('id', `alpine-radio-label-${i + 1}`))
+            get(`[description="access-${i}"]`)
+                .should(haveAttribute('id', `alpine-radio-description-${i + 1}`))
+        }
+
+        get('[option="access-1"]')
+            .click()
+            .should(haveAttribute('aria-checked', 'true'))
+    },
+)
+
+test('$radioOption.isActive, $radioOption.isChecked, $radioOption.isDisabled work',
+    [html`
+        <main x-data="{ access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio group>
+                <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                    <div
+                        :option="id"
+                        x-radio:option
+                        :value="id"
+                        :disabled="disabled"
+                        :class="{
+                            'active': $radioOption.isActive,
+                            'checked': $radioOption.isChecked,
+                            'disabled': $radioOption.isDisabled,
+                        }"
+                    >
+                        <span :label="id" x-radio:label x-text="name"></span>
+                        <span :description="id" x-radio:description x-text="description"></span>
+                    </div>
+                </template>
+            </div>
+        </main>
+    `],
+    ({ get }) => {
+        get('[option="access-1"]')
+            .should(notHaveClasses(['active', 'checked', 'disabled']))
+            .focus()
+            .should(haveClasses(['active']))
+            .should(notHaveClasses(['checked']))
+            .type(' ')
+            .should(haveClasses(['active', 'checked']))
+            .type('{downarrow}')
+        get('[option="access-2"]')
+            .should(haveClasses(['active', 'checked']))
+        get('[option="access-3"]')
+            .should(haveClasses(['disabled']))
+    },
+)
+
+test('can bind objects to the value',
+    [html`
+        <main x-data="{ active: null, access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio group x-model="active">
+                <template x-for="(option, i) in access" :key="option.id">
+                    <div
+                        :option="option.id"
+                        x-radio:option
+                        :value="option"
+                        :disabled="option.disabled"
+                    >
+                        <span :label="option.id" x-radio:label x-text="option.name"></span>
+                        <span :description="option.id" x-radio:description x-text="option.description"></span>
+                    </div>
+                </template>
+            </div>
+
+            <article x-text="JSON.stringify(active)"></article>
+        </main>
+    `],
+    ({ get }) => {
+        get('[option="access-2"]').click()
+        get('article')
+            .should(haveText(JSON.stringify({
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            })))
+    },
+)
+
+test('name prop',
+    [html`
+        <main
+            x-data="{
+                active: null,
+                access: [
+                    {
+                        id: 'access-1',
+                        name: 'Public access',
+                        description: 'This project would be available to anyone who has the link',
+                        disabled: false,
+                    },
+                    {
+                        id: 'access-2',
+                        name: 'Private to Project Members',
+                        description: 'Only members of this project would be able to access',
+                        disabled: false,
+                    },
+                    {
+                        id: 'access-3',
+                        name: 'Private to you',
+                        description: 'You are the only one able to access this project',
+                        disabled: true,
+                    },
+                    {
+                        id: 'access-4',
+                        name: 'Private to you',
+                        description: 'You are the only one able to access this project',
+                        disabled: false,
+                    },
+                ]
+            }
+        ">
+            <div x-radio group x-model="active" name="access">
+                <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                    <div
+                        :option="id"
+                        x-radio:option
+                        :value="id"
+                        :disabled="disabled"
+                    >
+                        <span :label="id" x-radio:label x-text="name"></span>
+                        <span :description="id" x-radio:description x-text="description"></span>
+                    </div>
+                </template>
+            </div>
+        </main>
+    `],
+    ({ get }) => {
+        get('input').should(notExist())
+        get('[option="access-2"]').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'access'))
+            .should(haveAttribute('value', 'access-2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[option="access-4"]').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'access'))
+            .should(haveAttribute('value', 'access-4'))
+            .should(haveAttribute('type', 'hidden'))
+    },
+)