feat: add AppDatePicker custom calendar component
This commit is contained in:
359
src/components/AppDatePicker.vue
Normal file
359
src/components/AppDatePicker.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: 'Select date',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null)
|
||||
const panelRef = ref<HTMLDivElement | null>(null)
|
||||
const panelStyle = ref<Record<string, string>>({})
|
||||
|
||||
// The month/year currently displayed in the calendar
|
||||
const viewYear = ref(new Date().getFullYear())
|
||||
const viewMonth = ref(new Date().getMonth()) // 0-indexed
|
||||
|
||||
// ── Formatting ──────────────────────────────────────────────────────
|
||||
const displayText = computed(() => {
|
||||
if (!props.modelValue) return null
|
||||
const [y, m, d] = props.modelValue.split('-').map(Number)
|
||||
const date = new Date(y, m - 1, d)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
})
|
||||
|
||||
const viewMonthLabel = computed(() => {
|
||||
const date = new Date(viewYear.value, viewMonth.value, 1)
|
||||
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
})
|
||||
|
||||
// ── Today helpers ───────────────────────────────────────────────────
|
||||
function todayString(): string {
|
||||
const now = new Date()
|
||||
return formatDate(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
}
|
||||
|
||||
function formatDate(y: number, m: number, d: number): string {
|
||||
const mm = String(m + 1).padStart(2, '0')
|
||||
const dd = String(d).padStart(2, '0')
|
||||
return `${y}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
// ── Calendar grid ───────────────────────────────────────────────────
|
||||
interface DayCell {
|
||||
date: number
|
||||
month: number // 0-indexed
|
||||
year: number
|
||||
isCurrentMonth: boolean
|
||||
dateString: string
|
||||
}
|
||||
|
||||
const dayCells = computed<DayCell[]>(() => {
|
||||
const y = viewYear.value
|
||||
const m = viewMonth.value
|
||||
|
||||
// First day of the month (0=Sun, 1=Mon, ...)
|
||||
const firstDayOfWeek = new Date(y, m, 1).getDay()
|
||||
// Shift so Monday=0
|
||||
const startOffset = (firstDayOfWeek + 6) % 7
|
||||
|
||||
const daysInMonth = new Date(y, m + 1, 0).getDate()
|
||||
const daysInPrevMonth = new Date(y, m, 0).getDate()
|
||||
|
||||
const cells: DayCell[] = []
|
||||
|
||||
// Previous month padding
|
||||
const prevMonth = m === 0 ? 11 : m - 1
|
||||
const prevYear = m === 0 ? y - 1 : y
|
||||
for (let i = startOffset - 1; i >= 0; i--) {
|
||||
const d = daysInPrevMonth - i
|
||||
cells.push({
|
||||
date: d,
|
||||
month: prevMonth,
|
||||
year: prevYear,
|
||||
isCurrentMonth: false,
|
||||
dateString: formatDate(prevYear, prevMonth, d),
|
||||
})
|
||||
}
|
||||
|
||||
// Current month days
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
cells.push({
|
||||
date: d,
|
||||
month: m,
|
||||
year: y,
|
||||
isCurrentMonth: true,
|
||||
dateString: formatDate(y, m, d),
|
||||
})
|
||||
}
|
||||
|
||||
// Next month padding (fill up to 42 cells = 6 rows)
|
||||
const nextMonth = m === 11 ? 0 : m + 1
|
||||
const nextYear = m === 11 ? y + 1 : y
|
||||
let nextDay = 1
|
||||
while (cells.length < 42) {
|
||||
cells.push({
|
||||
date: nextDay,
|
||||
month: nextMonth,
|
||||
year: nextYear,
|
||||
isCurrentMonth: false,
|
||||
dateString: formatDate(nextYear, nextMonth, nextDay),
|
||||
})
|
||||
nextDay++
|
||||
}
|
||||
|
||||
return cells
|
||||
})
|
||||
|
||||
const dayHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
|
||||
// ── Positioning ─────────────────────────────────────────────────────
|
||||
function updatePosition() {
|
||||
if (!triggerRef.value) return
|
||||
const rect = triggerRef.value.getBoundingClientRect()
|
||||
const panelWidth = 280
|
||||
|
||||
let left = rect.left
|
||||
// Shift left if it would overflow the viewport right edge
|
||||
if (left + panelWidth > window.innerWidth) {
|
||||
left = window.innerWidth - panelWidth - 8
|
||||
}
|
||||
if (left < 0) left = 0
|
||||
|
||||
panelStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${rect.bottom + 4}px`,
|
||||
left: `${left}px`,
|
||||
width: `${panelWidth}px`,
|
||||
zIndex: '9999',
|
||||
}
|
||||
}
|
||||
|
||||
// ── Open / Close ────────────────────────────────────────────────────
|
||||
function toggle() {
|
||||
if (isOpen.value) {
|
||||
close()
|
||||
} else {
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
// Sync view to modelValue or today
|
||||
if (props.modelValue) {
|
||||
const [y, m] = props.modelValue.split('-').map(Number)
|
||||
viewYear.value = y
|
||||
viewMonth.value = m - 1
|
||||
} else {
|
||||
const now = new Date()
|
||||
viewYear.value = now.getFullYear()
|
||||
viewMonth.value = now.getMonth()
|
||||
}
|
||||
|
||||
isOpen.value = true
|
||||
updatePosition()
|
||||
|
||||
nextTick(() => {
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
document.addEventListener('scroll', onScrollOrResize, true)
|
||||
window.addEventListener('resize', onScrollOrResize)
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
document.removeEventListener('click', onClickOutside, true)
|
||||
document.removeEventListener('scroll', onScrollOrResize, true)
|
||||
window.removeEventListener('resize', onScrollOrResize)
|
||||
}
|
||||
|
||||
// ── Month navigation ────────────────────────────────────────────────
|
||||
function prevMonthNav() {
|
||||
if (viewMonth.value === 0) {
|
||||
viewMonth.value = 11
|
||||
viewYear.value--
|
||||
} else {
|
||||
viewMonth.value--
|
||||
}
|
||||
}
|
||||
|
||||
function nextMonthNav() {
|
||||
if (viewMonth.value === 11) {
|
||||
viewMonth.value = 0
|
||||
viewYear.value++
|
||||
} else {
|
||||
viewMonth.value++
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection ───────────────────────────────────────────────────────
|
||||
function selectDay(cell: DayCell) {
|
||||
if (!cell.isCurrentMonth) return
|
||||
emit('update:modelValue', cell.dateString)
|
||||
close()
|
||||
}
|
||||
|
||||
function selectToday() {
|
||||
emit('update:modelValue', todayString())
|
||||
close()
|
||||
}
|
||||
|
||||
// ── Event handlers ──────────────────────────────────────────────────
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (
|
||||
triggerRef.value?.contains(target) ||
|
||||
panelRef.value?.contains(target)
|
||||
) {
|
||||
return
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
function onScrollOrResize() {
|
||||
if (isOpen.value) {
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sync view when modelValue changes externally ────────────────────
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val && isOpen.value) {
|
||||
const [y, m] = val.split('-').map(Number)
|
||||
viewYear.value = y
|
||||
viewMonth.value = m - 1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Cleanup ─────────────────────────────────────────────────────────
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClickOutside, true)
|
||||
document.removeEventListener('scroll', onScrollOrResize, true)
|
||||
window.removeEventListener('resize', onScrollOrResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Trigger button -->
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
@click="toggle"
|
||||
class="w-full flex items-center justify-between gap-2 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
|
||||
:style="
|
||||
isOpen
|
||||
? {
|
||||
borderColor: 'var(--color-accent)',
|
||||
boxShadow: '0 0 0 2px var(--color-accent-muted)',
|
||||
outline: 'none',
|
||||
}
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="displayText ? 'text-text-primary' : 'text-text-tertiary'"
|
||||
class="truncate"
|
||||
>
|
||||
{{ displayText ?? placeholder }}
|
||||
</span>
|
||||
<Calendar
|
||||
class="w-4 h-4 text-text-secondary shrink-0"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Calendar popover -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="panelRef"
|
||||
:style="panelStyle"
|
||||
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden animate-dropdown-enter"
|
||||
>
|
||||
<!-- Month/year header -->
|
||||
<div class="flex items-center justify-between px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
@click="prevMonthNav"
|
||||
class="p-1 rounded-lg hover:bg-bg-elevated transition-colors cursor-pointer text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" :stroke-width="2" />
|
||||
</button>
|
||||
<span class="text-[0.8125rem] font-medium text-text-primary select-none">
|
||||
{{ viewMonthLabel }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="nextMonthNav"
|
||||
class="p-1 rounded-lg hover:bg-bg-elevated transition-colors cursor-pointer text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<ChevronRight class="w-4 h-4" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Day-of-week headers -->
|
||||
<div class="grid grid-cols-7 px-2">
|
||||
<div
|
||||
v-for="header in dayHeaders"
|
||||
:key="header"
|
||||
class="text-center text-[0.6875rem] font-medium text-text-tertiary py-1 select-none"
|
||||
>
|
||||
{{ header }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Day grid -->
|
||||
<div class="grid grid-cols-7 px-2 pb-2">
|
||||
<button
|
||||
v-for="(cell, index) in dayCells"
|
||||
:key="index"
|
||||
type="button"
|
||||
:disabled="!cell.isCurrentMonth"
|
||||
@click="selectDay(cell)"
|
||||
class="relative flex items-center justify-center h-8 w-full text-[0.75rem] rounded-lg transition-colors select-none"
|
||||
:class="[
|
||||
!cell.isCurrentMonth
|
||||
? 'text-text-tertiary/40 cursor-default'
|
||||
: cell.dateString === modelValue
|
||||
? 'bg-accent text-bg-base font-medium cursor-pointer'
|
||||
: cell.dateString === todayString()
|
||||
? 'ring-1 ring-accent text-accent-text cursor-pointer hover:bg-bg-elevated'
|
||||
: 'text-text-primary cursor-pointer hover:bg-bg-elevated',
|
||||
]"
|
||||
>
|
||||
{{ cell.date }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Today shortcut -->
|
||||
<div class="border-t border-border-subtle px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="selectToday"
|
||||
class="w-full text-center text-[0.75rem] text-accent-text hover:text-accent cursor-pointer transition-colors py-0.5"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user