416 lines
10 KiB
Vue
416 lines
10 KiB
Vue
<template>
|
|
<div class="select-field">
|
|
<label v-if="label" :for="inputId" class="select-field__label">{{ label }}</label>
|
|
|
|
<div
|
|
:id="inputId"
|
|
ref="referenceEl"
|
|
tabindex="0"
|
|
class="select-field__trigger"
|
|
:class="{
|
|
'select-field__trigger--error': error,
|
|
'select-field__trigger--disabled': disabled,
|
|
'select-field__trigger--open': isOpen,
|
|
}"
|
|
@click="toggle"
|
|
>
|
|
<span class="select-field__value">{{ selectedLabel }}</span>
|
|
<button
|
|
v-if="showClear"
|
|
type="button"
|
|
class="select-field__clear"
|
|
aria-label="پاک کردن"
|
|
@click.stop="clearSelection"
|
|
>
|
|
<SvgIcon name="close" :size="14" color="currentColor" />
|
|
</button>
|
|
<SvgIcon
|
|
name="caret-down"
|
|
:size="20"
|
|
color="#DDDDDD"
|
|
class="select-field__caret"
|
|
:class="{ 'select-field__caret--open': isOpen }"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="error" class="select-field__error" :class="{ 'animate-shake': vibration }">
|
|
{{ error }}
|
|
</div>
|
|
|
|
<Teleport to="body">
|
|
<Transition name="dropdown">
|
|
<div
|
|
v-if="isOpen"
|
|
ref="floatingEl"
|
|
class="select-field__dropdown scrollbar-thin"
|
|
:style="{ width: dropdownWidth }"
|
|
>
|
|
<div v-if="searchable" class="select-field__search">
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
placeholder="جستجو..."
|
|
class="select-field__search-input"
|
|
@input="handleSearch"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="options.length === 0" class="select-field__empty">موردی یافت نشد</div>
|
|
|
|
<ul v-else class="select-field__list">
|
|
<li v-for="(opt, i) in options" :key="i">
|
|
<button
|
|
type="button"
|
|
class="select-field__option"
|
|
:class="{ 'select-field__option--active': isSelected(opt) }"
|
|
@click="select(opt)"
|
|
>
|
|
<template v-if="multiple">
|
|
<SvgIcon
|
|
:name="isSelected(opt) ? 'check-square' : 'square'"
|
|
:size="16"
|
|
class="select-field__check"
|
|
/>
|
|
</template>
|
|
<span class="select-field__option-text">{{ opt[optionLabel] }}</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
|
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
|
|
|
|
let uid = 0
|
|
const nextUid = () => `select-field-${++uid}`
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: [String, Number, Array, Boolean, Object], default: undefined },
|
|
name: { type: String, default: '' },
|
|
label: { type: String, default: '' },
|
|
placeholder: { type: String, default: 'انتخاب کنید' },
|
|
options: { type: Array, default: () => [] },
|
|
optionLabel: { type: String, default: 'name' },
|
|
optionValue: { type: String, default: 'id' },
|
|
multiple: { type: Boolean, default: false },
|
|
clearable: { type: Boolean, default: true },
|
|
disabled: { type: Boolean, default: false },
|
|
error: { type: String, default: '' },
|
|
vibration: { type: Boolean, default: false },
|
|
searchable: { type: Boolean, default: false },
|
|
onSearch: { type: Function, default: null },
|
|
searchDebounce: { type: Number, default: 400 },
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue', 'change'])
|
|
|
|
const inputId = computed(() => props.name || nextUid())
|
|
const referenceEl = ref(null)
|
|
const floatingEl = ref(null)
|
|
const isOpen = ref(false)
|
|
const dropdownWidth = ref('auto')
|
|
const searchQuery = ref('')
|
|
let cleanup = null
|
|
let debounceTimer = null
|
|
|
|
const isSelected = (opt) => {
|
|
const value = opt[props.optionValue]
|
|
return props.multiple
|
|
? Array.isArray(props.modelValue) && props.modelValue.includes(value)
|
|
: props.modelValue === value
|
|
}
|
|
|
|
const selectedLabel = computed(() => {
|
|
const value = props.modelValue
|
|
if (value === undefined || value === null || value === '') return props.placeholder
|
|
const options = props.options ?? []
|
|
if (props.multiple && Array.isArray(value)) {
|
|
const selected = options.filter((o) => value.includes(o[props.optionValue]))
|
|
if (selected.length === 0) return props.placeholder
|
|
if (selected.length <= 2) return selected.map((s) => s[props.optionLabel]).join('، ')
|
|
return `${selected.length} مورد انتخاب شده`
|
|
}
|
|
const found = options.find((o) => o[props.optionValue] === value)
|
|
return found ? found[props.optionLabel] : props.placeholder
|
|
})
|
|
|
|
const hasValue = computed(() =>
|
|
props.multiple
|
|
? Array.isArray(props.modelValue) && props.modelValue.length > 0
|
|
: props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== ''
|
|
)
|
|
|
|
const showClear = computed(() => props.clearable && !props.disabled && hasValue.value)
|
|
|
|
const clearSelection = () => {
|
|
const cleared = props.multiple ? [] : null
|
|
emit('update:modelValue', cleared)
|
|
emit('change', cleared)
|
|
}
|
|
|
|
const select = (opt) => {
|
|
if (props.disabled) return
|
|
const value = opt[props.optionValue]
|
|
if (props.multiple) {
|
|
let current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
|
if (current.includes(value)) {
|
|
current = current.filter((v) => v !== value)
|
|
} else {
|
|
current.push(value)
|
|
}
|
|
emit('update:modelValue', current)
|
|
emit('change', current)
|
|
} else {
|
|
emit('update:modelValue', value)
|
|
emit('change', value)
|
|
close()
|
|
}
|
|
}
|
|
|
|
const mountFloating = async () => {
|
|
await Promise.resolve()
|
|
const reference = referenceEl.value
|
|
const floating = floatingEl.value
|
|
if (!reference || !floating) return
|
|
dropdownWidth.value = `${reference.offsetWidth}px`
|
|
cleanup = autoUpdate(reference, floating, () => {
|
|
computePosition(reference, floating, {
|
|
placement: 'bottom-start',
|
|
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
|
}).then(({ x, y }) => {
|
|
Object.assign(floating.style, { left: `${x}px`, top: `${y}px`, position: 'absolute' })
|
|
})
|
|
})
|
|
document.addEventListener('mousedown', handleOutsideClick)
|
|
}
|
|
|
|
const unmountFloating = () => {
|
|
cleanup?.()
|
|
cleanup = null
|
|
document.removeEventListener('mousedown', handleOutsideClick)
|
|
}
|
|
|
|
const handleOutsideClick = (event) => {
|
|
const reference = referenceEl.value
|
|
const floating = floatingEl.value
|
|
if (
|
|
reference &&
|
|
!reference.contains(event.target) &&
|
|
floating &&
|
|
!floating.contains(event.target)
|
|
) {
|
|
close()
|
|
}
|
|
}
|
|
|
|
const open = () => {
|
|
isOpen.value = true
|
|
setTimeout(mountFloating, 0)
|
|
}
|
|
|
|
const close = () => {
|
|
isOpen.value = false
|
|
searchQuery.value = ''
|
|
unmountFloating()
|
|
}
|
|
|
|
const toggle = () => {
|
|
if (props.disabled) return
|
|
isOpen.value ? close() : open()
|
|
}
|
|
|
|
const handleSearch = () => {
|
|
if (!props.onSearch) return
|
|
clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(() => props.onSearch(searchQuery.value), props.searchDebounce)
|
|
}
|
|
|
|
onBeforeUnmount(() => unmountFloating())
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.select-field {
|
|
position: relative;
|
|
width: 100%;
|
|
margin-bottom: 0.75rem;
|
|
|
|
&__label {
|
|
display: flex;
|
|
margin-bottom: 0.25rem;
|
|
font-family: var(--font-family-fa);
|
|
font-weight: 300;
|
|
line-height: 1.5rem;
|
|
font-size: 0.875rem;
|
|
color: var(--color-prim-gray);
|
|
pointer-events: none;
|
|
}
|
|
|
|
&__trigger {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
height: 2.35rem;
|
|
padding: 0 1.25rem;
|
|
border: 1px solid var(--color-thd-gray);
|
|
border-radius: 1.5rem;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
font-family: var(--font-family-number-fa);
|
|
font-size: 0.9rem;
|
|
transition: all 0.2s;
|
|
outline: none;
|
|
|
|
&--error {
|
|
border-color: var(--color-error);
|
|
|
|
&:focus {
|
|
box-shadow: 0 0 0 1px var(--color-error);
|
|
}
|
|
}
|
|
|
|
&--disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
}
|
|
|
|
&__value {
|
|
flex: 1;
|
|
text-align: start;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
&__caret {
|
|
color: var(--color-thd-gray);
|
|
transition: transform 0.2s;
|
|
flex-shrink: 0;
|
|
|
|
&--open {
|
|
transform: rotate(180deg);
|
|
}
|
|
}
|
|
|
|
&__clear {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
margin-inline-end: 0.375rem;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--color-thd-gray);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: color 0.15s;
|
|
|
|
&:hover {
|
|
color: var(--color-error);
|
|
}
|
|
}
|
|
|
|
&__error {
|
|
margin-top: 0.25rem;
|
|
font-family: var(--font-family-fa);
|
|
font-size: 0.75rem;
|
|
color: var(--color-error);
|
|
}
|
|
|
|
&__dropdown {
|
|
position: absolute;
|
|
z-index: 9999;
|
|
max-height: 15rem;
|
|
overflow-y: auto;
|
|
border: 1px solid var(--color-thd-gray);
|
|
border-radius: 1rem;
|
|
background: #fff;
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 10%), 0 4px 6px -4px rgba(0, 0, 0, 10%);
|
|
}
|
|
|
|
&__search {
|
|
padding: 0.75rem;
|
|
}
|
|
|
|
&__search-input {
|
|
width: 100%;
|
|
padding: 0.5rem 0.75rem;
|
|
font-family: var(--font-family-fa);
|
|
font-size: 0.875rem;
|
|
border: 1px solid var(--color-thd-gray);
|
|
border-radius: 0.75rem;
|
|
outline: none;
|
|
}
|
|
|
|
&__empty {
|
|
padding: 0.75rem;
|
|
font-family: var(--font-family-fa);
|
|
font-size: 0.875rem;
|
|
color: var(--color-prim-gray);
|
|
}
|
|
|
|
&__list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0.25rem;
|
|
|
|
> li + li {
|
|
margin-top: 0.25rem;
|
|
}
|
|
}
|
|
|
|
&__option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
width: 100%;
|
|
min-height: 2.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border: none;
|
|
border-radius: 1.5rem;
|
|
background: transparent;
|
|
text-align: right;
|
|
font-family: var(--font-family-fa);
|
|
font-weight: 400;
|
|
line-height: 1.5;
|
|
cursor: pointer;
|
|
transition: background-color 0.15s;
|
|
|
|
&:hover {
|
|
background-color: #f3f4f6;
|
|
}
|
|
|
|
&--active {
|
|
background-color: #f3f4f6;
|
|
}
|
|
}
|
|
|
|
&__check {
|
|
color: var(--color-thd-gray);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
&__option-text {
|
|
font-size: 0.9rem;
|
|
}
|
|
}
|
|
|
|
.dropdown-enter-active,
|
|
.dropdown-leave-active {
|
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
}
|
|
|
|
.dropdown-enter-from,
|
|
.dropdown-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-6px);
|
|
}
|
|
</style>
|