461 lines
10 KiB
Vue
461 lines
10 KiB
Vue
<template>
|
|
<div
|
|
ref="rootEl"
|
|
:class="[cn.b(), cn.is('active', active), cn.is('filled', isFilled)]"
|
|
@keydown.esc="onEscape"
|
|
>
|
|
<div
|
|
ref="wrapperEl"
|
|
:class="cn.e('wrapper')"
|
|
tabindex="0"
|
|
@click="toggleDropdown"
|
|
@keydown.enter.space.prevent="toggleDropdown"
|
|
>
|
|
<p :class="cn.e('label')">
|
|
{{ label }}
|
|
</p>
|
|
|
|
<div :class="cn.e('content')">
|
|
<!-- <UiCoin -->
|
|
<!-- v-if="selectedOptionCoin" -->
|
|
<!-- :name="selectedOptionCoin" -->
|
|
<!-- :class="cn.e('coin')" -->
|
|
<!-- circle -->
|
|
<!-- /> -->
|
|
|
|
<input
|
|
ref="controlEl"
|
|
:value="selectedOptionLabel"
|
|
type="text"
|
|
:class="cn.e('control')"
|
|
autocomplete="off"
|
|
tabindex="-1"
|
|
:placeholder="placeholder || $t('ui.select.select_option')"
|
|
@keydown.enter.stop.prevent="toggleOption(options[0])"
|
|
>
|
|
|
|
<UiIconSCrossCompact
|
|
v-if="clearable && isFilled"
|
|
:class="cn.e('clear')"
|
|
@click.stop="clear"
|
|
/>
|
|
</div>
|
|
|
|
<UiIconChevronDown :class="cn.e('chevron')" />
|
|
</div>
|
|
|
|
<Transition
|
|
name="dropdown"
|
|
@enter="$emit('opened')"
|
|
@after-leave="onAfterLeave"
|
|
>
|
|
<div
|
|
v-if="active"
|
|
ref="popperEl"
|
|
:class="cn.e('popper')"
|
|
:style="floatingStyles"
|
|
>
|
|
<div
|
|
ref="dropdownEl"
|
|
role="listbox"
|
|
:class="cn.e('dropdown')"
|
|
>
|
|
<UiSearch
|
|
v-if="searchable"
|
|
ref="searchInput"
|
|
v-model="searchTerm"
|
|
:class="cn.e('search')"
|
|
:label="$t('ui.search')"
|
|
size="small"
|
|
/>
|
|
|
|
<div
|
|
ref="itemsEl"
|
|
:class="cn.e('items')"
|
|
>
|
|
<div
|
|
ref="scrollerEl"
|
|
:class="cn.e('items-scroller')"
|
|
>
|
|
<div
|
|
:style="{
|
|
position: 'relative',
|
|
height: `${virtualizer?.getTotalSize()}px`,
|
|
}"
|
|
>
|
|
<UiSelectOption
|
|
v-for="item in virtualItems"
|
|
:key="item.index"
|
|
:ref="(el) => virtualizer.measureElement(el?.$el)"
|
|
:data-index="item.index"
|
|
:label="getOptionLabel(item.option)"
|
|
:coin="getOptionCoin(item.option)"
|
|
:selected="isOptionSelected(item.option)"
|
|
:disabled="isOptionDisabled(item.option)"
|
|
:multiple="multiple"
|
|
:style="item.styles"
|
|
@click="toggleOption(item.option)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<div
|
|
v-if="invalid || caption"
|
|
:class="[cn.e('bottom')]"
|
|
>
|
|
<span
|
|
v-if="invalid"
|
|
:class="[cn.e('validation-message')]"
|
|
>
|
|
{{ errorMessage }}
|
|
</span>
|
|
|
|
<span
|
|
v-else-if="caption"
|
|
:class="[cn.e('caption')]"
|
|
>{{ caption }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref, toRef, watch } from 'vue'
|
|
import { isEqual } from 'lodash-es'
|
|
|
|
// import UiCoin from 'alfabit-ui/components/coin/coin.vue';
|
|
import { useField } from 'vee-validate'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { autoUpdate, useFloating } from '@floating-ui/vue'
|
|
import { useVirtualizer } from '@tanstack/vue-virtual'
|
|
import { observeElementRect } from '@tanstack/virtual-core'
|
|
import { onClickOutside } from '@vueuse/core'
|
|
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
|
import { useOverlayScrollbars } from 'overlayscrollbars-vue'
|
|
import UiSearch from '../search/index.vue'
|
|
import useFuseSearch from '../../composables/use-fuse-search.js'
|
|
import useClassname from '../../composables/use-classname'
|
|
import UiSelectOption from './option.vue'
|
|
import 'overlayscrollbars/overlayscrollbars.css'
|
|
import type { Props } from './types'
|
|
|
|
defineOptions({
|
|
name: 'UiSelect',
|
|
})
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
disabled: false,
|
|
required: false,
|
|
clearable: false,
|
|
searchable: false,
|
|
multiple: false,
|
|
emitValue: false,
|
|
mapOptions: false,
|
|
})
|
|
|
|
const emit = defineEmits([
|
|
'update:modelValue',
|
|
'show',
|
|
'closed',
|
|
'opened',
|
|
'blur',
|
|
'focus',
|
|
])
|
|
|
|
const rootEl = ref()
|
|
const wrapperEl = ref()
|
|
const popperEl = ref()
|
|
const dropdownEl = ref()
|
|
const controlEl = ref()
|
|
const virtualScrollEl = ref()
|
|
const scrollbarEl = ref()
|
|
|
|
const itemsEl = ref()
|
|
const scrollerEl = ref()
|
|
|
|
const active = ref(false)
|
|
const searchTerm = ref('')
|
|
|
|
const cn = useClassname('ui-select')
|
|
const { t } = useI18n()
|
|
// TODO: Так как позиция всегда фиксированная, может быть убрать useFloating?
|
|
const { floatingStyles } = useFloating(wrapperEl, popperEl, {
|
|
placement: 'bottom',
|
|
whileElementsMounted: autoUpdate,
|
|
open: active,
|
|
})
|
|
|
|
const { activate: activateFocusTrap, deactivate: deactivateFocusTrap }
|
|
= useFocusTrap(rootEl)
|
|
const [initScrollbar, scrollbarInstance] = useOverlayScrollbars({
|
|
options: {
|
|
scrollbars: {
|
|
theme: 'os-small-theme',
|
|
},
|
|
},
|
|
})
|
|
|
|
onClickOutside(rootEl, () => {
|
|
toggleDropdown(false)
|
|
})
|
|
|
|
const isEmptyValue = value => [null, undefined, ''].includes(value)
|
|
|
|
const {
|
|
value: modelValue,
|
|
errorMessage,
|
|
handleChange,
|
|
handleBlur,
|
|
validate,
|
|
} = useField(
|
|
toRef(props, 'id'),
|
|
computed(() => ({ required: props.required })),
|
|
{
|
|
initialValue: !isEmptyValue(props.modelValue)
|
|
? props.modelValue
|
|
: undefined,
|
|
validateOnValueUpdate: false,
|
|
syncVModel: true,
|
|
},
|
|
)
|
|
|
|
const invalid = computed(() => !props.disabled && !!errorMessage.value)
|
|
|
|
const localValue = computed(() => {
|
|
const isEmpty = isEmptyValue(modelValue.value)
|
|
const mapNull = props.mapOptions && !props.multiple
|
|
let val = []
|
|
|
|
if (!isEmpty || mapNull) {
|
|
val
|
|
= props.multiple && Array.isArray(modelValue.value)
|
|
? modelValue.value
|
|
: [modelValue.value]
|
|
}
|
|
|
|
if (props.mapOptions) {
|
|
const values = val.map((v) => {
|
|
return props.options.find(opt => isEqual(getOptionValue(opt), v))
|
|
})
|
|
|
|
return isEmpty && mapNull ? values.filter(v => !!v) : values
|
|
}
|
|
|
|
return val
|
|
})
|
|
|
|
const isFilled = computed(() => localValue.value.length > 0)
|
|
|
|
const getOptionValue = getPropValueFn(props.optionValue, 'value')
|
|
const getOptionLabel = getPropValueFn(props.optionLabel, 'label')
|
|
const getOptionCoin = getPropValueFn(
|
|
props.optionCoin,
|
|
'coin',
|
|
!props.forceCoin,
|
|
true,
|
|
)
|
|
|
|
function isOptionDisabled(opt) {
|
|
return getPropValueFn(props.optionDisabled, 'disabled')(opt) === true
|
|
}
|
|
|
|
const localOptionsValue = computed(() => {
|
|
return localValue.value.map(opt => getOptionValue(opt))
|
|
})
|
|
|
|
const optionsLabelMap = computed(() => {
|
|
return props.options.map((opt, idx) => ({
|
|
index: idx,
|
|
value: getOptionLabel(opt),
|
|
}))
|
|
})
|
|
|
|
const selectedOptionLabel = computed(() => {
|
|
if (props.multiple && localValue.value.length > 1)
|
|
return t('ui.select.selected', [localValue.value.length])
|
|
|
|
return getOptionLabel(localValue.value?.[0])
|
|
})
|
|
|
|
const selectedOptionCoin = computed(() => {
|
|
if (props.multiple && localValue.value.length > 1)
|
|
return null
|
|
|
|
return getOptionCoin(localValue.value?.[0])
|
|
})
|
|
|
|
const { result: searchResult } = useFuseSearch(
|
|
optionsLabelMap,
|
|
{
|
|
keys: ['value'],
|
|
},
|
|
searchTerm,
|
|
)
|
|
|
|
const options = computed(() => {
|
|
return searchResult.value.map(opt => props.options[opt.index])
|
|
})
|
|
|
|
const virtualizer = useVirtualizer({
|
|
get count() {
|
|
return options.value.length
|
|
},
|
|
getScrollElement: () => scrollerEl.value,
|
|
estimateSize: () => 44,
|
|
overscan: 5,
|
|
initialRect: {
|
|
width: 1,
|
|
height: 44,
|
|
},
|
|
observeElementRect: process.server ? () => {} : observeElementRect,
|
|
})
|
|
|
|
const virtualItems = computed(() => {
|
|
return virtualizer.value.getVirtualItems().map((virtualItem) => {
|
|
return {
|
|
...virtualItem,
|
|
option: options.value[virtualItem.index],
|
|
styles: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
transform: `translateY(${virtualItem.start}px)`,
|
|
},
|
|
}
|
|
})
|
|
})
|
|
|
|
watch(options, () => {
|
|
virtualizer.value.scrollToOffset(0)
|
|
})
|
|
|
|
watch([itemsEl, scrollerEl], ([itemsEl, scrollerEl]) => {
|
|
if (!itemsEl || !scrollerEl)
|
|
return
|
|
|
|
initScrollbar({
|
|
target: itemsEl,
|
|
elements: {
|
|
viewport: scrollerEl,
|
|
},
|
|
})
|
|
})
|
|
|
|
watch(popperEl, (value) => {
|
|
if (value)
|
|
activateFocusTrap()
|
|
else
|
|
deactivateFocusTrap()
|
|
})
|
|
|
|
onMounted(async () => {
|
|
if (!isEmptyValue(modelValue.value) && !props.disabled)
|
|
await validate()
|
|
})
|
|
|
|
function clear() {
|
|
handleChange(props.multiple ? [] : null)
|
|
|
|
if (active.value)
|
|
active.value = false
|
|
}
|
|
|
|
function toggleDropdown(state) {
|
|
if (props.disabled)
|
|
return
|
|
|
|
if (typeof state === 'boolean') {
|
|
active.value = state
|
|
}
|
|
else {
|
|
if (!popperEl.value || !popperEl.value.contains(document.activeElement))
|
|
active.value = !active.value
|
|
}
|
|
}
|
|
|
|
function getPropValueFn(
|
|
propValue,
|
|
defaultVal,
|
|
noPrimitives = false,
|
|
omitDefault = false,
|
|
) {
|
|
const val = propValue !== undefined ? propValue : defaultVal
|
|
|
|
if (typeof val === 'function')
|
|
return val
|
|
|
|
return (opt) => {
|
|
if (['string', 'number'].includes(typeof opt))
|
|
return noPrimitives ? null : opt
|
|
|
|
if (omitDefault)
|
|
return opt?.[val] ?? null
|
|
else
|
|
return opt?.[val] ?? opt?.[defaultVal] ?? null
|
|
}
|
|
}
|
|
|
|
function isOptionSelected(opt) {
|
|
const val = getOptionValue(opt)
|
|
|
|
return localOptionsValue.value.findIndex(v => isEqual(v, val)) > -1
|
|
}
|
|
|
|
function toggleOption(opt, keepOpen = false) {
|
|
if (isOptionDisabled(opt))
|
|
return
|
|
|
|
const optValue = getOptionValue(opt)
|
|
|
|
if (!props.multiple) {
|
|
if (
|
|
!isFilled.value
|
|
|| !isEqual(getOptionValue(localValue.value[0]), optValue)
|
|
)
|
|
handleChange(props.emitValue ? optValue : opt)
|
|
|
|
!keepOpen && toggleDropdown(false)
|
|
|
|
return
|
|
}
|
|
|
|
if (!isFilled.value) {
|
|
handleChange([props.emitValue ? optValue : opt])
|
|
|
|
return
|
|
}
|
|
|
|
const model = modelValue.value.slice()
|
|
const index = localOptionsValue.value.findIndex(v => isEqual(v, optValue))
|
|
|
|
if (index > -1)
|
|
model.splice(index, 1)
|
|
else
|
|
model.push(props.emitValue ? optValue : opt)
|
|
|
|
handleChange(model)
|
|
}
|
|
|
|
function onAfterLeave() {
|
|
scrollbarInstance()?.destroy()
|
|
|
|
if (props.searchable)
|
|
searchTerm.value = ''
|
|
}
|
|
|
|
function onEscape(event) {
|
|
if (active.value) {
|
|
event.stopPropagation()
|
|
active.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
@use 'styles';
|
|
</style>
|