Jason Beggs 2 năm trước cách đây
mục cha
commit
16bdb51d26

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

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

+ 257 - 0
packages/ui/src/helpers.js

@@ -0,0 +1,257 @@
+export default function (Alpine) {
+    Alpine.directive('list', (el, { expression, modifiers }, { evaluateLater, effect }) => {
+        let wrap = modifiers.includes('wrap')
+        let getOuterValue = () => null
+        let setOuterValue = () => {}
+
+        if (expression) {
+            let func = evaluateLater(expression)
+            getOuterValue = () => { let result; func(i => result = i); return result; }
+            let evaluateOuterSet = evaluateLater(`${expression} = __placeholder`)
+            setOuterValue = val => evaluateOuterSet(() => {}, { scope: { '__placeholder': val }})
+        }
+
+        let listEl = el
+
+        el._x_listState = {
+            wrap,
+            reactive: Alpine.reactive({
+                active: null,
+                selected: null,
+            }),
+            get active() { return this.reactive.active },
+            get selected() { return this.reactive.selected },
+            get activeEl() {
+                this.reactive.active
+
+                let item = this.items.find(i => i.value === this.reactive.active)
+
+                return item && item.el
+            },
+            get selectedEl() {
+                let item = this.items.find(i => i.value === this.reactive.selected)
+
+                return item && item.el
+            },
+            set active(value) { this.setActive(value) },
+            set selected(value) { this.setSelected(value) },
+            setSelected(value) {
+                console.log(value);
+
+                let item = this.items.find(i => i.value === value)
+
+                if (item && item.disabled) return
+
+                this.reactive.selected = value; setOuterValue(value)
+            },
+            setActive(value) {
+                let item = this.items.find(i => i.value === value)
+
+                if (item && item.disabled) return
+
+                this.reactive.active = value
+            },
+            deactivate() {
+                this.reactive.active = null
+            },
+            selectActive() {
+                this.selected = this.active
+            },
+            activateSelectedOrFirst() {
+                if (this.selected) this.active = this.selected
+                else this.first()?.activate()
+            },
+            activateSelectedOrLast() {
+                if (this.selected) this.active = this.selected
+                else this.last()?.activate()
+            },
+            items: [],
+            get filteredEls() { return this.items.filter(i => ! i.disabled).map(i => i.el) },
+            addItem(el, value, disabled = false) {
+                this.items.push({ el, value, disabled })
+                this.reorderList()
+            },
+            disableItem(el) {
+                this.items.find(i => i.el === el).disabled = true
+            },
+            removeItem(el) {
+                this.items = this.items.filter(i => i.el !== el)
+                this.reorderList()
+            },
+            reorderList() {
+                this.items = this.items.slice().sort((a, z) => {
+                    if (a === null || z === null) return 0
+
+                    let position = a.el.compareDocumentPosition(z.el)
+
+                    if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
+                    if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
+                    return 0
+                })
+            },
+            handleKeyboardNavigation(e) {
+                let item
+
+                switch (e.key) {
+                    case 'Tab':
+                    case 'Backspace':
+                    case 'Delete':
+                    case 'Meta':
+                        break;
+
+                        break;
+                    case ['ArrowDown', 'ArrowRight'][0]: // @todo handle orientation switching.
+                        e.preventDefault(); e.stopPropagation()
+                        item = this.active ? this.next() : this.first()
+                        break;
+
+                    case ['ArrowUp', 'ArrowLeft'][0]:
+                        e.preventDefault(); e.stopPropagation()
+                        item = this.active ? this.prev() : this.last()
+                        break;
+                    case 'Home':
+                    case 'PageUp':
+                        e.preventDefault(); e.stopPropagation()
+                        item = this.first()
+                        break;
+
+                    case 'End':
+                    case 'PageDown':
+                        e.preventDefault(); e.stopPropagation()
+                        item = this.last()
+                        break;
+
+                    default:
+                        if (e.key.length === 1) {
+                            // item = this.search(e.key)
+                        }
+                        break;
+                }
+
+                item && item.activate(({ el }) => {
+                    setTimeout(() => el.scrollIntoView({ block: 'nearest' }))
+                })
+            },
+            // Todo: the debounce doesn't work.
+            searchQuery: '',
+            clearSearch: Alpine.debounce(function () { this.searchQuery = '' }, 350),
+            search(key) {
+                this.searchQuery += key
+
+                let el = this.filteredEls.find(el => {
+                    return el.textContent.trim().toLowerCase().startsWith(this.searchQuery)
+                })
+
+                let obj = el ? generateItemObject(listEl, el) : null
+
+                this.clearSearch()
+
+                return obj
+            },
+            first() {
+                let el = this.filteredEls[0]
+
+                return el && generateItemObject(listEl, el)
+            },
+            last() {
+                let el = this.filteredEls[this.filteredEls.length-1]
+
+                return el && generateItemObject(listEl, el)
+            },
+            next() {
+                let current = this.activeEl || this.filteredEls[0]
+                let index = this.filteredEls.indexOf(current)
+
+                let el = this.wrap
+                    ? this.filteredEls[index + 1] || this.filteredEls[0]
+                    : this.filteredEls[index + 1] || this.filteredEls[index]
+
+                return el && generateItemObject(listEl, el)
+            },
+            prev() {
+                let current = this.activeEl || this.filteredEls[0]
+                let index = this.filteredEls.indexOf(current)
+
+                let el = this.wrap
+                    ? (index - 1 < 0 ? this.filteredEls[this.filteredEls.length-1] : this.filteredEls[index - 1])
+                    : (index - 1 < 0 ? this.filteredEls[0] : this.filteredEls[index - 1])
+
+                return el && generateItemObject(listEl, el)
+            },
+        }
+
+        effect(() => {
+            el._x_listState.setSelected(getOuterValue())
+        })
+    })
+
+    Alpine.magic('list', (el) => {
+        let listEl = Alpine.findClosest(el, el => el._x_listState)
+
+        return listEl._x_listState
+    })
+
+    Alpine.directive('item', (el, { expression }, { effect, evaluate, cleanup }) => {
+        el._x_listItem = true
+
+        let listEl = Alpine.findClosest(el, el => el._x_listState)
+
+        queueMicrotask(() => {
+            let value = Alpine.bound(el, 'value');
+
+            listEl._x_listState.addItem(el, value)
+
+            Alpine.bound(el, 'disabled') && listEl._x_listState.disableItem(el)
+        })
+
+        cleanup(() => {
+            listEl._x_listState.removeItem(el)
+            delete el._x_listItem
+        })
+    })
+
+    Alpine.magic('item', el => {
+        let listEl = Alpine.findClosest(el, el => el._x_listState)
+        let itemEl = Alpine.findClosest(el, el => el._x_listItem)
+
+        if (! listEl) throw 'Cant find x-list element'
+        if (! itemEl) throw 'Cant find x-item element'
+
+        return generateItemObject(listEl, itemEl)
+    })
+
+    function generateItemObject(listEl, el) {
+        let state = listEl._x_listState
+        let item = listEl._x_listState.items.find(i => i.el === el)
+
+        return {
+            activate(callback = () => {}) {
+                state.setActive(item.value)
+
+                callback(item)
+            },
+            deactivate() {
+                if (Alpine.raw(state.active) === Alpine.raw(item.value)) state.setActive(null)
+            },
+            select(callback = () => {}) {
+                state.setSelected(item.value)
+
+                callback(item)
+            },
+            isFirst() {
+                return state.items.findIndex(i => i.el.isSameNode(el)) === 0
+            },
+            get active() {
+                if (state.reactive.active) return state.reactive.active === item.value
+            },
+            get selected() {
+                if (state.reactive.selected) return state.reactive.selected === item.value
+            },
+            get disabled() {
+                return item.disabled
+            },
+            get el() { return item.el },
+            get value() { return item.value },
+        }
+    }
+}

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

@@ -1,12 +1,17 @@
+import helpers from './helpers';
+
 import dialog from './dialog'
 import disclosure from './disclosure'
+import listbox from './listbox'
 import popover from './popover'
 import notSwitch from './switch'
 import tabs from './tabs'
 
 export default function (Alpine) {
+    helpers(Alpine)
     dialog(Alpine)
     disclosure(Alpine)
+    listbox(Alpine)
     popover(Alpine)
     notSwitch(Alpine)
     tabs(Alpine)

+ 120 - 0
packages/ui/src/listbox.js

@@ -0,0 +1,120 @@
+
+export default function (Alpine) {
+    Alpine.directive('listbox', (el, directive) => {
+        if (!directive.value) handleRoot(el, Alpine)
+        else if (directive.value === 'label') handleLabel(el, Alpine)
+        else if (directive.value === 'button') handleButton(el, Alpine)
+        else if (directive.value === 'options') handleOptions(el, Alpine)
+        else if (directive.value === 'option') handleOption(el, Alpine)
+    })
+
+    Alpine.magic('listbox', (el, { evaluate }) => {
+        return evaluate('$list', el)
+    })
+
+    Alpine.magic('listboxOption', (el, { evaluate }) => {
+        return evaluate('$item', el)
+    })
+}
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
+        'x-list': '__value',
+        'x-modelable': '__value',
+        'x-data'() {
+            return {
+                __value: null,
+                __isOpen: false,
+                __open() {
+                    this.__isOpen = true
+
+                    this.$list.activateSelectedOrFirst()
+
+                    // 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.__options.focus({ preventScroll: true }))
+                },
+                __close() {
+                    this.__isOpen = false
+
+                    this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
+                }
+            }
+        },
+    })
+}
+
+function handleLabel(el, Alpine) {
+    Alpine.bind(el, {
+        'x-ref': '__label',
+        ':id'() { return this.$id('alpine-listbox-label') },
+        '@click'() { this.$refs.__button.focus({ preventScroll: true }) },
+    })
+}
+
+function handleButton(el, Alpine) {
+    Alpine.bind(el, {
+        'x-ref': '__button',
+        ':id'() { return this.$id('alpine-listbox-button') },
+        'aria-haspopup': 'true',
+        ':aria-labelledby'() { return this.$id('alpine-listbox-label') },
+        ':aria-expanded'() { return this.$data.__isOpen },
+        ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-listbox-options') },
+        'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
+        '@click'() { this.$data.__open() },
+        '@keydown.[down|up|space|enter].stop.prevent'() { this.$data.__open() },
+        '@keydown.up.stop.prevent'() { this.$data.__open() },
+        '@keydown.space.stop.prevent'() { this.$data.__open() },
+        '@keydown.enter.stop.prevent'() { this.$data.__open() },
+    })
+}
+
+function handleOptions(el, Alpine) {
+    Alpine.bind(el, {
+        tabindex: '0',
+        '@keydown'(e) { this.$list.handleKeyboardNavigation(e) },
+        // '@focus'() { this.$list.first().activate() },
+        '@keydown.enter.stop.prevent'() { this.$list.selectActive(); this.$data.__close() },
+        '@keydown.space.stop.prevent'() { this.$list.selectActive(); this.$data.__close() },
+        'x-ref': '__options',
+        'aria-orientation': 'vertical',
+        'role': 'listbox',
+        ':id'() { return this.$id('alpine-listbox-options') },
+        // ':aria-labelledby'() { return 'listbox-button-' + this.$data.__buttonId },
+        // ':aria-activedescendant'() { return this.$data.__activeEl && this.$data.__activeEl.id },
+        'x-show'() { return this.$data.__isOpen },
+        'x-trap'() { return this.$data.__isOpen },
+        '@click.outside'() { this.$data.__close() },
+        '@keydown.escape.stop.prevent'() { this.$data.__close() },
+    })
+}
+
+function handleOption(el, Alpine) {
+    Alpine.bind(el, () => {
+        return {
+            'x-data'() {
+                return {
+                    '__value': undefined,
+                    '__disabled': false,
+                    init() {
+                        queueMicrotask(() => {
+                            this.__value = Alpine.bound(el, 'value');
+                            this.__disabled = Alpine.bound(el, 'disabled', false);
+                            console.log(this.__value);
+                        })
+                    }
+                }
+            },
+            'x-item'() { return this.$data.__value },
+            ':id'() { return this.$id('alpine-listbox-option') },
+            ':tabindex'() { return this.$data.__disabled ? false : '-1' },
+            'role': 'option',
+            ':aria-selected'() { return this.$data.__selected === this.$data.__value },
+            '@click'() { this.$item.select(); this.$data.__close() },
+            '@mousemove'() { this.$item.activate() },
+            '@mouseleave'() { this.$item.deactivate() },
+        }
+    })
+}

+ 110 - 0
tests/cypress/integration/plugins/ui/listbox.spec.js

@@ -0,0 +1,110 @@
+import { beVisible, haveAttribute, haveText, html, notBeVisible, notHaveAttribute, test } from '../../../utils'
+
+test('it works with x-model',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb' },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                    >
+                        <span x-text="person.name"></span>
+
+                        <!-- <span x-show="$listboxOption.isSelected">
+                            selected
+                        </span> -->
+                    </li>
+                </template>
+            </ul>
+
+            <article x-text="active?.name"></article>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('ul').should(beVisible())
+        get('button').click()
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('[option="2"]').click()
+        get('ul').should(notBeVisible())
+        get('article').should(haveText('Arlene Mccoy'))
+    },
+)
+
+test('it works with internal state/$listbox',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb' },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                    >
+                        <span x-text="person.name"></span>
+
+                        <!-- <span x-show="$listboxOption.isSelected">
+                            selected
+                        </span> -->
+                    </li>
+                </template>
+            </ul>
+
+            <article x-text="$listbox.selected?.name"></article>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('ul').should(beVisible())
+        get('button').click()
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('[option="2"]').click()
+        get('ul').should(notBeVisible())
+        get('article').should(haveText('Arlene Mccoy'))
+    },
+)