123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 |
- import { generateContext, renderHiddenInputs } from './list-context'
- 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)
- }).before('bind')
- Alpine.magic('listbox', (el) => {
- let data = Alpine.$data(el)
- return {
- // @todo: remove "selected" and "active" when 1.0 is tagged...
- get selected() {
- return data.__value
- },
- get active() {
- let active = data.__context.getActiveItem()
- return active && active.value
- },
- get value() {
- return data.__value
- },
- get isOpen() {
- return data.__isOpen
- },
- get isDisabled() {
- return data.__isDisabled
- },
- get activeOption() {
- let active = data.__context.getActiveItem()
- return active && active.value
- },
- get activeIndex() {
- let active = data.__context.getActiveItem()
- return active && active.key
- },
- }
- })
- Alpine.magic('listboxOption', (el) => {
- let data = Alpine.$data(el)
- let optionEl = Alpine.findClosest(el, i => i.__optionKey)
- if (! optionEl) throw 'No x-combobox:option directive found...'
- return {
- get isActive() {
- return data.__context.isActiveKey(optionEl.__optionKey)
- },
- get isSelected() {
- return data.__isSelected(optionEl)
- },
- get isDisabled() {
- return data.__context.isDisabled(optionEl.__optionKey)
- },
- }
- })
- }
- function handleRoot(el, Alpine) {
- Alpine.bind(el, {
- // Setup...
- 'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
- 'x-modelable': '__value',
- // Initialize...
- 'x-data'() {
- return {
- /**
- * Listbox state...
- */
- __ready: false,
- __value: null,
- __isOpen: false,
- __context: undefined,
- __isMultiple: undefined,
- __isStatic: false,
- __isDisabled: undefined,
- __compareBy: null,
- __inputName: null,
- __orientation: 'vertical',
- __hold: false,
- /**
- * Comobox initialization...
- */
- init() {
- this.__isMultiple = Alpine.extractProp(el, 'multiple', false)
- this.__isDisabled = Alpine.extractProp(el, 'disabled', false)
- this.__inputName = Alpine.extractProp(el, 'name', null)
- 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.extractProp(el, 'default-value', this.__isMultiple ? [] : null)
- this.__value = defaultValue
- // 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(() => {
- Alpine.effect(() => {
- // 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)
- })
- // Keep the currently selected value in sync with the input value...
- Alpine.effect(() => {
- this.__resetInput()
- })
- })
- },
- __resetInput() {
- let input = this.$refs.__input
- if (! input) return
- let value = this.$data.__getCurrentValue()
- input.value = value
- },
- __getCurrentValue() {
- if (! this.$refs.__input) return ''
- 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
- return ''
- },
- __open() {
- if (this.__isOpen) return
- this.__isOpen = true
- this.__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.__context.deactivate()
- this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
- },
- __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)
- },
- }
- },
- })
- }
- 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, {
- // Setup...
- 'x-ref': '__button',
- ':id'() { return this.$id('alpine-listbox-button') },
- // Accessibility attributes...
- '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') },
- // Initialize....
- 'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
- // Register listeners...
- '@click'() { 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.enter.stop.prevent'() { this.$data.__open() },
- })
- }
- function handleOptions(el, Alpine) {
- Alpine.bind(el, {
- // Setup...
- 'x-ref': '__options',
- ':id'() { return this.$id('alpine-listbox-options') },
- // Accessibility attributes...
- 'role': 'listbox',
- tabindex: '0',
- ':aria-orientation'() {
- return this.$data.__orientation
- },
- ':aria-labelledby'() { return this.$id('alpine-listbox-button') },
- ':aria-activedescendant'() {
- if (! this.$data.__context.hasActive()) return
- let active = this.$data.__context.getActiveItem()
- return active ? active.el.id : null
- },
- // Initialize...
- 'x-init'() {
- this.$data.__isStatic = Alpine.extractProp(this.$el, 'static', false)
- if (Alpine.bound(this.$el, 'hold')) {
- this.$data.__hold = true;
- }
- },
- 'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
- 'x-trap'() { return this.$data.__isOpen },
- '@click.outside'() { this.$data.__close() },
- '@keydown.escape.stop.prevent'() { this.$data.__close() },
- '@focus'() { this.$data.__activateSelectedOrFirst() },
- '@keydown'(e) {
- queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, true, () => this.$data.__isOpen, () => this.$data.__open(), () => {}))
- },
- '@keydown.enter.stop.prevent'() {
- this.$data.__selectActive();
- this.$data.__isMultiple || this.$data.__close()
- },
- '@keydown.space.stop.prevent'() {
- this.$data.__selectActive();
- this.$data.__isMultiple || this.$data.__close()
- },
- })
- }
- function handleOption(el, Alpine) {
- Alpine.bind(el, () => {
- return {
- 'x-id'() { return ['alpine-listbox-option'] },
- ':id'() { return this.$id('alpine-listbox-option') },
- // Accessibility attributes...
- 'role': 'option',
- ':tabindex'() { return this.$listboxOption.isDisabled ? false : '-1' },
- ':aria-selected'() { return this.$listboxOption.isSelected },
- // Initialize...
- 'x-data'() {
- return {
- init() {
- let key = el.__optionKey = (Math.random() + 1).toString(36).substring(7)
- let value = Alpine.extractProp(el, 'value')
- let disabled = Alpine.extractProp(el, 'disabled', false, false)
- this.$data.__context.registerItem(key, el, value, disabled)
- },
- destroy() {
- this.$data.__context.unregisterItem(this.$el.__optionKey)
- },
- }
- },
- // Register listeners...
- '@click'() {
- if (this.$listboxOption.isDisabled) return;
- this.$data.__selectOption(el)
- this.$data.__isMultiple || this.$data.__close()
- },
- '@mouseenter'() { this.$data.__context.activateEl(el) },
- '@mouseleave'() {
- this.$data.__hold || this.$data.__context.deactivate()
- },
- }
- })
- }
- // Little utility to defer a callback into the microtask queue...
- function microtask(callback) {
- return new Promise(resolve => queueMicrotask(() => resolve(callback())))
- }
|