Files
banu-front/src/components/form/SelectField.vue
T
Server Migration b64f528c0d
Deploy banu-front / deploy (push) Successful in 1m40s
fix: complete registration frontend flow
2026-08-15 17:28:40 +03:30

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>