feat(datepicker): introduce ZDatepicker component for enhanced date selection functionality

This commit is contained in:
Matej Glasnák committed 2025-12-02 15:30:55 +01:00
1 parent 205b3a7e42
commit a1e71006f0
4 files changed
+506 -29

No files matched your search

@@ -0,0 +1,331 @@
<template>
<div
class="z-datepicker-wrapper"
:class="{ 'has-value': hasValue, 'is-focused': isFocused }"
@click="handleClick"
>
<label v-if="label" class="z-datepicker-label">{{ label }}</label>
<Datepicker
ref="datepickerRef"
:model-value="modelValue"
@update:model-value="handleUpdate"
@open="handleOpen"
@closed="handleClose"
v-bind="$attrs"
:placeholder="hasValue ? ' ' : placeholder"
/>
</div>
</template>
<script>
import Datepicker from '@vuepic/vue-datepicker';
export default {
name: 'ZDatepicker',
components: {
Datepicker,
},
props: {
modelValue: {
type: [Date, String, null],
default: null,
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
},
emits: ['update:modelValue', 'open', 'closed'],
data() {
return {
isFocused: false,
preventInputFocus: false,
savedScrollPosition: null,
focusListeners: new Map(),
};
},
computed: {
hasValue() {
return this.modelValue != null && this.modelValue !== '';
},
},
mounted() {
// Attach focus listeners on mount to catch any focus events
this.$nextTick(() => {
this.attachFocusListeners();
});
},
methods: {
handleUpdate(value) {
this.$emit('update:modelValue', value);
},
handleOpen(event) {
console.log(
'🔵 [z-datepicker] handleOpen called at:',
new Date().toISOString(),
);
this.isFocused = true;
// IMPORTANT: Set flag and save position BEFORE any other action
this.preventInputFocus = true;
console.log(
'🔵 [z-datepicker] preventInputFocus set to:',
this.preventInputFocus,
);
// Save scroll position
const modalContent = document.querySelector('.v-dialog .modal-content');
if (modalContent) {
this.savedScrollPosition = modalContent.scrollTop;
console.log(
'🔵 [z-datepicker] Saved scroll position:',
this.savedScrollPosition,
);
} else {
console.log('🔵 [z-datepicker] Modal content not found');
}
// Reattach focus event listeners (for dynamically added inputs)
this.attachFocusListeners();
// Reset after datepicker is opened
setTimeout(() => {
this.preventInputFocus = false;
this.savedScrollPosition = null;
console.log(
'🔵 [z-datepicker] Reset preventInputFocus and savedScrollPosition after 500ms',
);
}, 500);
// Emit to parent (for backward compatibility)
const wrapperElement =
event?.target?.closest?.('.z-datepicker-wrapper') || true;
console.log('🔵 [z-datepicker] Emitting open event to parent');
this.$emit('open', wrapperElement);
},
handleClose(event) {
this.isFocused = false;
this.$emit('closed', event);
},
handleClick(event) {
console.log('🔵 [z-datepicker] Wrapper clicked');
// Prevent any default behavior
event.preventDefault();
event.stopPropagation();
// CRITICAL: Set prevention flag BEFORE opening menu to catch early focus events
this.preventInputFocus = true;
// Save scroll position immediately
const modalContent = document.querySelector('.v-dialog .modal-content');
if (modalContent) {
this.savedScrollPosition = modalContent.scrollTop;
console.log(
'🔵 [z-datepicker] Pre-saved scroll position in handleClick:',
this.savedScrollPosition,
);
}
if (this.$refs.datepickerRef?.openMenu) {
this.$refs.datepickerRef.openMenu();
}
},
openMenu() {
if (this.$refs.datepickerRef?.openMenu) {
this.$refs.datepickerRef.openMenu();
}
},
attachFocusListeners() {
console.log('🔵 [z-datepicker] attachFocusListeners called');
// Remove old listeners first
this.removeFocusListeners();
// Attach new listeners to all autocomplete inputs AND text fields
const selectors = [
'.v-autocomplete input',
'.v-text-field input',
'input[type="text"]',
];
let totalListeners = 0;
selectors.forEach((selector) => {
const inputs = document.querySelectorAll(selector);
let selectorCount = 0;
inputs.forEach((input) => {
// Skip the datepicker's own input
if (input.closest('.dp__input_wrap')) {
return;
}
const listener = this.onInputFocus.bind(this);
this.focusListeners.set(input, listener);
input.addEventListener('focus', listener, true); // Use capture phase
selectorCount++;
totalListeners++;
});
console.log(
`🔵 [z-datepicker] Found ${selectorCount} inputs for selector: ${selector}`,
);
});
console.log(
`🔵 [z-datepicker] Total focus listeners attached: ${totalListeners}`,
);
},
onInputFocus(event) {
const target = event.target;
console.log('🔵 [z-datepicker] onInputFocus triggered on:', {
tagName: target.tagName,
type: target.type,
id: target.id,
className: target.className,
preventInputFocus: this.preventInputFocus,
savedScrollPosition: this.savedScrollPosition,
});
// Skip if it's the datepicker's own input
if (
target.closest('.dp__input_wrap') ||
target.closest('.z-datepicker-wrapper')
) {
console.log(
"🔵 [z-datepicker] Skipping - this is datepicker's own input",
);
return;
}
// If prevention flag is set, block the focus immediately
if (this.preventInputFocus) {
console.log('🔵 [z-datepicker] PREVENTING FOCUS on:', target);
const scrollBefore = document.querySelector(
'.v-dialog .modal-content',
)?.scrollTop;
console.log('🔵 [z-datepicker] Scroll position before:', scrollBefore);
// Prevent the focus - stop all propagation
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
// Blur the focused element immediately (no timeout)
target.blur();
// Restore scroll position if we have one saved
if (this.savedScrollPosition !== null) {
const modalContent = document.querySelector(
'.v-dialog .modal-content',
);
if (modalContent) {
modalContent.scrollTop = this.savedScrollPosition;
const scrollAfter = modalContent.scrollTop;
console.log(
'🔵 [z-datepicker] Scroll position restored to:',
scrollAfter,
);
}
}
} else {
console.log(
'🔵 [z-datepicker] Focus allowed (preventInputFocus:',
this.preventInputFocus,
', savedScrollPosition:',
this.savedScrollPosition,
')',
);
}
},
removeFocusListeners() {
this.focusListeners.forEach((listener, input) => {
input.removeEventListener('focus', listener);
});
this.focusListeners.clear();
},
},
beforeUnmount() {
// Cleanup listeners on component unmount
this.removeFocusListeners();
},
};
</script>
<style scoped lang="sass">
.z-datepicker-wrapper
position: relative
width: 100%
cursor: pointer
.z-datepicker-label
position: absolute
left: 16px
top: 50%
transform: translateY(-50%)
font-size: 16px
color: rgba(0, 0, 0, 0.38)
background: transparent
padding: 0
z-index: 1
pointer-events: none
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1)
opacity: 0
visibility: hidden
&.has-value .z-datepicker-label
opacity: 1
visibility: visible
top: 8px
transform: translateY(0)
font-size: 12px
background: white
padding: 0 4px
left: 13px
&.is-focused .z-datepicker-label
color: rgba(0, 0, 0, 0.87)
&.has-value .z-datepicker-label
color: rgba(0, 0, 0, 0.6)
&.has-value :deep(.dp__input_icon)
top: 33px !important
&.has-value :deep(.dp__input)
padding-top: 20px !important
padding-bottom: 8px !important
:deep(.dp__input)
padding-top: 14px !important
padding-bottom: 14px !important
:deep(.dp__input_wrap)
pointer-events: none
:deep(.dp__clear_icon)
pointer-events: auto !important
cursor: pointer !important
top: 28px !important
:deep(.dp__input_icon)
top: 27px !important
left: 3px !important
</style>
@@ -9,9 +9,20 @@ export default {
methods: { methods: {
setFocusEvent() { setFocusEvent() {
document.querySelectorAll('.v-autocomplete input').forEach((input) => { const inputs = document.querySelectorAll('.v-autocomplete input');
console.log(
'🟢 [VuetifyFixFocusMixin] setFocusEvent - Found',
inputs.length,
'autocomplete inputs',
);
inputs.forEach((input) => {
input.addEventListener('focus', this.onInputFocus); input.addEventListener('focus', this.onInputFocus);
}); });
console.log(
'🟢 [VuetifyFixFocusMixin] Attached focus listeners to',
inputs.length,
'inputs',
);
}, },
onDateFocus($event) { onDateFocus($event) {
@@ -21,31 +32,69 @@ export default {
}, },
onDateOpen($event) { onDateOpen($event) {
console.log(
'🟢 [VuetifyFixFocusMixin] onDateOpen called at:',
new Date().toISOString(),
'with event:',
$event,
);
// $event should be the element reference (or any truthy value to trigger saving) // $event should be the element reference (or any truthy value to trigger saving)
if ($event) { if ($event) {
// Find the scrollable modal container // Find the scrollable modal container
const modalContent = document.querySelector('.v-dialog .modal-content'); const modalContent = document.querySelector('.v-dialog .modal-content');
if (modalContent) { if (modalContent) {
this.savedScrollPosition = modalContent.scrollTop; this.savedScrollPosition = modalContent.scrollTop;
console.log(
'🟢 [VuetifyFixFocusMixin] Saved scroll position:',
this.savedScrollPosition,
);
} else {
console.log('🟢 [VuetifyFixFocusMixin] Modal content not found');
} }
this.preventInputFocus = true; this.preventInputFocus = true;
console.log(
'🟢 [VuetifyFixFocusMixin] preventInputFocus set to:',
this.preventInputFocus,
);
// Reset after datepicker is opened // Reset after datepicker is opened
setTimeout(() => { setTimeout(() => {
this.preventInputFocus = false; this.preventInputFocus = false;
this.savedScrollPosition = null; this.savedScrollPosition = null;
console.log(
'🟢 [VuetifyFixFocusMixin] Reset preventInputFocus and savedScrollPosition after 500ms',
);
}, 500); }, 500);
} }
}, },
onInputFocus($event) { onInputFocus($event) {
const target = $event.target;
console.log('🟢 [VuetifyFixFocusMixin] onInputFocus triggered on:', {
tagName: target.tagName,
type: target.type,
id: target.id,
className: target.className,
preventInputFocus: this.preventInputFocus,
savedScrollPosition: this.savedScrollPosition,
});
// If we have a saved scroll position and an unwanted focus happened // If we have a saved scroll position and an unwanted focus happened
if (this.preventInputFocus && this.savedScrollPosition !== null) { if (this.preventInputFocus && this.savedScrollPosition !== null) {
const target = $event.target; const target = $event.target;
// Only intercept text input focus (autocomplete fields) // Only intercept text input focus (autocomplete fields)
if (target.tagName === 'INPUT' && target.type === 'text') { if (target.tagName === 'INPUT' && target.type === 'text') {
console.log('🟢 [VuetifyFixFocusMixin] PREVENTING FOCUS on:', target);
const scrollBefore = document.querySelector(
'.v-dialog .modal-content',
)?.scrollTop;
console.log(
'🟢 [VuetifyFixFocusMixin] Scroll position before:',
scrollBefore,
);
// Blur the focused element // Blur the focused element
setTimeout(() => { setTimeout(() => {
target.blur(); target.blur();
@@ -55,9 +104,26 @@ export default {
); );
if (modalContent && this.savedScrollPosition !== null) { if (modalContent && this.savedScrollPosition !== null) {
modalContent.scrollTop = this.savedScrollPosition; modalContent.scrollTop = this.savedScrollPosition;
const scrollAfter = modalContent.scrollTop;
console.log(
'🟢 [VuetifyFixFocusMixin] Scroll position restored to:',
scrollAfter,
);
} }
}, 0); }, 0);
} else {
console.log(
'🟢 [VuetifyFixFocusMixin] Not a text input, skipping prevention',
);
} }
} else {
console.log(
'🟢 [VuetifyFixFocusMixin] Focus allowed (preventInputFocus:',
this.preventInputFocus,
', savedScrollPosition:',
this.savedScrollPosition,
')',
);
} }
}, },
}, },
@@ -57,24 +57,22 @@
<div class="splitted-inputs"> <div class="splitted-inputs">
<!-- TODO: Bialko validacie --> <!-- TODO: Bialko validacie -->
<div <div class="datepicker-wrapper left" ref="datepicker-wrapper">
@click="handleDatepickerClick" <Z-datepicker
class="datepicker-wrapper left"
ref="datepicker-wrapper"
>
<Datepicker
v-model="mastertask.deadline" v-model="mastertask.deadline"
ref="datepicker" ref="datepicker"
label="Dátum"
:auto-apply="true" :auto-apply="true"
:min-date="new Date()" :min-date="new Date()"
class="modal-input datepicker-input" class="modal-input"
format="dd.MM.yyyy" format="dd.MM.yyyy"
locale="sk" locale="sk"
select-text="Vybrať" select-text="Vybrať"
cancel-text="Zrušiť" cancel-text="Zrušiť"
placeholder="Dátum" placeholder="Deadline"
:class="{ error: v$.mastertask.deadline.$errors?.length }" :class="{ error: v$.mastertask.deadline.$errors?.length }"
:validations="v$.mastertask.deadline" :validations="v$.mastertask.deadline"
@open="onDateOpen"
/> />
</div> </div>
@@ -118,7 +116,7 @@
class="modal-input" class="modal-input"
variant="solo" variant="solo"
type="number" type="number"
:label="`${mastertask.duration} h`" label="Naplánované hodiny"
:disabled="true" :disabled="true"
></v-text-field> ></v-text-field>
@@ -312,7 +310,7 @@
</template> </template>
<script> <script>
import Datepicker from '@vuepic/vue-datepicker'; import ZDatepicker from '@/plugins/tm@_components/z-datepicker.vue';
import ZFilesUpload from '@/plugins/tm@_components/z-files-upload.vue'; import ZFilesUpload from '@/plugins/tm@_components/z-files-upload.vue';
import useVuelidate from '@vuelidate/core'; import useVuelidate from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators'; import { required, minLength } from '@vuelidate/validators';
@@ -344,7 +342,7 @@ export default {
components: { components: {
AMastertaskDescription, AMastertaskDescription,
ATasksTable, ATasksTable,
Datepicker, ZDatepicker,
ZFilesUpload, ZFilesUpload,
// ZAutocomplete, // ZAutocomplete,
QuillEditor, QuillEditor,
@@ -461,11 +459,29 @@ export default {
methods: { methods: {
handleDatepickerClick() { handleDatepickerClick() {
// Pass the datepicker wrapper element reference to the mixin // Open the datepicker - it now opens automatically via click on wrapper
const scrollTarget = this.$refs['datepicker-wrapper']; },
this.onDateOpen(scrollTarget);
// Open the datepicker onDateOpen($event) {
this.$refs['datepicker'].openMenu(); // $event should be the element reference (or any truthy value to trigger saving)
if ($event) {
// Find the scrollable modal container
const modalContent = document.querySelector('.v-dialog .modal-content');
if (modalContent) {
this.savedScrollPosition = modalContent.scrollTop;
}
this.preventInputFocus = true;
// Reattach focus event listeners
this.setFocusEvent();
// Reset after datepicker is opened
setTimeout(() => {
this.preventInputFocus = false;
this.savedScrollPosition = null;
}, 500);
}
}, },
closeBothModals() { closeBothModals() {
@@ -527,11 +543,26 @@ export default {
async showModal({ mastertask, editMode = false }) { async showModal({ mastertask, editMode = false }) {
this.isSaving = false; this.isSaving = false;
this.editMode = null; this.editMode = null;
// CRITICAL: Prevent v-dialog autofocus BEFORE modal opens
this.preventInputFocus = true;
console.log(
'🟠 [z-master-task-modal] showModal - preventInputFocus set to true BEFORE modal opens',
);
this.visible = true; this.visible = true;
this.mastertask = null; this.mastertask = null;
this.$nextTick(() => { this.$nextTick(() => {
this.setFocusEvent(); this.setFocusEvent();
this.v$.$reset(); this.v$.$reset();
// Reset after modal is fully rendered and v-dialog autofocus has been blocked
setTimeout(() => {
this.preventInputFocus = false;
console.log(
'🟠 [z-master-task-modal] showModal - preventInputFocus reset to false after 300ms',
);
}, 300);
}); });
try { try {
@@ -787,8 +818,4 @@ export default {
.datepicker-wrapper .datepicker-wrapper
width: 100% width: 100%
.datepicker-input
width: 100% !important
height: 100%
pointer-events: none
</style> </style>
+61 -8
View File
@@ -57,17 +57,15 @@
<div class="splitted-inputs"> <div class="splitted-inputs">
<!-- TODO: Bialko validacie --> <!-- TODO: Bialko validacie -->
<div <div class="w-100 modal-input">
@click="openDatepicker('datepicker-01')" <Z-datepicker
class="datepicker-wrapper"
>
<Datepicker
v-model="subtask.deadline" v-model="subtask.deadline"
@open="onDateOpen" @open="onDateOpen"
ref="datepicker-01" ref="datepicker-01"
label="Deadline"
:auto-apply="true" :auto-apply="true"
:min-date="new Date()" :min-date="new Date()"
class="modal-input left datepicker-input" class="modal-input"
format="dd.MM.yyyy" format="dd.MM.yyyy"
locale="sk" locale="sk"
select-text="Vybrať" select-text="Vybrať"
@@ -348,7 +346,7 @@
</template> </template>
<script> <script>
import Datepicker from '@vuepic/vue-datepicker'; import ZDatepicker from '@/plugins/tm@_components/z-datepicker.vue';
import ZFilesUpload from '@/plugins/tm@_components/z-files-upload.vue'; import ZFilesUpload from '@/plugins/tm@_components/z-files-upload.vue';
import useVuelidate from '@vuelidate/core'; import useVuelidate from '@vuelidate/core';
import { required, requiredIf, optional } from '@vuelidate/validators'; import { required, requiredIf, optional } from '@vuelidate/validators';
@@ -375,7 +373,7 @@ export default {
ZTaskReadonlyInfo, ZTaskReadonlyInfo,
ATimeblock, ATimeblock,
ZFilesUpload, ZFilesUpload,
Datepicker, ZDatepicker,
// ATaskTimeblocks, // ATaskTimeblocks,
QuillEditor, QuillEditor,
}, },
@@ -505,6 +503,46 @@ export default {
}, },
methods: { methods: {
// Override onDateOpen from VuetifyFixFocusMixin to add logging
onDateOpen($event) {
console.log(
'🟡 [z-task-modal] onDateOpen called at:',
new Date().toISOString(),
'with event:',
$event,
);
// Call the parent mixin's onDateOpen method
if (
VuetifyFixFocusMixin.methods &&
VuetifyFixFocusMixin.methods.onDateOpen
) {
VuetifyFixFocusMixin.methods.onDateOpen.call(this, $event);
}
},
// Override onInputFocus from VuetifyFixFocusMixin to add logging
onInputFocus($event) {
const target = $event?.target;
console.log(
'🟡 [z-task-modal] onInputFocus called at:',
new Date().toISOString(),
'on:',
{
tagName: target?.tagName,
type: target?.type,
id: target?.id,
className: target?.className,
},
);
// Call the parent mixin's onInputFocus method
if (
VuetifyFixFocusMixin.methods &&
VuetifyFixFocusMixin.methods.onInputFocus
) {
VuetifyFixFocusMixin.methods.onInputFocus.call(this, $event);
}
},
toggleEditMode() { toggleEditMode() {
if (this.subtask.type == 'office365') if (this.subtask.type == 'office365')
return this.$events.emit('show-unable-to-edit-office-task-modal', { return this.$events.emit('show-unable-to-edit-office-task-modal', {
@@ -560,6 +598,13 @@ export default {
async showModal({ subtask, editMode = false, mastertask = {} }) { async showModal({ subtask, editMode = false, mastertask = {} }) {
this.isSaving = false; this.isSaving = false;
this.subtask = null; this.subtask = null;
// CRITICAL: Prevent v-dialog autofocus BEFORE modal opens
this.preventInputFocus = true;
console.log(
'🟡 [z-task-modal] showModal - preventInputFocus set to true BEFORE modal opens',
);
this.visible = true; this.visible = true;
this.timeblocks = [ this.timeblocks = [
{ {
@@ -571,6 +616,14 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
this.v$.$reset(); this.v$.$reset();
this.setFocusEvent(); this.setFocusEvent();
// Reset after modal is fully rendered and v-dialog autofocus has been blocked
setTimeout(() => {
this.preventInputFocus = false;
console.log(
'🟡 [z-task-modal] showModal - preventInputFocus reset to false after 300ms',
);
}, 300);
}); });
try { try {