complete Complete User Request Submission section RAD-1

This commit is contained in:
2026-01-03 19:14:30 +03:30
commit 7137ec5792
393 changed files with 152602 additions and 0 deletions
@@ -0,0 +1,56 @@
<script setup>
import {Link} from "@inertiajs/inertia-vue3";
const props = defineProps([
'currentPage',
'lastPage',
'total',
'perPage',
'links',
])
</script>
<template>
<section class="pagination" v-if="lastPage > 1">
<template v-for="(link , index) in links" :key="index">
<div class="paginate" v-if="link.label == 'قبلی'">
<Link v-if="link.url" :href="link.url">
<i class="fi fi-rr-angle-small-right flex text-xl"></i>
</Link>
</div>
<div class="paginate" v-else-if="link.label == 'بعدی'">
<Link v-if="link.url" :href="link.url">
<i class="fi fi-rr-angle-small-left flex text-xl"></i>
</Link>
</div>
<div class="paginate flex items-center" :class="{'!text-white !bg-client-primary' : currentPage == link.label }" v-else>
<Link v-if="link.url" :href="link.url" class="flex pt-1">{{link.label}}</Link>
<Link v-else href="" class="flex">{{link.label}}</Link>
</div>
</template>
</section>
</template>
<style lang="postcss">
section.pagination{
padding: 1rem;
display: flex;
align-items: center;
}
section.pagination div.paginate:first-child a{
border-top-right-radius: .4rem;
border-bottom-right-radius: .4rem;
}
section.pagination div.paginate:last-child a{
border-top-left-radius: .4rem;
border-bottom-left-radius: .4rem;
}
section.pagination div.paginate a{
display: flex;
height: 2.2rem;
width: 2.2rem;
align-items: center;
justify-content: center;
border: 1px solid #e6e6e6;
@apply text-client-Fontcolor
}
</style>
@@ -0,0 +1,179 @@
<script setup>
import { ref, watch } from 'vue';
import Pagination from './Pagination.vue';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import Toolbar from 'primevue/toolbar';
const props = defineProps({
data: {
type: Array,
required: true,
default: () => []
},
columns: {
type: Array,
required: true,
default: () => []
},
selectable: {
type: Boolean,
default: false
},
modelValue: {
type: Array,
default: () => []
},
pagination: {
type: Object,
default: () => null
},
searchable: {
type: Boolean,
default: true
},
searchValue: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'selection-change', 'page-change', 'search-change']);
const localSelection = ref(props.modelValue);
watch(() => props.modelValue, (newValue) => {
localSelection.value = newValue;
}, { immediate: true });
const handleSelectionChange = (event) => {
localSelection.value = event;
emit('update:modelValue', event);
emit('selection-change', event);
};
const localSearchValue = ref(props.searchValue);
watch(() => props.searchValue, (newValue) => {
localSearchValue.value = newValue;
}, { immediate: true });
const handleSearchClick = () => {
emit('search-change', localSearchValue.value);
};
const renderColumnContent = (column, data) => {
let content = '';
if (column.render && typeof column.render === 'function') {
content = column.render(data);
} else {
content = data[column.key];
}
const defaultStyle = '!text-[11px] !font-normal';
const customStyle = column.style || defaultStyle;
return `<span class="${customStyle} text-right wrap-break-word text-sm">${content}</span>`;
};
const getColumnStyle = (column) => {
const baseStyle = { minWidth: '120px' };
if (column.width) {
baseStyle.width = column.width + 'px';
baseStyle.minWidth = column.width + 'px';
baseStyle.maxWidth = column.width + 'px';
}
return baseStyle;
};
</script>
<template>
<div class="table-generic">
<div v-if="searchable" class="p-4">
<Toolbar class="">
<template #start>
<div class="grid grid-cols-2a justify-start gap-2">
<InputText
size="small"
class="placeholder:!font-light placeholder:!text-xs"
placeholder="جستجو..."
type="text"
v-model="localSearchValue"
/>
<Button
:pt="{icon : 'flex'}"
icon="fi fi-rr-search"
class="!text-xs"
severity="secondary"
@click="handleSearchClick"
/>
</div>
<div v-if="localSelection && localSelection.length > 0" class="mr-2 !h-9 grid grid-cols-1">
<slot name="bulk-action" :selection="localSelection"></slot>
</div>
</template>
<template #end>
<slot name="toolbar-actions"></slot>
</template>
</Toolbar>
</div>
<DataTable
:value="data"
dataKey="id"
v-model:selection="localSelection"
:showGridlines="false"
:stripedRows="false"
@selection-change="handleSelectionChange"
>
<template #empty>
<div class="flex justify-center py-4 items-center h-full">
<span class="text-center text-sm">هیچ داده‌ای پیدا نشده!</span>
</div>
</template>
<Column
v-if="selectable"
selectionMode="multiple"
headerStyle="width: 4rem"
/>
<Column
v-for="column in columns"
:key="column.key"
:field="column.key"
:header="column.title"
:sortable="column.hasSort === true"
:style="getColumnStyle(column)"
>
<template #body="{ data }">
<slot v-if="column.slot" :name="`column-${column.key}`" :data="data" :column="column">
<div v-html="renderColumnContent(column, data)"></div>
</slot>
<div v-else v-html="renderColumnContent(column, data)"></div>
</template>
</Column>
</DataTable>
<Pagination
v-if="pagination && pagination.total"
:total="pagination.total"
:current-page="pagination.current_page"
:perPage="pagination.per_page"
:lastPage="pagination.last_page"
:links="pagination.links"
@page-change="(page) => emit('page-change', page)"
/>
</div>
</template>
<style scoped>
.table-generic {
width: 100%;
}
</style>
@@ -0,0 +1,162 @@
<template>
<div class="ui-button">
<Button
:class="buttonClasses"
:style="buttonStyle"
:disabled="disabled"
:loading="loading"
:label="buttonLabel"
:size="size"
:severity="severity"
:outlined="outlined"
:text="text"
:raised="raised"
:rounded="rounded"
:link="link"
:plain="plain"
:pt="pt"
@click="handleClick"
>
<template v-if="icon && iconPosition === 'start'" #icon>
<i :class="[icon, iconClass]" class="ui-button-icon"></i>
</template>
<template v-if="icon && iconPosition === 'end'" #default>
{{ label }}
<i :class="[icon, iconClass]" class="ui-button-icon-end"></i>
</template>
<slot />
</Button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Button from 'primevue/button'
const props = defineProps({
label: {
type: String,
default: ''
},
icon: {
type: String,
default: ''
},
icon: {
type: String,
default: ''
},
iconPosition: {
type: String,
default: 'start',
validator: (value) => ['start', 'end'].includes(value)
},
iconClass: {
type: String,
default: ''
},
size: {
type: String,
default: 'default',
validator: (value) => ['small', 'default', 'large'].includes(value)
},
severity: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dominant'].includes(value)
},
variant: {
type: String,
default: 'filled',
validator: (value) => ['filled', 'outlined', 'text', 'link', 'plain'].includes(value)
},
disabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
outlined: {
type: Boolean,
default: false
},
text: {
type: Boolean,
default: false
},
raised: {
type: Boolean,
default: false
},
rounded: {
type: Boolean,
default: false
},
link: {
type: Boolean,
default: false
},
plain: {
type: Boolean,
default: false
},
fullWidth: {
type: Boolean,
default: false
},
height: {
type: String,
default: ''
},
pt: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['click'])
const buttonClasses = computed(() => {
const classes = []
if (props.fullWidth) {
classes.push('ui-button--full-width')
}
if (props.severity === 'dominant') {
classes.push('ui-button--dominant')
}
if (props.severity === 'secondary') {
classes.push('!font-[600] !text-[11px] !rounded-[6px]')
}
return classes.join(' ')
})
const buttonLabel = computed(() => {
if (props.icon && props.iconPosition === 'end') {
return ''
}
return props.label
})
const buttonStyle = computed(() => {
const style = {}
if (props.height) {
style.height = props.height
}
return style
})
const handleClick = (event) => {
emit('click', event)
}
</script>
@@ -0,0 +1,122 @@
<template>
<Card :class="[cardClasses, 'border-[1px] border-complement-50']" :pt="pt">
<template #header>
<div class="ui-card-header grid grid-cols-a pb-2 justify-start items-center">
<h1 class="font-bold text-[11px] text-complement-700 title-generic">{{ title }}</h1>
</div>
</template>
<template #content>
<slot />
</template>
</Card>
</template>
<script setup>
import { computed } from 'vue'
import Card from 'primevue/card'
const props = defineProps({
title: {
type: String,
default: ''
},
subtitle: {
type: String,
default: ''
},
variant: {
type: String,
default: 'default',
validator: (value) => ['default', 'elevated', 'outlined', 'filled'].includes(value)
},
size: {
type: String,
default: 'default',
validator: (value) => ['small', 'default', 'large'].includes(value)
},
shadow: {
type: String,
default: 'default',
validator: (value) => ['none', 'sm', 'default', 'md', 'lg', 'xl'].includes(value)
},
border: {
type: Boolean,
default: true
},
rounded: {
type: Boolean,
default: true
},
hover: {
type: Boolean,
default: false
},
pt: {
type: Object,
default: () => ({})
}
})
const cardClasses = computed(() => {
const classes = ['ui-card']
switch (props.size) {
case 'small':
classes.push('ui-card--small')
break
case 'large':
classes.push('ui-card--large')
break
default:
classes.push('ui-card--default')
}
switch (props.variant) {
case 'elevated':
classes.push('ui-card--elevated')
break
case 'outlined':
classes.push('ui-card--outlined')
break
case 'filled':
classes.push('ui-card--filled')
break
default:
classes.push('ui-card--default')
}
if (props.shadow !== 'none') {
switch (props.shadow) {
case 'sm':
classes.push('ui-card--shadow-sm')
break
case 'md':
classes.push('ui-card--shadow-md')
break
case 'lg':
classes.push('ui-card--shadow-lg')
break
case 'xl':
classes.push('ui-card--shadow-xl')
break
default:
classes.push('ui-card--shadow')
}
}
if (!props.border) {
classes.push('ui-card--no-border')
}
if (props.rounded) {
classes.push('ui-card--rounded')
}
if (props.hover) {
classes.push('ui-card--hover')
}
return classes.join(' ')
})
</script>
@@ -0,0 +1,515 @@
<template>
<div class="ui-input-wrapper" :class="directionClass">
<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', prefixClasses]">
<i v-if="actualIconEnd" :class="[actualIconEnd, iconColorClass]" class="ui-input-icon-end !mt-2"></i>
<div
v-if="isSvgIconEnd && svgIconEndContent"
class="ui-input-icon-end has-svg"
:class="iconColorClass"
v-html="svgIconEndContent"
/>
<span v-if="prefixEnd" class="ui-input-prefix-end" :class="prefixColorClass">{{ prefixEnd }}</span>
<InputText
:id="id"
:model-value="displayValue"
:class="inputClassesNoBorder"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:type="inputType"
:maxlength="maxlength"
:minlength="minlength"
:autocomplete="autocomplete"
:autofocus="autofocus"
:spellcheck="spellcheck"
:tabindex="tabindex"
@input="handleInput"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
@keyup="handleKeyup"
@keydown="handleKeydown"
/>
<span v-if="prefixStart" class="ui-input-prefix-start" :class="prefixColorClass">{{ prefixStart }}</span>
<i v-if="actualIconStart" :class="[actualIconStart, iconColorClass]" class="ui-input-icon-start !mt-2"></i>
<div
v-if="isSvgIconStart && svgIconStartContent"
class="ui-input-icon-start has-svg"
:class="iconColorClass"
v-html="svgIconStartContent"
/>
<i
v-if="showPasswordToggle"
:class="[passwordToggleIcon, iconColorClass]"
class="ui-input-password-toggle !mt-2 cursor-pointer"
@click="togglePasswordVisibility"
></i>
</div>
<div v-else-if="prefix" :class="['.ui-input-wrapper-border', props.wrapperBorderClass, directionClass]">
<span class="ui-input-prefix">{{ prefix }}</span>
<InputText
:id="id"
:model-value="displayValue"
:class="inputClassesNoBorder"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:type="inputType"
:maxlength="maxlength"
:minlength="minlength"
:autocomplete="autocomplete"
:autofocus="autofocus"
:spellcheck="spellcheck"
:tabindex="tabindex"
@input="handleInput"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
@keyup="handleKeyup"
@keydown="handleKeydown"
/>
<i
v-if="showPasswordToggle"
:class="[passwordToggleIcon, iconColorClass]"
class="ui-input-password-toggle !mt-2 cursor-pointer"
@click="togglePasswordVisibility"
></i>
</div>
<div v-else class="relative">
<InputText
:id="id"
:model-value="displayValue"
:class="inputClasses"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:type="inputType"
:maxlength="maxlength"
:minlength="minlength"
:autocomplete="autocomplete"
:autofocus="autofocus"
:spellcheck="spellcheck"
:tabindex="tabindex"
@input="handleInput"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
@keyup="handleKeyup"
@keydown="handleKeydown"
/>
<i
v-if="showPasswordToggle"
:class="[passwordToggleIcon, iconColorClass]"
class="ui-input-password-toggle absolute top-1/2 left-3 transform -translate-y-1/2 cursor-pointer"
@click="togglePasswordVisibility"
></i>
</div>
</div>
<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 } from 'vue'
import InputText from 'primevue/inputtext'
import { convertPersianToEnglish } from '../../utils/numberUtils.js'
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
},
label: {
type: String,
default: ''
},
placeholder: {
type: String,
default: ''
},
type: {
type: String,
default: 'text',
validator: (value) => ['text', 'password', 'email', 'number', 'tel', 'url', 'search'].includes(value)
},
id: {
type: String,
default: () => `input-${Math.random().toString(36).substr(2, 9)}`
},
required: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
},
error: {
type: String,
default: ''
},
hint: {
type: String,
default: ''
},
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
},
size: {
type: String,
default: 'default',
validator: (value) => ['small', 'default', 'large'].includes(value)
},
variant: {
type: String,
default: 'default',
validator: (value) => ['default', 'outlined', 'filled'].includes(value)
},
customClass: {
type: String,
default: ''
},
prefix: {
type: String,
default: ''
},
prefixStart: {
type: String,
default: ''
},
prefixEnd: {
type: String,
default: ''
},
iconStart: {
type: String,
default: ''
},
iconEnd: {
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'
},
passwordDisplayType: {
type: String,
default: 'dots',
validator: (value) => ['default', 'dots', 'asterisks'].includes(value)
},
convertPersianNumbers: {
type: Boolean,
default: false
},
wrapperBorderClass: {
type: String,
default: 'ui-input-wrapper-border'
}
})
const emit = defineEmits([
'update:modelValue',
'input',
'change',
'focus',
'blur',
'keyup',
'keydown'
])
const inputValue = ref(props.modelValue)
const passwordVisible = ref(false)
watch(() => props.modelValue, (newValue) => {
inputValue.value = newValue
})
watch(inputValue, (newValue) => {
emit('update:modelValue', newValue)
})
const directionClass = computed(() => {
return props.direction === 'ltr' ? 'ui-input--ltr' : 'ui-input--rtl'
})
const hasPrefixes = computed(() => {
return props.prefixStart || props.prefixEnd || props.iconStart || props.iconEnd
})
const isSvgIconStart = computed(() => {
return props.iconStart && props.iconStart.startsWith('svg:')
})
const isSvgIconEnd = computed(() => {
return props.iconEnd && props.iconEnd.startsWith('svg:')
})
const svgIconStartName = computed(() => {
return isSvgIconStart.value ? props.iconStart.replace('svg:', '') : ''
})
const svgIconEndName = computed(() => {
return isSvgIconEnd.value ? props.iconEnd.replace('svg:', '') : ''
})
const actualIconStart = computed(() => {
return isSvgIconStart.value ? '' : props.iconStart
})
const actualIconEnd = computed(() => {
return isSvgIconEnd.value ? '' : props.iconEnd
})
const showPasswordToggle = computed(() => {
return props.type === 'password'
})
const inputType = computed(() => {
if (props.type === 'password') {
if (passwordVisible.value || props.passwordDisplayType !== 'default') {
return 'text'
}
return 'password'
}
return props.type
})
const displayValue = computed(() => {
if (props.type === 'password' && !passwordVisible.value && inputValue.value) {
if (props.passwordDisplayType === 'dots') {
return '•'.repeat(inputValue.value.length)
} else if (props.passwordDisplayType === 'asterisks') {
return '*'.repeat(inputValue.value.length)
}
}
return inputValue.value
})
const passwordToggleIcon = computed(() => {
return passwordVisible.value ? 'fi-rr-eye-crossed' : 'fi-rr-eye'
})
const prefixClasses = computed(() => {
const classes = [directionClass.value]
if (props.prefixStart) classes.push('ui-input-with-prefix-start')
if (props.prefixEnd) classes.push('ui-input-with-prefix-end')
if (props.iconStart || isSvgIconStart.value) classes.push('ui-input-with-icon-start')
if (props.iconEnd || isSvgIconEnd.value) classes.push('ui-input-with-icon-end')
if (showPasswordToggle.value) classes.push('ui-input-with-password-toggle')
if (props.wrapperBorderClass) {
classes.push(props.wrapperBorderClass + ' ui-input-wrapper-border')
}
return classes.join(' ')
})
const prefixColorClass = computed(() => {
return `text-${props.prefixColor}`
})
const iconColorClass = computed(() => {
return `text-${props.iconColor}`
})
const loadSvgContent = async (svgName) => {
try {
const svgModule = await import(`../../components/svg/${svgName}.svg?raw`)
return svgModule.default
} catch (error) {
return null
}
}
const svgIconStartContent = ref('')
const svgIconEndContent = ref('')
watch(() => svgIconStartName.value, async (newName) => {
if (newName) {
const content = await loadSvgContent(newName)
svgIconStartContent.value = content
} else {
svgIconStartContent.value = ''
}
}, { immediate: true })
watch(() => svgIconEndName.value, async (newName) => {
if (newName) {
const content = await loadSvgContent(newName)
svgIconEndContent.value = content
} else {
svgIconEndContent.value = ''
}
}, { immediate: true })
const togglePasswordVisibility = () => {
passwordVisible.value = !passwordVisible.value
}
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(' ')
})
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')
}
switch (props.variant) {
case 'outlined':
classes.push('border-2')
break
case 'filled':
classes.push('bg-gray-100')
break
default:
classes.push('border')
}
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 handleInput = (event) => {
let newValue = event.target.value
if (props.convertPersianNumbers) {
newValue = convertPersianToEnglish(newValue)
event.target.value = newValue
}
if (props.type === 'password' && !passwordVisible.value && props.passwordDisplayType !== 'default') {
const currentLength = inputValue.value.length
const newLength = newValue.length
if (newLength > currentLength) {
inputValue.value = inputValue.value + newValue.slice(currentLength)
} else if (newLength < currentLength) {
inputValue.value = inputValue.value.slice(0, newLength)
}
} else {
inputValue.value = newValue
}
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)
}
</script>
@@ -0,0 +1,613 @@
<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>
@@ -0,0 +1,300 @@
<template>
<div class="ui-select-wrapper" :class="directionClass">
<label v-if="label" :for="id" class="ui-select-label">
{{ label }}
<span v-if="required" class="ui-select-required-indicator">*</span>
</label>
<div class="ui-select">
<div v-if="hasPrefixes" class="ui-select-wrapper-border" :class="prefixClasses">
<template v-if="direction === 'rtl'">
<i v-if="iconEnd" :class="[iconEnd, iconColorClass]" class="ui-select-icon-end !mt-2"></i>
<span v-if="prefixEnd" class="ui-select-prefix-end" :class="prefixColorClass">{{ prefixEnd }}</span>
<Dropdown
:id="id"
:model-value="internalModelValue"
:options="options"
:option-label="optionLabel"
:option-value="optionValue"
:dataKey="optionValue"
:placeholder="placeholder"
:disabled="disabled"
:class="selectClassesNoBorder"
empty-filter-message="نتیجه‌ای یافت نشد"
:empty-message="emptyMessage"
:filter="filter"
:reset-filter-on-hide="true"
:filter-placeholder="filterPlaceholder"
:auto-filter-focus="false"
:show-clear="showClear"
class="ui-select-dropdown"
@update:model-value="handleUpdate"
@change="handleUpdate"
@focus="handleFocus"
@blur="handleBlur"
/>
<span v-if="prefixStart" class="ui-select-prefix-start" :class="prefixColorClass">{{ prefixStart }}</span>
<i v-if="iconStart" :class="[iconStart, iconColorClass]" class="ui-select-icon-start !mt-2"></i>
</template>
<template v-else>
<i v-if="iconStart" :class="[iconStart, iconColorClass]" class="ui-select-icon-start !mt-2"></i>
<span v-if="prefixStart" class="ui-select-prefix-start" :class="prefixColorClass">{{ prefixStart }}</span>
<Dropdown
:id="id"
:model-value="internalModelValue"
:options="options"
:option-label="optionLabel"
:option-value="optionValue"
:dataKey="optionValue"
:placeholder="placeholder"
:disabled="disabled"
:class="selectClassesNoBorder"
empty-filter-message="نتیجه‌ای یافت نشد"
:empty-message="emptyMessage"
:filter="filter"
:reset-filter-on-hide="true"
:filter-placeholder="filterPlaceholder"
:auto-filter-focus="false"
:show-clear="showClear"
class="ui-select-dropdown"
@update:model-value="handleUpdate"
@change="handleUpdate"
@focus="handleFocus"
@blur="handleBlur"
/>
<span v-if="prefixEnd" class="ui-select-prefix-end" :class="prefixColorClass">{{ prefixEnd }}</span>
<i v-if="iconEnd" :class="[iconEnd, iconColorClass]" class="ui-select-icon-end !mt-2"></i>
</template>
</div>
<div v-else-if="prefix" class="ui-select-wrapper-border" :class="directionClass">
<span class="ui-select-prefix">{{ prefix }}</span>
<Dropdown
:id="id"
:model-value="internalModelValue"
:options="options"
:option-label="optionLabel"
:option-value="optionValue"
:dataKey="optionValue"
:placeholder="placeholder"
:disabled="disabled"
:class="selectClassesNoBorder"
empty-filter-message="نتیجه‌ای یافت نشد"
:empty-message="emptyMessage"
:filter="filter"
:reset-filter-on-hide="true"
:filter-placeholder="filterPlaceholder"
:show-clear="showClear"
:auto-filter-focus="false"
class="ui-select-dropdown"
@update:model-value="handleUpdate"
@change="handleUpdate"
@focus="handleFocus"
@blur="handleBlur"
/>
</div>
<div v-else class="relative select">
<Dropdown
:id="id"
:model-value="internalModelValue"
:options="options"
:option-label="optionLabel"
:option-value="optionValue"
:dataKey="optionValue"
:placeholder="placeholder"
:disabled="disabled"
:class="selectClasses"
empty-filter-message="نتیجه‌ای یافت نشد"
:empty-message="emptyMessage"
:filter="filter"
:reset-filter-on-hide="true"
:filter-placeholder="filterPlaceholder"
:show-clear="showClear"
class="ui-select-dropdown"
@update:model-value="handleUpdate"
@change="handleUpdate"
@focus="handleFocus"
@blur="handleBlur"
/>
</div>
</div>
<small v-if="error" class="ui-select-error-message">{{ error }}</small>
<small v-if="hint" class="ui-select-hint-message">{{ hint }}</small>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Dropdown from 'primevue/dropdown'
const props = defineProps({
id: {
type: String,
default: () => `ui-select-${Math.random().toString(36).substr(2, 9)}`
},
modelValue: { type: [String, Number, Object, null], default: null },
options: {
type: Array,
default: () => []
},
optionLabel: { type: String, default: 'name' },
optionValue: { type: String, default: undefined },
placeholder: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
error: {
type: String,
default: ''
},
hint: {
type: String,
default: ''
},
required: {
type: Boolean,
default: false
},
label: {
type: String,
default: ''
},
direction: {
type: String,
default: 'rtl',
validator: (value) => ['rtl', 'ltr'].includes(value)
},
prefix: {
type: String,
default: ''
},
prefixStart: {
type: String,
default: ''
},
prefixEnd: {
type: String,
default: ''
},
prefixColor: {
type: String,
default: 'dominant-600'
},
iconStart: {
type: String,
default: ''
},
iconEnd: {
type: String,
default: ''
},
iconColor: {
type: String,
default: 'dominant-600'
},
filter: {
type: Boolean,
default: false
},
filterPlaceholder: {
type: String,
default: 'جستجو...'
},
emptyMessage: {
type: String,
default: 'نتیجه‌ای یافت نشد'
},
showClear: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue','change','focus','blur'])
const internalModelValue = computed(() => {
if (props.optionValue && props.modelValue && typeof props.modelValue === 'object') {
return props.modelValue[props.optionValue]
}
return props.modelValue
})
const extractValue = (e) => (e && typeof e === 'object' && 'value' in e) ? e.value : e
const handleUpdate = (e) => {
const val = extractValue(e)
if (props.optionValue && val !== null && val !== undefined) {
const selectedOption = props.options.find(o => o[props.optionValue] === val) ?? null
emit('update:modelValue', selectedOption)
emit('change', selectedOption)
} else {
emit('update:modelValue', val)
emit('change', val)
}
}
const handleFocus = (e) => emit('focus', e)
const handleBlur = (e) => emit('blur', e)
const directionClass = computed(() => {
return props.direction === 'ltr' ? 'ui-select--ltr' : 'ui-select--rtl'
})
const hasPrefixes = computed(() => {
return props.prefixStart || props.prefixEnd || props.iconStart || props.iconEnd
})
const prefixClasses = computed(() => {
const classes = [directionClass.value]
if (props.prefixStart) classes.push('ui-select-with-prefix-start')
if (props.prefixEnd) classes.push('ui-select-with-prefix-end')
if (props.iconStart) classes.push('ui-select-with-icon-start')
if (props.iconEnd) classes.push('ui-select-with-icon-end')
return classes.join(' ')
})
const prefixColorClass = computed(() => {
return `text-${props.prefixColor}`
})
const iconColorClass = computed(() => {
return `text-${props.iconColor}`
})
const selectClassesNoBorder = computed(() => {
const classes = []
if (props.error) {
classes.push('has-error')
}
if (props.disabled) {
classes.push('disabled')
}
return classes.join(' ')
})
const selectClasses = computed(() => {
const classes = []
if (props.error) {
classes.push('has-error')
}
if (props.disabled) {
classes.push('disabled')
}
return classes.join(' ')
})
</script>