Skip to content

Instantly share code, notes, and snippets.

@Xinecraft
Created July 1, 2025 18:14
Show Gist options
  • Save Xinecraft/e71e20391fc5d409a674a9aa423f8de8 to your computer and use it in GitHub Desktop.
Save Xinecraft/e71e20391fc5d409a674a9aa423f8de8 to your computer and use it in GitHub Desktop.
ShadCN Vue - Enhanced Calendar with Integrated Date Picker
<script lang="ts" setup>
import { cn } from '@/lib/utils';
import { CalendarDate } from '@internationalized/date';
import { CalendarRoot, type CalendarRootProps, useForwardPropsEmits } from 'reka-ui';
import { computed, type HTMLAttributes, ref, watch } from 'vue';
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarHeader,
CalendarHeading,
CalendarNextButton,
CalendarPrevButton,
} from '.';
import { TimePicker } from '@/components/ui/time-picker';
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes['class'] }>();
const formattedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(formattedProps);
const dateTimeValue = defineModel();
function initializeTime() {
if (dateTimeValue.value instanceof Date) {
return dateTimeValue.value;
}
return new Date(new Date().setHours(0, 0, 0, 0));
}
const selectedTime = ref(initializeTime());
function initializeCalendarDate() {
if (dateTimeValue.value instanceof Date) {
const today = new Date(dateTimeValue.value);
return new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
}
return null;
}
const selectedCalendarDate = ref(initializeCalendarDate());
function createCombinedDateTime() {
if (selectedCalendarDate.value == null) {
const today = new Date();
selectedCalendarDate.value = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
}
const combinedDateTime = new Date(selectedCalendarDate.value);
combinedDateTime.setHours(selectedTime.value.getHours());
combinedDateTime.setMinutes(selectedTime.value.getMinutes());
return combinedDateTime;
}
watch([selectedCalendarDate, selectedTime], () => {
dateTimeValue.value = createCombinedDateTime();
});
</script>
<template>
<div>
<CalendarRoot v-slot="{ grid, weekDays }" :class="cn('p-3', props.class)" v-bind="forwarded" v-model="selectedCalendarDate">
<CalendarHeader>
<CalendarPrevButton />
<CalendarHeading />
<CalendarNextButton />
</CalendarHeader>
<div class="mt-4 flex flex-col gap-y-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell v-for="day in weekDays" :key="day">
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
<CalendarCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate">
<CalendarCellTrigger :day="weekDate" :month="month.value" />
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</div>
<TimePicker v-model:date="selectedTime" with-labels class="-mx-3 mt-4 border-t px-4 pt-3" />
</CalendarRoot>
</div>
</template>
<script setup lang="ts">
import InputDatePicker from '@/components/InputDatePicker.vue';
import { ref } from 'vue';
const date = ref();
</script>
<template>
<div class="flex min-h-dvh w-screen flex-col items-center justify-center">
<InputDatePicker v-model="date" />
<p class="mt-4">{{ date }}</p>
</div>
</template>
export { default as TimePicker } from './TimePicker.vue';
export { default as TimePickerInput } from './TimePickerInput.vue';
<script setup lang="ts">
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { CalendarIcon } from 'lucide-vue-next';
interface Props {
placeholder?: string;
class?: string;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Escolha uma data',
});
const date = defineModel();
</script>
<template>
<div>
<Popover side="left">
<PopoverTrigger as-child>
<Button
type="button"
variant="outline"
:class="cn(date == null ? 'font-normal text-muted-foreground' : '', props.class, 'justify-start')"
>
<CalendarIcon class="mr-2 h-4 w-4" />
<span class="overflow-hidden">
{{ date?.toLocaleString() || placeholder }}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-auto p-0">
<Calendar v-model="date" locale="pt-BR" initial-focus />
</PopoverContent>
</Popover>
</div>
</template>
<template>
<div class="flex items-center gap-2">
<div class="flex flex-col items-center gap-1">
<Label v-if="withLabels" for="hours" class="text-xs">Hours</Label>
<TimePickerInput
:picker="withPeriod ? '12hours' : 'hours'"
:period="period"
:date="internalDate"
ref="hourRef"
@rightFocus="focusMinuteRef"
@update:date="updateDate"
/>
</div>
<div v-if="!withLabels">:</div>
<div class="flex flex-col items-center gap-1">
<Label v-if="withLabels" for="minutes" class="text-xs">Minutes</Label>
<TimePickerInput
picker="minutes"
:date="internalDate"
ref="minuteRef"
@leftFocus="focusHourRef"
@rightFocus="focusRightConditional"
@update:date="updateDate"
/>
</div>
<div v-if="!withLabels && withSeconds">:</div>
<div v-if="withSeconds" class="flex flex-col items-center gap-1">
<Label v-if="withLabels" for="seconds" class="text-xs">Seconds</Label>
<TimePickerInput
picker="seconds"
:date="internalDate"
ref="secondRef"
@leftFocus="focusMinuteRef"
@rightFocus="focusPeriodRef"
@update:date="updateDate"
/>
</div>
</div>
</template>
<script setup>
import { Label } from '@/components/ui/label/index.js';
import { TimePickerInput } from '@/components/ui/time-picker';
import { computed, ref } from 'vue';
const props = defineProps({
date: {
type: Date,
default: () => new Date(new Date().setHours(0, 0, 0, 0)),
},
withSeconds: {
type: Boolean,
default: false,
},
withPeriod: {
type: Boolean,
default: false,
},
withLabels: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:date']);
const internalDate = computed({
get: () => props.date,
set: (value) => emit('update:date', value),
});
const period = ref('PM');
const hourRef = ref(null);
const minuteRef = ref(null);
const secondRef = ref(null);
const periodRef = ref(null);
const focusMinuteRef = () => minuteRef.value?.$el.focus();
const focusHourRef = () => hourRef.value?.$el.focus();
const focusSecondRef = () => secondRef.value?.$el.focus();
const focusPeriodRef = () => periodRef.value?.$el.focus();
const focusLeftConditional = () => {
if (props.withSeconds) {
focusSecondRef();
} else {
focusMinuteRef();
}
};
const focusRightConditional = () => {
if (props.withSeconds) {
focusSecondRef();
} else {
focusPeriodRef();
}
};
const updateDate = (newDate) => {
internalDate.value = newDate;
};
</script>
<template>
<Input
:id="picker"
:name="picker"
:class="inputClasses"
:value="calculatedValue"
:defaultValue="calculatedValue"
:type="type"
inputmode="decimal"
@keydown="handleKeyDown"
/>
</template>
<script setup>
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { computed, ref, watch } from 'vue';
import { getArrowByType, getDateByType, setDateByType } from './utils.ts';
const props = defineProps({
picker: String,
date: {
type: Date,
default: () => new Date(new Date().setHours(0, 0, 0, 0)),
},
period: String,
class: String,
type: {
type: String,
default: 'tel',
},
id: String,
name: String,
});
const emit = defineEmits(['update:date', 'rightFocus', 'leftFocus']);
const flag = ref(false);
const prevIntKey = ref('');
const inputClasses = computed(() =>
cn(
'w-12 text-center font-mono text-base tabular-nums caret-transparent focus:bg-accent focus:text-accent-foreground [&::-webkit-inner-spin-button]:appearance-none',
props.class,
),
);
const calculatedValue = computed(() => getDateByType(props.date, props.picker));
watch(flag, (newFlag) => {
if (newFlag) {
const timer = setTimeout(() => {
flag.value = false;
}, 2000);
return () => clearTimeout(timer);
}
});
watch(
() => props.period,
(newPeriod) => {
if (newPeriod) {
const tempDate = new Date(props.date);
emit('update:date', setDateByType(tempDate, tempDate.getHours() % 12, props.picker, newPeriod));
}
},
);
const calculateNewValue = (key) => {
if (props.picker === '12hours') {
if (flag.value && prevIntKey.value === '1' && ['0', '1', '2'].includes(key)) {
const newValue = '1' + key;
prevIntKey.value = '';
return newValue;
}
if (flag.value) {
prevIntKey.value = '';
return prevIntKey.value + key;
}
prevIntKey.value = key;
return '0' + key;
}
return !flag.value ? '0' + key : calculatedValue.value.slice(1, 2) + key;
};
const handleKeyDown = (e) => {
if (e.key === 'Tab') return;
e.preventDefault();
if (e.key === 'ArrowRight') emit('rightFocus');
if (e.key === 'ArrowLeft') emit('leftFocus');
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
const step = e.key === 'ArrowUp' ? 1 : -1;
const newValue = getArrowByType(calculatedValue.value, step, props.picker);
if (flag.value) flag.value = false;
const tempDate = new Date(props.date);
emit('update:date', setDateByType(tempDate, newValue, props.picker, props.period));
}
if (e.key >= '0' && e.key <= '9') {
const newValue = calculateNewValue(e.key);
if (flag.value && (newValue === '10' || newValue === '11')) {
emit('rightFocus');
}
flag.value = !flag.value;
const tempDate = new Date(props.date);
emit('update:date', setDateByType(tempDate, newValue, props.picker, props.period));
}
};
</script>
/**
* regular expression to check for valid hour format (01-23)
*/
export function isValidHour(value: string) {
return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value);
}
/**
* regular expression to check for valid 12 hour format (01-12)
*/
export function isValid12Hour(value: string) {
return /^(0[1-9]|1[0-2])$/.test(value);
}
/**
* regular expression to check for valid minute format (00-59)
*/
export function isValidMinuteOrSecond(value: string) {
return /^[0-5][0-9]$/.test(value);
}
type GetValidNumberConfig = { max: number; min?: number; loop?: boolean };
export function getValidNumber(value: string, { max, min = 0, loop = false }: GetValidNumberConfig) {
let numericValue = parseInt(value, 10);
if (!isNaN(numericValue)) {
if (!loop) {
if (numericValue > max) numericValue = max;
if (numericValue < min) numericValue = min;
} else {
if (numericValue > max) numericValue = min;
if (numericValue < min) numericValue = max;
}
return numericValue.toString().padStart(2, '0');
}
return '00';
}
export function getValidHour(value: string) {
if (isValidHour(value)) return value;
return getValidNumber(value, { max: 23 });
}
export function getValid12Hour(value: string) {
if (isValid12Hour(value)) return value;
return getValidNumber(value, { min: 1, max: 12 });
}
export function getValidMinuteOrSecond(value: string) {
if (isValidMinuteOrSecond(value)) return value;
return getValidNumber(value, { max: 59 });
}
type GetValidArrowNumberConfig = {
min: number;
max: number;
step: number;
};
export function getValidArrowNumber(value: string, { min, max, step }: GetValidArrowNumberConfig) {
let numericValue = parseInt(value, 10);
if (!isNaN(numericValue)) {
numericValue += step;
return getValidNumber(String(numericValue), { min, max, loop: true });
}
return '00';
}
export function getValidArrowHour(value: string, step: number) {
return getValidArrowNumber(value, { min: 0, max: 23, step });
}
export function getValidArrow12Hour(value: string, step: number) {
return getValidArrowNumber(value, { min: 1, max: 12, step });
}
export function getValidArrowMinuteOrSecond(value: string, step: number) {
return getValidArrowNumber(value, { min: 0, max: 59, step });
}
export function setMinutes(date: Date, value: string) {
const minutes = getValidMinuteOrSecond(value);
date.setMinutes(parseInt(minutes, 10));
return date;
}
export function setSeconds(date: Date, value: string) {
const seconds = getValidMinuteOrSecond(value);
date.setSeconds(parseInt(seconds, 10));
return date;
}
export function setHours(date: Date, value: string) {
const hours = getValidHour(value);
date.setHours(parseInt(hours, 10));
return date;
}
export function set12Hours(date: Date, value: string, period: Period) {
const hours = parseInt(getValid12Hour(value), 10);
const convertedHours = convert12HourTo24Hour(hours, period);
date.setHours(convertedHours);
return date;
}
export type TimePickerType = 'minutes' | 'seconds' | 'hours' | '12hours';
export type Period = 'AM' | 'PM';
export function setDateByType(date: Date, value: string, type: TimePickerType, period?: Period) {
switch (type) {
case 'minutes':
return setMinutes(date, value);
case 'seconds':
return setSeconds(date, value);
case 'hours':
return setHours(date, value);
case '12hours': {
if (!period) return date;
return set12Hours(date, value, period);
}
default:
return date;
}
}
export function getDateByType(date: Date, type: TimePickerType) {
switch (type) {
case 'minutes':
return getValidMinuteOrSecond(String(date.getMinutes()));
case 'seconds':
return getValidMinuteOrSecond(String(date.getSeconds()));
case 'hours':
return getValidHour(String(date.getHours()));
case '12hours':
const hours = display12HourValue(date.getHours());
return getValid12Hour(String(hours));
default:
return '00';
}
}
export function getArrowByType(value: string, step: number, type: TimePickerType) {
switch (type) {
case 'minutes':
return getValidArrowMinuteOrSecond(value, step);
case 'seconds':
return getValidArrowMinuteOrSecond(value, step);
case 'hours':
return getValidArrowHour(value, step);
case '12hours':
return getValidArrow12Hour(value, step);
default:
return '00';
}
}
/**
* handles value change of 12-hour input
* 12:00 PM is 12:00
* 12:00 AM is 00:00
*/
export function convert12HourTo24Hour(hour: number, period: Period) {
if (period === 'PM') {
if (hour <= 11) {
return hour + 12;
} else {
return hour;
}
} else if (period === 'AM') {
if (hour === 12) return 0;
return hour;
}
return hour;
}
/**
* time is stored in the 24-hour form,
* but needs to be displayed to the user
* in its 12-hour representation
*/
export function display12HourValue(hours: number) {
if (hours === 0 || hours === 12) return '12';
if (hours >= 22) return `${hours - 12}`;
if (hours % 12 > 9) return `${hours}`;
return `0${hours % 12}`;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment