Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active June 13, 2024 06:12
Show Gist options
  • Save loilo/22d205f7276fd70d424aa1e3b46de7dd to your computer and use it in GitHub Desktop.
Save loilo/22d205f7276fd70d424aa1e3b46de7dd to your computer and use it in GitHub Desktop.
Select Dropdown with Alpine.js

Alpine.js x-select Directive

x-select is an Alpine.js directive which provides a styled enhancement over a native <select> element.

Its main goal is to be a progressive enhancement adding no actual features to a select dropdown apart from making it more visually appealing to use (especially for select fields with multiple).

Further goals include:

  • Accessibility: Appropriate ARIA roles are set, expected keyboard shortcuts can be used.
  • Easy stylability: Custom properties exist for the main features.
  • Minimal API surface:
    • No Alpine-based options. Just enhance the HTML that's already there.
    • Make adjustments by modifying the original <select> HTML (e.g. to add options, disable the element etc.).
    • Keep interactions working as expected (e.g. <label>, validation, form data).

The directive does currently not support:

  • Any <option> feature apart from value (e.g. disabled options, <optgroup>, <hr> inside the select)
  • Advanced features like search, chip view etc.
  • Native keyboard shortcuts (going to an option by typing its letters out)

A demo can be found on CodePen.

Installation

Example installation using a bundler with CSS support:

import Alpine from 'alpinejs'

import SelectPlugin from './alpine-select.js'
import './alpine-select.css'

Alpine.plugin(SelectPlugin)

Usage

Introduction

The simplest use case:

<select x-data x-select>
  <option value="1">One</option>
  <option value="2">Two</option>
  <option value="3">Three</option>
</select>

Model

Pass a variable to get an x-model-like two-way binding:

<div x-data="{ value: '1' }">
  <select x-data x-select="value">
    <option value="1">One</option>
    <option value="2">Two</option>
    <option value="3">Three</option>
  </select>
  
  <p>Selected Value: <span x-text="value"></span></p>
</div>

Attributes

The following <select>-native attributes are respected (and watched for changes):

<label for="dropdown">Dropdown:</label>
<select
  x-data
  x-select

  id="dropdown"
  placeholder="Pick some numbers"
  multiple
  disabled
  required
>
  <option value="1">One</option>
  <option value="2">Two</option>
  <option value="3">Three</option>
</select>
:root {
--theme-color: hsl(143, 62%, 59%);
--multiselect--text-color: #000;
--multiselect--active-text-color: #fff;
--multiselect--disabled-text-color: #ccc;
--multiselect--background-color: #fff;
--multiselect--active-background-color: var(--theme-color);
--multiselect--disabled-background-color: #fff5;
--multiselect--focus-width: 2px;
--multiselect--focus-offset: 3px;
--multiselect--focus-color: var(--theme-color);
--multiselect--trigger-padding-y: 1.25rem;
--multiselect--trigger-padding-x: 1.5rem;
--multiselect--trigger-icon-color: var(--theme-color);
--multiselect--trigger-icon-width: 11px;
--multiselect--trigger-icon-height: 7px;
--multiselect--trigger-icon-url: url('data:image/svg+xml;utf8,%3Csvg%20width%3D%2211%22%20height%3D%227%22%20viewBox%3D%220%200%2011%207%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M10.715.29a.96.96%200%200%200-1.375%200L5.5%204.607%201.66.291a.96.96%200%200%200-1.375%200%201.005%201.005%200%200%200%200%201.402l4.467%205.02c.205.21.479.302.748.285a.956.956%200%200%200%20.749-.284l4.466-5.021c.38-.387.38-1.015%200-1.402Z%22%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E');
--multiselect--border-radius: 4px;
--multiselect--border-color: #ccc;
--multiselect--border-width: 1px;
--multiselect--border: var(--multiselect--border-width) solid var(--multiselect--border-color);
--multiselect--input-font-size: 15px;
--multiselect--listbox-shadow: 0 0.5em 1.25em #00000018;
--multiselect--listbox-font-size: var(--multiselect--input-font-size);
--multiselect--check-color: var(--theme-color);
--multiselect--active-check-color: var(--multiselect--active-text-color);
--multiselect--check-icon-width: 12px;
--multiselect--check-icon-height: 9px;
--multiselect--check-icon-url: url('data:image/svg+xml;utf8,%3Csvg%20width%3D%2212%22%20height%3D%229%22%20viewBox%3D%220%200%2012%209%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M11.701.31a1.087%201.087%200%200%201%200%201.497L5.063%208.689A1%201%200%200%201%204.34%209a1%201%200%200%201-.722-.31L.299%205.248a1.085%201.085%200%200%201-.264-1.022c.094-.366.37-.65.722-.749a.996.996%200%200%201%20.985.274l2.599%202.692L10.258.31c.191-.198.45-.31.722-.31.27%200%20.53.112.721.31Z%22%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E');
--multiselect--border-top-left-radius: var(--multiselect--border-radius);
--multiselect--border-top-right-radius: var(--multiselect--border-radius);
--multiselect--border-bottom-left-radius: var(--multiselect--border-radius);
--multiselect--border-bottom-right-radius: var(--multiselect--border-radius);
}
body.admin-bar {
--multiselect--admin-bar: 46px;
@media (width >= 783px) {
--multiselect--admin-bar: 32px;
}
}
.multiselect--select-wrapper {
position: relative;
margin-bottom: var(--multiselect--input-margin-bottom, 1em);
color: var(--multiselect--text-color);
transition: filter var(--multiselect--transition-duration, 150ms);
user-select: none;
&.from-top {
.multiselect--select-listbox {
top: calc(var(--multiselect--position-top, 0px) + var(--multiselect--admin-bar, 0px));
}
}
&.from-bottom {
.multiselect--select-listbox {
bottom: calc(
var(--multiselect--position-bottom, 0px) + var(--multiselect--offset-bottom, 0px)
);
}
}
&.is-expanded {
filter: drop-shadow(var(--multiselect--listbox-shadow));
&.from-top:not(.is-overlapping),
&.from-none {
.multiselect--select-listbox {
&:not(.multiselect--select-wrapper.is-too-wide-for-screen *) {
--multiselect--border-top-left-radius: 0;
}
&:not(.multiselect--select-wrapper.is-too-wide-for-trigger *) {
--multiselect--border-top-right-radius: 0;
}
}
.multiselect--select-trigger {
--multiselect--border-bottom-left-radius: 0;
--multiselect--border-bottom-right-radius: 0;
}
}
&.from-top:not(.is-overlapping) {
.multiselect--select-listbox {
border-top: none;
}
}
&.from-bottom.is-too-wide-for-screen .multiselect--select-listbox {
--multiselect--border-bottom-left-radius: 0;
--multiselect--border-bottom-right-radius: 0;
}
&.from-bottom:not(.is-overlapping):not(.is-too-wide-for-trigger) {
.multiselect--select-listbox {
border-bottom: none;
border-bottom-right-radius: 0;
}
}
&.from-bottom:not(.is-overlapping) {
&.is-too-wide-for-trigger .multiselect--select-listbox {
margin-bottom: calc(-1 * var(--multiselect--border-width));
}
.multiselect--select-listbox {
border-bottom-left-radius: 0;
}
.multiselect--select-trigger {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
.multiselect--select-listbox {
background-color: var(--multiselect--background-color);
}
}
}
.multiselect--select-field {
all: unset;
position: absolute;
width: 100%;
height: 100%;
box-sizing: content-box;
opacity: 0;
pointer-events: none;
&.touched:invalid + button {
background-color: var(--input-invalid-background);
}
}
.multiselect--select-trigger {
all: unset;
cursor: default;
position: relative;
max-width: 100%;
width: 100%;
box-sizing: border-box;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: var(--multiselect--border-top-left-radius)
var(--multiselect--border-top-right-radius) var(--multiselect--border-bottom-right-radius)
var(--multiselect--border-bottom-left-radius);
border: var(--multiselect--border);
background-color: var(--multiselect--background-color);
padding: calc(var(--multiselect--trigger-padding-y) + var(--multiselect--border-width))
calc(var(--multiselect--trigger-padding-x) + var(--multiselect--border-width));
padding-right: calc(
2 * var(--multiselect--trigger-padding-x) + var(--multiselect--trigger-icon-width)
);
font-size: var(--multiselect--input-font-size);
text-align: left;
appearance: none;
transition: border-radius var(--multiselect--transition-duration, 150ms);
&::after {
content: '';
position: absolute;
width: var(--multiselect--trigger-icon-width);
height: var(--multiselect--trigger-icon-height);
right: var(--multiselect--trigger-padding-x);
top: calc(50% - 0.5 * var(--multiselect--trigger-icon-height));
mask-image: var(--multiselect--trigger-icon-url);
background-color: var(--multiselect--trigger-icon-color);
}
&:focus {
outline: none;
}
&:focus-visible {
outline: var(--multiselect--focus-width, 2px) solid var(--multiselect--focus-color);
outline-offset: var(--multiselect--focus-offset, 2px);
}
&.inert {
pointer-events: none;
}
&:disabled,
&.disabled {
color: var(--multiselect--disabled-text-color);
background-color: var(--multiselect--disabled-trigger-background-color);
}
}
.multiselect--select-trigger-label {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.multiselect--select-listbox {
--multiselect--border-top-left-radius: var(--multiselect--border-radius);
--multiselect--border-top-right-radius: var(--multiselect--border-radius);
--multiselect--border-bottom-left-radius: var(--multiselect--border-radius);
--multiselect--border-bottom-right-radius: var(--multiselect--border-radius);
position: absolute;
right: var(--position-right, auto);
font-size: var(--multiselect--listbox-font-size);
box-sizing: border-box;
width: fit-content;
min-width: 100%;
max-width: min(calc(100vw - 20px), 100%);
max-height: 90vh;
overflow: auto;
z-index: 999;
list-style-type: none;
margin: 0 0.5em 0 0;
margin-top: calc(-1 * var(--multiselect--border-width));
padding: 0.5em 0;
background-color: #fff;
border: var(--multiselect--border);
border-radius: var(--multiselect--border-top-left-radius)
var(--multiselect--border-top-right-radius) var(--multiselect--border-bottom-right-radius)
var(--multiselect--border-bottom-left-radius);
transition: border-radius var(--multiselect--transition-duration, 150ms);
&:focus {
outline: none;
}
}
.multiselect--select-listbox-item {
position: relative;
width: max-content;
min-width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.7em 1em 0.75em calc(var(--multiselect--check-icon-width) + 1.85em);
line-height: 1;
cursor: default;
white-space: var(--multiselect--select-white-space, initial);
&.is-active {
background-color: var(--multiselect--active-background-color);
color: var(--multiselect--active-text-color);
&.is-selected::before {
background-color: var(--multiselect--active-check-color);
}
}
&.is-selected::before {
content: '';
position: absolute;
width: var(--multiselect--check-icon-width);
height: var(--multiselect--check-icon-height);
top: calc(50% - 0.5 * var(--multiselect--check-icon-height));
left: 1.1em;
mask-image: var(--multiselect--check-icon-url);
mask-repeat: no-repeat;
background-color: var(--multiselect--check-color);
}
}
/**
* 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[
'--multiselect--position-bottom'
] = `${listboxTriggerRect.height}px`
localPositionClasses = ['from-bottom']
} else if (spaceAboveTrigger > spaceBelowTrigger) {
localPositioningStyles['--multiselect--position-bottom'] = `${Math.round(
listboxTriggerRect.bottom - screenHeight,
)}px`
localPositionClasses = ['from-bottom']
this.$data.listboxOverlapsTrigger = true
} else {
localPositioningStyles['--multiselect--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="multiselect--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('multiselect--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="multiselect--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="multiselect--select-trigger-label" x-text="getPlaceholder(props.selectedOption)"></span>
</button>
<template x-if="expanded">
<ul
x-ref="listbox"
class="multiselect--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="multiselect--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('multiselect--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()
}
})
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment