Files
banu-front/src/components/form/DatePickerField.vue
T
sajjadtalkhabi 38a396ba21
Deploy banu-front / deploy (push) Successful in 1m27s
fix
2026-07-07 17:36:43 +03:30

607 lines
15 KiB
Vue

<template>
<div class="datepicker-field">
<label v-if="label" :for="inputId" class="datepicker-field__label">{{ label }}</label>
<div
:id="inputId"
ref="referenceRef"
tabindex="0"
class="datepicker-field__trigger"
:class="{
'datepicker-field__trigger--error': error,
'datepicker-field__trigger--disabled': disabled,
}"
@click="toggle"
>
<span class="datepicker-field__value">{{ displayValue }}</span>
<SvgIcon name="calendar" :size="20" color="#DDDDDD" class="datepicker-field__icon" />
</div>
<div v-if="error" class="datepicker-field__error" :class="{ 'animate-shake': vibration }">
{{ error }}
</div>
<Teleport to="body">
<transition name="fade">
<div
v-show="isOpen"
ref="floatingRef"
class="date-picker-dropdown datepicker-field__dropdown"
:style="floatingStyles"
>
<DatePicker
v-model="internalValue"
locale="fa"
inline
editable
compact-time
:min="jalaliMin"
:max="jalaliMax"
:type="type"
:simple="simple"
:range="range"
:auto-submit="false"
:time-picker="type === 'datetime'"
:compact-time="type === 'datetime'"
:format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'"
:display-format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'"
:confirm="type === 'datetime'"
@update:model-value="onSelect"
/>
</div>
</transition>
</Teleport>
</div>
</template>
<script>
import SvgIcon from '@/components/icons/SvgIcon.vue'
import DatePicker from 'vue3-persian-datetime-picker'
import { computePosition, autoUpdate, offset, shift, flip, size } from '@floating-ui/dom'
import {
JalaliToGregorianString,
gregorianToJalaliString,
gregorianToJalaliStringLong,
JalaliToGregorianStringWithTime,
} from '@/utils/date-convertor'
let uid = 0
const nextUid = () => `datepicker-field-${++uid}`
export default {
name: 'DatePickerField',
components: { DatePicker, SvgIcon },
props: {
modelValue: { type: String, default: '' },
name: { type: String, default: '' },
label: { type: String, default: '' },
placeholder: { type: String, default: 'یک تاریخ را انتخاب کنید' },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
vibration: { type: Boolean, default: false },
min: { type: String, default: undefined },
max: { type: String, default: undefined },
type: { type: String, default: 'date' },
simple: { type: Boolean, default: false },
range: { type: [Boolean, Array], default: false },
autoSubmit: { type: Boolean, default: true },
},
emits: ['update:modelValue', 'change'],
data() {
return {
inputId: this.name || nextUid(),
isOpen: false,
internalValue: null,
cleanup: null,
floatingStyles: {
position: 'absolute',
top: '0px',
left: '0px',
},
confirmHandler: null,
mutationObserver: null,
}
},
computed: {
displayValue() {
return this.internalValue || this.placeholder
},
jalaliMin() {
if (!this.min) return
if (this.type === 'datetime') {
return gregorianToJalaliStringLong(this.min, '/', false).trim().replace('، ', ' ')
}
return gregorianToJalaliString(this.min, '/', false)
},
jalaliMax() {
if (!this.max) return
if (this.type === 'datetime') {
return gregorianToJalaliStringLong(this.max, '/', false).trim().replace('، ', ' ')
}
return gregorianToJalaliString(this.max, '/', false)
},
},
watch: {
modelValue: {
immediate: true,
handler(val) {
this.syncFromModel(val)
},
},
},
beforeUnmount() {
this.cleanup?.()
this.removeConfirmButtonListener()
document.removeEventListener('mousedown', this.handleOutside)
},
methods: {
syncFromModel(val) {
if (val && typeof val === 'string') {
this.internalValue =
this.type === 'datetime'
? gregorianToJalaliStringLong(val, '/', false)
: gregorianToJalaliString(val, '/', false)
} else {
this.internalValue = null
}
},
toggle() {
if (this.disabled) return
this.isOpen ? this.close() : this.open()
},
open() {
this.isOpen = true
this.$nextTick(() => {
this.initFloating()
if (this.type === 'datetime') {
this.$nextTick(() => {
this.attachConfirmButtonListener()
})
}
})
},
close() {
this.isOpen = false
this.cleanup?.()
this.cleanup = null
this.removeConfirmButtonListener()
},
removeConfirmButtonListener() {
if (this.confirmHandler) {
this.confirmHandler.element.removeEventListener('click', this.confirmHandler.handler)
delete this.confirmHandler.element.dataset.confirmListener
this.confirmHandler = null
}
if (this.mutationObserver) {
this.mutationObserver.disconnect()
this.mutationObserver = null
}
},
initFloating() {
const reference = this.$refs.referenceRef
const floating = this.$refs.floatingRef
if (!reference || !floating) return
this.cleanup = autoUpdate(reference, floating, () => {
computePosition(reference, floating, {
placement: 'bottom-start',
middleware: [
offset(6),
flip(),
shift({ padding: 8 }),
size({
apply({ rects }) {
Object.assign(floating.style, {
minWidth: `${rects.reference.width}px`,
})
},
}),
],
}).then(({ x, y }) => {
Object.assign(this.floatingStyles, {
left: `${x}px`,
top: `${y}px`,
})
})
})
document.addEventListener('mousedown', this.handleOutside)
},
handleOutside(e) {
const r = this.$refs.referenceRef
const f = this.$refs.floatingRef
if (r?.contains(e.target) || f?.contains(e.target)) return
this.close()
document.removeEventListener('mousedown', this.handleOutside)
},
onSelect(jalaliValue) {
if (!jalaliValue) return
if (this.type === 'datetime') {
const gregorian = JalaliToGregorianStringWithTime(jalaliValue)
this.$emit('update:modelValue', gregorian)
this.$emit('change', gregorian)
this.internalValue = jalaliValue
} else {
const gregorian = JalaliToGregorianString(jalaliValue)
this.$emit('update:modelValue', gregorian)
this.$emit('change', gregorian)
this.internalValue = jalaliValue
this.close()
}
},
attachConfirmButtonListener() {
const findAndAttach = () => {
const dropdown = this.$refs.floatingRef
if (!dropdown) return false
let confirmBtn = dropdown.querySelector('.vpd-actions')
if (confirmBtn) {
const allButtons = confirmBtn.querySelectorAll('button')
for (const btn of allButtons) {
if (btn.innerHTML.includes('svg') || btn.innerText.includes('تایید')) {
confirmBtn = btn
break
}
}
}
if (confirmBtn && !Object.hasOwn(confirmBtn.dataset, 'confirmListener')) {
const handler = (e) => {
e.preventDefault()
e.stopPropagation()
this.handleConfirm()
}
confirmBtn.addEventListener('click', handler, true)
confirmBtn.dataset.confirmListener = 'true'
this.confirmHandler = { element: confirmBtn, handler }
return true
}
return false
}
if (!findAndAttach()) {
const timeouts = [100, 300, 500, 1000]
timeouts.forEach((delay) => {
setTimeout(() => {
if (this.isOpen) {
findAndAttach()
}
}, delay)
})
}
this.setupMutationObserver()
},
setupMutationObserver() {
const dropdown = this.$refs.floatingRef
if (!dropdown) return
this.mutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
setTimeout(() => {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (this.isConfirmButton(node)) {
this.attachListenerToButton(node)
}
const btns = node.querySelectorAll ? node.querySelectorAll('button') : []
for (const btn of btns) {
if (this.isConfirmButton(btn)) {
this.attachListenerToButton(btn)
}
}
}
}
}, 50)
}
}
})
this.mutationObserver.observe(dropdown, {
childList: true,
subtree: true,
})
},
isConfirmButton(element) {
if (!element || !element.tagName) return false
if (
element.classList?.contains('vpd-confirm-btn') ||
element.classList?.contains('vpd-action-btn')
) {
return true
}
if (element.innerText?.includes('تایید')) {
return true
}
if (element.innerHTML?.includes('svg')) {
const svgPaths = element.innerHTML.match(/d="[^"]*"/g) || []
const hasCheckIcon = svgPaths.some(
(path) =>
path.includes('M20 6L9 17l-5-5') ||
path.includes('M9 16.17L4.83 12') ||
path.includes('M5 13l4 4L19 7')
)
return hasCheckIcon
}
return false
},
attachListenerToButton(btn) {
if (Object.hasOwn(btn.dataset, 'confirmListener')) return
const handler = (e) => {
e.preventDefault()
e.stopPropagation()
this.handleConfirm()
}
btn.addEventListener('click', handler, true)
btn.dataset.confirmListener = 'true'
this.confirmHandler = { element: btn, handler }
},
handleConfirm() {
if (!this.internalValue) return
try {
const gregorian = JalaliToGregorianStringWithTime(this.internalValue)
this.$emit('update:modelValue', gregorian)
this.$emit('change', gregorian)
this.close()
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error in handleConfirm:', error)
}
},
},
}
</script>
<style lang="scss" scoped>
.datepicker-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;
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;
}
&__icon {
color: var(--color-thd-gray);
flex-shrink: 0;
}
&__error {
margin-top: 0.25rem;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-error);
}
&__dropdown {
z-index: 50;
border: 1px solid var(--color-thd-gray);
border-radius: 0.75rem;
background: #fff;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 10%), 0 4px 6px -4px rgba(0, 0, 0, 10%);
}
}
</style>
<style>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.date-picker-dropdown .vpd-main .vpd-input-group {
display: none;
}
.date-picker-dropdown .vpd-wrapper .vpd-container {
margin: 0 !important;
font-family: iran-yekan;
width: 100%;
}
.date-picker-dropdown .vpd-wrapper .vpd-content {
width: auto;
border-radius: 8px;
}
.date-picker-dropdown .vpd-wrapper .vpd-header .vpd-date {
display: none;
}
.date-picker-dropdown .vpd-wrapper .vpd-header {
background-color: transparent !important;
padding: 5px;
}
.date-picker-dropdown .vpd-wrapper .vpd-header .vpd-year-label {
color: #5f5f5f;
text-align: center;
}
.date-picker-dropdown .vpd-wrapper .vpd-body {
overflow-x: hidden;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-controls button {
display: flex;
align-items: center;
justify-content: center;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-clearfix.vpd-week {
display: flex;
justify-content: space-between;
align-items: center;
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-clearfix.vpd-week
.vpd-weekday {
width: auto;
float: none;
clear: both;
padding: 0 12px;
}
@media screen and (max-width: 400px) {
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-clearfix.vpd-week,
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days {
padding: 0 5px;
}
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.direction-prev
.vpd-clearfix {
display: flex;
justify-content: space-between;
align-items: center;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days {
height: fit-content !important;
padding: 0 12px;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days .vpd-day {
width: 35px;
height: 35px;
float: right;
line-height: 35px;
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.vpd-day:not([disabled='true']).vpd-selected
.vpd-day-effect,
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.vpd-day:not([disabled='true']):hover
.vpd-day-effect {
background-color: #f36675 !important;
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.vpd-day
.vpd-day-effect {
width: 35px;
height: 35px;
top: -2px;
left: 0;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-close-addon {
background-color: #f5f5f5;
border-radius: 5px;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-month-label {
width: auto;
}
</style>