Date Picker
Vue date-timeDate input that opens a Calendar in a Popover. Handles parsing, formatting, min/max bounds, and disabled dates. Use the Range Calendar version for from/to selections.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/date-picker.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/date-picker.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/date-picker.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/date-picker.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/date-picker
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValue Controlled value. Shape depends on `type`. ISO `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm` when `show-time`). | SingleValue | MultipleValue | RangeValue | null | optional |
type | DatePickerType | 'single' | optional |
placeholder | string | 'Pick a date' | optional |
disabled | boolean | false | optional |
readOnly | boolean | false | optional |
clearable | boolean | true | optional |
format Format for the trigger label. String presets or Intl.DateTimeFormatOptions for full flexibility. Ignored when `show-time` (always shows date + time). | FormatValue | 'medium' | optional |
dateFormat Backward-compat alias for `format`. | FormatValue | — | optional |
locale | string | 'en-US' | optional |
numberOfMonths | number | — | optional |
weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 0 | optional |
fixedWeeks | boolean | false | optional |
minValue | string | DateValue | — | optional |
maxValue | string | DateValue | — | optional |
layout Header layout: control which parts (month/year) become dropdowns. | DatePickerLayout | 'default' | optional |
picker | DatePickerPicker | 'day' | optional |
showCurrentDate Show "Today" shortcut at popover top (single + day picker only). | boolean | false | optional |
needConfirm Require clicking OK before applying the selected value. | boolean | false | optional |
status Validation status — applies colored border to the trigger. | DatePickerStatus | undefined | optional |
size Size variant of the trigger input. | DatePickerSize | 'middle' | optional |
placement Placement of the popover relative to the trigger. | DatePickerPlacement | 'bottomLeft' | optional |
showTime Pair the calendar with a time selector. Value becomes `YYYY-MM-DDTHH:mm`. | boolean | false | optional |
showSeconds Show seconds column in time picker. Only used when `show-time`. | boolean | false | optional |
use24Hour 24h vs AM/PM column. Only used when `show-time`. | boolean | false | optional |
minuteStep Minute step for the time column. Only used when `show-time`. | number | 5 | optional |
secondStep Second step for the time column. Only used when `show-time` and `show-seconds`. | number | 1 | optional |
defaultTime Default time for newly picked dates when `show-time` and no prior selection. `HH:mm` or `HH:mm:ss`. | string | '12:00' | optional |
presets Preset shortcuts for quick selection. | DatePickerPreset[] | — | optional |
separator Custom separator between range start and end dates. | string | '~' | optional |
disabledDate Function to determine if a specific date should be disabled. | (current: DateValue) => boolean | — | optional |
disabledTime Function to determine if specific times should be disabled. Only used when `show-time`. | (current?: DateValue) => DisabledTimeResult | — | optional |
triggerClass | HTMLAttributes['class'] | — | optional |
class | HTMLAttributes['class'] | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
RangeValue type RangeValue { start: string | DateValue; end: string | DateValue } | null
export interface DatePickerPreset {
label: string
value: SingleValue | MultipleValue | RangeValue
category?: string
} DisabledTimeResult interface DisabledTimeResult {
disabledHours?: () => number[]
disabledMinutes?: (selectedHour: number) => number[]
disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[]
} TimeShape type TimeShape { h: number; m: number; s: number }
const lastTime = computed<TimeShape>(() => {
const v = internal.value
if (props.type === 'single' && v && 'hour' in (v as object)) {
const dt = v as CalendarDateTime
return { h: dt.hour, m: dt.minute, s: dt.second }
}
if (props.type === 'range') {
const r = v as { start?: DateValue } | undefined
if (r?.start && 'hour' in (r.start as object)) {
const dt = r.start as CalendarDateTime
return { h: dt.hour, m: dt.minute, s: dt.second }
}
}
const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(props.defaultTime)
if (!m) return { h: 12, m: 0, s: 0 }
return { h: Number(m[1]), m: Number(m[2]), s: m[3] ? Number(m[3]) : 0 }
} Dependencies
Files (2)
-
app/components/ui/date-picker/DatePicker.vue 37.2 kB
<script setup lang="ts"> import type { DateValue } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { computed, ref, watch } from 'vue' import { Calendar as CalendarIcon, X } from 'lucide-vue-next' import { CalendarDate, CalendarDateTime, getLocalTimeZone, parseDate, parseDateTime, today, } from '@internationalized/date' import { useDateFormatter } from 'reka-ui' import { toDate } from 'reka-ui/date' import { Button } from '@/components/ui/button' import { Calendar } from '@/components/ui/calendar' import { RangeCalendar } from '@/components/ui/range-calendar' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { TimeColumns } from '@/components/ui/time-picker' import { cn } from '@/lib/utils' export type DatePickerType = 'single' | 'multiple' | 'range' export type DatePickerLayout = 'default' | 'month-and-year' | 'month-only' | 'year-only' export type DatePickerPicker = 'day' | 'week' | 'month' | 'quarter' | 'year' export type DatePickerStatus = 'error' | 'warning' export type DatePickerSize = 'small' | 'middle' | 'large' export type DatePickerPlacement = | 'top' | 'bottom' | 'left' | 'right' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' type SingleValue = string | DateValue | null type MultipleValue = (string | DateValue)[] | null type RangeValue = { start: string | DateValue; end: string | DateValue } | null export interface DatePickerPreset { label: string value: SingleValue | MultipleValue | RangeValue category?: string } export interface DisabledTimeResult { disabledHours?: () => number[] disabledMinutes?: (selectedHour: number) => number[] disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[] } type FormatValue = 'short' | 'medium' | 'long' | 'full' | Intl.DateTimeFormatOptions interface Props { /** Controlled value. Shape depends on `type`. ISO `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm` when `show-time`). */ modelValue?: SingleValue | MultipleValue | RangeValue type?: DatePickerType placeholder?: string disabled?: boolean readOnly?: boolean clearable?: boolean /** Format for the trigger label. String presets or Intl.DateTimeFormatOptions for full flexibility. Ignored when `show-time` (always shows date + time). */ format?: FormatValue /** Backward-compat alias for `format`. */ dateFormat?: FormatValue locale?: string numberOfMonths?: number weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 fixedWeeks?: boolean minValue?: string | DateValue maxValue?: string | DateValue /** Header layout: control which parts (month/year) become dropdowns. */ layout?: DatePickerLayout /** * Granularity of selection. * - `day` (default): pick a single day from the calendar grid. * - `week`: pick a full week — value is the week's start date (Monday or per locale). * - `month`: pick a whole month — value snaps to the 1st day of the month. * - `quarter`: pick a quarter — value snaps to the quarter's start date. * - `year`: pick a whole year — value snaps to Jan 1 of the year. * Only honored for `type='single'`. */ picker?: DatePickerPicker /** Show "Today" shortcut at popover top (single + day picker only). */ showCurrentDate?: boolean /** Require clicking OK before applying the selected value. */ needConfirm?: boolean /** Validation status — applies colored border to the trigger. */ status?: DatePickerStatus /** Size variant of the trigger input. */ size?: DatePickerSize /** Placement of the popover relative to the trigger. */ placement?: DatePickerPlacement /** Pair the calendar with a time selector. Value becomes `YYYY-MM-DDTHH:mm`. */ showTime?: boolean /** Show seconds column in time picker. Only used when `show-time`. */ showSeconds?: boolean /** 24h vs AM/PM column. Only used when `show-time`. */ use24Hour?: boolean /** Minute step for the time column. Only used when `show-time`. */ minuteStep?: number /** Second step for the time column. Only used when `show-time` and `show-seconds`. */ secondStep?: number /** Default time for newly picked dates when `show-time` and no prior selection. `HH:mm` or `HH:mm:ss`. */ defaultTime?: string /** Preset shortcuts for quick selection. */ presets?: DatePickerPreset[] /** Custom separator between range start and end dates. */ separator?: string /** Function to determine if a specific date should be disabled. */ disabledDate?: (current: DateValue) => boolean /** Function to determine if specific times should be disabled. Only used when `show-time`. */ disabledTime?: (current?: DateValue) => DisabledTimeResult triggerClass?: HTMLAttributes['class'] class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { modelValue: null, type: 'single', placeholder: 'Pick a date', disabled: false, readOnly: false, clearable: true, format: 'medium', locale: 'en-US', weekStartsOn: 0, fixedWeeks: false, layout: 'default', picker: 'day', showCurrentDate: false, needConfirm: false, status: undefined, size: 'middle', placement: 'bottomLeft', showTime: false, showSeconds: false, use24Hour: false, minuteStep: 5, secondStep: 1, defaultTime: '12:00', separator: '~', }) const emits = defineEmits<{ 'update:modelValue': [value: SingleValue | MultipleValue | RangeValue] }>() const open = ref(false) const formatter = useDateFormatter(props.locale) const effectiveFormat = computed(() => props.dateFormat ?? props.format) const effectiveNumberOfMonths = computed(() => props.numberOfMonths ?? (props.type === 'range' ? 2 : 1)) const effectivePresets = computed<DatePickerPreset[] | undefined>(() => { if (props.presets) return props.presets if (props.type !== 'range') return undefined const t = today(getLocalTimeZone()) return [ { label: 'Today', value: { start: t, end: t } }, { label: 'Yesterday', value: { start: t.subtract({ days: 1 }), end: t.subtract({ days: 1 }) } }, { label: 'Last 7 days', value: { start: t.subtract({ days: 6 }), end: t } }, { label: 'Last 30 days', value: { start: t.subtract({ days: 29 }), end: t } }, { label: 'This month', value: { start: t.set({ day: 1 }), end: t } }, { label: 'Last month', value: (() => { const lastMonthEnd = t.set({ day: 1 }).subtract({ days: 1 }) const lastMonthStart = lastMonthEnd.set({ day: 1 }) return { start: lastMonthStart, end: lastMonthEnd } })(), }, { label: 'Year to date', value: { start: t.set({ month: 1, day: 1 }), end: t } }, ] }) const presetGroups = computed(() => { const groups = new Map<string | undefined, DatePickerPreset[]>() for (const p of effectivePresets.value ?? []) { const cat = p.category if (!groups.has(cat)) groups.set(cat, []) groups.get(cat)!.push(p) } return Array.from(groups.entries()).map(([category, presets]) => ({ category, presets })) }) // ---- Need confirm: preview state ---- const previewValue = ref<typeof internal.value>(undefined) const activeValue = computed(() => (props.needConfirm ? (previewValue.value ?? internal.value) : internal.value)) function commitPreview() { if (props.needConfirm && previewValue.value !== undefined) { handleUpdate(previewValue.value) previewValue.value = undefined } open.value = false } function cancelPreview() { previewValue.value = undefined open.value = false } watch(open, (v) => { if (!v) previewValue.value = undefined }) function coerce(v: string | DateValue | null | undefined): DateValue | null { if (!v) return null if (typeof v === 'string') { try { return v.includes('T') ? parseDateTime(v) : parseDate(v) } catch { return null } } return v } function coerceShape(v: Props['modelValue']) { if (props.type === 'multiple') { if (!Array.isArray(v)) return undefined return v.map(coerce).filter((x): x is DateValue => x != null) } if (props.type === 'range') { if (!v || Array.isArray(v) || typeof v === 'string' || !('start' in (v as object))) return undefined const r = v as { start: string | DateValue; end: string | DateValue } const start = coerce(r.start) const end = coerce(r.end) if (!start) return undefined return end ? { start, end } : { start } } return coerce(v as SingleValue) ?? undefined } const internal = ref(coerceShape(props.modelValue)) watch( () => props.modelValue, (v) => { internal.value = coerceShape(v) }, { deep: true }, ) const minDate = computed(() => coerce(props.minValue) ?? undefined) const maxDate = computed(() => coerce(props.maxValue) ?? undefined) function fmtDate(d: DateValue) { const fmt = effectiveFormat.value if (typeof fmt === 'object') { return formatter.custom(toDate(d), fmt) } return formatter.custom(toDate(d), { dateStyle: fmt }) } function fmtMonth(d: DateValue) { return formatter.custom(toDate(d), { month: 'long', year: 'numeric' }) } function fmtTime(d: DateValue) { if (!('hour' in d)) return '' const dt = d as CalendarDateTime const h24 = String(dt.hour).padStart(2, '0') const m = String(dt.minute).padStart(2, '0') if (props.showSeconds) { const s = String(dt.second).padStart(2, '0') if (props.use24Hour) return `${h24}:${m}:${s}` const h12 = dt.hour % 12 === 0 ? 12 : dt.hour % 12 const period = dt.hour >= 12 ? 'PM' : 'AM' return `${h12}:${m}:${s} ${period}` } if (props.use24Hour) { return `${h24}:${m}` } const h12 = dt.hour % 12 === 0 ? 12 : dt.hour % 12 const period = dt.hour >= 12 ? 'PM' : 'AM' return `${h12}:${m} ${period}` } function fmtDateTime(d: DateValue) { return `${fmtDate(d)} ${fmtTime(d)}`.trim() } const display = computed(() => { const v = internal.value if (!v) return '' if (props.type === 'multiple') { const arr = v as DateValue[] if (!arr.length) return '' if (arr.length === 1) return fmtDate(arr[0]!) if (arr.length <= 3) return arr.map(fmtDate).join(', ') return `${arr.length} dates selected` } if (props.type === 'range') { const r = v as { start?: DateValue; end?: DateValue } const fmt = props.showTime ? fmtDateTime : fmtDate if (r.start && r.end) return `${fmt(r.start)} ${props.separator} ${fmt(r.end)}` if (r.start) return `${fmt(r.start)} ${props.separator} …` return '' } if (props.picker === 'week') { const ws = weekStart(v as DateValue) return `Week ${weekNumber(ws)}, ${ws.year}` } if (props.picker === 'month') return fmtMonth(v as DateValue) if (props.picker === 'quarter') { const q = Math.ceil((v as DateValue).month / 3) return `Q${q} ${(v as DateValue).year}` } if (props.picker === 'year') return String((v as DateValue).year) return props.showTime ? fmtDateTime(v as DateValue) : fmtDate(v as DateValue) }) const hasValue = computed(() => { const v = internal.value if (!v) return false if (props.type === 'multiple') return (v as DateValue[]).length > 0 if (props.type === 'range') return Boolean((v as { start?: DateValue }).start) return true }) function emitOut(v: typeof internal.value) { if (props.type === 'multiple') { const arr = (v as DateValue[]) ?? [] emits( 'update:modelValue', arr.map((d) => d.toString()), ) return } if (props.type === 'range') { const r = v as { start?: DateValue; end?: DateValue } | undefined if (!r?.start || !r?.end) return emits('update:modelValue', { start: r.start.toString(), end: r.end.toString(), }) return } emits('update:modelValue', v ? (v as DateValue).toString() : null) } function handleUpdate(v: typeof internal.value) { internal.value = v emitOut(v) if (props.needConfirm) return // Don't close on day click when show-time is on — user still needs to pick the time. if (props.type === 'single' && v && !props.showTime) open.value = false if (props.type === 'range') { const r = v as { start?: DateValue; end?: DateValue } | undefined if (r?.start && r?.end && !props.showTime) open.value = false } } function clear(event: Event) { event.stopPropagation() if (props.disabled || props.readOnly) return if (props.type === 'multiple') handleUpdate([]) else handleUpdate(undefined) } function applyPreset(preset: DatePickerPreset) { if (props.disabled || props.readOnly) return const v = preset.value if (!v) { handleUpdate(undefined) open.value = false return } if (props.type === 'multiple') { const arr = (v as MultipleValue) ?? [] handleUpdate(arr.map(coerce).filter((x): x is DateValue => x != null) as never) open.value = false return } if (props.type === 'range') { const r = v as RangeValue if (!r?.start) { handleUpdate(undefined) open.value = false return } const start = coerce(r.start) const end = coerce(r.end) if (!start) { handleUpdate(undefined) open.value = false return } handleUpdate(end ? { start, end } : ({ start } as never)) open.value = false return } handleUpdate(coerce(v as SingleValue) as never) open.value = false } const placeholderDate = today(getLocalTimeZone()) const calendarType = computed(() => { if (props.type === 'multiple') return { type: 'multiple' as const } if (props.type === 'range') return { type: 'range' as const } return undefined }) const calendarLayout = computed(() => (props.layout === 'default' ? undefined : props.layout)) function pickToday() { if (props.type !== 'single') return const t = today(getLocalTimeZone()) if (props.picker === 'week') { handleUpdate(weekStart(t)) return } handleUpdate(t) } // ---- Week picker utilities ---- function weekStart(d: DateValue): CalendarDate { // ISO week starts on Monday (1), but respect weekStartsOn prop const jsDate = toDate(d) const dayOfWeek = jsDate.getDay() const offset = (dayOfWeek - props.weekStartsOn + 7) % 7 return new CalendarDate(d.year, d.month, d.day).subtract({ days: offset }) } function weekNumber(d: DateValue): number { const jsDate = toDate(d) const target = new Date(jsDate.valueOf()) const dayNr = (jsDate.getDay() + 6) % 7 target.setDate(target.getDate() - dayNr + 3) const firstThursday = target.valueOf() target.setMonth(0, 1) if (target.getDay() !== 4) { target.setMonth(0, 1 + ((4 - target.getDay() + 7) % 7)) } return 1 + Math.ceil((firstThursday - target.valueOf()) / 604800000) } function weekAnchorForMonth(year: number, month: number): CalendarDate { const firstOfMonth = new CalendarDate(year, month, 1) return weekStart(firstOfMonth) } const weekGrid = computed(() => { const anchor = monthYearAnchor.value const start = weekAnchorForMonth(anchor.year, anchor.month) // Generate 6 weeks to cover the month return Array.from({ length: 6 }, (_, i) => { const weekStartDate = start.add({ weeks: i }) const weekEndDate = weekStartDate.add({ days: 6 }) return { start: weekStartDate, end: weekEndDate, weekNum: weekNumber(weekStartDate), } }) }) function isWeekSelected(ws: CalendarDate) { const v = activeValue.value as DateValue | undefined if (!v) return false const selectedWeekStart = weekStart(v) return selectedWeekStart.compare(ws) === 0 } function isWeekDisabled(ws: CalendarDate) { if (minDate.value && ws.compare(minDate.value) < 0) return true const weekEnd = ws.add({ days: 6 }) if (maxDate.value && weekEnd.compare(maxDate.value) > 0) return true if (props.disabledDate && props.disabledDate(ws)) return true return false } function pickWeek(ws: CalendarDate) { if (isWeekDisabled(ws)) return if (props.needConfirm) { previewValue.value = ws return } handleUpdate(ws) } // ---- Quarter picker utilities ---- const quarterLabels = ['Q1', 'Q2', 'Q3', 'Q4'] const quarterMonths = [1, 4, 7, 10] as const function quarterStart(year: number, quarterIdx: number): CalendarDate { return new CalendarDate(year, quarterMonths[quarterIdx]!, 1) } function isQuarterSelected(qIdx: number) { const v = activeValue.value as DateValue | undefined if (!v) return false return v.year === monthYearAnchor.value.year && Math.ceil(v.month / 3) === qIdx + 1 } function isQuarterDisabled(qIdx: number) { const d = quarterStart(monthYearAnchor.value.year, qIdx) if (minDate.value && d.compare(minDate.value) < 0) return true const lastMonth = d.add({ months: 2 }) const lastDay = lastMonth.add({ months: 1 }).subtract({ days: 1 }) if (maxDate.value && lastDay.compare(maxDate.value) > 0) return true if (props.disabledDate && props.disabledDate(d)) return true return false } function pickQuarter(qIdx: number) { const d = quarterStart(monthYearAnchor.value.year, qIdx) if (isQuarterDisabled(qIdx)) return if (props.needConfirm) { previewValue.value = d return } handleUpdate(d) } // ---- Calendar value passed to Reka must be a CalendarDate (no time component), // so it doesn't refuse the value or repaint with a stale time. function stripTime(d?: DateValue) { if (!d) return d return new CalendarDate(d.year, d.month, d.day) } const calendarValue = computed(() => { const v = internal.value if (props.type === 'multiple') return (v as DateValue[] | undefined)?.map(stripTime) as never if (props.type === 'range') { const r = v as { start?: DateValue; end?: DateValue } | undefined if (!r) return undefined as never return { start: stripTime(r.start), end: stripTime(r.end) } as never } return stripTime(v as DateValue | undefined) as never }) // Wrap update from calendar — re-attach time component if show-time is on. type TimeShape = { h: number; m: number; s: number } const lastTime = computed<TimeShape>(() => { const v = internal.value if (props.type === 'single' && v && 'hour' in (v as object)) { const dt = v as CalendarDateTime return { h: dt.hour, m: dt.minute, s: dt.second } } if (props.type === 'range') { const r = v as { start?: DateValue } | undefined if (r?.start && 'hour' in (r.start as object)) { const dt = r.start as CalendarDateTime return { h: dt.hour, m: dt.minute, s: dt.second } } } const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(props.defaultTime) if (!m) return { h: 12, m: 0, s: 0 } return { h: Number(m[1]), m: Number(m[2]), s: m[3] ? Number(m[3]) : 0 } }) function withTime(d: DateValue, t: TimeShape = lastTime.value) { return new CalendarDateTime(d.year, d.month, d.day, t.h, t.m, t.s) } function handleCalendarUpdate(v: unknown) { if (props.needConfirm) { if (props.type === 'multiple') { previewValue.value = ((v as DateValue[])?.map((d) => withTime(d)) as never) ?? [] } else if (props.type === 'range') { const r = v as { start?: DateValue; end?: DateValue } | undefined if (!r?.start) { previewValue.value = undefined } else { const start = props.showTime ? withTime(r.start) : r.start const end = r.end ? (props.showTime ? withTime(r.end) : r.end) : undefined previewValue.value = end ? { start, end } : ({ start } as never) } } else { previewValue.value = v ? ((props.showTime ? withTime(v as DateValue) : (v as DateValue)) as never) : undefined } return } if (!props.showTime) { handleUpdate(v as typeof internal.value) return } if (props.type === 'multiple') { const arr = (v as DateValue[]) ?? [] handleUpdate(arr.map((d) => withTime(d)) as never) return } if (props.type === 'range') { const r = v as { start?: DateValue; end?: DateValue } | undefined if (!r?.start) return handleUpdate(undefined) const start = withTime(r.start) const end = r.end ? withTime(r.end) : undefined handleUpdate(end ? { start, end } : ({ start } as never)) return } if (v) handleUpdate(withTime(v as DateValue) as never) } // Time column for show-time mode. const timeForColumns = computed(() => { const t = lastTime.value if (props.showSeconds) { return `${String(t.h).padStart(2, '0')}:${String(t.m).padStart(2, '0')}:${String(t.s).padStart(2, '0')}` } return `${String(t.h).padStart(2, '0')}:${String(t.m).padStart(2, '0')}` }) const timeFormat = computed(() => { if (props.showSeconds) return 'HH:mm:ss' return 'HH:mm' }) function handleTimeUpdate(value: string) { if (!internal.value) return const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(value) if (!m) return const t: TimeShape = { h: Number(m[1]), m: Number(m[2]), s: m[3] ? Number(m[3]) : 0 } if (props.type === 'single') { handleUpdate(withTime(internal.value as DateValue, t) as never) return } if (props.type === 'range') { const r = internal.value as { start?: DateValue; end?: DateValue } if (!r.start) return const start = withTime(r.start, t) const end = r.end ? withTime(r.end, t) : undefined handleUpdate(end ? { start, end } : ({ start } as never)) } } const disabledTimeConfig = computed(() => { if (!props.disabledTime) return undefined let current: DateValue | undefined if (props.type === 'range') { const r = activeValue.value as { start?: DateValue } | undefined current = r?.start } else { current = activeValue.value as DateValue | undefined } return props.disabledTime(current) }) // ---- Month / year picker mode ---- const monthYearAnchor = ref<DateValue>((internal.value as DateValue) ?? today(getLocalTimeZone())) watch(open, (v) => { if (v) monthYearAnchor.value = (internal.value as DateValue) ?? today(getLocalTimeZone()) }) const monthLabels = computed(() => { const base = today(getLocalTimeZone()) return Array.from({ length: 12 }, (_, m) => { const d = new CalendarDate(base.year, m + 1, 1) return formatter.custom(toDate(d), { month: 'short' }) }) }) const yearGrid = computed(() => { const y = monthYearAnchor.value.year const start = y - (y % 12) return Array.from({ length: 12 }, (_, i) => start + i) }) function shiftAnchor(dir: -1 | 1) { const a = monthYearAnchor.value if (props.picker === 'month' || props.picker === 'week') { monthYearAnchor.value = new CalendarDate(a.year + dir, a.month, 1) } else if (props.picker === 'year' || props.picker === 'quarter') { monthYearAnchor.value = new CalendarDate(a.year + dir, 1, 1) } } function isMonthSelected(monthIdx: number) { const v = activeValue.value as DateValue | undefined if (!v) return false return v.year === monthYearAnchor.value.year && v.month === monthIdx + 1 } function isYearSelected(year: number) { const v = activeValue.value as DateValue | undefined return !!v && v.year === year } function pickMonth(monthIdx: number) { const d = new CalendarDate(monthYearAnchor.value.year, monthIdx + 1, 1) if (isMonthDisabled(monthIdx)) return if (props.needConfirm) { previewValue.value = d return } handleUpdate(d) } function pickYear(year: number) { const d = new CalendarDate(year, 1, 1) if (isYearDisabled(year)) return if (props.picker === 'year') { if (props.needConfirm) { previewValue.value = d return } handleUpdate(d) } else { monthYearAnchor.value = d } } function isMonthDisabled(monthIdx: number) { const d = new CalendarDate(monthYearAnchor.value.year, monthIdx + 1, 1) if (minDate.value && d.compare(minDate.value) < 0) return true if (maxDate.value && d.compare(maxDate.value) > 0) return true if (props.disabledDate && props.disabledDate(d)) return true return false } function isYearDisabled(year: number) { const d = new CalendarDate(year, 1, 1) if (minDate.value && new CalendarDate(year, 12, 31).compare(minDate.value) < 0) return true if (maxDate.value && d.compare(maxDate.value) > 0) return true if (props.disabledDate && props.disabledDate(d)) return true return false } // ---- Popover placement ---- const popoverPlacement = computed(() => { const map: Record<string, { side: 'top' | 'bottom' | 'left' | 'right'; align: 'start' | 'center' | 'end' }> = { top: { side: 'top', align: 'center' }, bottom: { side: 'bottom', align: 'center' }, left: { side: 'left', align: 'center' }, right: { side: 'right', align: 'center' }, topLeft: { side: 'top', align: 'start' }, topRight: { side: 'top', align: 'end' }, bottomLeft: { side: 'bottom', align: 'start' }, bottomRight: { side: 'bottom', align: 'end' }, } return map[props.placement] ?? { side: 'bottom', align: 'start' } }) // ---- Size ---- const buttonSize = computed(() => { if (props.size === 'small') return 'sm' if (props.size === 'large') return 'lg' return 'default' }) const triggerClasses = computed(() => cn( props.showTime ? 'min-w-[280px]' : 'min-w-[240px]', 'justify-start gap-2 text-left font-normal', !hasValue.value && 'text-muted-foreground', props.status === 'error' && 'border-destructive focus-visible:ring-destructive', props.status === 'warning' && 'border-warning focus-visible:ring-warning', props.triggerClass, props.class, ), ) function applyAndClose() { open.value = false } </script> <template> <Popover v-model:open="open"> <PopoverTrigger as-child> <Button type="button" variant="outline" :size="buttonSize" :disabled="disabled" :class="triggerClasses" data-uipkge data-slot="date-picker" > <CalendarIcon class="size-4" aria-hidden="true" /> <span class="flex-1 truncate">{{ display || placeholder }}</span> <button type="button" v-if="clearable && hasValue && !disabled && !readOnly" class="text-muted-foreground hover:text-foreground focus-visible:ring-ring -mr-1 inline-flex size-9 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none" aria-label="Clear date" @click.stop="clear" > <X class="size-3.5" /> </button> </Button> </PopoverTrigger> <PopoverContent class="w-auto p-0" :side="popoverPlacement.side as 'top' | 'bottom' | 'left' | 'right' | undefined" :align="popoverPlacement.align as 'center' | 'end' | 'start' | undefined" > <div v-if="showCurrentDate && type === 'single' && (picker === 'day' || picker === 'week')" class="flex justify-end border-b px-3 py-2" > <button type="button" class="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded px-2 py-1.5 text-xs focus-visible:ring-2 focus-visible:outline-none" @click="pickToday" > Today </button> </div> <div v-if="picker !== 'day' && type === 'single'" class="w-[260px] p-3" data-uipkge data-slot="month-year-picker"> <!-- Week picker --> <template v-if="picker === 'week'"> <div class="mb-3 flex items-center justify-between"> <button type="button" class="hover:bg-accent focus-visible:ring-ring inline-flex size-9 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none" aria-label="Previous" @click="shiftAnchor(-1)" > <span class="text-muted-foreground text-sm">‹</span> </button> <span class="text-sm font-medium"> {{ formatter.custom(toDate(monthYearAnchor as DateValue), { month: 'short', year: 'numeric' }) }} </span> <button type="button" class="hover:bg-accent focus-visible:ring-ring inline-flex size-9 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none" aria-label="Next" @click="shiftAnchor(1)" > <span class="text-muted-foreground text-sm">›</span> </button> </div> <div class="flex flex-col gap-1"> <button type="button" v-for="(week, i) in weekGrid" :key="i" data-uipkge data-slot="week-picker-cell" :data-active="isWeekSelected(week.start)" :disabled="isWeekDisabled(week.start)" :aria-pressed="isWeekSelected(week.start)" :aria-disabled="isWeekDisabled(week.start) || undefined" class="hover:bg-accent focus-visible:ring-ring flex items-center justify-between rounded px-3 py-2 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent" :class="isWeekSelected(week.start) ? 'bg-primary text-primary-foreground hover:bg-primary' : ''" @click="pickWeek(week.start)" > <span class="font-medium">Week {{ week.weekNum }}</span> <span class="text-muted-foreground text-xs" :class="isWeekSelected(week.start) ? 'text-primary-foreground' : ''" > {{ week.start.day }} {{ formatter.custom(toDate(week.start), { month: 'short' }) }} – {{ week.end.day }} {{ formatter.custom(toDate(week.end), { month: 'short' }) }} </span> </button> </div> </template> <!-- Quarter picker --> <template v-else-if="picker === 'quarter'"> <div class="mb-3 flex items-center justify-between"> <button type="button" class="hover:bg-accent focus-visible:ring-ring inline-flex size-9 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none" aria-label="Previous" @click="shiftAnchor(-1)" > <span class="text-muted-foreground text-sm">‹</span> </button> <span class="text-sm font-medium">{{ monthYearAnchor.year }}</span> <button type="button" class="hover:bg-accent focus-visible:ring-ring inline-flex size-9 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none" aria-label="Next" @click="shiftAnchor(1)" > <span class="text-muted-foreground text-sm">›</span> </button> </div> <div class="grid grid-cols-2 gap-2"> <button type="button" v-for="(label, q) in quarterLabels" :key="q" data-uipkge data-slot="quarter-picker-cell" :data-active="isQuarterSelected(q)" :disabled="isQuarterDisabled(q)" :aria-pressed="isQuarterSelected(q)" :aria-disabled="isQuarterDisabled(q) || undefined" class="hover:bg-accent focus-visible:ring-ring rounded px-4 py-6 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent" :class="isQuarterSelected(q) ? 'bg-primary text-primary-foreground hover:bg-primary' : ''" @click="pickQuarter(q)" > {{ label }} </button> </div> </template> <!-- Month / Year picker --> <template v-else> <div class="mb-3 flex items-center justify-between"> <button type="button" class="hover:bg-accent focus-visible:ring-ring inline-flex size-9 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none" aria-label="Previous" @click="shiftAnchor(-1)" > <span class="text-muted-foreground text-sm">‹</span> </button> <span class="text-sm font-medium"> {{ picker === 'month' ? monthYearAnchor.year : `${yearGrid[0]} – ${yearGrid[yearGrid.length - 1]}` }} </span> <button type="button" class="hover:bg-accent focus-visible:ring-ring inline-flex size-9 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none" aria-label="Next" @click="shiftAnchor(1)" > <span class="text-muted-foreground text-sm">›</span> </button> </div> <div v-if="picker === 'month'" class="grid grid-cols-3 gap-2"> <button type="button" v-for="(label, m) in monthLabels" :key="m" :disabled="isMonthDisabled(m)" data-uipkge data-slot="month-picker-cell" :data-active="isMonthSelected(m)" :aria-pressed="isMonthSelected(m)" :aria-disabled="isMonthDisabled(m) || undefined" class="hover:bg-accent focus-visible:ring-ring rounded px-2 py-2 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent" :class="isMonthSelected(m) ? 'bg-primary text-primary-foreground hover:bg-primary' : ''" @click="pickMonth(m)" > {{ label }} </button> </div> <div v-else class="grid grid-cols-3 gap-2"> <button type="button" v-for="y in yearGrid" :key="y" :disabled="isYearDisabled(y)" data-uipkge data-slot="year-picker-cell" :data-active="isYearSelected(y)" :aria-pressed="isYearSelected(y)" :aria-disabled="isYearDisabled(y) || undefined" class="hover:bg-accent focus-visible:ring-ring rounded px-2 py-2 text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent" :class="isYearSelected(y) ? 'bg-primary text-primary-foreground hover:bg-primary' : ''" @click="pickYear(y)" > {{ y }} </button> </div> </template> </div> <div v-else class="flex"> <aside v-if="presetGroups.length" class="flex w-40 flex-col gap-1 border-r p-2"> <template v-for="(group, gIdx) in presetGroups" :key="gIdx"> <div v-if="group.category" class="text-muted-foreground px-2 pt-1 text-xs font-semibold tracking-wider uppercase" > {{ group.category }} </div> <button type="button" v-for="p in group.presets" :key="p.label" class="hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1.5 text-left text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none" @click="applyPreset(p)" > {{ p.label }} </button> </template> </aside> <RangeCalendar v-if="type === 'range'" :model-value="calendarValue" :placeholder="placeholderDate" :number-of-months="effectiveNumberOfMonths" :week-starts-on="weekStartsOn" :fixed-weeks="fixedWeeks" :min-value="minDate" :max-value="maxDate" :read-only="readOnly" :is-date-disabled="disabledDate" @update:model-value="handleCalendarUpdate" /> <Calendar v-else :model-value="calendarValue" :type="calendarType" :placeholder="placeholderDate" :number-of-months="effectiveNumberOfMonths" :week-starts-on="weekStartsOn" :fixed-weeks="fixedWeeks" :min-value="minDate" :max-value="maxDate" :read-only="readOnly" :layout="calendarLayout" :is-date-disabled="disabledDate" @update:model-value="handleCalendarUpdate" > <template #cell="{ day, month }"> <slot name="cell" :day="day" :month="month"> {{ day.day }} </slot> </template> </Calendar> <div v-if="showTime" class="flex flex-col border-l"> <div class="flex items-center justify-between border-b px-3 py-2"> <span class="text-muted-foreground text-xs tracking-widest uppercase">Time</span> <button type="button" class="text-primary focus-visible:ring-ring rounded px-2 py-1.5 text-xs font-medium focus-visible:ring-2 focus-visible:outline-none" @click="applyAndClose" > Done </button> </div> <TimeColumns :model-value="timeForColumns" :format="timeFormat" :use24-hour="use24Hour" :minute-step="minuteStep" :second-step="secondStep" :visible="open" :disabled-hours="disabledTimeConfig?.disabledHours" :disabled-minutes="disabledTimeConfig?.disabledMinutes" :disabled-seconds="disabledTimeConfig?.disabledSeconds" @update:model-value="handleTimeUpdate" /> </div> </div> <!-- Need confirm footer --> <div v-if="needConfirm" class="flex items-center justify-end gap-2 border-t px-3 py-2"> <button type="button" class="hover:bg-accent focus-visible:ring-ring rounded px-3 py-1.5 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none" @click="cancelPreview" > Cancel </button> <button type="button" class="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring rounded px-3 py-1.5 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none" @click="commitPreview" > OK </button> </div> </PopoverContent> </Popover> </template> -
app/components/ui/date-picker/index.ts 0.3 kB
export { default as DatePicker } from './DatePicker.vue' export type { DatePickerType, DatePickerPicker, DatePickerStatus, DatePickerLayout, DatePickerSize, DatePickerPlacement, DatePickerPreset, DisabledTimeResult, } from './DatePicker.vue'
Raw manifest: https://uipkge.dev/r/vue/date-picker.json