Files
radicall/resources/js/Client/components/ui/UiInputWithDropdown.vue
T

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>