Files
Kotyata/layers/ui/components/select/index.vue
2026-03-17 13:24:22 +03:00

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>