initial
This commit is contained in:
460
layers/ui/components/select/index.vue
Normal file
460
layers/ui/components/select/index.vue
Normal file
@@ -0,0 +1,460 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user