mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 20:19:34 +01:00
324 lines
10 KiB
Vue
324 lines
10 KiB
Vue
<template>
|
|
<component
|
|
:is="searchable ? 'Combobox' : 'Listbox'"
|
|
v-slot="{ open }"
|
|
:by="by"
|
|
:name="name"
|
|
:model-value="modelValue"
|
|
:multiple="multiple"
|
|
:disabled="disabled"
|
|
as="div"
|
|
:class="ui.wrapper"
|
|
@update:model-value="onUpdate"
|
|
>
|
|
<input :value="modelValue" :required="required" class="absolute inset-0 w-px opacity-0 cursor-default" tabindex="-1" aria-hidden="true">
|
|
|
|
<component
|
|
:is="searchable ? 'ComboboxButton' : 'ListboxButton'"
|
|
ref="trigger"
|
|
as="div"
|
|
role="button"
|
|
class="inline-flex w-full"
|
|
>
|
|
<slot :open="open" :disabled="disabled">
|
|
<button :class="selectMenuClass" :disabled="disabled" type="button">
|
|
<span v-if="icon" :class="leadingIconClass">
|
|
<UIcon :name="icon" :class="iconClass" />
|
|
</span>
|
|
|
|
<slot name="label">
|
|
<span v-if="modelValue" class="block truncate">{{ typeof modelValue === 'string' ? modelValue : modelValue[optionAttribute] }}</span>
|
|
<span v-else class="block truncate text-gray-400 dark:text-gray-500">{{ placeholder || ' ' }}</span>
|
|
</slot>
|
|
|
|
<span v-if="trailingIcon" :class="trailingIconClass">
|
|
<UIcon :name="trailingIcon" :class="iconClass" aria-hidden="true" />
|
|
</span>
|
|
</button>
|
|
</slot>
|
|
</component>
|
|
|
|
<div v-if="open" ref="container" :class="[ui.container, ui.width]">
|
|
<transition v-bind="ui.transition">
|
|
<component :is="searchable ? 'ComboboxOptions' : 'ListboxOptions'" static :class="[ui.base, ui.divide, ui.ring, ui.rounded, ui.shadow, ui.background, ui.padding, ui.height]">
|
|
<ComboboxInput
|
|
v-if="searchable"
|
|
ref="searchInput"
|
|
:display-value="() => query"
|
|
name="q"
|
|
placeholder="Search..."
|
|
autofocus
|
|
autocomplete="off"
|
|
:class="ui.input"
|
|
@change="query = $event.target.value"
|
|
/>
|
|
<component
|
|
:is="searchable ? 'ComboboxOption' : 'ListboxOption'"
|
|
v-for="(option, index) in filteredOptions"
|
|
v-slot="{ active, selected, disabled: optionDisabled }"
|
|
:key="index"
|
|
as="template"
|
|
:value="option"
|
|
:disabled="option.disabled"
|
|
>
|
|
<li :class="[ui.option.base, ui.option.rounded, ui.option.padding, ui.option.size, ui.option.color, active ? ui.option.active : ui.option.inactive, selected && ui.option.selected, optionDisabled && ui.option.disabled]">
|
|
<div :class="ui.option.container">
|
|
<slot name="option" :option="option" :active="active" :selected="selected">
|
|
<UIcon v-if="option.icon" :name="option.icon" :class="[ui.option.icon.base, active ? ui.option.icon.active : ui.option.icon.inactive, option.iconClass]" aria-hidden="true" />
|
|
<UAvatar
|
|
v-else-if="option.avatar"
|
|
v-bind="{ size: ui.option.avatar.size, ...option.avatar }"
|
|
:class="ui.option.avatar.base"
|
|
aria-hidden="true"
|
|
/>
|
|
<span v-else-if="option.chip" :class="ui.option.chip.base" :style="{ background: `#${option.chip}` }" />
|
|
|
|
<span class="truncate">{{ typeof option === 'string' ? option : option[optionAttribute] }}</span>
|
|
</slot>
|
|
</div>
|
|
|
|
<span v-if="selected" :class="[ui.option.selectedIcon.wrapper, ui.option.selectedIcon.padding]">
|
|
<UIcon :name="selectedIcon" :class="ui.option.selectedIcon.base" aria-hidden="true" />
|
|
</span>
|
|
</li>
|
|
</component>
|
|
|
|
<component :is="searchable ? 'ComboboxOption' : 'ListboxOption'" v-if="creatable && queryOption && !filteredOptions.length" v-slot="{ active, selected }" :value="queryOption" as="template">
|
|
<li :class="[ui.option.base, ui.option.rounded, ui.option.padding, ui.option.size, ui.option.color, active ? ui.option.active : ui.option.inactive]">
|
|
<div :class="ui.option.container">
|
|
<slot name="option-create" :option="queryOption" :active="active" :selected="selected">
|
|
<span class="block truncate">Create "{{ queryOption[optionAttribute] }}"</span>
|
|
</slot>
|
|
</div>
|
|
</li>
|
|
</component>
|
|
<p v-else-if="searchable && query && !filteredOptions.length" :class="ui.option.empty">
|
|
<slot name="option-empty" :query="query">
|
|
No results found for "{{ query }}".
|
|
</slot>
|
|
</p>
|
|
</component>
|
|
</transition>
|
|
</div>
|
|
</component>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { ref, computed, watch, defineComponent } from 'vue'
|
|
import type { PropType, ComponentPublicInstance } from 'vue'
|
|
import { defu } from 'defu'
|
|
import { Combobox, ComboboxButton, ComboboxOptions, ComboboxOption, ComboboxInput, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/vue'
|
|
import UIcon from '../elements/Icon.vue'
|
|
import UAvatar from '../elements/Avatar.vue'
|
|
import { classNames } from '../../utils'
|
|
import { usePopper } from '../../composables/usePopper'
|
|
import type { PopperOptions } from '../../types'
|
|
import { useAppConfig } from '#imports'
|
|
// TODO: Remove
|
|
// @ts-expect-error
|
|
import appConfig from '#build/app.config'
|
|
|
|
// const appConfig = useAppConfig()
|
|
|
|
export default defineComponent({
|
|
components: {
|
|
Combobox,
|
|
ComboboxButton,
|
|
ComboboxOptions,
|
|
ComboboxOption,
|
|
ComboboxInput,
|
|
Listbox,
|
|
ListboxButton,
|
|
ListboxOptions,
|
|
ListboxOption,
|
|
UIcon,
|
|
UAvatar
|
|
},
|
|
props: {
|
|
modelValue: {
|
|
type: [String, Number, Object, Array],
|
|
default: ''
|
|
},
|
|
by: {
|
|
type: String,
|
|
default: undefined
|
|
},
|
|
options: {
|
|
type: Array as PropType<{ [key: string]: any, disabled?: boolean }[] | string[]>,
|
|
default: () => []
|
|
},
|
|
name: {
|
|
type: String,
|
|
default: null
|
|
},
|
|
required: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
icon: {
|
|
type: String,
|
|
default: null
|
|
},
|
|
trailingIcon: {
|
|
type: String,
|
|
default: () => appConfig.ui.select.default.trailingIcon
|
|
},
|
|
selectedIcon: {
|
|
type: String,
|
|
default: () => appConfig.ui.selectMenu.default.selectedIcon
|
|
},
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
multiple: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
searchable: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
creatable: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
placeholder: {
|
|
type: String,
|
|
default: null
|
|
},
|
|
size: {
|
|
type: String,
|
|
default: () => appConfig.ui.select.default.size,
|
|
validator (value: string) {
|
|
return Object.keys(appConfig.ui.select.size).includes(value)
|
|
}
|
|
},
|
|
appearance: {
|
|
type: String,
|
|
default: () => appConfig.ui.select.default.appearance,
|
|
validator (value: string) {
|
|
return Object.keys(appConfig.ui.select.appearance).includes(value)
|
|
}
|
|
},
|
|
optionAttribute: {
|
|
type: String,
|
|
default: 'label'
|
|
},
|
|
searchAttributes: {
|
|
type: Array,
|
|
default: null
|
|
},
|
|
popper: {
|
|
type: Object as PropType<PopperOptions>,
|
|
default: () => ({})
|
|
},
|
|
ui: {
|
|
type: Object as PropType<Partial<typeof appConfig.ui.selectMenu>>,
|
|
default: () => appConfig.ui.selectMenu
|
|
},
|
|
uiSelect: {
|
|
type: Object as PropType<Partial<typeof appConfig.ui.select>>,
|
|
default: () => appConfig.ui.select
|
|
}
|
|
},
|
|
emits: ['update:modelValue', 'open', 'close'],
|
|
setup (props, { emit }) {
|
|
// TODO: Remove
|
|
const appConfig = useAppConfig()
|
|
|
|
const ui = computed<Partial<typeof appConfig.ui.selectMenu>>(() => defu({}, props.ui, appConfig.ui.selectMenu))
|
|
const uiSelect = computed<Partial<typeof appConfig.ui.select>>(() => defu({}, props.uiSelect, appConfig.ui.select))
|
|
|
|
const popper = computed<PopperOptions>(() => defu({}, props.popper, ui.value.popper as PopperOptions))
|
|
|
|
const [trigger, container] = usePopper(popper.value)
|
|
|
|
const query = ref('')
|
|
const searchInput = ref<ComponentPublicInstance<HTMLElement>>()
|
|
|
|
const selectMenuClass = computed(() => {
|
|
return classNames(
|
|
uiSelect.value.base,
|
|
'text-left cursor-default',
|
|
uiSelect.value.size[props.size],
|
|
uiSelect.value.gap[props.size],
|
|
uiSelect.value.padding[props.size],
|
|
uiSelect.value.appearance[props.appearance],
|
|
!!props.icon && uiSelect.value.leading.padding[props.size],
|
|
uiSelect.value.trailing.padding[props.size],
|
|
uiSelect.value.custom,
|
|
'inline-flex items-center'
|
|
)
|
|
})
|
|
|
|
const iconClass = computed(() => {
|
|
return classNames(
|
|
uiSelect.value.icon.base,
|
|
uiSelect.value.icon.size[props.size]
|
|
)
|
|
})
|
|
|
|
const leadingIconClass = computed(() => {
|
|
return classNames(
|
|
uiSelect.value.icon.leading.wrapper,
|
|
uiSelect.value.icon.leading.padding[props.size]
|
|
)
|
|
})
|
|
|
|
const trailingIconClass = computed(() => {
|
|
return classNames(
|
|
uiSelect.value.icon.trailing.wrapper,
|
|
uiSelect.value.icon.trailing.padding[props.size]
|
|
)
|
|
})
|
|
|
|
const filteredOptions = computed(() =>
|
|
query.value === ''
|
|
? props.options
|
|
: (props.options as any[]).filter((option: any) => {
|
|
return (props.searchAttributes?.length ? props.searchAttributes : [props.optionAttribute]).some((searchAttribute: any) => {
|
|
return typeof option === 'string' ? option.search(new RegExp(query.value, 'i')) !== -1 : (option[searchAttribute] && option[searchAttribute].search(new RegExp(query.value, 'i')) !== -1)
|
|
})
|
|
})
|
|
)
|
|
|
|
const queryOption = computed(() => {
|
|
return query.value === '' ? null : { [props.optionAttribute]: query.value }
|
|
})
|
|
|
|
watch(container, (value) => {
|
|
if (value) {
|
|
emit('open')
|
|
} else {
|
|
emit('close')
|
|
}
|
|
})
|
|
|
|
function onUpdate (event: any) {
|
|
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)
|
|
}
|
|
|
|
return {
|
|
// eslint-disable-next-line vue/no-dupe-keys
|
|
ui,
|
|
trigger,
|
|
container,
|
|
selectMenuClass,
|
|
iconClass,
|
|
leadingIconClass,
|
|
trailingIconClass,
|
|
filteredOptions,
|
|
queryOption,
|
|
query,
|
|
onUpdate
|
|
}
|
|
}
|
|
})
|
|
</script>
|