feat: add AppDatePicker custom calendar component

This commit is contained in:
Your Name
2026-02-17 22:24:47 +02:00
parent 8cdd30b9e4
commit b9aace912b

View 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>