|
/** |
|
* Implements the x-select directive |
|
* @param {import('alpinejs').default} Alpine |
|
*/ |
|
export default Alpine => { |
|
Alpine.data('_selectImplementation', () => { |
|
return { |
|
$select: null, |
|
|
|
// Props |
|
get props() { |
|
return this.$data.$select?.props ?? {} |
|
}, |
|
|
|
// Data |
|
touched: false, |
|
activatedIndex: undefined, |
|
keyPressed: false, |
|
expanded: false, |
|
positioningStyles: {}, |
|
positionClasses: [], |
|
listboxOverlapsTrigger: false, |
|
valueOnOpen: undefined, |
|
//animatingListbox: false, |
|
|
|
// Computed |
|
get listboxVisible() { |
|
return this.$data.expanded // || this.$data.animatingListbox |
|
}, |
|
get valueArray() { |
|
return this.$data.props.multiple |
|
? this.$data.props.modelValue |
|
: [this.$data.props.modelValue] |
|
}, |
|
get idPrefix() { |
|
return this.$data.props.id |
|
}, |
|
get idListbox() { |
|
return `${this.$data.idPrefix}-listbox` |
|
}, |
|
get idTrigger() { |
|
return `${this.$data.idPrefix}-trigger` |
|
}, |
|
get hasSelected() { |
|
return this.$data.props.multiple |
|
? this.$data.props.selectedOption.length > 0 |
|
: typeof this.$data.props.selectedOption !== 'undefined' |
|
}, |
|
get firstSelectedOptionIndex() { |
|
return this.$data.hasSelected |
|
? this.$data.props.options.indexOf( |
|
this.$data.props.multiple |
|
? this.$data.props.selectedOption[0] |
|
: this.$data.props.selectedOption, |
|
) |
|
: undefined |
|
}, |
|
getPlaceholder(option) { |
|
const placeholderValue = this.$data.props.placeholder |
|
if (!this.$data.hasSelected) return placeholderValue.trim() || '\u00A0' |
|
|
|
if (this.$data.props.multiple) { |
|
return this.$data.props.selectedOption.map(option => option.label).join(', ') |
|
} else { |
|
return this.$data.props.selectedOption.label |
|
} |
|
}, |
|
get hasActivatedItem() { |
|
return typeof this.$data.activatedIndex === 'number' |
|
}, |
|
get activatedOption() { |
|
return !this.$data.hasActivatedItem |
|
? undefined |
|
: this.$data.props.options[this.$data.activatedIndex] |
|
}, |
|
get activatedId() { |
|
return !this.$data.hasActivatedItem ? undefined : this.$data.activatedOption.id |
|
}, |
|
|
|
// Functions |
|
pressKey(key) { |
|
this.$data.keyPressed = key |
|
setTimeout(() => { |
|
this.$data.keyPressed = false |
|
}, 0) |
|
}, |
|
scrollListbox({ direction = 'auto', index = this.$data.activatedIndex } = {}) { |
|
let hasSelected = typeof index === 'number' |
|
let listbox = this.$refs.listbox |
|
if (!listbox) return |
|
|
|
let listboxRect = listbox.getBoundingClientRect() |
|
let listboxTriggerRect = this.$refs.listboxTrigger.getBoundingClientRect() |
|
let listboxItem = listbox.children[index] |
|
let previousItem = listbox.children[index - 1] ?? listboxItem |
|
let nextItem = listbox.children[index + 1] ?? listboxItem |
|
let previousItemRect = previousItem.getBoundingClientRect() |
|
let nextItemRect = nextItem.getBoundingClientRect() |
|
|
|
if (direction === 'auto' && this.$data.listboxOverlapsTrigger && hasSelected) { |
|
let diff = listboxTriggerRect.top - listboxRect.top |
|
let top = listboxItem.offsetTop - diff |
|
listbox.scrollTo({ top }) |
|
} else if ( |
|
direction === 'down' && |
|
nextItemRect.bottom + listboxTriggerRect.height > listboxRect.bottom |
|
) { |
|
listbox.scrollTo({ |
|
top: |
|
index === this.$data.props.options.length - 1 |
|
? listbox.scrollHeight |
|
: nextItem.offsetTop + nextItemRect.height - listboxRect.height, |
|
behavior: 'smooth', |
|
}) |
|
} else if ( |
|
direction === 'up' && |
|
previousItemRect.top - listboxTriggerRect.height < listboxRect.top |
|
) { |
|
listbox.scrollTo({ |
|
top: index === 0 ? 0 : previousItem.offsetTop, |
|
behavior: 'smooth', |
|
}) |
|
} |
|
}, |
|
calculatePosition() { |
|
let listbox = this.$refs.listbox |
|
let listboxTrigger = this.$refs.listboxTrigger |
|
if (!listbox || !listboxTrigger) return |
|
let screenWidth = Math.min(window.innerWidth, window.outerWidth) |
|
let screenHeight = window.innerHeight |
|
let listboxRect = listbox.getBoundingClientRect() |
|
let listboxTriggerRect = listboxTrigger.getBoundingClientRect() |
|
let spaceBelowTrigger = screenHeight - listboxTriggerRect.bottom |
|
let spaceAboveTrigger = listboxTriggerRect.top |
|
let listboxTooTall = listboxRect.bottom > screenHeight |
|
let listboxTooWide = listboxRect.right > screenWidth |
|
let listboxTooWideForTrigger = listboxRect.width > listboxTriggerRect.width |
|
let localPositioningStyles = {} |
|
let localPositionClasses = [] |
|
|
|
if (listboxTooTall) { |
|
if (spaceAboveTrigger > spaceBelowTrigger && spaceAboveTrigger > listboxRect.height) { |
|
localPositioningStyles[ |
|
'--x-select--position-bottom' |
|
] = `${listboxTriggerRect.height}px` |
|
localPositionClasses = ['from-bottom'] |
|
} else if (spaceAboveTrigger > spaceBelowTrigger) { |
|
localPositioningStyles['--x-select--position-bottom'] = `${Math.round( |
|
listboxTriggerRect.bottom - screenHeight, |
|
)}px` |
|
localPositionClasses = ['from-bottom'] |
|
this.$data.listboxOverlapsTrigger = true |
|
} else { |
|
localPositioningStyles['--x-select--position-top'] = `${Math.round( |
|
-listboxTriggerRect.top, |
|
)}px` |
|
localPositionClasses = ['from-top'] |
|
this.$data.listboxOverlapsTrigger = true |
|
} |
|
} else { |
|
localPositionClasses = ['from-none'] |
|
} |
|
if (listboxTooWideForTrigger) { |
|
localPositionClasses.push('is-too-wide-for-trigger') |
|
} |
|
if (listboxTooWide) { |
|
localPositionClasses.push('is-too-wide-for-screen') |
|
} |
|
this.$data.positionClasses = localPositionClasses |
|
this.$data.positioningStyles = localPositioningStyles |
|
}, |
|
activate(index) { |
|
if (index < 0) return |
|
if (index >= this.$data.props.options.length) return |
|
this.$data.activatedIndex = index |
|
}, |
|
activateFirst() { |
|
this.$data.activate(0) |
|
}, |
|
activateLast() { |
|
this.$data.activate(this.$data.props.options.length - 1) |
|
}, |
|
activateFirstSelected() { |
|
if (this.$data.hasSelected) { |
|
this.$data.activate(this.$data.firstSelectedOptionIndex) |
|
} else { |
|
this.$data.activateFirst() |
|
} |
|
}, |
|
activatePrevious() { |
|
if (!this.$data.hasActivatedItem) { |
|
this.$data.activateLast() |
|
} else { |
|
this.$data.activate(this.$data.activatedIndex - 1) |
|
} |
|
}, |
|
activateNext() { |
|
if (!this.$data.hasActivatedItem) { |
|
this.$data.activateFirst() |
|
} else { |
|
this.$data.activate(this.$data.activatedIndex + 1) |
|
} |
|
}, |
|
open() { |
|
this.$data.valueOnOpen = this.$data.props.modelValue |
|
this.$data.expanded = true |
|
}, |
|
close() { |
|
this.$data.touched = true |
|
if (this.$refs.listbox?.matches(':focus')) { |
|
this.$refs.listboxTrigger.focus({ preventScroll: true }) |
|
} |
|
this.$data.expanded = false |
|
this.$data.$select.el.dispatchEvent(new CustomEvent('select:close', { bubbles: true })) |
|
|
|
if ( |
|
JSON.stringify(this.$data.valueOnOpen) !== JSON.stringify(this.$data.props.modelValue) |
|
) { |
|
this.$data.$select.el.dispatchEvent( |
|
new CustomEvent('select:change', { |
|
bubbles: true, |
|
detail: { |
|
value: this.$data.props.modelValue, |
|
}, |
|
}), |
|
) |
|
} |
|
}, |
|
select(value) { |
|
this.$data.$select.setValue(value) |
|
this.$data.$select.el.dispatchEvent( |
|
new CustomEvent('select:input', { |
|
bubbles: true, |
|
detail: { |
|
value, |
|
}, |
|
}), |
|
) |
|
}, |
|
selectSingle(value) { |
|
this.$data.select(this.$data.props.multiple ? [value] : value) |
|
}, |
|
selectSingleAndClose(value) { |
|
this.$data.select(value) |
|
this.$data.close() |
|
}, |
|
toggle(value) { |
|
const currentValue = this.$data.props.modelValue |
|
if (currentValue.includes(value)) { |
|
this.$data.$select.setValue(currentValue.filter(selectedValue => selectedValue !== value)) |
|
} else { |
|
this.$data.$select.setValue([...currentValue, value]) |
|
} |
|
}, |
|
commitSingleValue(value) { |
|
if (this.$data.props.multiple) { |
|
this.$data.toggle(value) |
|
} else { |
|
this.$data.selectSingleAndClose(value) |
|
} |
|
}, |
|
onListboxKeydown(event) { |
|
switch (event.key) { |
|
case 'Escape': |
|
event.preventDefault() |
|
this.$data.pressKey(event.key) |
|
this.$data.close() |
|
break |
|
|
|
case 'ArrowDown': |
|
event.preventDefault() |
|
this.$data.pressKey(event.key) |
|
this.$data.activateNext() |
|
break |
|
|
|
case 'ArrowUp': |
|
event.preventDefault() |
|
this.$data.pressKey(event.key) |
|
this.$data.activatePrevious() |
|
break |
|
|
|
case 'Home': |
|
event.preventDefault() |
|
this.$data.pressKey(event.key) |
|
this.$data.activateFirst() |
|
break |
|
|
|
case 'End': |
|
event.preventDefault() |
|
this.$data.pressKey(event.key) |
|
this.$data.activateLast() |
|
break |
|
|
|
case 'Enter': |
|
case ' ': |
|
event.preventDefault() |
|
this.$data.pressKey(event.key) |
|
if (!this.$data.hasActivatedItem) { |
|
this.$data.close() |
|
break |
|
} |
|
if (this.$data.props.multiple) { |
|
this.$data.toggle(this.$data.activatedOption.value) |
|
} else { |
|
this.$data.selectSingleAndClose(this.$data.activatedOption.value) |
|
} |
|
break |
|
} |
|
}, |
|
|
|
// Initialize |
|
init() { |
|
this.$watch('$data?.$select?.props?.disabled', disabled => { |
|
if (disabled) { |
|
this.$data.close() |
|
} |
|
}) |
|
|
|
this.$watch('listboxVisible', visible => { |
|
if (!visible) { |
|
this.$data.positioningStyles = {} |
|
this.$data.positionClasses = [] |
|
this.$data.listboxOverlapsTrigger = false |
|
} |
|
}) |
|
|
|
this.$watch('expanded', expanded => { |
|
if (!expanded) { |
|
this.$data.activatedIndex = undefined |
|
} else { |
|
this.$data.activateFirstSelected() |
|
let index = this.$data.hasSelected ? this.$data.firstSelectedOptionIndex : 0 |
|
|
|
Alpine.nextTick(() => { |
|
this.$data.calculatePosition() |
|
|
|
setTimeout(() => { |
|
this.$refs.listbox.focus({ preventScroll: true }) |
|
this.$data.scrollListbox({ index }) |
|
}, 0) |
|
}) |
|
} |
|
}) |
|
|
|
this.$watch('activatedIndex', (activatedIndex, oldActivatedIndex) => { |
|
if (this.$data.hasActivatedItem && this.$data.keyPressed) { |
|
this.$data.scrollListbox({ |
|
direction: (activatedIndex ?? 0) > (oldActivatedIndex ?? 0) ? 'down' : 'up', |
|
}) |
|
} |
|
}) |
|
}, |
|
} |
|
}) |
|
|
|
Alpine.directive('select', async (el, { expression }, { evaluate, cleanup, Alpine }) => { |
|
const html = /*html*/ `<div |
|
x-data="_selectImplementation()" |
|
class="x-select--select-wrapper" |
|
:class="{ |
|
'is-expanded': expanded, |
|
'is-overlapping': listboxOverlapsTrigger, |
|
...Object.fromEntries(positionClasses.map(c => [c, true])), |
|
}" |
|
></div>` |
|
|
|
el.insertAdjacentHTML('beforebegin', html) |
|
el.previousElementSibling.prepend(el) |
|
|
|
el.classList.add('x-select--select-field') |
|
el.setAttribute(':class', '{ touched: $data.touched }') |
|
el.tabIndex = -1 |
|
el.setAttribute('inert', '') |
|
el.setAttribute('aria-hidden', 'true') |
|
el.setAttribute('x-on:focus', '$refs.listboxTrigger.focus()') |
|
|
|
if (expression) { |
|
el.setAttribute('x-model', expression) |
|
Alpine.nextTick(() => updateSelectedOptions()) |
|
evaluate(`$watch('${expression}', () => $data.$select.updateSelectedOptions())`) |
|
} |
|
|
|
const optionsMap = new WeakMap() |
|
function readOptions() { |
|
const options = [...el.options].filter(option => !option.disabled) |
|
return options.map(option => { |
|
let id |
|
if (optionsMap.has(option)) { |
|
id = optionsMap.get(option) |
|
} else { |
|
id = crypto.randomUUID() |
|
optionsMap.set(option, id) |
|
} |
|
|
|
return { |
|
id, |
|
value: option.value, |
|
label: option.textContent, |
|
} |
|
}) |
|
} |
|
|
|
const state = Alpine.reactive({ |
|
id: el.id ?? crypto.randomUUID(), |
|
placeholder: el.getAttribute('placeholder') ?? '', |
|
required: el.required, |
|
multiple: el.multiple, |
|
disabled: el.disabled, |
|
options: readOptions(), |
|
|
|
selectedOptions: [], |
|
|
|
get selectedOption() { |
|
if (this.multiple) { |
|
return this.selectedOptions |
|
} else { |
|
return this.selectedOptions[0] |
|
} |
|
}, |
|
get modelValue() { |
|
if (this.multiple) { |
|
return this.selectedOptions.map(option => option.value) |
|
} else { |
|
return this.selectedOptions[0]?.value |
|
} |
|
}, |
|
}) |
|
|
|
function readSelectedOptions() { |
|
return [...el.options].flatMap((option, index) => |
|
option.selected ? state.options[index] : [], |
|
) |
|
} |
|
|
|
state.selectedOptions = readSelectedOptions() |
|
|
|
const updateMultiple = () => (state.multiple = el.multiple) |
|
const updateDisabled = () => (state.disabled = el.disabled) |
|
const updateId = () => (state.id = el.id ?? crypto.randomUUID()) |
|
const updatePlaceholder = () => (state.placeholder = el.getAttribute('placeholder') ?? '') |
|
const updateRequired = () => (state.required = el.required) |
|
|
|
const updateState = (option, getter) => { |
|
const newData = getter() |
|
if (JSON.stringify(newData) !== JSON.stringify(state[option])) { |
|
state[option] = newData |
|
} |
|
} |
|
|
|
const updateOptions = () => updateState('options', readOptions) |
|
const updateSelectedOptions = () => updateState('selectedOptions', readSelectedOptions) |
|
|
|
el.addEventListener('change', updateSelectedOptions) |
|
|
|
const observer = new MutationObserver(entries => { |
|
for (const entry of entries) { |
|
if (entry.type === 'attributes') { |
|
switch (entry.attributeName) { |
|
case 'multiple': |
|
updateMultiple() |
|
updateSelectedOptions() |
|
|
|
if (expression) { |
|
el.dispatchEvent(new Event('change', { bubbles: true })) |
|
} |
|
break |
|
case 'disabled': |
|
updateDisabled() |
|
break |
|
case 'id': |
|
updateId() |
|
break |
|
case 'placeholder': |
|
updatePlaceholder() |
|
break |
|
case 'required': |
|
updateRequired() |
|
break |
|
} |
|
} else if (entry.type === 'childList') { |
|
updateOptions() |
|
updateSelectedOptions() |
|
} |
|
} |
|
}) |
|
|
|
observer.observe(el, { |
|
childList: true, |
|
attributes: true, |
|
subtree: true, |
|
attributeFilter: ['multiple', 'disabled', 'id', 'placeholder', 'required'], |
|
}) |
|
|
|
// Expose API to $select |
|
await Alpine.nextTick() |
|
const $select = { |
|
el, |
|
wrapperElement: el.parentElement, |
|
props: { |
|
get selectedOption() { |
|
return state.selectedOption |
|
}, |
|
get modelValue() { |
|
return state.modelValue |
|
}, |
|
get options() { |
|
return state.options |
|
}, |
|
get id() { |
|
return state.id |
|
}, |
|
get disabled() { |
|
return state.disabled |
|
}, |
|
get multiple() { |
|
return state.multiple |
|
}, |
|
get placeholder() { |
|
return state.placeholder |
|
}, |
|
get required() { |
|
return state.required |
|
}, |
|
}, |
|
setValue(value) { |
|
value = Array.isArray(value) ? value : [value] |
|
|
|
for (const option of el.options) { |
|
option.selected = value.includes(option.value) |
|
} |
|
|
|
el.dispatchEvent(new Event('change', { bubbles: true })) |
|
}, |
|
updateSelectedOptions, |
|
refresh() { |
|
updateMultiple() |
|
updateDisabled() |
|
|
|
updateOptions() |
|
updateId() |
|
updatePlaceholder() |
|
|
|
updateSelectedOptions() |
|
}, |
|
} |
|
evaluate('$data').$select = $select |
|
Alpine.evaluate(el.parentElement, '$data').$select = $select |
|
|
|
// Can't inject this earlier because we need the $select API to be available |
|
el.insertAdjacentHTML( |
|
'afterend', |
|
/*html*/ ` |
|
<button |
|
x-ref="listboxTrigger" |
|
type="button" |
|
class="x-select--select-trigger" |
|
:class="{ inert: expanded }" |
|
:disabled="props.disabled" |
|
:id="idTrigger" |
|
aria-haspopup="true" |
|
:aria-expanded="expanded" |
|
:aria-controls="idListbox" |
|
@click="open()" |
|
@keydown.up.prevent="$el.click()" |
|
@keydown.down.prevent="$el.click()" |
|
> |
|
<span class="x-select--select-trigger-label" x-text="getPlaceholder(props.selectedOption)"></span> |
|
</button> |
|
<template x-if="expanded"> |
|
<ul |
|
x-ref="listbox" |
|
class="x-select--select-listbox" |
|
:id="idListbox" |
|
:style="positioningStyles" |
|
tabindex="0" |
|
role="listbox" |
|
aria-orientation="vertical" |
|
:aria-labelledby="idTrigger" |
|
:aria-activedescendant="activatedId" |
|
@keydown="onListboxKeydown" |
|
@mouseout="activatedIndex = undefined" |
|
@blur="close()" |
|
> |
|
<template x-for="({ id, value, label }, index) in props.options"> |
|
<li |
|
:key="id" |
|
:id="id" |
|
class="x-select--select-listbox-item" |
|
:class="{ |
|
'is-active': activatedIndex === index, |
|
'is-selected': valueArray.includes(value) |
|
}" |
|
role="option" |
|
:aria-selected="valueArray.includes(value)" |
|
:value="value" |
|
tabindex="-1" |
|
@mouseenter="activatedIndex = index" |
|
@mousedown.prevent="commitSingleValue(value)" |
|
x-text="label" |
|
></li> |
|
</template> |
|
</ul> |
|
</template> |
|
`, |
|
) |
|
|
|
// Stop observing select on cleanup |
|
cleanup(() => { |
|
observer.disconnect() |
|
el.removeEventListener('change', updateSelectedOptions) |
|
|
|
el.classList.remove('x-select--select-field') |
|
el.removeAttribute('inert') |
|
el.removeAttribute('x-model') |
|
el.removeAttribute('x-on:focus') |
|
el.removeAttribute(':class') |
|
el.removeAttribute('tabindex') |
|
el.removeAttribute('aria-hidden') |
|
|
|
if (document.contains(el)) { |
|
const container = el.parentElement |
|
container.after(el) |
|
container.remove() |
|
} |
|
}) |
|
}) |
|
} |