Created
July 1, 2025 18:14
-
-
Save Xinecraft/e71e20391fc5d409a674a9aa423f8de8 to your computer and use it in GitHub Desktop.
ShadCN Vue - Enhanced Calendar with Integrated Date Picker
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export { default as TimePicker } from './TimePicker.vue'; | |
| export { default as TimePickerInput } from './TimePickerInput.vue'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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