123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510 |
- import { generateContext, renderHiddenInputs } from './list-context'
- export default function (Alpine) {
- Alpine.directive('combobox', (el, directive, { evaluate }) => {
- if (directive.value === 'input') handleInput(el, Alpine)
- else if (directive.value === 'button') handleButton(el, Alpine)
- else if (directive.value === 'label') handleLabel(el, Alpine)
- else if (directive.value === 'options') handleOptions(el, Alpine)
- else if (directive.value === 'option') handleOption(el, Alpine, directive, evaluate)
- else handleRoot(el, Alpine)
- }).before('bind')
- Alpine.magic('combobox', el => {
- let data = Alpine.$data(el)
- return {
- 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()
- if (active) {
- return Object.values(Alpine.raw(data.__context.items)).findIndex(i => Alpine.raw(active) == Alpine.raw(i))
- }
- return null
- },
- }
- })
- Alpine.magic('comboboxOption', el => {
- let data = Alpine.$data(el)
- // It's not great depending on the existance of the attribute in the DOM
- // but it's probably the fastest and most reliable at this point...
- let optionEl = Alpine.findClosest(el, i => {
- return i.hasAttribute('x-combobox:option')
- })
- if (! optionEl) throw 'No x-combobox:option directive found...'
- return {
- get isActive() {
- return data.__context.isActiveKey(Alpine.$data(optionEl).__optionKey)
- },
- get isSelected() {
- return data.__isSelected(optionEl)
- },
- get isDisabled() {
- return data.__context.isDisabled(Alpine.$data(optionEl).__optionKey)
- },
- }
- })
- }
- function handleRoot(el, Alpine) {
- Alpine.bind(el, {
- // Setup...
- 'x-id'() { return ['alpine-combobox-button', 'alpine-combobox-options', 'alpine-combobox-label'] },
- 'x-modelable': '__value',
- // Initialize...
- 'x-data'() {
- return {
- /**
- * Combobox state...
- */
- __ready: false,
- __value: null,
- __isOpen: false,
- __context: undefined,
- __isMultiple: undefined,
- __isStatic: false,
- __isDisabled: undefined,
- __displayValue: undefined,
- __compareBy: null,
- __inputName: null,
- __isTyping: false,
- __hold: false,
- /**
- * Combobox initialization...
- */
- init() {
- 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.__context = generateContext(Alpine, this.__isMultiple, 'vertical', () => this.__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(Alpine, this.$el, this.__inputName, this.__value)
- })
- // Set initial combobox values in the input and properly clear it when the value is reset programmatically...
- Alpine.effect(() => ! this.__isMultiple && this.__resetInput())
- })
- },
- __startTyping() {
- this.__isTyping = true
- },
- __stopTyping() {
- this.__isTyping = false
- },
- __resetInput() {
- let input = this.$refs.__input
- if (! input) return
- let value = this.__getCurrentValue()
- input.value = value
- },
- __getCurrentValue() {
- if (! this.$refs.__input) return ''
- if (! this.__value) return ''
- if (this.__displayValue) return this.__displayValue(this.__value)
- if (typeof this.__value === 'string') return this.__value
- return ''
- },
- __open() {
- if (this.__isOpen) return
- this.__isOpen = true
- let input = this.$refs.__input
- // Make sure we always notify the parent component
- // that the starting value is the empty string
- // when we open the combobox (ignoring any existing value)
- // to avoid inconsistent displaying.
- // Setting the input to empty and back to the real value
- // also helps VoiceOver to annunce the content properly
- // See https://github.com/tailwindlabs/headlessui/pull/2153
- if (input) {
- let value = input.value
- let { selectionStart, selectionEnd, selectionDirection } = input
- input.value = ''
- input.dispatchEvent(new Event('change'))
- input.value = value
- if (selectionDirection !== null) {
- input.setSelectionRange(selectionStart, selectionEnd, selectionDirection)
- } else {
- input.setSelectionRange(selectionStart, selectionEnd)
- }
- }
- // 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.__input.focus({ preventScroll: true })
- this.__activateSelectedOrFirst()
- })
- },
- __close() {
- this.__isOpen = false
- this.__context.deactivate()
- },
- __activateSelectedOrFirst(activateSelected = true) {
- if (! this.__isOpen) return
- if (this.__context.hasActive() && this.__context.wasActivatedByKeyPress()) return
- let firstSelectedValue
- if (this.__isMultiple) {
- let selectedItem = this.__context.getItemsByValues(this.__value)
- firstSelectedValue = selectedItem.length ? selectedItem[0].value : null
- } else {
- firstSelectedValue = this.__value
- }
- let firstSelected = null
- if (activateSelected && firstSelectedValue) {
- firstSelected = this.__context.getItemByValue(firstSelectedValue)
- }
- if (firstSelected) {
- this.__context.activateAndScrollToKey(firstSelected.key)
- return
- }
- this.__context.activateAndScrollToKey(this.__context.firstKey())
- },
- __selectActive() {
- let active = this.__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) => {
- // Handle null values
- if ((! a || typeof a !== 'object') || (! b || typeof b !== 'object')) {
- return Alpine.raw(a) === Alpine.raw(b)
- }
- return a[property] === b[property];
- }
- }
- return by(a, b)
- },
- }
- },
- // Register event listeners..
- '@mousedown.window'(e) {
- if (
- !! ! this.$refs.__input.contains(e.target)
- && ! this.$refs.__button.contains(e.target)
- && ! this.$refs.__options.contains(e.target)
- ) {
- this.__close()
- this.__resetInput()
- }
- }
- })
- }
- function handleInput(el, Alpine) {
- Alpine.bind(el, {
- // Setup...
- 'x-ref': '__input',
- ':id'() { return this.$id('alpine-combobox-input') },
- // Accessibility attributes...
- 'role': 'combobox',
- 'tabindex': '0',
- 'aria-autocomplete': 'list',
- // 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 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'(e) {
- if(this.$data.__isTyping) {
- this.$data.__open();
- this.$dispatch('change')
- }
- },
- '@blur'() { this.$data.__stopTyping(false) },
- '@keydown'(e) {
- queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, false, () => this.$data.__isOpen, () => this.$data.__open(), (state) => this.$data.__isTyping = state))
- },
- '@keydown.enter.prevent.stop'() {
- this.$data.__selectActive()
- this.$data.__stopTyping()
- if (! this.$data.__isMultiple) {
- this.$data.__close()
- this.$data.__resetInput()
- }
- },
- '@keydown.escape.prevent'(e) {
- if (! this.$data.__static) e.stopPropagation()
- this.$data.__stopTyping()
- this.$data.__close()
- this.$data.__resetInput()
- },
- '@keydown.tab'() {
- this.$data.__stopTyping()
- if (this.$data.__isOpen) { this.$data.__close() }
- 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()
- }
- })
- },
- })
- }
- function handleButton(el, Alpine) {
- Alpine.bind(el, {
- // Setup...
- 'x-ref': '__button',
- ':id'() { return this.$id('alpine-combobox-button') },
- // Accessibility attributes...
- 'aria-haspopup': 'true',
- // 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',
- // Initialize....
- 'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
- // Register listeners...
- '@click'(e) {
- if (this.$data.__isDisabled) return
- if (this.$data.__isOpen) {
- this.$data.__close()
- this.$data.__resetInput()
- } else {
- e.preventDefault()
- this.$data.__open()
- }
- this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
- },
- })
- }
- function handleLabel(el, Alpine) {
- Alpine.bind(el, {
- 'x-ref': '__label',
- ':id'() { return this.$id('alpine-combobox-label') },
- '@click'() { this.$refs.__input.focus({ preventScroll: true }) },
- })
- }
- function handleOptions(el, Alpine) {
- Alpine.bind(el, {
- // Setup...
- 'x-ref': '__options',
- ':id'() { return this.$id('alpine-combobox-options') },
- // Accessibility attributes...
- 'role': 'listbox',
- ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
- // Initialize...
- 'x-init'() {
- this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
- if (Alpine.bound(this.$el, 'hold')) {
- this.$data.__hold = true;
- }
- },
- 'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
- })
- }
- function handleOption(el, Alpine) {
- Alpine.bind(el, {
- // Setup...
- 'x-id'() { return ['alpine-combobox-option'] },
- ':id'() { return this.$id('alpine-combobox-option') },
- // Accessibility attributes...
- 'role': 'option',
- ':tabindex'() { return this.$comboboxOption.isDisabled ? undefined : '-1' },
- // Only the active element should have aria-selected="true"...
- 'x-effect'() {
- this.$comboboxOption.isSelected
- ? el.setAttribute('aria-selected', true)
- : el.setAttribute('aria-selected', false)
- },
- ':aria-disabled'() { return this.$comboboxOption.isDisabled },
- // Initialize...
- 'x-data'() {
- return {
- '__optionKey': null,
- init() {
- this.__optionKey = (Math.random() + 1).toString(36).substring(7)
- let value = Alpine.extractProp(this.$el, 'value')
- let disabled = Alpine.extractProp(this.$el, 'disabled', false, false)
- // memoize the context as it's not going to change
- // and calling this.$data on mouse action is expensive
- this.__context.registerItem(this.__optionKey, this.$el, value, disabled)
- },
- destroy() {
- this.__context.unregisterItem(this.__optionKey)
- }
- }
- },
- // Register listeners...
- '@click'() {
- if (this.$comboboxOption.isDisabled) return;
- this.__selectOption(this.$el)
- if (! this.__isMultiple) {
- this.__close()
- this.__resetInput()
- }
- this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
- },
- '@mouseenter'(e) {
- this.__context.activateEl(this.$el)
- },
- '@mousemove'(e) {
- if (this.__context.isActiveEl(this.$el)) return
- this.__context.activateEl(this.$el)
- },
- '@mouseleave'(e) {
- if (this.__hold) return
- this.__context.deactivate()
- },
- })
- }
- // Little utility to defer a callback into the microtask queue...
- function microtask(callback) {
- return new Promise(resolve => queueMicrotask(() => resolve(callback())))
- }
|