607 lines
15 KiB
Vue
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>
|