614 lines
14 KiB
Vue
614 lines
14 KiB
Vue
<template>
|
|
<div class="ui-input-dropdown-wrapper" :class="directionClass" ref="wrapperEl">
|
|
<label v-if="label" :for="id" class="ui-input-label">
|
|
{{ label }}
|
|
<span v-if="required" class="ui-input-required-indicator">*</span>
|
|
</label>
|
|
|
|
<div class="ui-input">
|
|
<div v-if="hasPrefixes" class="ui-input-wrapper-border" :class="prefixClasses">
|
|
<i v-if="iconEnd" :class="[iconEnd, iconColorClass]" class="ui-input-icon-end !mt-2"></i>
|
|
<span v-if="prefixEnd" class="ui-input-prefix-end" :class="prefixColorClass">{{ prefixEnd }}</span>
|
|
|
|
<InputText
|
|
:id="id"
|
|
v-model="inputValue"
|
|
:class="inputClassesNoBorder"
|
|
:placeholder="placeholder"
|
|
:disabled="disabled"
|
|
:readonly="readonly"
|
|
:type="type"
|
|
:maxlength="maxlength"
|
|
:minlength="minlength"
|
|
:autocomplete="autocomplete"
|
|
:autofocus="autofocus"
|
|
:spellcheck="spellcheck"
|
|
:tabindex="tabindex"
|
|
@input="handleInput"
|
|
@change="handleChange"
|
|
@focus="handleFocus"
|
|
@blur="handleBlur"
|
|
@keyup="handleKeyup"
|
|
@keydown="handleKeydown"
|
|
/>
|
|
|
|
<button
|
|
v-if="props.prefixOptions.length > 0"
|
|
ref="buttonRef"
|
|
type="button"
|
|
class="ui-input-prefix-dropdown-button"
|
|
:class="prefixColorClass"
|
|
@click="toggleDropdown"
|
|
>
|
|
|
|
<pre class="ui-input-prefix-text">{{ selectedPrefix.prefix }}</pre>
|
|
<i v-if="selectedPrefix.icon" :class="selectedPrefix.icon" class="ui-input-prefix-icon"></i>
|
|
</button>
|
|
|
|
<i v-if="iconStart" :class="[iconStart, iconColorClass]" class="ui-input-icon-start !mt-2"></i>
|
|
</div>
|
|
|
|
<InputText
|
|
v-else
|
|
:id="id"
|
|
v-model="inputValue"
|
|
:class="inputClasses"
|
|
:placeholder="placeholder"
|
|
:disabled="disabled"
|
|
:readonly="readonly"
|
|
:type="type"
|
|
:maxlength="maxlength"
|
|
:minlength="minlength"
|
|
:autocomplete="autocomplete"
|
|
:autofocus="autofocus"
|
|
:spellcheck="spellcheck"
|
|
:tabindex="tabindex"
|
|
@input="handleInput"
|
|
@change="handleChange"
|
|
@focus="handleFocus"
|
|
@blur="handleBlur"
|
|
@keyup="handleKeyup"
|
|
@keydown="handleKeydown"
|
|
/>
|
|
</div>
|
|
|
|
<Transition
|
|
name="dropdown"
|
|
@enter="onDropdownEnter"
|
|
@leave="onDropdownLeave"
|
|
@after-leave="onDropdownAfterLeave"
|
|
>
|
|
<div
|
|
v-if="isDropdownOpen && props.prefixOptions.length > 0"
|
|
ref="dropdownRef"
|
|
class="ui-input-dropdown-menu"
|
|
:class="[ `ui-input-dropdown-menu--${dropdownPosition}`, dropdownDirectionClass, dropdownTextAlignClass, dropdownClass ]"
|
|
>
|
|
<div v-if="showSearch" class="ui-input-dropdown-search" :class="dropdownSearchWrapperClass">
|
|
<InputText
|
|
v-model="searchQuery"
|
|
:placeholder="searchPlaceholder"
|
|
class="ui-input-dropdown-search-input"
|
|
:class="dropdownSearchInputClass"
|
|
@input="handleSearch"
|
|
/>
|
|
</div>
|
|
<div class="ui-input-dropdown-list">
|
|
<button
|
|
v-for="item in filteredOptions"
|
|
:key="item.value"
|
|
type="button"
|
|
class="ui-input-dropdown-item"
|
|
:class="[ selectedPrefix.value === item.value ? 'ui-input-dropdown-item-selected' : '', dropdownItemClass, selectedPrefix.value === item.value ? dropdownItemSelectedClass : '' ]"
|
|
@click="selectOption(item)"
|
|
>
|
|
<i v-if="item.icon" :class="item.icon" class="ui-input-dropdown-item-icon"></i>
|
|
|
|
<span class="ui-input-dropdown-item-label">{{ item.enLabel }}</span>
|
|
<span class="ui-input-dropdown-item-prefix">({{ item.prefix }})</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<small v-if="error" class="ui-input-error-message">
|
|
{{ error }}
|
|
</small>
|
|
|
|
<small v-if="hint" class="ui-input-hint-message">
|
|
{{ hint }}
|
|
</small>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
|
import InputText from 'primevue/inputtext'
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: [String, Number],
|
|
default: ''
|
|
},
|
|
label: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
placeholder: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
type: {
|
|
type: String,
|
|
default: 'text'
|
|
},
|
|
size: {
|
|
type: String,
|
|
default: 'medium',
|
|
validator: (value) => ['small', 'medium', 'large'].includes(value)
|
|
},
|
|
variant: {
|
|
type: String,
|
|
default: 'outlined',
|
|
validator: (value) => ['outlined', 'filled'].includes(value)
|
|
},
|
|
error: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
hint: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
readonly: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
required: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
maxlength: {
|
|
type: Number,
|
|
default: null
|
|
},
|
|
minlength: {
|
|
type: Number,
|
|
default: null
|
|
},
|
|
autocomplete: {
|
|
type: String,
|
|
default: 'off'
|
|
},
|
|
autofocus: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
spellcheck: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
tabindex: {
|
|
type: Number,
|
|
default: null
|
|
},
|
|
customClass: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
prefixEnd: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
iconStart: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
iconEnd: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
prefixStart: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
direction: {
|
|
type: String,
|
|
default: 'rtl',
|
|
validator: (value) => ['rtl', 'ltr'].includes(value)
|
|
},
|
|
prefixColor: {
|
|
type: String,
|
|
default: 'dominant-700'
|
|
},
|
|
iconColor: {
|
|
type: String,
|
|
default: 'dominant-700'
|
|
},
|
|
showSearch: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
searchPlaceholder: {
|
|
type: String,
|
|
default: 'جستجو...'
|
|
},
|
|
dropdownDirection: {
|
|
type: String,
|
|
default: '',
|
|
validator: (value) => ['', 'rtl', 'ltr'].includes(value)
|
|
},
|
|
dropdownTextAlign: {
|
|
type: String,
|
|
default: '',
|
|
validator: (value) => ['', 'left', 'right', 'start', 'end'].includes(value)
|
|
},
|
|
dropdownClass: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
dropdownSearchWrapperClass: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
dropdownSearchInputClass: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
dropdownItemClass: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
dropdownItemSelectedClass: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
prefixOptions: {
|
|
type: Array,
|
|
default: () => [],
|
|
validator: (value) => {
|
|
return value.every(item =>
|
|
typeof item === 'object' &&
|
|
item.value &&
|
|
item.prefix &&
|
|
item.label
|
|
)
|
|
}
|
|
},
|
|
defaultPrefixValue: {
|
|
type: String,
|
|
default: ''
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits([
|
|
'update:modelValue',
|
|
'input',
|
|
'change',
|
|
'focus',
|
|
'blur',
|
|
'keyup',
|
|
'keydown',
|
|
'prefix-change'
|
|
])
|
|
|
|
const inputValue = ref(props.modelValue)
|
|
const isDropdownOpen = ref(false)
|
|
const isDropdownClosing = ref(false)
|
|
const searchQuery = ref('')
|
|
const dropdownPosition = ref('bottom')
|
|
const dropdownRef = ref(null)
|
|
const buttonRef = ref(null)
|
|
const wrapperEl = ref(null)
|
|
|
|
const id = `input-${Math.random().toString(36).substr(2, 9)}`
|
|
|
|
const directionClass = computed(() => {
|
|
return `ui-input--${props.direction}`
|
|
})
|
|
|
|
const hasPrefixes = computed(() => {
|
|
return props.prefixOptions.length > 0 || props.prefixEnd || props.prefixStart || props.iconStart || props.iconEnd
|
|
})
|
|
|
|
const prefixClasses = computed(() => {
|
|
const classes = []
|
|
|
|
if (props.prefixOptions.length > 0) {
|
|
classes.push('ui-input-with-prefixes')
|
|
}
|
|
|
|
if (props.prefixEnd) {
|
|
classes.push('ui-input-with-prefix-end')
|
|
}
|
|
|
|
if (props.iconStart) {
|
|
classes.push('ui-input-with-icon-start')
|
|
}
|
|
|
|
if (props.iconEnd) {
|
|
classes.push('ui-input-with-icon-end')
|
|
}
|
|
|
|
return classes.join(' ')
|
|
})
|
|
|
|
const prefixColorClass = computed(() => {
|
|
return `text-${props.prefixColor}`
|
|
})
|
|
|
|
const iconColorClass = computed(() => {
|
|
return `text-${props.iconColor}`
|
|
})
|
|
|
|
const effectiveDropdownDirection = computed(() => {
|
|
return props.dropdownDirection || props.direction
|
|
})
|
|
|
|
const dropdownDirectionClass = computed(() => {
|
|
return `ui-input-dropdown--${effectiveDropdownDirection.value}`
|
|
})
|
|
|
|
const dropdownTextAlignClass = computed(() => {
|
|
if (props.dropdownTextAlign === 'left') return 'text-left'
|
|
if (props.dropdownTextAlign === 'right') return 'text-right'
|
|
if (props.dropdownTextAlign === 'start') {
|
|
return effectiveDropdownDirection.value === 'rtl' ? 'text-right' : 'text-left'
|
|
}
|
|
if (props.dropdownTextAlign === 'end') {
|
|
return effectiveDropdownDirection.value === 'rtl' ? 'text-left' : 'text-right'
|
|
}
|
|
return effectiveDropdownDirection.value === 'rtl' ? 'text-right' : 'text-left'
|
|
})
|
|
|
|
const selectedPrefix = computed(() => {
|
|
if (props.defaultPrefixValue) {
|
|
const found = props.prefixOptions.find(option => option.value === props.defaultPrefixValue)
|
|
if (found) return found
|
|
}
|
|
return props.prefixOptions[0] || { value: '', prefix: '', label: '', icon: '' }
|
|
})
|
|
|
|
const filteredOptions = computed(() => {
|
|
if (!searchQuery.value) return props.prefixOptions
|
|
|
|
const query = searchQuery.value.toLowerCase().trim()
|
|
|
|
if (query.length <= 2) {
|
|
return props.prefixOptions.filter(option =>
|
|
option.prefix.startsWith(query) ||
|
|
option.value.toLowerCase().startsWith(query)
|
|
)
|
|
}
|
|
|
|
return props.prefixOptions.filter(option => {
|
|
if (option.prefix === query ||
|
|
option.value.toLowerCase() === query ||
|
|
option.label.toLowerCase() === query ||
|
|
(option.enLabel && option.enLabel.toLowerCase() === query)) {
|
|
return true
|
|
}
|
|
|
|
if (option.prefix.startsWith(query) ||
|
|
option.label.toLowerCase().startsWith(query) ||
|
|
(option.enLabel && option.enLabel.toLowerCase().startsWith(query))) {
|
|
return true
|
|
}
|
|
|
|
if (option.label.toLowerCase().includes(query) ||
|
|
(option.enLabel && option.enLabel.toLowerCase().includes(query)) ||
|
|
option.prefix.includes(query) ||
|
|
option.value.toLowerCase().includes(query)) {
|
|
return true
|
|
}
|
|
|
|
const cleanQuery = query.replace(/[\s\-_\-]/g, '')
|
|
const cleanLabel = option.label.replace(/[\s\-_\-]/g, '').toLowerCase()
|
|
const cleanEnLabel = option.enLabel ? option.enLabel.replace(/[\s\-_\-]/g, '').toLowerCase() : ''
|
|
|
|
if (cleanLabel.includes(cleanQuery) ||
|
|
cleanEnLabel.includes(cleanQuery)) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
})
|
|
})
|
|
|
|
const inputClasses = computed(() => {
|
|
const classes = []
|
|
|
|
switch (props.size) {
|
|
case 'small':
|
|
classes.push('p-2', 'text-xs')
|
|
break
|
|
case 'large':
|
|
classes.push('p-4', 'text-base')
|
|
break
|
|
default:
|
|
classes.push('p-3', 'text-sm')
|
|
}
|
|
|
|
if (props.variant === 'filled') {
|
|
classes.push('bg-gray-100')
|
|
}
|
|
|
|
if (props.error) {
|
|
classes.push('border-red-500')
|
|
}
|
|
|
|
if (props.disabled) {
|
|
classes.push('opacity-50', 'cursor-not-allowed')
|
|
}
|
|
|
|
if (props.customClass) {
|
|
classes.push(props.customClass)
|
|
}
|
|
|
|
return classes.join(' ')
|
|
})
|
|
|
|
const inputClassesNoBorder = computed(() => {
|
|
const classes = []
|
|
|
|
switch (props.size) {
|
|
case 'small':
|
|
classes.push('p-2', 'text-xs')
|
|
break
|
|
case 'large':
|
|
classes.push('p-4', 'text-base')
|
|
break
|
|
default:
|
|
classes.push('p-3', 'text-sm')
|
|
}
|
|
|
|
if (props.variant === 'filled') {
|
|
classes.push('bg-gray-100')
|
|
}
|
|
|
|
if (props.error) {
|
|
classes.push('border-red-500')
|
|
}
|
|
|
|
if (props.disabled) {
|
|
classes.push('opacity-50', 'cursor-not-allowed')
|
|
}
|
|
|
|
if (props.customClass) {
|
|
classes.push(props.customClass)
|
|
}
|
|
|
|
return classes.join(' ')
|
|
})
|
|
|
|
watch(() => props.modelValue, (newValue) => {
|
|
inputValue.value = newValue
|
|
})
|
|
|
|
watch(inputValue, (newValue) => {
|
|
emit('update:modelValue', newValue)
|
|
})
|
|
|
|
const calculateDropdownPosition = () => {
|
|
if (!buttonRef.value || !dropdownRef.value) return 'bottom'
|
|
|
|
const buttonRect = buttonRef.value.getBoundingClientRect()
|
|
const dropdownHeight = 300 // تقریبی ارتفاع dropdown
|
|
const windowHeight = window.innerHeight
|
|
const spaceBelow = windowHeight - buttonRect.bottom
|
|
const spaceAbove = buttonRect.top
|
|
|
|
if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
|
|
return 'top'
|
|
}
|
|
return 'bottom'
|
|
}
|
|
|
|
const toggleDropdown = () => {
|
|
if (props.disabled) return
|
|
|
|
if (isDropdownOpen.value) {
|
|
closeDropdown()
|
|
} else {
|
|
openDropdown()
|
|
}
|
|
}
|
|
|
|
const openDropdown = () => {
|
|
isDropdownClosing.value = false
|
|
isDropdownOpen.value = true
|
|
searchQuery.value = ''
|
|
nextTick(() => {
|
|
dropdownPosition.value = calculateDropdownPosition()
|
|
const searchInput = document.querySelector('.ui-input-dropdown-search-input')
|
|
if (searchInput) searchInput.focus()
|
|
})
|
|
}
|
|
|
|
const closeDropdown = () => {
|
|
isDropdownClosing.value = true
|
|
isDropdownOpen.value = false
|
|
searchQuery.value = ''
|
|
}
|
|
|
|
const onDropdownEnter = (el) => {
|
|
el.style.animation = 'dropdownSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
|
}
|
|
|
|
const onDropdownLeave = (el) => {
|
|
el.style.animation = 'dropdownSlideOut 0.2s cubic-bezier(0.4, 0, 0.2, 1)'
|
|
}
|
|
|
|
const onDropdownAfterLeave = () => {
|
|
isDropdownClosing.value = false
|
|
}
|
|
|
|
const closeOnOutsideClick = (event) => {
|
|
const root = event.target.closest('.ui-input-dropdown-wrapper')
|
|
if (!root || root !== wrapperEl.value) {
|
|
closeDropdown()
|
|
}
|
|
}
|
|
|
|
const onKeydown = (event) => {
|
|
if (event.key === 'Escape') {
|
|
closeDropdown()
|
|
}
|
|
}
|
|
|
|
const selectOption = (option) => {
|
|
emit('prefix-change', option)
|
|
closeDropdown()
|
|
}
|
|
|
|
const handleSearch = () => {
|
|
|
|
}
|
|
|
|
const handleInput = (event) => {
|
|
emit('input', event)
|
|
}
|
|
|
|
const handleChange = (event) => {
|
|
emit('change', event)
|
|
}
|
|
|
|
const handleFocus = (event) => {
|
|
emit('focus', event)
|
|
}
|
|
|
|
const handleBlur = (event) => {
|
|
emit('blur', event)
|
|
}
|
|
|
|
const handleKeyup = (event) => {
|
|
emit('keyup', event)
|
|
}
|
|
|
|
const handleKeydown = (event) => {
|
|
emit('keydown', event)
|
|
}
|
|
|
|
const handleWindowResize = () => {
|
|
if (isDropdownOpen.value) {
|
|
dropdownPosition.value = calculateDropdownPosition()
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('resize', handleWindowResize)
|
|
document.addEventListener('click', closeOnOutsideClick, true)
|
|
document.addEventListener('keydown', onKeydown)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', handleWindowResize)
|
|
document.removeEventListener('click', closeOnOutsideClick, true)
|
|
document.removeEventListener('keydown', onKeydown)
|
|
})
|
|
</script>
|