mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 20:19:34 +01:00
339 lines
9.3 KiB
Vue
339 lines
9.3 KiB
Vue
<template>
|
|
<Combobox
|
|
v-slot="{ open }"
|
|
:by="by"
|
|
:model-value="modelValue"
|
|
:multiple="multiple"
|
|
:nullable="nullable"
|
|
:disabled="disabled"
|
|
as="div"
|
|
:class="wrapperClass"
|
|
@update:model-value="onUpdate"
|
|
>
|
|
<input :value="modelValue" :required="required" class="absolute inset-0 w-px opacity-0 cursor-default" tabindex="-1">
|
|
|
|
<ComboboxButton ref="trigger" v-slot="{ disabled }" as="div">
|
|
<slot :open="open" :disabled="disabled">
|
|
<button :class="selectCustomClass" :disabled="disabled" type="button">
|
|
<slot name="label">
|
|
<span v-if="modelValue" class="block truncate">{{ modelValue[textAttribute] }}</span>
|
|
<span v-else class="block truncate u-text-gray-400">{{ placeholder }}</span>
|
|
</slot>
|
|
<slot name="icon">
|
|
<span :class="iconWrapperClass">
|
|
<Icon :name="icon" :class="iconClass" aria-hidden="true" />
|
|
</span>
|
|
</slot>
|
|
</button>
|
|
</slot>
|
|
</ComboboxButton>
|
|
|
|
<div v-if="open" ref="container" :class="[listContainerClass, listWidthClass]">
|
|
<transition appear v-bind="listTransitionClass">
|
|
<ComboboxOptions static :class="listBaseClass">
|
|
<ComboboxInput
|
|
v-if="searchable"
|
|
ref="searchInput"
|
|
:display-value="() => query"
|
|
name="q"
|
|
placeholder="Search..."
|
|
autofocus
|
|
autocomplete="off"
|
|
:class="listInputClass"
|
|
@change="query = $event.target.value"
|
|
/>
|
|
<ComboboxOption
|
|
v-for="(option, index) in filteredOptions"
|
|
v-slot="{ active, selected, disabled }"
|
|
:key="index"
|
|
as="template"
|
|
:value="option"
|
|
:disabled="option.disabled"
|
|
>
|
|
<li :class="resolveOptionClass({ active, selected, disabled })">
|
|
<div :class="listOptionContainerClass">
|
|
<slot name="option" :option="option" :active="active" :selected="selected">
|
|
<span class="block truncate">{{ option[textAttribute] }}</span>
|
|
</slot>
|
|
</div>
|
|
|
|
<span v-if="selected" :class="resolveOptionIconClass({ active })">
|
|
<Icon :name="listOptionIcon" :class="listOptionIconSizeClass" aria-hidden="true" />
|
|
</span>
|
|
</li>
|
|
</ComboboxOption>
|
|
|
|
<ComboboxOption v-if="creatable && queryOption && !filteredOptions.length" v-slot="{ active, selected }" :value="queryOption" as="template">
|
|
<li :class="resolveOptionClass({ active, selected })">
|
|
<div :class="listOptionContainerClass">
|
|
<slot name="option-create" :option="queryOption" :active="active" :selected="selected">
|
|
<span class="block truncate">Create "{{ queryOption[textAttribute] }}"</span>
|
|
</slot>
|
|
</div>
|
|
</li>
|
|
</ComboboxOption>
|
|
<p v-else-if="searchable && query && !filteredOptions.length" :class="listOptionEmptyClass">
|
|
<slot name="option-empty" :query="query">
|
|
No results found for "{{ query }}".
|
|
</slot>
|
|
</p>
|
|
</ComboboxOptions>
|
|
</transition>
|
|
</div>
|
|
</Combobox>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import type { PropType, ComponentPublicInstance } from 'vue'
|
|
import { defu } from 'defu'
|
|
import {
|
|
Combobox,
|
|
ComboboxButton,
|
|
ComboboxOptions,
|
|
ComboboxOption,
|
|
ComboboxInput
|
|
} from '@headlessui/vue'
|
|
import Icon from '../elements/Icon.vue'
|
|
import { classNames } from '../../utils'
|
|
import { usePopper } from '../../composables/usePopper'
|
|
import type { PopperOptions } from '../../types'
|
|
import $ui from '#build/ui'
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: [String, Number, Object, Array],
|
|
default: ''
|
|
},
|
|
by: {
|
|
type: String,
|
|
default: undefined
|
|
},
|
|
options: {
|
|
type: Array as PropType<{ disabled?: boolean }[]>,
|
|
default: () => []
|
|
},
|
|
required: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
multiple: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
nullable: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
searchable: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
creatable: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
placeholder: {
|
|
type: String,
|
|
default: 'Select an option'
|
|
},
|
|
size: {
|
|
type: String,
|
|
default: 'md',
|
|
validator (value: string) {
|
|
return Object.keys($ui.selectCustom.size).includes(value)
|
|
}
|
|
},
|
|
wrapperClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.wrapper
|
|
},
|
|
baseClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.base
|
|
},
|
|
icon: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.icon.name
|
|
},
|
|
iconBaseClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.icon.base
|
|
},
|
|
customClass: {
|
|
type: String,
|
|
default: null
|
|
},
|
|
appearance: {
|
|
type: String,
|
|
default: 'default',
|
|
validator (value: string) {
|
|
return Object.keys($ui.selectCustom.appearance).includes(value)
|
|
}
|
|
},
|
|
listBaseClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.base
|
|
},
|
|
listContainerClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.container
|
|
},
|
|
listWidthClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.width
|
|
},
|
|
listInputClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.input
|
|
},
|
|
listTransitionClass: {
|
|
type: Object,
|
|
default: () => $ui.selectCustom.list.transition
|
|
},
|
|
listOptionBaseClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.base
|
|
},
|
|
listOptionContainerClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.container
|
|
},
|
|
listOptionActiveClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.active
|
|
},
|
|
listOptionInactiveClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.inactive
|
|
},
|
|
listOptionSelectedClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.selected
|
|
},
|
|
listOptionUnselectedClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.unselected
|
|
},
|
|
listOptionDisabledClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.disabled
|
|
},
|
|
listOptionEmptyClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.empty
|
|
},
|
|
listOptionIcon: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.icon.name
|
|
},
|
|
listOptionIconBaseClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.icon.base
|
|
},
|
|
listOptionIconActiveClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.icon.active
|
|
},
|
|
listOptionIconInactiveClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.icon.inactive
|
|
},
|
|
listOptionIconSizeClass: {
|
|
type: String,
|
|
default: () => $ui.selectCustom.list.option.icon.size
|
|
},
|
|
textAttribute: {
|
|
type: String,
|
|
default: 'text'
|
|
},
|
|
searchAttributes: {
|
|
type: Array,
|
|
default: null
|
|
},
|
|
popperOptions: {
|
|
type: Object as PropType<PopperOptions>,
|
|
default: () => {}
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue'])
|
|
|
|
const popperOptions = computed<PopperOptions>(() => defu({}, props.popperOptions, $ui.selectCustom.popperOptions))
|
|
|
|
const [trigger, container] = usePopper(popperOptions.value)
|
|
|
|
const query = ref('')
|
|
const searchInput = ref<ComponentPublicInstance<HTMLElement>>()
|
|
|
|
const selectCustomClass = computed(() => {
|
|
return classNames(
|
|
props.baseClass,
|
|
$ui.selectCustom.size[props.size],
|
|
$ui.selectCustom.spacing[props.size],
|
|
$ui.selectCustom.appearance[props.appearance],
|
|
$ui.selectCustom.trailing.spacing[props.size],
|
|
props.customClass
|
|
)
|
|
})
|
|
|
|
const iconClass = computed(() => {
|
|
return classNames(
|
|
props.iconBaseClass,
|
|
$ui.selectCustom.icon.size[props.size],
|
|
'mr-2'
|
|
)
|
|
})
|
|
|
|
const filteredOptions = computed(() =>
|
|
query.value === ''
|
|
? props.options
|
|
: props.options.filter((option: any) => {
|
|
return (props.searchAttributes?.length ? props.searchAttributes : [props.textAttribute]).some((searchAttribute: any) => {
|
|
return option[searchAttribute] && option[searchAttribute].search(new RegExp(query.value, 'i')) !== -1
|
|
})
|
|
})
|
|
)
|
|
|
|
const queryOption = computed(() => {
|
|
return query.value === '' ? null : { [props.textAttribute]: query.value }
|
|
})
|
|
|
|
const iconWrapperClass = classNames(
|
|
$ui.selectCustom.icon.trailing.wrapper
|
|
)
|
|
|
|
function resolveOptionClass ({ active, selected, disabled }: { active: boolean, selected: boolean, disabled?: boolean }) {
|
|
return classNames(
|
|
props.listOptionBaseClass,
|
|
active ? props.listOptionActiveClass : props.listOptionInactiveClass,
|
|
selected ? props.listOptionSelectedClass : props.listOptionUnselectedClass,
|
|
disabled && props.listOptionDisabledClass
|
|
)
|
|
}
|
|
|
|
function resolveOptionIconClass ({ active }: { active: boolean }) {
|
|
return classNames(
|
|
props.listOptionIconBaseClass,
|
|
active ? props.listOptionIconActiveClass : props.listOptionIconInactiveClass
|
|
)
|
|
}
|
|
|
|
function onUpdate (event) {
|
|
if (query.value && searchInput.value?.$el) {
|
|
query.value = ''
|
|
// explicitly set input text because `ComboboxInput` `displayValue` is not reactive
|
|
searchInput.value.$el.value = ''
|
|
}
|
|
emit('update:modelValue', event)
|
|
}
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
export default { name: 'USelectCustom' }
|
|
</script>
|