mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-17 21:48:07 +01:00
feat: rewrite to use app config and rework docs (#143)
Co-authored-by: Daniel Roe <daniel@roe.dev> Co-authored-by: Sébastien Chopin <seb@nuxt.com>
This commit is contained in:
329
src/runtime/components/forms/SelectMenu.vue
Normal file
329
src/runtime/components/forms/SelectMenu.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<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"
|
||||
>
|
||||
<!-- TODO: check that `name` fixes required -->
|
||||
<!-- <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">
|
||||
<Icon :name="icon" :class="iconClass" />
|
||||
</span>
|
||||
|
||||
<slot name="label">
|
||||
<span v-if="modelValue" class="block truncate">{{ typeof modelValue === 'string' ? modelValue : (modelValue as any)[optionAttribute] }}</span>
|
||||
<span v-else class="block truncate text-gray-400 dark:text-gray-500">{{ placeholder || ' ' }}</span>
|
||||
</slot>
|
||||
|
||||
<span :class="trailingIconClass">
|
||||
<Icon name="i-heroicons-chevron-down-20-solid" :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.spacing, 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="resolveOptionClass({ active, disabled: optionDisabled })">
|
||||
<div :class="ui.option.container">
|
||||
<slot name="option" :option="option" :active="active" :selected="selected">
|
||||
<Icon 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" />
|
||||
<Avatar
|
||||
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.selected.wrapper">
|
||||
<Icon :name="selectedIcon" :class="ui.option.selected.icon" 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="resolveOptionClass({ active })">
|
||||
<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 Icon from '../elements/Icon.vue'
|
||||
import Avatar 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,
|
||||
Icon,
|
||||
Avatar
|
||||
},
|
||||
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
|
||||
},
|
||||
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.spacing[props.size],
|
||||
uiSelect.value.appearance[props.appearance],
|
||||
!!props.icon && uiSelect.value.leading.spacing[props.size],
|
||||
uiSelect.value.trailing.spacing[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.spacing[props.size]
|
||||
)
|
||||
})
|
||||
|
||||
const trailingIconClass = computed(() => {
|
||||
return classNames(
|
||||
uiSelect.value.icon.trailing.wrapper,
|
||||
uiSelect.value.icon.trailing.spacing[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 resolveOptionClass ({ active, disabled }: { active: boolean, disabled?: boolean }) {
|
||||
return classNames(
|
||||
ui.value.option.base,
|
||||
active ? ui.value.option.active : ui.value.option.inactive,
|
||||
disabled && ui.value.option.disabled
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
resolveOptionClass,
|
||||
onUpdate
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user