Jason Beggs 2 gadi atpakaļ
vecāks
revīzija
3f68ac1741

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

@@ -196,6 +196,7 @@ let directiveOrder = [
     'radio',
     'switch',
     'disclosure',
+    'menu',
     'bind',
     'init',
     'for',

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

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

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

@@ -0,0 +1,208 @@
+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
+            },
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-id'() { return ['alpine-menu-button', 'alpine-menu-items'] },
+        '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() {
+                    this.__isOpen = false
+
+                    this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
+                }
+            }
+        },
+    })
+}
+
+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 },
+        'x-trap'() { 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 = !!this.$el.disabled
+                    },
+                    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 ? false : '-1' },
+            'role': 'menuitem',
+            '@mousemove'() { this.$el.__isDisabled || this.$menuItem.isActive || this.$el.__activate() },
+            '@mouseleave'() { this.$el.__isDisabled || !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(first, receive)
+        }
+
+        if (first.__isDisabled) return this.next(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) return this.previous(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 || next.tagName.toLowerCase() === 'template') return this.next(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 || prev.tagName.toLowerCase() === 'template') return this.previous(prev, receive, fallback)
+        return receive(prev)
+    },
+    searchQuery: '',
+    clearSearch(Alpine) {
+        Alpine.debounce(function () { this.searchQuery = '' }, 350)
+    },
+    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 && receiver(el)
+
+        this.clearSearch(Alpine)
+    },
+}

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

@@ -0,0 +1,201 @@
+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('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']))
+        get('[items]')
+            .type('{uparrow}')
+        get('[href="#account-settings"]')
+            .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('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'))
+    },
+)