Jason Beggs 2 anni fa
parent
commit
006dfcf6bf

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

@@ -1,9 +1,11 @@
 import dialog from './dialog'
 import popover from './popover'
+import radioGroup from './radio-group'
 import tabs from './tabs'
 
 export default function (Alpine) {
     dialog(Alpine)
     popover(Alpine)
+    radioGroup(Alpine)
     tabs(Alpine)
 }

+ 191 - 0
packages/ui/src/radio-group.js

@@ -0,0 +1,191 @@
+
+export default function (Alpine) {
+    Alpine.directive('radio-group', (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('radioGroupOption', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get active() {
+                return $data.__option === $data.__active
+            },
+            get checked() {
+                return $data.__option === $data.__value
+            },
+            get disabled() {
+                if ($data.__rootDisabled) return true
+
+                return $data.__disabledOptions.has($data.__option)
+            },
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    let disabled = Alpine.bound(el, 'disabled');
+
+    Alpine.bind(el, {
+        'x-data'() {
+            return {
+                init() {
+                    // Need the "microtask" here so that x-model has a chance to initialize.
+                    queueMicrotask(() => {
+                        // Set our internal "selected" every time the x-modeled value changes.
+                        Alpine.effect(() => {
+                            this.__value = this.$el._x_model.get()
+                        })
+                    })
+
+                    // 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: disabled,
+                __change(value) {
+                    if (this.__rootDisabled) return
+
+                    this.__rootEl._x_model.set(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)
+                },
+            }
+        },
+        'role': 'radiogroup',
+        'x-id'() { return ['alpine-radiogroup-label', 'alpine-radiogroup-description'] },
+        ':aria-labelledby'() { return this.__hasLabel && this.$id('alpine-radiogroup-label') },
+        ':aria-describedby'() { return this.__hasDescription && this.$id('alpine-radiogroup-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) {
+    let value = Alpine.bound(el, 'value');
+    let disabled = Alpine.bound(el, 'disabled');
+
+    Alpine.bind(el, {
+        'x-init'() {
+            this.$data.__addOption(value, this.$el, disabled)
+        },
+        'x-data'() {
+            return {
+                init() { },
+                __option: value,
+                __hasLabel: false,
+                __hasDescription: false,
+            }
+        },
+        'x-id'() { return ['alpine-radiogroup-label', 'alpine-radiogroup-description'] },
+        'role': 'radio',
+        ':aria-checked'() { return this.$radioGroupOption.checked },
+        ':aria-disabled'() { return this.$radioGroupOption.disabled },
+        ':aria-labelledby'() { return this.__hasLabel && this.$id('alpine-radiogroup-label') },
+        ':aria-describedby'() { return this.__hasDescription && this.$id('alpine-radiogroup-description') },
+        ':tabindex'() {
+            if (this.$radioGroupOption.disabled || disabled) return -1
+            if (this.$radioGroupOption.checked) return 0
+            if (!this.$data.__value && this.$data.__isFirstOption(value)) return 0
+            return -1
+        },
+        '@click'() {
+            if (this.$radioGroupOption.disabled) return
+            this.$data.__change(value)
+            this.$el.focus()
+        },
+        '@focus'() {
+            if (this.$radioGroupOption.disabled) return
+            this.$data.__setActive(value)
+        },
+        '@blur'() {
+            if (this.$data.__active === value) this.$data.__setActive(undefined)
+        },
+        '@keydown.space.stop.prevent'() { this.$data.__change(value) },
+    })
+}
+
+function handleLabel(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__hasLabel = true },
+        ':id'() { return this.$id('alpine-radiogroup-label') },
+    })
+}
+
+function handleDescription(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__hasDescription = true },
+        ':id'() { return this.$id('alpine-radiogroup-description') },
+    })
+}

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

@@ -0,0 +1,300 @@
+import { haveAttribute, haveFocus, html, notHaveFocus, test } 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-group x-model="active">
+                <fieldset>
+                    <legend>
+                        <h2 x-radio-group:label>Privacy setting</h2>
+                    </legend>
+
+                    <div>
+                        <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                            <div :option="id" x-radio-group:option :value="id" :disabled="disabled">
+                                <span x-radio-group:label x-text="name"></span>
+                                <span x-radio-group: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('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-group x-model="active" :disabled="true">
+                <fieldset>
+                    <legend>
+                        <h2 x-radio-group:label>Privacy setting</h2>
+                    </legend>
+
+                    <div>
+                        <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                            <div :option="id" x-radio-group:option :value="id" :disabled="disabled">
+                                <span x-radio-group:label x-text="name"></span>
+                                <span x-radio-group: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-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-group x-model="active">
+                <fieldset>
+                    <legend>
+                        <h2 x-radio-group:label>Privacy setting</h2>
+                    </legend>
+
+                    <div>
+                        <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                            <div :option="id" x-radio-group:option :value="id" :disabled="disabled">
+                                <span x-radio-group:label x-text="name"></span>
+                                <span x-radio-group: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-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-group x-model="active">
+                <fieldset>
+                    <legend>
+                        <h2 x-radio-group:label>Privacy setting</h2>
+                    </legend>
+
+                    <div>
+                        <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                            <div :option="id" x-radio-group:option :value="id" :disabled="disabled">
+                                <span x-radio-group:label x-text="name"></span>
+                                <span x-radio-group: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-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, options: [
+            {
+                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 group x-model="active">
+                <fieldset>
+                    <legend>
+                        <h2 x-radio-group:label>Privacy setting</h2>
+                        <p x-radio-group:description>Some description</p>
+                    </legend>
+
+                    <div>
+                        <template x-for="({ id, name, description, disabled }, i) in options" :key="id">
+                            <div :option="id" x-radio-group:option="({ value: id, disabled: disabled })">
+                                <span :label="id" x-radio-group:label x-text="name"></span>
+                                <span :description="id" x-radio-group:description x-text="description"></span>
+                            </div>
+                        </template>
+                    </div>
+                </fieldset>
+            </div>
+        </main>
+    `],
+    ({ get }) => {
+        get('[group]').should(haveAttribute('role', 'radiogroup'))
+            .should(haveAttribute('aria-labelledby', 'alpine-radiogroup-label-1'))
+            .should(haveAttribute('aria-describedby', 'alpine-radiogroup-description-1'))
+        get('h2').should(haveAttribute('id', 'alpine-radiogroup-label-1'))
+        get('p').should(haveAttribute('id', 'alpine-radiogroup-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-radiogroup-label-${i + 1}`))
+                .should(haveAttribute('aria-describedby', `alpine-radiogroup-description-${i + 1}`))
+            get(`[label="access-${i}"]`)
+                .should(haveAttribute('id', `alpine-radiogroup-label-${i + 1}`))
+            get(`[description="access-${i}"]`)
+                .should(haveAttribute('id', `alpine-radiogroup-description-${i + 1}`))
+        }
+
+        get('[option="access-1"]')
+            .click()
+            .should(haveAttribute('aria-checked', 'true'))
+    },
+)