|
@@ -14,17 +14,24 @@ export default function (Alpine) {
|
|
let data = Alpine.$data(el)
|
|
let data = Alpine.$data(el)
|
|
|
|
|
|
return {
|
|
return {
|
|
|
|
+ get value() {
|
|
|
|
+ return data.__value
|
|
|
|
+ },
|
|
get isOpen() {
|
|
get isOpen() {
|
|
return data.__isOpen
|
|
return data.__isOpen
|
|
},
|
|
},
|
|
get isDisabled() {
|
|
get isDisabled() {
|
|
return data.__isDisabled
|
|
return data.__isDisabled
|
|
},
|
|
},
|
|
- get selected() {
|
|
|
|
- return data.__value
|
|
|
|
|
|
+ get activeOption() {
|
|
|
|
+ let active = data.__context.getActiveItem()
|
|
|
|
+
|
|
|
|
+ return active && active.value
|
|
},
|
|
},
|
|
- get active() {
|
|
|
|
- return data.__context.active
|
|
|
|
|
|
+ get activeIndex() {
|
|
|
|
+ let active = data.__context.getActiveItem()
|
|
|
|
+
|
|
|
|
+ return active && active.key
|
|
},
|
|
},
|
|
}
|
|
}
|
|
})
|
|
})
|
|
@@ -36,17 +43,15 @@ export default function (Alpine) {
|
|
|
|
|
|
if (! optionEl) throw 'No x-combobox:option directive found...'
|
|
if (! optionEl) throw 'No x-combobox:option directive found...'
|
|
|
|
|
|
- let context = data.__context
|
|
|
|
-
|
|
|
|
return {
|
|
return {
|
|
get isActive() {
|
|
get isActive() {
|
|
- return context.isActiveEl(optionEl)
|
|
|
|
|
|
+ return data.__context.isActiveKey(optionEl.__optionKey)
|
|
},
|
|
},
|
|
get isSelected() {
|
|
get isSelected() {
|
|
- return context.isSelectedEl(optionEl)
|
|
|
|
|
|
+ return data.__isSelected(optionEl)
|
|
},
|
|
},
|
|
get isDisabled() {
|
|
get isDisabled() {
|
|
- return context.isDisabledEl(optionEl)
|
|
|
|
|
|
+ return data.__context.isDisabled(optionEl.__optionKey)
|
|
},
|
|
},
|
|
}
|
|
}
|
|
})
|
|
})
|
|
@@ -54,10 +59,16 @@ export default function (Alpine) {
|
|
|
|
|
|
function handleRoot(el, Alpine) {
|
|
function handleRoot(el, Alpine) {
|
|
Alpine.bind(el, {
|
|
Alpine.bind(el, {
|
|
|
|
+ // Setup...
|
|
'x-id'() { return ['alpine-combobox-button', 'alpine-combobox-options', 'alpine-combobox-label'] },
|
|
'x-id'() { return ['alpine-combobox-button', 'alpine-combobox-options', 'alpine-combobox-label'] },
|
|
'x-modelable': '__value',
|
|
'x-modelable': '__value',
|
|
|
|
+
|
|
|
|
+ // Initialize...
|
|
'x-data'() {
|
|
'x-data'() {
|
|
return {
|
|
return {
|
|
|
|
+ /**
|
|
|
|
+ * Combobox state...
|
|
|
|
+ */
|
|
__ready: false,
|
|
__ready: false,
|
|
__value: null,
|
|
__value: null,
|
|
__isOpen: false,
|
|
__isOpen: false,
|
|
@@ -69,16 +80,25 @@ function handleRoot(el, Alpine) {
|
|
__compareBy: null,
|
|
__compareBy: null,
|
|
__inputName: null,
|
|
__inputName: null,
|
|
__orientation: 'vertical',
|
|
__orientation: 'vertical',
|
|
- init() {
|
|
|
|
- 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'
|
|
|
|
|
|
+ __isTyping: false,
|
|
|
|
+ __hold: false,
|
|
|
|
|
|
- this.__context = generateContext(this.__isMultiple, this.__orientation)
|
|
|
|
-
|
|
|
|
- let defaultValue = Alpine.bound(el, 'default-value', null)
|
|
|
|
|
|
+ /**
|
|
|
|
+ * Comobox initialization...
|
|
|
|
+ */
|
|
|
|
+ init() {
|
|
|
|
+ // We have to put this in a microtask so that all the bindings
|
|
|
|
+ // have a chance to register so we can resolve them properly...
|
|
|
|
+ // First, let's set initial state from
|
|
|
|
+ this.__isMultiple = Alpine.extractProp(el, 'multiple', false)
|
|
|
|
+ this.__isDisabled = Alpine.extractProp(el, 'disabled', false)
|
|
|
|
+ this.__inputName = Alpine.extractProp(el, 'name', null)
|
|
|
|
+ this.__nullable = Alpine.extractProp(el, 'nullable', false)
|
|
|
|
+ this.__compareBy = Alpine.extractProp(el, 'by')
|
|
|
|
+ this.__orientation = Alpine.extractProp(el, 'horizontal', false) ? 'horizontal' : 'vertical'
|
|
|
|
+ this.__context = generateContext(this.__isMultiple, this.__orientation, () => this.$data.__activateSelectedOrFirst())
|
|
|
|
+
|
|
|
|
+ let defaultValue = Alpine.bound(el, 'default-value', this.__isMultiple ? [] : null)
|
|
|
|
|
|
this.__value = defaultValue
|
|
this.__value = defaultValue
|
|
|
|
|
|
@@ -86,43 +106,40 @@ function handleRoot(el, Alpine) {
|
|
// to settle up currently selected Values (this prevents this next bit
|
|
// to settle up currently selected Values (this prevents this next bit
|
|
// of code from running multiple times on startup...)
|
|
// of code from running multiple times on startup...)
|
|
queueMicrotask(() => {
|
|
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(() => {
|
|
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,
|
|
// Everytime the value changes, we need to re-render the hidden inputs,
|
|
// if a user passed the "name" prop...
|
|
// if a user passed the "name" prop...
|
|
this.__inputName && renderHiddenInputs(this.$el, this.__inputName, this.__value)
|
|
this.__inputName && renderHiddenInputs(this.$el, this.__inputName, this.__value)
|
|
})
|
|
})
|
|
|
|
|
|
|
|
+ let nonReactiveThis = Alpine.raw(this)
|
|
|
|
+
|
|
|
|
+ // Keep the currently selected value in sync with the input value...
|
|
Alpine.effect(() => {
|
|
Alpine.effect(() => {
|
|
- if (this.__value) {
|
|
|
|
- let input = this.$refs.__input
|
|
|
|
- if (input) input.value = this.$data.__getCurrentValue()
|
|
|
|
- }
|
|
|
|
|
|
+ if (nonReactiveThis.__isTyping) return
|
|
|
|
+
|
|
|
|
+ this.__resetInput()
|
|
})
|
|
})
|
|
})
|
|
})
|
|
},
|
|
},
|
|
|
|
+ __startTyping() {
|
|
|
|
+ this.__isTyping = true
|
|
|
|
+ },
|
|
|
|
+ __stopTyping() {
|
|
|
|
+ this.__isTyping = false
|
|
|
|
+ },
|
|
|
|
+ __resetInput() {
|
|
|
|
+ let input = this.$refs.__input
|
|
|
|
+ if (! input) return
|
|
|
|
+
|
|
|
|
+ let value = this.$data.__getCurrentValue()
|
|
|
|
+
|
|
|
|
+ input.value = value
|
|
|
|
+ },
|
|
__getCurrentValue() {
|
|
__getCurrentValue() {
|
|
if (! this.$refs.__input) return ''
|
|
if (! this.$refs.__input) return ''
|
|
- if (this.$data.__displayValue) return this.$data.__displayValue(this.__value)
|
|
|
|
|
|
+ if (! this.__value) return ''
|
|
|
|
+ if (this.$data.__displayValue && this.__value !== undefined) return this.$data.__displayValue(this.__value)
|
|
if (typeof this.__value === 'string') return this.__value
|
|
if (typeof this.__value === 'string') return this.__value
|
|
return ''
|
|
return ''
|
|
},
|
|
},
|
|
@@ -130,7 +147,7 @@ function handleRoot(el, Alpine) {
|
|
if (this.__isOpen) return
|
|
if (this.__isOpen) return
|
|
this.__isOpen = true
|
|
this.__isOpen = true
|
|
|
|
|
|
- this.__context.activateSelectedOrFirst()
|
|
|
|
|
|
+ this.__activateSelectedOrFirst()
|
|
|
|
|
|
// Safari needs more of a "tick" for focusing after x-show for some reason.
|
|
// 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
|
|
// Probably because Alpine adds an extra tick when x-showing for @click.outside
|
|
@@ -141,11 +158,87 @@ function handleRoot(el, Alpine) {
|
|
__close() {
|
|
__close() {
|
|
this.__isOpen = false
|
|
this.__isOpen = false
|
|
|
|
|
|
- // I think this shouldn't be here...
|
|
|
|
- // this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
|
|
|
|
- }
|
|
|
|
|
|
+ this.__context.deactivate()
|
|
|
|
+ },
|
|
|
|
+ __activateSelectedOrFirst(activateSelected = true) {
|
|
|
|
+ if (! this.__isOpen) return
|
|
|
|
+
|
|
|
|
+ if (this.__context.activeKey) {
|
|
|
|
+ this.__context.activateAndScrollToKey(this.__context.activeKey)
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ let firstSelectedValue
|
|
|
|
+
|
|
|
|
+ if (this.__isMultiple) {
|
|
|
|
+ firstSelectedValue = this.__value.find(i => {
|
|
|
|
+ return !! this.__context.getItemByValue(i)
|
|
|
|
+ })
|
|
|
|
+ } else {
|
|
|
|
+ firstSelectedValue = this.__value
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (activateSelected && firstSelectedValue) {
|
|
|
|
+ let firstSelected = this.__context.getItemByValue(firstSelectedValue)
|
|
|
|
+
|
|
|
|
+ firstSelected && this.__context.activateAndScrollToKey(firstSelected.key)
|
|
|
|
+ } else {
|
|
|
|
+ this.__context.activateAndScrollToKey(this.__context.firstKey())
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+ __selectActive() {
|
|
|
|
+ let active = this.$data.__context.getActiveItem()
|
|
|
|
+ if (active) this.__toggleSelected(active.value)
|
|
|
|
+ },
|
|
|
|
+ __selectOption(el) {
|
|
|
|
+ let item = this.__context.getItemByEl(el)
|
|
|
|
+
|
|
|
|
+ if (item) this.__toggleSelected(item.value)
|
|
|
|
+ },
|
|
|
|
+ __isSelected(el) {
|
|
|
|
+ let item = this.__context.getItemByEl(el)
|
|
|
|
+
|
|
|
|
+ if (! item) return false
|
|
|
|
+ if (! item.value) return false
|
|
|
|
+
|
|
|
|
+ return this.__hasSelected(item.value)
|
|
|
|
+ },
|
|
|
|
+ __toggleSelected(value) {
|
|
|
|
+ if (! this.__isMultiple) {
|
|
|
|
+ this.__value = value
|
|
|
|
+
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ let index = this.__value.findIndex(j => this.__compare(j, value))
|
|
|
|
+
|
|
|
|
+ if (index === -1) {
|
|
|
|
+ this.__value.push(value)
|
|
|
|
+ } else {
|
|
|
|
+ this.__value.splice(index, 1)
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+ __hasSelected(value) {
|
|
|
|
+ if (! this.__isMultiple) return this.__compare(this.__value, value)
|
|
|
|
+
|
|
|
|
+ return this.__value.some(i => this.__compare(i, value))
|
|
|
|
+ },
|
|
|
|
+ __compare(a, b) {
|
|
|
|
+ let by = this.__compareBy
|
|
|
|
+
|
|
|
|
+ if (! by) by = (a, b) => Alpine.raw(a) === Alpine.raw(b)
|
|
|
|
+
|
|
|
|
+ if (typeof by === 'string') {
|
|
|
|
+ let property = by
|
|
|
|
+ by = (a, b) => a[property] === b[property]
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return by(a, b)
|
|
|
|
+ },
|
|
}
|
|
}
|
|
},
|
|
},
|
|
|
|
+
|
|
|
|
+ // Register event liseners..
|
|
'@mousedown.window'(e) {
|
|
'@mousedown.window'(e) {
|
|
if (
|
|
if (
|
|
!! ! this.$refs.__input.contains(e.target)
|
|
!! ! this.$refs.__input.contains(e.target)
|
|
@@ -160,62 +253,105 @@ function handleRoot(el, Alpine) {
|
|
|
|
|
|
function handleInput(el, Alpine) {
|
|
function handleInput(el, Alpine) {
|
|
Alpine.bind(el, {
|
|
Alpine.bind(el, {
|
|
|
|
+ // Setup...
|
|
'x-ref': '__input',
|
|
'x-ref': '__input',
|
|
':id'() { return this.$id('alpine-combobox-input') },
|
|
':id'() { return this.$id('alpine-combobox-input') },
|
|
|
|
+
|
|
|
|
+ // Accessibility attributes...
|
|
'role': 'combobox',
|
|
'role': 'combobox',
|
|
'tabindex': '0',
|
|
'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) },
|
|
|
|
- 'x-init'() {
|
|
|
|
- queueMicrotask(() => {
|
|
|
|
- // Alpine.effect(() => {
|
|
|
|
- // this.$data.__disabled = Alpine.bound(this.$el, 'disabled', false)
|
|
|
|
- // })
|
|
|
|
|
|
+ // We need to defer this evaluation a bit because $refs that get declared later
|
|
|
|
+ // in the DOM aren't available yet when x-ref is the result of an Alpine.bind object.
|
|
|
|
+ async ':aria-controls'() { return await microtask(() => this.$refs.__options && this.$refs.__options.id) },
|
|
|
|
+ ':aria-expanded'() { return this.$data.__isDisabled ? undefined : this.$data.__isOpen },
|
|
|
|
+ ':aria-multiselectable'() { return this.$data.__isMultiple ? true : undefined },
|
|
|
|
+ ':aria-activedescendant'() {
|
|
|
|
+ if (! this.$data.__context.hasActive()) return
|
|
|
|
|
|
- let displayValueFn = Alpine.bound(this.$el, 'display-value')
|
|
|
|
- if (displayValueFn) this.$data.__displayValue = displayValueFn
|
|
|
|
- })
|
|
|
|
|
|
+ let active = this.$data.__context.getActiveItem()
|
|
|
|
+
|
|
|
|
+ return active ? active.el.id : null
|
|
},
|
|
},
|
|
|
|
+ ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
|
|
|
|
+
|
|
|
|
+ // Initialize...
|
|
|
|
+ 'x-init'() {
|
|
|
|
+ let displayValueFn = Alpine.extractProp(this.$el, 'display-value')
|
|
|
|
+ if (displayValueFn) this.$data.__displayValue = displayValueFn
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ // Register listeners...
|
|
'@input.stop'() {
|
|
'@input.stop'() {
|
|
this.$data.__open(); this.$dispatch('change')
|
|
this.$data.__open(); this.$dispatch('change')
|
|
- // setTimeout(() => this.$data.__context.reorderKeys())
|
|
|
|
},
|
|
},
|
|
- // '@change.stop'() {
|
|
|
|
- // setTimeout(() => this.$data.__context.reorderKeys())
|
|
|
|
- // },
|
|
|
|
|
|
+ '@blur'() { this.$data.__isTyping = false } ,
|
|
|
|
+ '@keydown'(e) {
|
|
|
|
+ queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, () => this.$data.__isOpen, () => this.$data.__open(), (state) => this.$data.__isTyping = state, () => this.$data.__resetInput()))
|
|
|
|
+ },
|
|
'@keydown.enter.prevent.stop'() {
|
|
'@keydown.enter.prevent.stop'() {
|
|
- this.$data.__context.selectActive();
|
|
|
|
|
|
+ this.$data.__selectActive()
|
|
|
|
+
|
|
this.$data.__isMultiple || this.$data.__close()
|
|
this.$data.__isMultiple || this.$data.__close()
|
|
|
|
+
|
|
|
|
+ this.$data.__isTyping = false
|
|
},
|
|
},
|
|
- '@keydown'(e) {
|
|
|
|
- queueMicrotask(() => 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) {
|
|
'@keydown.escape.prevent'(e) {
|
|
if (! this.$data.__static) e.stopPropagation()
|
|
if (! this.$data.__static) e.stopPropagation()
|
|
|
|
|
|
this.$data.__close()
|
|
this.$data.__close()
|
|
|
|
+
|
|
|
|
+ this.$data.__isTyping = false
|
|
|
|
+ },
|
|
|
|
+ '@keydown.tab'() {
|
|
|
|
+ if (this.$data.__isOpen) { this.$data.__close() }
|
|
|
|
+
|
|
|
|
+ this.$data.__stopTyping()
|
|
|
|
+ this.$data.__resetInput()
|
|
|
|
+ },
|
|
|
|
+ '@keydown.backspace'(e) {
|
|
|
|
+ if (this.$data.__isMultiple) return
|
|
|
|
+ if (! this.$data.__nullable) return
|
|
|
|
+
|
|
|
|
+ let input = e.target
|
|
|
|
+
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
+ if (input.value === '') {
|
|
|
|
+ this.$data.__value = null
|
|
|
|
+
|
|
|
|
+ let options = this.$refs.__options
|
|
|
|
+ if (options) {
|
|
|
|
+ options.scrollTop = 0
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ this.$data.__context.deactivate()
|
|
|
|
+ }
|
|
|
|
+ })
|
|
},
|
|
},
|
|
- '@keydown.tab'() { if (this.$data.__isOpen) { this.$data.__context.selectActive(); this.$data.__close() }},
|
|
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
function handleButton(el, Alpine) {
|
|
function handleButton(el, Alpine) {
|
|
Alpine.bind(el, {
|
|
Alpine.bind(el, {
|
|
|
|
+ // Setup...
|
|
'x-ref': '__button',
|
|
'x-ref': '__button',
|
|
':id'() { return this.$id('alpine-combobox-button') },
|
|
':id'() { return this.$id('alpine-combobox-button') },
|
|
|
|
+
|
|
|
|
+ // Accessibility attributes...
|
|
'aria-haspopup': 'true',
|
|
'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 },
|
|
|
|
- ':disabled'() { return this.$data.__disabled },
|
|
|
|
|
|
+ // We need to defer this evaluation a bit because $refs that get declared later
|
|
|
|
+ // in the DOM aren't available yet when x-ref is the result of an Alpine.bind object.
|
|
|
|
+ async ':aria-controls'() { return await microtask(() => this.$refs.__options && this.$refs.__options.id) },
|
|
|
|
+ ':aria-labelledby'() { return this.$refs.__label ? [this.$refs.__label.id, this.$el.id].join(' ') : null },
|
|
|
|
+ ':aria-expanded'() { return this.$data.__isDisabled ? null : this.$data.__isOpen },
|
|
|
|
+ ':disabled'() { return this.$data.__isDisabled },
|
|
'tabindex': '-1',
|
|
'tabindex': '-1',
|
|
|
|
+
|
|
|
|
+ // Initialize....
|
|
'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
|
|
'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
|
|
|
|
+
|
|
|
|
+ // Register listeners...
|
|
'@click'(e) {
|
|
'@click'(e) {
|
|
- if (this.$data.__disabled) return
|
|
|
|
|
|
+ if (this.$data.__isDisabled) return
|
|
if (this.$data.__isOpen) {
|
|
if (this.$data.__isOpen) {
|
|
this.$data.__close()
|
|
this.$data.__close()
|
|
} else {
|
|
} else {
|
|
@@ -225,28 +361,6 @@ function handleButton(el, Alpine) {
|
|
|
|
|
|
this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
|
|
this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
|
|
},
|
|
},
|
|
- '@keydown.down.prevent.stop'() {
|
|
|
|
- if (! this.$data.__isOpen) {
|
|
|
|
- this.$data.__open()
|
|
|
|
- this.$list.activateSelectedOrFirst()
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
|
|
|
|
- },
|
|
|
|
- '@keydown.up.prevent.stop'() {
|
|
|
|
- if (! this.$data.__isOpen) {
|
|
|
|
- this.$data.__open()
|
|
|
|
- this.$list.activateSelectedOrLast()
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
|
|
|
|
- },
|
|
|
|
- '@keydown.escape.prevent'(e) {
|
|
|
|
- if (! this.$data.__static) e.stopPropagation()
|
|
|
|
-
|
|
|
|
- this.$data.__close()
|
|
|
|
- this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
|
|
|
|
- },
|
|
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
@@ -260,114 +374,88 @@ function handleLabel(el, Alpine) {
|
|
|
|
|
|
function handleOptions(el, Alpine) {
|
|
function handleOptions(el, Alpine) {
|
|
Alpine.bind(el, {
|
|
Alpine.bind(el, {
|
|
|
|
+ // Setup...
|
|
'x-ref': '__options',
|
|
'x-ref': '__options',
|
|
|
|
+ ':id'() { return this.$id('alpine-combobox-options') },
|
|
|
|
+
|
|
|
|
+ // Accessibility attributes...
|
|
|
|
+ 'role': 'combobox',
|
|
|
|
+ ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
|
|
|
|
+ ':aria-activedescendant'() {
|
|
|
|
+ if (! this.$data.__context.hasActive()) return
|
|
|
|
+
|
|
|
|
+ let active = this.$data.__context.getActiveItem()
|
|
|
|
+
|
|
|
|
+ return active ? active.el.id : null
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ // Initialize...
|
|
'x-init'() {
|
|
'x-init'() {
|
|
this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
|
|
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')
|
|
|
|
- // })
|
|
|
|
|
|
+ if (Alpine.bound(this.$el, 'hold')) {
|
|
|
|
+ this.$data.__hold = true;
|
|
|
|
+ }
|
|
},
|
|
},
|
|
- '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 },
|
|
|
|
|
|
+
|
|
'x-show'() { return this.$data.__isOpen },
|
|
'x-show'() { return this.$data.__isOpen },
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
function handleOption(el, Alpine) {
|
|
function handleOption(el, Alpine) {
|
|
Alpine.bind(el, {
|
|
Alpine.bind(el, {
|
|
|
|
+ // Setup...
|
|
|
|
+ 'x-id'() { return ['alpine-combobox-option'] },
|
|
|
|
+ ':id'() { return this.$id('alpine-combobox-option') },
|
|
|
|
+
|
|
|
|
+ // Accessibility attributes...
|
|
'role': 'option',
|
|
'role': 'option',
|
|
- 'x-init'() {
|
|
|
|
- el._x_optionReady = Alpine.reactive({ state: false })
|
|
|
|
|
|
+ ':tabindex'() { return this.$comboboxOption.isDisabled ? undefined : '-1' },
|
|
|
|
+ ':aria-selected'() { return this.$comboboxOption.isSelected },
|
|
|
|
+ ':aria-disabled'() { return this.$comboboxOption.isDisabled },
|
|
|
|
|
|
- el.__optionKey = this.$data.__context.createItem(el)
|
|
|
|
|
|
+ // Initialize...
|
|
|
|
+ 'x-data'() {
|
|
|
|
+ return {
|
|
|
|
+ init() {
|
|
|
|
+ let key = el.__optionKey = (Math.random() + 1).toString(36).substring(7)
|
|
|
|
|
|
- queueMicrotask(() => {
|
|
|
|
- let value = Alpine.bound(el, 'value')
|
|
|
|
- let disabled = Alpine.bound(el, 'disabled')
|
|
|
|
|
|
+ let value = Alpine.extractProp(el, 'value')
|
|
|
|
+ let disabled = Alpine.extractProp(el, 'disabled', false, false)
|
|
|
|
|
|
- this.$data.__context.updateItem(el.__optionKey, value, disabled)
|
|
|
|
|
|
+ this.$data.__context.registerItem(key, el, value, disabled)
|
|
|
|
|
|
- // @todo: make sure this is what you want...
|
|
|
|
- el._x_forCleanup = () => {
|
|
|
|
- this.$data.__context.destroyItem(el)
|
|
|
|
- }
|
|
|
|
- })
|
|
|
|
|
|
+ // @todo: make sure the "destroy" hook is good enough and we don't need this...
|
|
|
|
+ // el._x_forCleanup = () => {
|
|
|
|
+ // this.$data.__context.unregisterItem(key)
|
|
|
|
+ // }
|
|
|
|
+ },
|
|
|
|
+ destroy() {
|
|
|
|
+ this.$data.__context.unregisterItem(this.$el.__optionKey)
|
|
|
|
+ },
|
|
|
|
+ }
|
|
},
|
|
},
|
|
- ':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 },
|
|
|
|
|
|
+
|
|
|
|
+ // Register listeners...
|
|
'@click'() {
|
|
'@click'() {
|
|
if (this.$comboboxOption.isDisabled) return;
|
|
if (this.$comboboxOption.isDisabled) return;
|
|
- this.$data.__context.selectEl(el);
|
|
|
|
|
|
+
|
|
|
|
+ this.$data.__selectOption(el)
|
|
|
|
+
|
|
this.$data.__isMultiple || this.$data.__close()
|
|
this.$data.__isMultiple || this.$data.__close()
|
|
|
|
+
|
|
this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
|
|
this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
|
|
},
|
|
},
|
|
|
|
+
|
|
// @todo: this is a memory leak for _x_cleanups...
|
|
// @todo: this is a memory leak for _x_cleanups...
|
|
'@mouseenter'() { this.$data.__context.activateEl(el) },
|
|
'@mouseenter'() { this.$data.__context.activateEl(el) },
|
|
- '@mouseleave'() { this.$data.__context.deactivate() },
|
|
|
|
|
|
+ '@mouseleave'() {
|
|
|
|
+ this.$data.__hold || this.$data.__context.deactivate()
|
|
|
|
+ },
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
-/* <div x-data="{
|
|
|
|
- query: '',
|
|
|
|
- selected: null,
|
|
|
|
- people: [
|
|
|
|
- { id: 1, name: 'Kevin' },
|
|
|
|
- { id: 2, name: 'Caleb' },
|
|
|
|
- ],
|
|
|
|
- get filteredPeople() {
|
|
|
|
- return this.people.filter(i => {
|
|
|
|
- return i.name.toLowerCase().includes(this.query.toLowerCase())
|
|
|
|
- })
|
|
|
|
- }
|
|
|
|
-}">
|
|
|
|
-<p x-text="query"></p>
|
|
|
|
-<div class="fixed top-16 w-72">
|
|
|
|
- <div x-combobox x-model="selected">
|
|
|
|
- <div class="relative mt-1">
|
|
|
|
- <div class="relative w-full cursor-default overflow-hidden rounded-lg bg-white text-left shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-teal-300 sm:text-sm">
|
|
|
|
- <input x-combobox:input class="w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-gray-900 focus:ring-0" :display-value="() => (person) => person.name" @change="query = $event.target.value" />
|
|
|
|
- <button x-combobox:button class="absolute inset-y-0 right-0 flex items-center pr-2">
|
|
|
|
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="h-5 w-5 text-gray-400"><path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
|
|
|
|
- </button>
|
|
|
|
- </div>
|
|
|
|
- <ul x-combobox:options class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
|
|
|
- <div x-show="filteredPeople.length === 0 && query !== ''" class="relative cursor-default select-none py-2 px-4 text-gray-700">
|
|
|
|
- Nothing found.
|
|
|
|
- </div>
|
|
|
|
-
|
|
|
|
- <template x-for="person in filteredPeople" :key="person.id">
|
|
|
|
- <li x-combobox:option :value="person" class="relative cursor-default select-none py-2 pl-10 pr-4" :class="{ 'bg-teal-600 text-white': $comboboxOption.active, 'text-gray-900': !$comboboxOption.active, }">
|
|
|
|
- <span x-text="person.name" class="block truncate" :class="{ 'font-medium': $comboboxOption.selected, 'font-normal': ! $comboboxOption.selected }"></span>
|
|
|
|
-
|
|
|
|
- <template x-if="$comboboxOption.selected">
|
|
|
|
- <span class="absolute inset-y-0 left-0 flex items-center pl-3" :class="{ 'text-white': $comboboxOption.active, 'text-teal-600': !$comboboxOption.active }">
|
|
|
|
- <CheckIcon class="h-5 w-5" aria-hidden="true" />
|
|
|
|
- </span>
|
|
|
|
- </template>
|
|
|
|
- </li>
|
|
|
|
- </template>
|
|
|
|
- </ul>
|
|
|
|
- </div>
|
|
|
|
- </div>
|
|
|
|
- </div>
|
|
|
|
-</div> */
|
|
|
|
|
|
+// Little utility to defer a callback into the microtask queue...
|
|
|
|
+function microtask(callback) {
|
|
|
|
+ return new Promise(resolve => queueMicrotask(() => resolve(callback())))
|
|
|
|
+}
|