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