feat: Replace vue-datepicker-next from codebase
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
calendarType: {
|
||||
type: String,
|
||||
default: 'start',
|
||||
},
|
||||
firstButtonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
viewMode: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['prev', 'next', 'setView']);
|
||||
|
||||
const { YEAR } = CALENDAR_PERIODS;
|
||||
|
||||
const onClickPrev = type => {
|
||||
emit('prev', type);
|
||||
};
|
||||
|
||||
const onClickNext = type => {
|
||||
emit('next', type);
|
||||
};
|
||||
|
||||
const onClickSetView = (type, mode) => {
|
||||
emit('setView', type, mode);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start justify-between w-full h-9">
|
||||
<NextButton
|
||||
slate
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-chevron-left"
|
||||
class="rtl:rotate-180"
|
||||
@click.stop="onClickPrev(calendarType)"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="firstButtonLabel"
|
||||
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
|
||||
@click.stop="onClickSetView(calendarType, viewMode)"
|
||||
>
|
||||
{{ firstButtonLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="buttonLabel"
|
||||
class="p-0 text-sm font-medium text-center text-n-slate-12"
|
||||
:class="{ 'hover:text-n-brand': viewMode }"
|
||||
@click.stop="onClickSetView(calendarType, YEAR)"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<NextButton
|
||||
slate
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-chevron-right"
|
||||
class="rtl:rotate-180"
|
||||
@click.stop="onClickNext(calendarType)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { parse, isValid, isAfter, isBefore } from 'date-fns';
|
||||
import {
|
||||
getIntlDateFormatForLocale,
|
||||
CALENDAR_TYPES,
|
||||
} from '../helpers/DatePickerHelper';
|
||||
|
||||
const props = defineProps({
|
||||
calendarType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
dateValue: Date,
|
||||
compareDate: Date,
|
||||
isDisabled: Boolean,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'validate', 'error']);
|
||||
|
||||
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
|
||||
|
||||
const dateFormat = computed(() => getIntlDateFormatForLocale()?.toUpperCase());
|
||||
|
||||
const localDateValue = computed({
|
||||
get: () => props.dateValue?.toLocaleDateString(navigator.language) || '',
|
||||
set: newValue => {
|
||||
const format = getIntlDateFormatForLocale();
|
||||
const parsedDate = parse(newValue, format, new Date());
|
||||
if (isValid(parsedDate)) {
|
||||
emit('update', parsedDate);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const validateDate = () => {
|
||||
if (!isValid(props.dateValue)) {
|
||||
emit('error', `Please enter the date in valid format: ${dateFormat.value}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { calendarType, compareDate, dateValue } = props;
|
||||
const isStartCalendar = calendarType === START_CALENDAR;
|
||||
const isEndCalendar = calendarType === END_CALENDAR;
|
||||
|
||||
if (compareDate && isStartCalendar && isAfter(dateValue, compareDate)) {
|
||||
emit('error', 'Start date must be before the end date.');
|
||||
} else if (compareDate && isEndCalendar && isBefore(dateValue, compareDate)) {
|
||||
emit('error', 'End date must be after the start date.');
|
||||
} else {
|
||||
emit('validate', dateValue);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[82px] flex flex-col items-start px-5 gap-1.5 pt-4 w-full">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
calendarType === START_CALENDAR
|
||||
? $t('DATE_PICKER.DATE_RANGE_INPUT.START')
|
||||
: $t('DATE_PICKER.DATE_RANGE_INPUT.END')
|
||||
}}
|
||||
</span>
|
||||
<input
|
||||
v-model="localDateValue"
|
||||
type="text"
|
||||
class="!text-sm !mb-0 disabled:!outline-n-strong"
|
||||
:placeholder="dateFormat"
|
||||
:disabled="isDisabled"
|
||||
@keypress.enter="validateDate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { dateRanges } from '../helpers/DatePickerHelper';
|
||||
|
||||
defineProps({
|
||||
selectedRange: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['setRange']);
|
||||
|
||||
const setDateRange = range => {
|
||||
emit('setRange', range);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-[200px] flex flex-col items-start">
|
||||
<h4
|
||||
class="w-full px-5 py-4 text-xs font-bold capitalize text-start text-n-slate-10"
|
||||
>
|
||||
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
|
||||
</h4>
|
||||
<div class="flex flex-col items-start w-full">
|
||||
<template v-for="range in dateRanges" :key="range.label">
|
||||
<div v-if="range.separator" class="w-full border-t border-n-strong" />
|
||||
<button
|
||||
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
|
||||
:class="
|
||||
range.value === selectedRange
|
||||
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
@click="setDateRange(range)"
|
||||
>
|
||||
{{ $t(range.label) }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['clear', 'change']);
|
||||
|
||||
const onClickClear = () => {
|
||||
emit('clear');
|
||||
};
|
||||
|
||||
const onClickApply = () => {
|
||||
emit('change');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[56px] flex justify-between gap-2 px-2 py-3 items-center">
|
||||
<NextButton
|
||||
slate
|
||||
ghost
|
||||
sm
|
||||
:label="$t('DATE_PICKER.CLEAR_BUTTON')"
|
||||
@click="onClickClear"
|
||||
/>
|
||||
<NextButton
|
||||
sm
|
||||
:label="$t('DATE_PICKER.APPLY_BUTTON')"
|
||||
@click="onClickApply"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { format, getMonth, setMonth, startOfMonth } from 'date-fns';
|
||||
import {
|
||||
yearName,
|
||||
CALENDAR_TYPES,
|
||||
CALENDAR_PERIODS,
|
||||
} from '../helpers/DatePickerHelper';
|
||||
|
||||
import CalendarAction from './CalendarAction.vue';
|
||||
|
||||
const props = defineProps({
|
||||
calendarType: {
|
||||
type: String,
|
||||
default: 'start',
|
||||
},
|
||||
startCurrentDate: Date,
|
||||
endCurrentDate: Date,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectMonth', 'prev', 'next', 'setView']);
|
||||
const { START_CALENDAR } = CALENDAR_TYPES;
|
||||
const { MONTH, YEAR } = CALENDAR_PERIODS;
|
||||
|
||||
const months = Array.from({ length: 12 }, (_, index) =>
|
||||
format(setMonth(startOfMonth(new Date()), index), 'MMM')
|
||||
);
|
||||
|
||||
const activeMonthIndex = computed(() => {
|
||||
const date =
|
||||
props.calendarType === START_CALENDAR
|
||||
? props.startCurrentDate
|
||||
: props.endCurrentDate;
|
||||
return getMonth(date);
|
||||
});
|
||||
|
||||
const setViewMode = (type, mode) => {
|
||||
emit('setView', type, mode);
|
||||
};
|
||||
|
||||
const onClickPrev = () => {
|
||||
emit('prev');
|
||||
};
|
||||
|
||||
const onClickNext = () => {
|
||||
emit('next');
|
||||
};
|
||||
|
||||
const selectMonth = index => {
|
||||
emit('selectMonth', index);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-2 max-h-[312px]">
|
||||
<CalendarAction
|
||||
:view-mode="YEAR"
|
||||
:calendar-type="calendarType"
|
||||
:button-label="
|
||||
yearName(
|
||||
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate,
|
||||
MONTH
|
||||
)
|
||||
"
|
||||
@set-view="setViewMode"
|
||||
@prev="onClickPrev"
|
||||
@next="onClickNext"
|
||||
/>
|
||||
|
||||
<div class="grid w-full grid-cols-3 gap-x-3 gap-y-2 auto-rows-[61px]">
|
||||
<button
|
||||
v-for="(month, index) in months"
|
||||
:key="index"
|
||||
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[92px] h-10 rounded-lg py-2.5 px-2"
|
||||
:class="{
|
||||
'bg-n-brand text-white hover:bg-n-blue-10':
|
||||
index === activeMonthIndex,
|
||||
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
|
||||
index !== activeMonthIndex,
|
||||
}"
|
||||
@click.stop="selectMonth(index)"
|
||||
>
|
||||
{{ month }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup>
|
||||
import { startOfDay } from 'date-fns';
|
||||
import {
|
||||
monthName,
|
||||
yearName,
|
||||
getWeeksForMonth,
|
||||
isToday,
|
||||
dayIsInRange,
|
||||
isCurrentMonth,
|
||||
isLastDayOfMonth,
|
||||
isHoveringDayInRange,
|
||||
isHoveringNextDayInRange,
|
||||
CALENDAR_TYPES,
|
||||
CALENDAR_PERIODS,
|
||||
} from '../helpers/DatePickerHelper';
|
||||
|
||||
import CalendarWeekLabel from './CalendarWeekLabel.vue';
|
||||
import CalendarAction from './CalendarAction.vue';
|
||||
|
||||
const props = defineProps({
|
||||
calendarType: {
|
||||
type: String,
|
||||
default: 'start',
|
||||
},
|
||||
currentDate: Date,
|
||||
startCurrentDate: Date,
|
||||
endCurrentDate: Date,
|
||||
selectedStartDate: Date,
|
||||
selectingEndDate: Boolean,
|
||||
selectedEndDate: Date,
|
||||
hoveredEndDate: Date,
|
||||
minDate: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'updateHoveredEndDate',
|
||||
'selectDate',
|
||||
'prev',
|
||||
'next',
|
||||
'setView',
|
||||
]);
|
||||
|
||||
const { START_CALENDAR } = CALENDAR_TYPES;
|
||||
const { MONTH } = CALENDAR_PERIODS;
|
||||
|
||||
const isDayDisabled = day => {
|
||||
if (!props.minDate) return false;
|
||||
return startOfDay(day) < startOfDay(props.minDate);
|
||||
};
|
||||
|
||||
const emitHoveredEndDate = day => {
|
||||
if (isDayDisabled(day)) return;
|
||||
emit('updateHoveredEndDate', day);
|
||||
};
|
||||
|
||||
const emitSelectDate = day => {
|
||||
if (isDayDisabled(day)) return;
|
||||
emit('selectDate', day);
|
||||
};
|
||||
const onClickPrev = () => {
|
||||
emit('prev');
|
||||
};
|
||||
|
||||
const onClickNext = () => {
|
||||
emit('next');
|
||||
};
|
||||
|
||||
const setViewMode = (type, mode) => {
|
||||
emit('setView', type, mode);
|
||||
};
|
||||
|
||||
const weeks = calendarType => {
|
||||
return getWeeksForMonth(
|
||||
calendarType === START_CALENDAR
|
||||
? props.startCurrentDate
|
||||
: props.endCurrentDate
|
||||
);
|
||||
};
|
||||
|
||||
const isSelectedStartOrEndDate = day => {
|
||||
return (
|
||||
dayIsInRange(day, props.selectedStartDate, props.selectedStartDate) ||
|
||||
dayIsInRange(day, props.selectedEndDate, props.selectedEndDate)
|
||||
);
|
||||
};
|
||||
|
||||
const isInRange = day => {
|
||||
return dayIsInRange(day, props.selectedStartDate, props.selectedEndDate);
|
||||
};
|
||||
|
||||
const isInCurrentMonth = day => {
|
||||
return isCurrentMonth(
|
||||
day,
|
||||
props.calendarType === START_CALENDAR
|
||||
? props.startCurrentDate
|
||||
: props.endCurrentDate
|
||||
);
|
||||
};
|
||||
|
||||
const isHoveringInRange = day => {
|
||||
return isHoveringDayInRange(
|
||||
day,
|
||||
props.selectedStartDate,
|
||||
props.selectingEndDate,
|
||||
props.hoveredEndDate
|
||||
);
|
||||
};
|
||||
|
||||
const isNextDayInRange = day => {
|
||||
return isHoveringNextDayInRange(
|
||||
day,
|
||||
props.selectedStartDate,
|
||||
props.selectedEndDate,
|
||||
props.hoveredEndDate
|
||||
);
|
||||
};
|
||||
|
||||
const dayClasses = day => ({
|
||||
'text-n-slate-10 pointer-events-none': !isInCurrentMonth(day),
|
||||
'text-n-slate-10 pointer-events-none opacity-40':
|
||||
isInCurrentMonth(day) && isDayDisabled(day),
|
||||
'text-n-slate-12 hover:text-n-slate-12 hover:bg-n-blue-6 dark:hover:bg-n-blue-7':
|
||||
isInCurrentMonth(day) && !isDayDisabled(day),
|
||||
'bg-n-brand text-white':
|
||||
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
|
||||
'bg-n-blue-4 dark:bg-n-blue-5':
|
||||
(isInRange(day) || isHoveringInRange(day)) &&
|
||||
!isSelectedStartOrEndDate(day) &&
|
||||
isInCurrentMonth(day),
|
||||
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-11':
|
||||
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-2 max-h-[312px]">
|
||||
<CalendarAction
|
||||
:view-mode="MONTH"
|
||||
:calendar-type="calendarType"
|
||||
:first-button-label="
|
||||
monthName(
|
||||
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate
|
||||
)
|
||||
"
|
||||
:button-label="
|
||||
yearName(
|
||||
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate
|
||||
)
|
||||
"
|
||||
@prev="onClickPrev"
|
||||
@next="onClickNext"
|
||||
@set-view="setViewMode"
|
||||
/>
|
||||
<CalendarWeekLabel />
|
||||
<div
|
||||
v-for="week in weeks(calendarType)"
|
||||
:key="week[0].getTime()"
|
||||
class="grid max-w-md grid-cols-7 gap-2 mx-auto overflow-hidden rounded-lg"
|
||||
>
|
||||
<div
|
||||
v-for="day in week"
|
||||
:key="day.getTime()"
|
||||
class="flex relative items-center justify-center w-9 h-8 py-1.5 px-2 font-medium text-sm rounded-lg cursor-pointer"
|
||||
:class="dayClasses(day)"
|
||||
@mouseenter="emitHoveredEndDate(day)"
|
||||
@mouseleave="emitHoveredEndDate(null)"
|
||||
@click="emitSelectDate(day)"
|
||||
>
|
||||
{{ day.getDate() }}
|
||||
<span
|
||||
v-if="
|
||||
(isInRange(day) || isHoveringInRange(day)) &&
|
||||
isNextDayInRange(day) &&
|
||||
!isLastDayOfMonth(day) &&
|
||||
isInCurrentMonth(day)
|
||||
"
|
||||
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-n-blue-4 dark:bg-n-blue-5 -z-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
import { calendarWeeks } from '../helpers/DatePickerHelper';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-md mx-auto grid grid-cols-7 gap-2">
|
||||
<div
|
||||
v-for="day in calendarWeeks"
|
||||
:key="day.id"
|
||||
class="flex items-center justify-center font-medium text-sm w-9 h-7 py-1.5 px-2"
|
||||
>
|
||||
{{ day.label }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { getYear, addYears, subYears } from 'date-fns';
|
||||
import { CALENDAR_TYPES } from '../helpers/DatePickerHelper';
|
||||
|
||||
import CalendarAction from './CalendarAction.vue';
|
||||
|
||||
const props = defineProps({
|
||||
calendarType: {
|
||||
type: String,
|
||||
default: 'start',
|
||||
},
|
||||
startCurrentDate: Date,
|
||||
endCurrentDate: Date,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectYear']);
|
||||
|
||||
const { START_CALENDAR } = CALENDAR_TYPES;
|
||||
|
||||
const calculateStartYear = date => {
|
||||
const year = getYear(date);
|
||||
return year - (year % 10); // Align with the beginning of a decade
|
||||
};
|
||||
|
||||
const startYear = ref(
|
||||
calculateStartYear(
|
||||
props.calendarType === START_CALENDAR
|
||||
? props.startCurrentDate
|
||||
: props.endCurrentDate
|
||||
)
|
||||
);
|
||||
|
||||
const years = computed(() =>
|
||||
Array.from({ length: 10 }, (_, i) => startYear.value + i)
|
||||
);
|
||||
|
||||
const firstYear = computed(() => years.value[0]);
|
||||
const lastYear = computed(() => years.value[years.value.length - 1]);
|
||||
|
||||
const activeYear = computed(() => {
|
||||
const date =
|
||||
props.calendarType === START_CALENDAR
|
||||
? props.startCurrentDate
|
||||
: props.endCurrentDate;
|
||||
return getYear(date);
|
||||
});
|
||||
|
||||
const onClickPrev = () => {
|
||||
startYear.value = subYears(new Date(startYear.value, 0, 1), 10).getFullYear();
|
||||
};
|
||||
|
||||
const onClickNext = () => {
|
||||
startYear.value = addYears(new Date(startYear.value, 0, 1), 10).getFullYear();
|
||||
};
|
||||
|
||||
const selectYear = year => {
|
||||
emit('selectYear', year);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-2 max-h-[312px]">
|
||||
<CalendarAction
|
||||
:calendar-type="calendarType"
|
||||
:button-label="`${firstYear} - ${lastYear}`"
|
||||
@prev="onClickPrev"
|
||||
@next="onClickNext"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-2 w-full auto-rows-[47px]">
|
||||
<button
|
||||
v-for="year in years"
|
||||
:key="year"
|
||||
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[144px] h-10 rounded-lg py-2.5 px-2"
|
||||
:class="{
|
||||
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
|
||||
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
|
||||
}"
|
||||
@click.stop="selectYear(year)"
|
||||
>
|
||||
{{ year }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { dateRanges } from '../helpers/DatePickerHelper';
|
||||
import { format, isSameYear, isValid } from 'date-fns';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedStartDate: Date,
|
||||
selectedEndDate: Date,
|
||||
selectedRange: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showMonthNavigation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
canNavigateNext: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
navigationLabel: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['open', 'navigateMonth']);
|
||||
|
||||
const formatDateRange = computed(() => {
|
||||
const startDate = props.selectedStartDate;
|
||||
const endDate = props.selectedEndDate;
|
||||
|
||||
if (!isValid(startDate) || !isValid(endDate)) {
|
||||
return 'Select a date range';
|
||||
}
|
||||
|
||||
const crossesYears = !isSameYear(startDate, endDate);
|
||||
|
||||
// Always show years when crossing year boundaries
|
||||
if (crossesYears) {
|
||||
return `${format(startDate, 'MMM d, yyyy')} - ${format(endDate, 'MMM d, yyyy')}`;
|
||||
}
|
||||
|
||||
// For same year, always show the year for clarity
|
||||
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`;
|
||||
});
|
||||
|
||||
const activeDateRange = computed(
|
||||
() => dateRanges.find(range => range.value === props.selectedRange).label
|
||||
);
|
||||
|
||||
const openDatePicker = () => {
|
||||
emit('open');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button
|
||||
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1 flex-shrink-0"
|
||||
@click="openDatePicker"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-calendar-range"
|
||||
class="text-n-slate-11 size-3.5 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12 truncate">
|
||||
{{ navigationLabel || $t(activeDateRange) }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-n-slate-11 truncate">
|
||||
{{ formatDateRange }}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="text-n-slate-12 size-4 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<NextButton
|
||||
v-if="showMonthNavigation"
|
||||
v-tooltip.top="$t('DATE_PICKER.PREVIOUS_PERIOD')"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
icon="i-lucide-chevron-left"
|
||||
class="rtl:rotate-180"
|
||||
@click="emit('navigateMonth', 'prev')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showMonthNavigation"
|
||||
v-tooltip.top="$t('DATE_PICKER.NEXT_PERIOD')"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
icon="i-lucide-chevron-right"
|
||||
class="rtl:rotate-180"
|
||||
:disabled="!canNavigateNext"
|
||||
@click="emit('navigateMonth', 'next')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,430 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import {
|
||||
TIME_COLUMNS,
|
||||
TIME_PERIODS,
|
||||
TIME_FORMATS,
|
||||
} from '../helpers/DatePickerHelper';
|
||||
|
||||
const props = defineProps({
|
||||
minTime: { type: Date, default: null },
|
||||
});
|
||||
|
||||
const model = defineModel({
|
||||
type: Object,
|
||||
default: () => ({ hour: 9, minute: 0, second: 0 }),
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { HOUR, HOUR_12, MINUTE, SECOND, PERIOD } = TIME_COLUMNS;
|
||||
const { AM, PM } = TIME_PERIODS;
|
||||
const { H24, H12 } = TIME_FORMATS;
|
||||
|
||||
const ITEM_HEIGHT = 40;
|
||||
const CENTER_ROW = 2;
|
||||
const VISIBLE_ROWS = 5;
|
||||
const SNAP_DELAY = 120;
|
||||
const SEPARATOR = ':';
|
||||
|
||||
const is24HourFormat = ref(false);
|
||||
const selectedTime = reactive({
|
||||
hour: model.value.hour,
|
||||
minute: model.value.minute,
|
||||
second: model.value.second ?? 0,
|
||||
});
|
||||
const activeIndex = reactive({});
|
||||
const dragOffset = reactive({});
|
||||
const interactionMode = reactive({});
|
||||
const touchStartY = {};
|
||||
const snapTimers = {};
|
||||
const columnRefs = reactive({});
|
||||
const focusedColumn = ref(null);
|
||||
|
||||
const period = computed(() => (selectedTime.hour >= 12 ? PM : AM));
|
||||
const showPeriod = computed(() => !is24HourFormat.value);
|
||||
|
||||
const minBound = computed(() => {
|
||||
if (!props.minTime) return null;
|
||||
const d = props.minTime;
|
||||
return { h: d.getHours(), m: d.getMinutes(), s: d.getSeconds() };
|
||||
});
|
||||
|
||||
const toTimeNumber = (h, m, s) => h * 3600 + m * 60 + s;
|
||||
|
||||
const convertTo12Hour = hour => {
|
||||
if (hour === 0) return 12;
|
||||
return hour > 12 ? hour - 12 : hour;
|
||||
};
|
||||
|
||||
const convertTo24Hour = (hour12, timePeriod) => {
|
||||
if (timePeriod === AM) return hour12 === 12 ? 0 : hour12;
|
||||
return hour12 === 12 ? 12 : hour12 + 12;
|
||||
};
|
||||
|
||||
const isItemDisabled = (key, val) => {
|
||||
const mb = minBound.value;
|
||||
if (!mb) return false;
|
||||
const minVal = toTimeNumber(mb.h, mb.m, mb.s);
|
||||
const { hour, minute } = selectedTime;
|
||||
if (key === HOUR) return toTimeNumber(val, 0, 0) < toTimeNumber(mb.h, 0, 0);
|
||||
if (key === MINUTE) return toTimeNumber(hour, val, 0) < minVal;
|
||||
if (key === SECOND) return toTimeNumber(hour, minute, val) < minVal;
|
||||
return false;
|
||||
};
|
||||
|
||||
const formatLabel = value => String(value).padStart(2, '0');
|
||||
|
||||
const buildItems = (count, key) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
value: i,
|
||||
label: formatLabel(i),
|
||||
disabled: isItemDisabled(key, i),
|
||||
}));
|
||||
|
||||
const buildHour12Items = () =>
|
||||
Array.from({ length: 12 }, (_, i) => {
|
||||
const display = i === 0 ? 12 : i;
|
||||
return {
|
||||
value: display,
|
||||
label: formatLabel(display),
|
||||
disabled: isItemDisabled(HOUR, convertTo24Hour(display, period.value)),
|
||||
};
|
||||
});
|
||||
|
||||
const periodItems = computed(() => [
|
||||
{ value: AM, label: AM, disabled: minBound.value?.h >= 12 },
|
||||
{ value: PM, label: PM, disabled: false },
|
||||
]);
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
key: is24HourFormat.value ? HOUR : HOUR_12,
|
||||
label: t('DATE_PICKER.HOUR'),
|
||||
items: is24HourFormat.value ? buildItems(24, HOUR) : buildHour12Items(),
|
||||
},
|
||||
{
|
||||
key: MINUTE,
|
||||
label: t('DATE_PICKER.MINUTE'),
|
||||
items: buildItems(60, MINUTE),
|
||||
},
|
||||
{
|
||||
key: SECOND,
|
||||
label: t('DATE_PICKER.SECOND'),
|
||||
items: buildItems(60, SECOND),
|
||||
},
|
||||
{ key: PERIOD, label: '', items: periodItems.value },
|
||||
]);
|
||||
|
||||
const findColumn = key => columns.value.find(c => c.key === key);
|
||||
|
||||
const findNearestEnabled = (items, index) => {
|
||||
for (let d = 0; d < items.length; d += 1) {
|
||||
if (index + d < items.length && !items[index + d].disabled)
|
||||
return index + d;
|
||||
if (index - d >= 0 && !items[index - d].disabled) return index - d;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const getActiveIndex = key => {
|
||||
if (key === HOUR) return selectedTime.hour;
|
||||
if (key === HOUR_12) {
|
||||
const h = convertTo12Hour(selectedTime.hour);
|
||||
return h === 12 ? 0 : h;
|
||||
}
|
||||
if (key === PERIOD) return period.value === AM ? 0 : 1;
|
||||
return selectedTime[key];
|
||||
};
|
||||
|
||||
const getColumnTranslateY = key => {
|
||||
const idx = -(activeIndex[key] ?? 0) * ITEM_HEIGHT;
|
||||
return CENTER_ROW * ITEM_HEIGHT + idx + (dragOffset[key] ?? 0);
|
||||
};
|
||||
|
||||
const getMaxDragOffset = key => {
|
||||
const column = findColumn(key);
|
||||
if (!column) return { maxDown: 0, maxUp: 0 };
|
||||
const currentIdx = activeIndex[key] ?? 0;
|
||||
const firstEnabled = column.items.findIndex(i => !i.disabled);
|
||||
const lastEnabled = column.items.findLastIndex(i => !i.disabled);
|
||||
if (firstEnabled === -1) return { maxDown: 0, maxUp: 0 };
|
||||
return {
|
||||
maxDown: (currentIdx - firstEnabled) * ITEM_HEIGHT,
|
||||
maxUp: (lastEnabled - currentIdx) * ITEM_HEIGHT,
|
||||
};
|
||||
};
|
||||
|
||||
const clampDragOffset = (key, rawOffset) => {
|
||||
const { maxDown, maxUp } = getMaxDragOffset(key);
|
||||
return Math.max(-maxUp, Math.min(maxDown, rawOffset));
|
||||
};
|
||||
|
||||
let lastEmittedSignature = '';
|
||||
const emitTimeValue = () => {
|
||||
const sig = `${selectedTime.hour}:${selectedTime.minute}:${selectedTime.second}`;
|
||||
if (sig === lastEmittedSignature) return;
|
||||
lastEmittedSignature = sig;
|
||||
model.value = { ...selectedTime };
|
||||
};
|
||||
|
||||
const applySelection = (key, item) => {
|
||||
if (key === HOUR_12) {
|
||||
selectedTime.hour = convertTo24Hour(item.value, period.value);
|
||||
} else if (key === PERIOD) {
|
||||
selectedTime.hour = convertTo24Hour(
|
||||
convertTo12Hour(selectedTime.hour),
|
||||
item.value
|
||||
);
|
||||
} else {
|
||||
selectedTime[key] = item.value;
|
||||
}
|
||||
emitTimeValue();
|
||||
};
|
||||
|
||||
const selectIndex = (key, targetIndex) => {
|
||||
const column = findColumn(key);
|
||||
if (!column) return;
|
||||
const clamped = Math.max(0, Math.min(targetIndex, column.items.length - 1));
|
||||
const resolved = column.items[clamped]?.disabled
|
||||
? findNearestEnabled(column.items, clamped)
|
||||
: clamped;
|
||||
activeIndex[key] = resolved;
|
||||
const item = column.items[resolved];
|
||||
if (item) applySelection(key, item);
|
||||
};
|
||||
|
||||
const selectByValue = (key, value) => {
|
||||
const column = findColumn(key);
|
||||
if (!column) return;
|
||||
const idx = column.items.findIndex(i => i.value === value && !i.disabled);
|
||||
if (idx !== -1) selectIndex(key, idx);
|
||||
};
|
||||
|
||||
const snapToNearest = key => {
|
||||
const offset = dragOffset[key] ?? 0;
|
||||
const steps = Math.round(-offset / ITEM_HEIGHT);
|
||||
dragOffset[key] = 0;
|
||||
interactionMode[key] = null;
|
||||
if (steps !== 0) selectIndex(key, (activeIndex[key] ?? 0) + steps);
|
||||
};
|
||||
|
||||
const isItemSelected = (key, val) => {
|
||||
if (key === HOUR_12) return convertTo12Hour(selectedTime.hour) === val;
|
||||
if (key === PERIOD) return period.value === val;
|
||||
return selectedTime[key] === val;
|
||||
};
|
||||
|
||||
const isColumnHidden = key => key === PERIOD && !showPeriod.value;
|
||||
|
||||
const onWheelScroll = (key, event) => {
|
||||
event.preventDefault();
|
||||
interactionMode[key] = 'wheel';
|
||||
dragOffset[key] = clampDragOffset(key, (dragOffset[key] ?? 0) - event.deltaY);
|
||||
clearTimeout(snapTimers[key]);
|
||||
snapTimers[key] = setTimeout(() => snapToNearest(key), SNAP_DELAY);
|
||||
};
|
||||
|
||||
const onTouchStart = (key, event) => {
|
||||
interactionMode[key] = 'touch';
|
||||
touchStartY[key] = event.touches[0].clientY;
|
||||
dragOffset[key] = 0;
|
||||
};
|
||||
|
||||
const onTouchMove = (key, event) => {
|
||||
event.preventDefault();
|
||||
dragOffset[key] = clampDragOffset(
|
||||
key,
|
||||
event.touches[0].clientY - touchStartY[key]
|
||||
);
|
||||
};
|
||||
|
||||
const onTouchEnd = key => snapToNearest(key);
|
||||
|
||||
const visibleColumnKeys = computed(() =>
|
||||
columns.value.filter(col => !isColumnHidden(col.key)).map(col => col.key)
|
||||
);
|
||||
|
||||
const onKeyDown = (key, event) => {
|
||||
const { key: pressed, shiftKey } = event;
|
||||
if (pressed === 'ArrowUp' || pressed === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
selectIndex(
|
||||
key,
|
||||
(activeIndex[key] ?? 0) + (pressed === 'ArrowDown' ? 1 : -1)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (pressed === 'Tab') {
|
||||
const keys = visibleColumnKeys.value;
|
||||
const next = keys.indexOf(key) + (shiftKey ? -1 : 1);
|
||||
if (next >= 0 && next < keys.length) {
|
||||
event.preventDefault();
|
||||
columnRefs[keys[next]]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const syncAllColumns = () => {
|
||||
columns.value.forEach(col => {
|
||||
activeIndex[col.key] = getActiveIndex(col.key);
|
||||
});
|
||||
};
|
||||
|
||||
const formatTabs = [
|
||||
{ label: t('DATE_PICKER.FORMAT_24H'), value: H24 },
|
||||
{ label: t('DATE_PICKER.FORMAT_12H'), value: H12 },
|
||||
];
|
||||
const activeFormatTab = computed(() => (is24HourFormat.value ? 0 : 1));
|
||||
|
||||
const onFormatChange = tab => {
|
||||
is24HourFormat.value = tab.value === H24;
|
||||
syncAllColumns();
|
||||
};
|
||||
|
||||
watch(model, v => {
|
||||
selectedTime.hour = v.hour;
|
||||
selectedTime.minute = v.minute;
|
||||
selectedTime.second = v.second ?? 0;
|
||||
syncAllColumns();
|
||||
});
|
||||
|
||||
onMounted(syncAllColumns);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col items-center py-2 w-full">
|
||||
<div class="mb-4">
|
||||
<TabBar
|
||||
:tabs="formatTabs"
|
||||
:initial-active-tab="activeFormatTab"
|
||||
@tab-changed="onFormatChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center z-[1] mb-1">
|
||||
<template v-for="(col, colIdx) in columns" :key="`label-${col.key}`">
|
||||
<span
|
||||
class="text-center text-xs font-medium text-n-slate-11 transition-all duration-300 ease-in-out overflow-hidden"
|
||||
:class="
|
||||
isColumnHidden(col.key) ? 'w-0 opacity-0' : 'w-14 opacity-100'
|
||||
"
|
||||
>
|
||||
{{ col.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="colIdx < columns.length - 1"
|
||||
class="text-lg font-520 text-transparent transition-all duration-300 ease-in-out overflow-hidden"
|
||||
:class="
|
||||
isColumnHidden(columns[colIdx + 1]?.key)
|
||||
? 'w-0 opacity-0'
|
||||
: 'opacity-100'
|
||||
"
|
||||
>
|
||||
{{ SEPARATOR }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<div
|
||||
class="absolute inset-x-6 h-10 rounded-2xl bg-n-solid-active outline outline-1 -outline-offset-1 outline-n-weak pointer-events-none shadow-inner"
|
||||
:style="{ top: `${CENTER_ROW * ITEM_HEIGHT}px` }"
|
||||
/>
|
||||
<div class="flex items-center justify-center z-[1] relative">
|
||||
<template v-for="(col, colIdx) in columns" :key="col.key">
|
||||
<div
|
||||
:ref="
|
||||
el => {
|
||||
if (el) columnRefs[col.key] = el;
|
||||
}
|
||||
"
|
||||
:tabindex="isColumnHidden(col.key) ? -1 : 0"
|
||||
class="time-wheel relative overflow-hidden transition-all duration-300 ease-in-out outline-none"
|
||||
:class="
|
||||
isColumnHidden(col.key) ? 'w-0 opacity-0' : 'w-14 opacity-100'
|
||||
"
|
||||
:style="{ height: `${VISIBLE_ROWS * ITEM_HEIGHT}px` }"
|
||||
@wheel.prevent="onWheelScroll(col.key, $event)"
|
||||
@touchstart="onTouchStart(col.key, $event)"
|
||||
@touchmove.prevent="onTouchMove(col.key, $event)"
|
||||
@touchend="onTouchEnd(col.key)"
|
||||
@keydown="onKeyDown(col.key, $event)"
|
||||
@focus="focusedColumn = col.key"
|
||||
@blur="focusedColumn = null"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center"
|
||||
:class="{
|
||||
'transition-transform duration-200 ease-out':
|
||||
!interactionMode[col.key],
|
||||
'transition-transform duration-100 ease-out':
|
||||
interactionMode[col.key] === 'wheel',
|
||||
}"
|
||||
:style="{
|
||||
transform: `translateY(${getColumnTranslateY(col.key)}px)`,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-for="item in col.items"
|
||||
:key="item.value"
|
||||
:disabled="item.disabled"
|
||||
tabindex="-1"
|
||||
class="flex items-center justify-center w-14 shrink-0 text-base font-semibold transition-colors outline-none"
|
||||
:style="{ height: `${ITEM_HEIGHT}px` }"
|
||||
:class="[
|
||||
isItemSelected(col.key, item.value)
|
||||
? 'text-n-slate-12'
|
||||
: 'text-n-slate-9',
|
||||
item.disabled
|
||||
? 'opacity-20 cursor-not-allowed'
|
||||
: 'cursor-pointer',
|
||||
isItemSelected(col.key, item.value) &&
|
||||
focusedColumn === col.key
|
||||
? 'outline outline-1 outline-n-brand -outline-offset-4 rounded-xl'
|
||||
: '',
|
||||
]"
|
||||
@click="selectByValue(col.key, item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="colIdx < columns.length - 1 && col.key !== SECOND"
|
||||
class="text-lg font-520 text-n-slate-11"
|
||||
>
|
||||
{{ SEPARATOR }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.time-wheel {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.3) 15%,
|
||||
rgba(0, 0, 0, 0.7) 30%,
|
||||
black 40%,
|
||||
black 60%,
|
||||
rgba(0, 0, 0, 0.7) 70%,
|
||||
rgba(0, 0, 0, 0.3) 85%,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.3) 15%,
|
||||
rgba(0, 0, 0, 0.7) 30%,
|
||||
black 40%,
|
||||
black 60%,
|
||||
rgba(0, 0, 0, 0.7) 70%,
|
||||
rgba(0, 0, 0, 0.3) 85%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user