first commit
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
<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>
|
||||
<SvgIcon
|
||||
name="caret-down"
|
||||
:size="20"
|
||||
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 { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
|
||||
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
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, required: true },
|
||||
optionLabel: { type: String, default: 'name' },
|
||||
optionValue: { type: String, default: 'id' },
|
||||
multiple: { type: Boolean, default: false },
|
||||
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
|
||||
if (props.multiple && Array.isArray(value)) {
|
||||
const selected = props.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 = props.options.find((o) => o[props.optionValue] === value)
|
||||
return found ? found[props.optionLabel] : props.placeholder
|
||||
})
|
||||
|
||||
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;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-validate);
|
||||
}
|
||||
|
||||
&--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);
|
||||
}
|
||||
}
|
||||
|
||||
&__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;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-validate);
|
||||
}
|
||||
}
|
||||
|
||||
&__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%;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 1.5rem;
|
||||
background: transparent;
|
||||
text-align: right;
|
||||
font-family: var(--font-family-fa);
|
||||
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>
|
||||
Reference in New Issue
Block a user