|
@@ -1,7 +1,8 @@
|
|
|
|
+import { generateContext } from './lists-context'
|
|
|
|
|
|
export default function (Alpine) {
|
|
export default function (Alpine) {
|
|
Alpine.directive('listbox', (el, directive) => {
|
|
Alpine.directive('listbox', (el, directive) => {
|
|
- if (!directive.value) handleRoot(el, Alpine)
|
|
|
|
|
|
+ if (! directive.value) handleRoot(el, Alpine)
|
|
else if (directive.value === 'label') handleLabel(el, Alpine)
|
|
else if (directive.value === 'label') handleLabel(el, Alpine)
|
|
else if (directive.value === 'button') handleButton(el, Alpine)
|
|
else if (directive.value === 'button') handleButton(el, Alpine)
|
|
else if (directive.value === 'options') handleOptions(el, Alpine)
|
|
else if (directive.value === 'options') handleOptions(el, Alpine)
|
|
@@ -9,27 +10,80 @@ export default function (Alpine) {
|
|
})
|
|
})
|
|
|
|
|
|
Alpine.magic('listbox', (el, { evaluate }) => {
|
|
Alpine.magic('listbox', (el, { evaluate }) => {
|
|
- return evaluate('$list', el)
|
|
|
|
|
|
+ let data = Alpine.$data(el)
|
|
|
|
+
|
|
|
|
+ if (! data.__ready) return {
|
|
|
|
+ isDisabled: false,
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return {
|
|
|
|
+ get isDisabled() {
|
|
|
|
+ return data.__isDisabled
|
|
|
|
+ },
|
|
|
|
+ }
|
|
})
|
|
})
|
|
|
|
|
|
Alpine.magic('listboxOption', (el, { evaluate }) => {
|
|
Alpine.magic('listboxOption', (el, { evaluate }) => {
|
|
- return evaluate('$item', el)
|
|
|
|
|
|
+ let data = Alpine.$data(el)
|
|
|
|
+
|
|
|
|
+ let stub = {
|
|
|
|
+ isDisabled: false,
|
|
|
|
+ isSelected: false,
|
|
|
|
+ isActive: false,
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ 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) {
|
|
function handleRoot(el, Alpine) {
|
|
Alpine.bind(el, {
|
|
Alpine.bind(el, {
|
|
'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
|
|
'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
|
|
- 'x-list': '__value',
|
|
|
|
'x-modelable': '__value',
|
|
'x-modelable': '__value',
|
|
'x-data'() {
|
|
'x-data'() {
|
|
return {
|
|
return {
|
|
|
|
+ __ready: false,
|
|
__value: null,
|
|
__value: null,
|
|
__isOpen: false,
|
|
__isOpen: false,
|
|
|
|
+ __context: undefined,
|
|
|
|
+ __isMultiple: undefined,
|
|
|
|
+ __isDisabled: undefined,
|
|
|
|
+ init() {
|
|
|
|
+ this.__isMultiple = Alpine.bound(el, 'multiple', false)
|
|
|
|
+ this.__isDisabled = Alpine.bound(el, 'disabled', false)
|
|
|
|
+
|
|
|
|
+ this.__context = generateContext(this.__isMultiple)
|
|
|
|
+
|
|
|
|
+ Alpine.effect(() => {
|
|
|
|
+ this.__value = this.__context.selectedValueOrValues()
|
|
|
|
+ })
|
|
|
|
+
|
|
|
|
+ setTimeout(() => {
|
|
|
|
+ this.__ready = true
|
|
|
|
+ })
|
|
|
|
+ },
|
|
__open() {
|
|
__open() {
|
|
this.__isOpen = true
|
|
this.__isOpen = true
|
|
|
|
|
|
- this.$list.activateSelectedOrFirst()
|
|
|
|
|
|
+ this.__context.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
|
|
@@ -65,8 +119,14 @@ function handleButton(el, Alpine) {
|
|
':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-listbox-options') },
|
|
':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' },
|
|
'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
|
|
'@click'() { this.$data.__open() },
|
|
'@click'() { this.$data.__open() },
|
|
- '@keydown.[down|up|space|enter].stop.prevent'() { this.$data.__open() },
|
|
|
|
- '@keydown.up.stop.prevent'() { this.$data.__open() },
|
|
|
|
|
|
+ '@keydown'(e) {
|
|
|
|
+ if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
|
|
|
+ e.stopPropagation()
|
|
|
|
+ e.preventDefault()
|
|
|
|
+
|
|
|
|
+ this.$data.__open()
|
|
|
|
+ }
|
|
|
|
+ },
|
|
'@keydown.space.stop.prevent'() { this.$data.__open() },
|
|
'@keydown.space.stop.prevent'() { this.$data.__open() },
|
|
'@keydown.enter.stop.prevent'() { this.$data.__open() },
|
|
'@keydown.enter.stop.prevent'() { this.$data.__open() },
|
|
})
|
|
})
|
|
@@ -74,47 +134,53 @@ function handleButton(el, Alpine) {
|
|
|
|
|
|
function handleOptions(el, Alpine) {
|
|
function handleOptions(el, Alpine) {
|
|
Alpine.bind(el, {
|
|
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',
|
|
'x-ref': '__options',
|
|
- 'aria-orientation': 'vertical',
|
|
|
|
- 'role': 'listbox',
|
|
|
|
':id'() { return this.$id('alpine-listbox-options') },
|
|
':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-show'() { return this.$data.__isOpen },
|
|
- 'x-trap'() { return this.$data.__isOpen },
|
|
|
|
'@click.outside'() { this.$data.__close() },
|
|
'@click.outside'() { this.$data.__close() },
|
|
'@keydown.escape.stop.prevent'() { this.$data.__close() },
|
|
'@keydown.escape.stop.prevent'() { this.$data.__close() },
|
|
|
|
+ tabindex: '0',
|
|
|
|
+ 'role': 'listbox',
|
|
|
|
+ 'aria-orientation': 'vertical',
|
|
|
|
+ ':aria-labelledby'() { return this.$id('alpine-listbox-button') },
|
|
|
|
+ ':aria-activedescendant'() { return this.__context.activateEl() && this.__context.activateEl().id },
|
|
|
|
+ '@focus'() { this.__context.activateSelectedOrFirst() },
|
|
|
|
+ 'x-trap'() { return this.$data.__isOpen },
|
|
|
|
+ '@keydown'(e) { this.__context.activateByKeyEvent(e) },
|
|
|
|
+ '@keydown.enter.stop.prevent'() {
|
|
|
|
+ this.__context.selectActive();
|
|
|
|
+
|
|
|
|
+ this.$data.__isMultiple || this.$data.__close()
|
|
|
|
+ },
|
|
|
|
+ '@keydown.space.stop.prevent'() {
|
|
|
|
+ this.__context.selectActive();
|
|
|
|
+
|
|
|
|
+ this.$data.__isMultiple || this.$data.__close()
|
|
|
|
+ },
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
function handleOption(el, Alpine) {
|
|
function handleOption(el, Alpine) {
|
|
Alpine.bind(el, () => {
|
|
Alpine.bind(el, () => {
|
|
return {
|
|
return {
|
|
- 'x-data'() {
|
|
|
|
- return {
|
|
|
|
- '__value': undefined,
|
|
|
|
- '__disabled': false,
|
|
|
|
- init() {
|
|
|
|
- queueMicrotask(() => {
|
|
|
|
- this.__value = Alpine.bound(el, 'value');
|
|
|
|
- this.__disabled = Alpine.bound(el, 'disabled', false);
|
|
|
|
- })
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
- },
|
|
|
|
- 'x-item'() { return this.$data.__value },
|
|
|
|
':id'() { return this.$id('alpine-listbox-option') },
|
|
':id'() { return this.$id('alpine-listbox-option') },
|
|
- ':tabindex'() { return this.$data.__disabled ? false : '-1' },
|
|
|
|
|
|
+ ':tabindex'() { return this.$listbox.isDisabled ? false : '-1' },
|
|
'role': 'option',
|
|
'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() },
|
|
|
|
|
|
+ 'x-init'() {
|
|
|
|
+ queueMicrotask(() => {
|
|
|
|
+ let value = Alpine.bound(el, 'value')
|
|
|
|
+ let disabled = Alpine.bound(el, 'disabled')
|
|
|
|
+
|
|
|
|
+ el.__optionKey = this.$data.__context.initItem(el, value, disabled)
|
|
|
|
+ })
|
|
|
|
+ },
|
|
|
|
+ ':aria-selected'() { return this.$listboxOption.isSelected },
|
|
|
|
+ '@click'() {
|
|
|
|
+ this.$data.__context.selectEl(el);
|
|
|
|
+ this.$data.__isMultiple || this.$data.__close()
|
|
|
|
+ },
|
|
|
|
+ '@mousemove'() { this.$data.__context.activateEl(el) },
|
|
|
|
+ '@mouseleave'() { this.$data.__context.deactivate() },
|
|
}
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|