Caleb Porzio 2 éve
szülő
commit
b6da2c4ec8
2 módosított fájl, 214 hozzáadás és 364 törlés
  1. 195 364
      packages/ui/src/combobox.js
  2. 19 0
      packages/ui/src/list-context.js

+ 195 - 364
packages/ui/src/combobox.js

@@ -1,3 +1,4 @@
+import { generateContext } from "./list-context"
 
 export default function (Alpine) {
     Alpine.directive('combobox', (el, directive, { evaluate }) => {
@@ -9,55 +10,157 @@ export default function (Alpine) {
         else                                         handleRoot(el, Alpine)
     })
 
-    Alpine.magic('comboboxOption', el => {
-        let $data = Alpine.$data(el)
+    Alpine.magic('combobox', el => {
+        let data = Alpine.$data(el)
+
+        if (! data.__ready) return {
+            isDisabled: false,
+            isOpen: false,
+            selected: null,
+            active: null,
+        }
 
-        return $data.$item
+        return {
+            get isOpen() {
+                return data.__isOpen
+            },
+            get isDisabled() {
+                return data.__isDisabled
+            },
+            get selected() {
+                return data.__value
+            },
+            get active() {
+                return data.__context.active
+            },
+        }
     })
 
-    registerListStuff(Alpine)
+    Alpine.magic('comboboxOption', el => {
+        let data = Alpine.$data(el)
+
+        let stub = {
+            isDisabled: false,
+            isSelected: false,
+            isActive: true,
+        }
+
+        if (! data.__ready) return stub
+
+        let optionEl = Alpine.findClosest(el, i => i.__optionKey)
+
+        if (! optionEl) return stub
+
+        let context = data.__context
+
+        return {
+            get isActive() {
+                return context.isActiveEl(optionEl)
+            },
+            get isSelected() {
+                return context.isSelectedEl(optionEl)
+            },
+            get isDisabled() {
+                return context.isDisabledEl(optionEl)
+            },
+        }
+    })
 }
 
 function handleRoot(el, Alpine) {
     Alpine.bind(el, {
         'x-id'() { return ['alpine-combobox-button', 'alpine-combobox-options', 'alpine-combobox-label'] },
-        'x-list': '__value',
         'x-modelable': '__value',
         'x-data'() {
             return {
+                __ready: false,
+                __value: null,
+                __isOpen: false,
+                __context: undefined,
+                __isMultiple: undefined,
+                __isStatic: false,
+                __isDisabled: undefined,
+                __displayValue: undefined,
+                __compareBy: null,
+                __inputName: null,
+                __orientation: 'vertical',
                 init() {
-                    this.$nextTick(() => {
-                        this.syncInputValue()
-
-                        Alpine.effect(() => this.syncInputValue())
+                    this.__isMultiple = Alpine.bound(el, 'multiple', false)
+                    this.__isDisabled = Alpine.bound(el, 'disabled', false)
+                    this.__inputName = Alpine.bound(el, 'name', null)
+                    this.__compareBy = Alpine.bound(el, 'by')
+                    this.__orientation = Alpine.bound(el, 'horizontal', false) ? 'horizontal' : 'vertical'
+
+                    this.__context = generateContext(this.__isMultiple, this.__orientation)
+
+                    let defaultValue = Alpine.bound(el, 'default-value', null)
+
+                    this.__value = defaultValue
+
+                    // @todo: remove me...
+                    window._reorder = () => this.__context.reorderKeys()
+
+                    // We have to wait for the rest of the HTML to initialize in Alpine before
+                    // we mark this component as "ready".
+                    queueMicrotask(() => {
+                        this.__ready = true
+
+                        // We have to wait again until after the "ready" processes are finished
+                        // to settle up currently selected Values (this prevents this next bit
+                        // of code from running multiple times on startup...)
+                        queueMicrotask(() => {
+                            // This "fingerprint" acts as a checksum of the last-known "value"
+                            // passed into x-model. We need to track this so that we can determine
+                            // from the reactive effect if it was the value that changed externally
+                            // or an option was selected internally...
+                            let lastValueFingerprint = false
+
+                            Alpine.effect(() => {
+                                // Accessing selected keys, so a change in it always triggers this effect...
+                                this.__context.selectedKeys
+
+                                if (lastValueFingerprint === false || lastValueFingerprint !== JSON.stringify(this.__value)) {
+                                    // Here we know that the value changed externally and we can add the selection...
+                                    this.__context.selectValue(this.__value, this.__compareBy)
+                                } else {
+                                    // Here we know that an option was selected and we can change the value...
+                                    this.__value = this.__context.selectedValueOrValues()
+                                }
+
+
+                                // Generate the "value" checksum for comparison next time...
+                                lastValueFingerprint = JSON.stringify(this.__value)
+
+                                // Everytime the value changes, we need to re-render the hidden inputs,
+                                // if a user passed the "name" prop...
+                                this.__inputName && renderHiddenInputs(this.$el, this.__inputName, this.__value)
+                            })
+
+                            Alpine.effect(() => {
+                                if (this.__value) {
+                                    this.$refs.__input.value = this.$data.__displayValue(this.__value)
+                                }
+                            })
+                        })
                     })
                 },
-                __value: null,
-                __disabled: false,
-                __static: false,
-                __hold: false,
-                __displayValue: i => i,
-                __isOpen: false,
-                __optionsEl: null,
                 __open() {
-                    // @todo handle disabling the entire combobox.
                     if (this.__isOpen) return
                     this.__isOpen = true
 
-                    this.$list.activateSelectedOrFirst()
-                },
-                __close() {
-                    this.syncInputValue()
+                    this.__context.activateSelectedOrFirst()
 
-                    if (this.__static) return
-                    if (! this.__isOpen) return
+                    // 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))
 
-                    this.__isOpen = false
-                    this.$list.active = null
-                },
-                syncInputValue() {
-                    if (this.$list.selected) this.$refs.__input.value = this.__displayValue(this.$list.selected)
+                    nextTick(() => this.$refs.__options.focus({ preventScroll: true }))
                 },
+                __close() {
+                    this.__isOpen = false
+
+                    this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
+                }
             }
         },
         '@mousedown.window'(e) {
@@ -78,24 +181,31 @@ function handleInput(el, Alpine) {
         ':id'() { return this.$id('alpine-combobox-input') },
         'role': 'combobox',
         'tabindex': '0',
-        ':aria-controls'() { return this.$data.__optionsEl && this.$data.__optionsEl.id },
-        ':aria-expanded'() { return this.$data.__disabled ? undefined : this.$data.__isOpen },
-        ':aria-activedescendant'() { return this.$data.$list.activeEl ? this.$data.$list.activeEl.id : null },
-        ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
+        // ':aria-controls'() { return this.$data.__optionsEl && this.$data.__optionsEl.id },
+        // ':aria-expanded'() { return this.$data.__disabled ? undefined : this.$data.__isOpen },
+        // ':aria-activedescendant'() { return this.$data.$list.activeEl ? this.$data.$list.activeEl.id : null },
+        // ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
         'x-init'() {
             queueMicrotask(() => {
-                Alpine.effect(() => {
-                    this.$data.__disabled = Alpine.bound(this.$el, 'disabled', false)
-                })
+                // Alpine.effect(() => {
+                //     this.$data.__disabled = Alpine.bound(this.$el, 'disabled', false)
+                // })
 
                 let displayValueFn = Alpine.bound(this.$el, 'display-value')
                 if (displayValueFn) this.$data.__displayValue = displayValueFn
             })
         },
-        '@input.stop'() { this.$data.__open(); this.$dispatch('change') },
-        '@change.stop'() {},
-        '@keydown.enter.prevent.stop'() { this.$list.selectActive(); this.$data.__close() },
-        '@keydown'(e) { this.$list.handleKeyboardNavigation(e) },
+        '@input.stop'() {
+            this.$data.__open(); this.$dispatch('change')
+            setTimeout(() => this.$data.__context.reorderKeys())
+        },
+        '@change.stop'() {
+            setTimeout(() => this.$data.__context.reorderKeys())
+        },
+        '@keydown.enter.prevent.stop'() { this.$data.__context.selectActive(); this.$data.__close() },
+        '@keydown'(e) {
+            this.$data.__context.activateByKeyEvent(e)
+         },
         '@keydown.down'(e) { if(! this.$data.__isOpen) this.$data.__open(); },
         '@keydown.up'(e) { if(! this.$data.__isOpen) this.$data.__open(); },
         '@keydown.escape.prevent'(e) {
@@ -103,7 +213,7 @@ function handleInput(el, Alpine) {
 
             this.$data.__close()
         },
-        '@keydown.tab'() { if (this.$data.__isOpen) { this.$list.selectActive(); this.$data.__close() }},
+        '@keydown.tab'() { if (this.$data.__isOpen) { this.$data.__context.selectActive(); this.$data.__close() }},
     })
 }
 
@@ -112,9 +222,9 @@ function handleButton(el, Alpine) {
         'x-ref': '__button',
         ':id'() { return this.$id('alpine-combobox-button') },
         'aria-haspopup': 'true',
-        ':aria-labelledby'() { return this.$refs.__label ? [this.$refs.__label.id, this.$el.id].join(' ') : null },
-        ':aria-expanded'() { return this.$data.__disabled ? null : this.$data.__isOpen },
-        ':aria-controls'() { return this.$data.__optionsEl ? this.$data.__optionsEl.id : null },
+        // ':aria-labelledby'() { return this.$refs.__label ? [this.$refs.__label.id, this.$el.id].join(' ') : null },
+        // ':aria-expanded'() { return this.$data.__disabled ? null : this.$data.__isOpen },
+        // ':aria-controls'() { return this.$data.__optionsEl ? this.$data.__optionsEl.id : null },
         ':disabled'() { return this.$data.__disabled },
         'tabindex': '-1',
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
@@ -166,342 +276,63 @@ function handleOptions(el, Alpine) {
     Alpine.bind(el, {
         'x-ref': '__options',
         'x-init'() {
-            this.$data.__optionsEl = this.$el
-
-            queueMicrotask(() => {
-                if (Alpine.bound(this.$el, 'static')) {
-                    this.$data.__open()
-                    this.$data.__static = true;
-                }
-
-                if (Alpine.bound(this.$el, 'hold')) {
-                    this.$data.__hold = true;
-                }
-            })
+            this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
 
+            // if (Alpine.bound(this.$el, 'hold')) {
+            //     this.$data.__hold = true;
+            // }
             // Add `role="none"` to all non option elements.
-            this.$nextTick(() => {
-                let walker = document.createTreeWalker(
-                    this.$el,
-                    NodeFilter.SHOW_ELEMENT,
-                    { acceptNode: node => {
-                        if (node.getAttribute('role') === 'option') 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')
-            })
+            // this.$nextTick(() => {
+            //     let walker = document.createTreeWalker(
+            //         this.$el,
+            //         NodeFilter.SHOW_ELEMENT,
+            //         { acceptNode: node => {
+            //             if (node.getAttribute('role') === 'option') 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')
+            // })
         },
         'role': 'listbox',
         ':id'() { return this.$id('alpine-combobox-options') },
-        ':aria-labelledby'() { return this.$id('alpine-combobox-button') },
-        ':aria-activedescendant'() { return this.$list.activeEl ? this.$list.activeEl.id : null },
+        // ':aria-labelledby'() { return this.$id('alpine-combobox-button') },
+        // ':aria-activedescendant'() { return this.$list.activeEl ? this.$list.activeEl.id : null },
         'x-show'() { return this.$data.__isOpen },
     })
 }
 
-function handleOption(el, Alpine, directive, evaluate) {
-    let value = evaluate(directive.expression)
-
+function handleOption(el, Alpine) {
     Alpine.bind(el, {
         'role': 'option',
-        'x-item'() { return value },
-        ':id'() { return this.$id('alpine-combobox-option') },
-        ':tabindex'() { return this.$item.disabled ? undefined : '-1' },
-        ':aria-selected'() { return this.$item.selected },
-        ':aria-disabled'() { return this.$item.disabled },
-        '@click'(e) {
-            if (this.$item.disabled) e.preventDefault()
-            this.$item.select()
-            this.$data.__close()
-            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
-        },
-        '@focus'() {
-            if (this.$item.disabled) return this.$list.deactivate()
-            this.$item.activate()
-        },
-        '@pointermove'() {
-            if (this.$item.disabled || this.$item.active) return
-            this.$item.activate()
-        },
-        '@mousemove'() {
-            if (this.$item.disabled || this.$item.active) return
-            this.$item.activate()
-        },
-        '@pointerleave'() {
-            if (this.$item.disabled || ! this.$item.active || this.$data.__hold) return
-            this.$list.deactivate()
-        },
-        '@mouseleave'() {
-            if (this.$item.disabled || ! this.$item.active || this.$data.__hold) return
-            this.$list.deactivate()
-        },
-    })
-}
-
-function registerListStuff(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) {
-                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 }) => {
-        let value
-        el._x_listItem = true
-
-        if (expression) value = evaluate(expression)
-
-        let listEl = Alpine.findClosest(el, el => el._x_listState)
-
-        console.log(value)
-        listEl._x_listState.addItem(el, value)
-
-        queueMicrotask(() => {
-            Alpine.bound(el, 'disabled') && listEl._x_listState.disableItem(el)
-        })
-
-        cleanup(() => {
-            listEl._x_listState.removeItem(el)
-            delete el._x_listItem
-        })
-    })
+        'x-init'() {
+            el._x_optionReady = Alpine.reactive({ state: false })
 
-    Alpine.magic('item', el => {
-        let listEl = Alpine.findClosest(el, el => el._x_listState)
-        let itemEl = Alpine.findClosest(el, el => el._x_listItem)
+            queueMicrotask(() => {
+                el._x_optionReady.state = true
 
-        if (! listEl) throw 'Cant find x-list element'
-        if (! itemEl) throw 'Cant find x-item element'
+                let value = Alpine.bound(el, 'value')
+                let disabled = Alpine.bound(el, 'disabled')
 
-        return generateItemObject(listEl, itemEl)
+                el.__optionKey = this.$data.__context.initItem(el, value, disabled)
+            })
+        },
+        ':id'() { return this.$id('alpine-combobox-option') },
+        // ':tabindex'() { return this.$item.disabled ? undefined : '-1' },
+        // ':aria-selected'() { return this.$item.selected },
+        // ':aria-disabled'() { return this.$item.disabled },
+        '@click'() {
+            if (this.$comboboxOption.isDisabled) return;
+            this.$data.__context.selectEl(el);
+            this.$data.__isMultiple || this.$data.__close()
+        },
+        // @todo: this is a memory leak for _x_cleanups...
+        '@mouseenter'() { this.$data.__context.activateEl(el) },
+        '@mouseleave'() { this.$data.__context.deactivate() },
     })
-
-    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
-
-                return null
-            },
-            get selected() {
-                if (state.reactive.selected) return state.reactive.selected === item.value
-
-                return null
-            },
-            get disabled() {
-                return item.disabled
-            },
-            get el() { return item.el },
-            get value() { return item.value },
-        }
-    }
 }
 
 /* <div x-data="{

+ 19 - 0
packages/ui/src/list-context.js

@@ -17,6 +17,25 @@ export function generateContext(multiple, orientation) {
          *  Initialization...
          */
         initItem(el, value, disabled) {
+            // First, check if there is an existing value...
+            if (Object.values(this.values).includes(value)) {
+                let key = Object.keys(this.values).find(key => this.values[key] === value)
+
+                // Remove the old el association and replace it with this one...
+                delete this.elsByKey[key]
+                this.elsByKey[key] = el
+
+                // Refresh the searchable text...
+                this.searchableText[key] = el.textContent.trim().toLowerCase()
+
+                // Refresh disabled...
+                disabled && (! this.disabledKeys.includes(key)) && this.disabledKeys.push(key)
+
+                console.log('refreshed item')
+
+                return key
+            }
+
             let key = (Math.random() + 1).toString(36).substring(7)
 
             // Register value by key...