Calendar
A month grid for choosing one date, with full keyboard navigation and min / max limits.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add calendarpnpm dlx ermineui add calendaryarn dlx ermineui add calendarbunx ermineui add calendarThat copies 3 files into src/components/ui/calendar/. Files you already have are kept.
Use
import { Calendar } from '@/components/ui/calendar'<script setup lang="ts">
import { Calendar } from '@/components/ui/calendar'
</script>
<template>
<Calendar />
</template>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.
What gets copied
| File | Where it goes |
|---|---|
Calendar.vue | src/components/ui/calendar/Calendar.vue |
dates.ts | src/components/ui/calendar/dates.ts |
index.ts | src/components/ui/calendar/index.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
Calendar.vue 344 lines
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, useId, watch } from 'vue'
import { addDays, addMonths, formatDate, parseDate, startOfMonth, today as currentDate, weekday } from './dates'
const props = withDefaults(
defineProps<{
/** Earliest date that can be chosen, as 'YYYY-MM-DD'. */
min?: string
/** Latest date that can be chosen, as 'YYYY-MM-DD'. */
max?: string
/** Return true for dates that cannot be chosen, such as weekends. */
isDateDisabled?: (date: string) => boolean
/** Highlight a span of days, for choosing a range. Both ends are 'YYYY-MM-DD'; the date range picker sets these. */
rangeStart?: string
rangeEnd?: string
/** Language for month and weekday names. Fixed by default so server and browser render the same text. */
locale?: string
/** First column of the grid: 0 for Sunday, 1 for Monday, and so on. */
weekStartsOn?: number
}>(),
{ locale: 'en-US', weekStartsOn: 0 },
)
const emit = defineEmits<{
/** A day was chosen — also fires when it is the day already selected. */
select: [date: string]
/** The pointer or keyboard focus is on a day — lets a range picker show the span before it is chosen. */
preview: [date: string]
}>()
/** The chosen date as 'YYYY-MM-DD'. */
const value = defineModel<string>()
const grid = ref<HTMLElement | null>(null)
const titleId = useId()
// Filled in after mount: the server does not know what day it is for the visitor.
const today = ref<string>()
function clamp(date: string): string {
if (props.min && date < props.min) return props.min
if (props.max && date > props.max) return props.max
return date
}
/** The one day Tab lands on. The month on screen is always the month this day is in. */
const focused = ref(clamp(parseDate(value.value) ? value.value! : currentDate()))
const month = computed(() => startOfMonth(focused.value))
watch(value, (next) => {
if (parseDate(next)) focused.value = next!
})
onMounted(() => {
today.value = currentDate()
})
const title = computed(() => formatDate(month.value, props.locale, { month: 'long', year: 'numeric' }))
/** Six weeks, always, so the calendar keeps its height from month to month. */
const weeks = computed(() => {
const offset = (weekday(month.value) - props.weekStartsOn + 7) % 7
const start = addDays(month.value, -offset)
return Array.from({ length: 6 }, (_week, row) =>
Array.from({ length: 7 }, (_day, column) => {
const date = addDays(start, row * 7 + column)
return {
date,
day: Number(date.slice(8)),
label: formatDate(date, props.locale, { dateStyle: 'full' }),
outside: startOfMonth(date) !== month.value,
disabled: isDisabled(date),
rangeStart: date === props.rangeStart,
rangeEnd: date === props.rangeEnd,
inRange: !!props.rangeStart && !!props.rangeEnd && date >= props.rangeStart && date <= props.rangeEnd,
}
}),
)
})
const weekdays = computed(() =>
(weeks.value[0] ?? []).map(({ date }) => ({
short: formatDate(date, props.locale, { weekday: 'short' }),
long: formatDate(date, props.locale, { weekday: 'long' }),
})),
)
const canGoBack = computed(() => !props.min || month.value > startOfMonth(props.min))
const canGoForward = computed(() => !props.max || month.value < startOfMonth(props.max))
function isDisabled(date: string): boolean {
if (props.min && date < props.min) return true
if (props.max && date > props.max) return true
return props.isDateDisabled?.(date) ?? false
}
function shiftMonth(months: number) {
focused.value = clamp(addMonths(focused.value, months))
}
async function focusDate(date: string) {
focused.value = clamp(date)
await nextTick()
grid.value?.querySelector<HTMLElement>(`[data-date="${focused.value}"]`)?.focus()
}
function choose(date: string) {
if (isDisabled(date)) return
value.value = date
emit('select', date)
// Choosing a day from the next or previous month redraws the grid; keep focus on that day.
focusDate(date)
}
function onKeydown(event: KeyboardEvent) {
const current = focused.value
const column = (weekday(current) - props.weekStartsOn + 7) % 7
let target: string
switch (event.key) {
case 'ArrowLeft':
target = addDays(current, -1)
break
case 'ArrowRight':
target = addDays(current, 1)
break
case 'ArrowUp':
target = addDays(current, -7)
break
case 'ArrowDown':
target = addDays(current, 7)
break
case 'Home':
target = addDays(current, -column)
break
case 'End':
target = addDays(current, 6 - column)
break
case 'PageUp':
target = addMonths(current, event.shiftKey ? -12 : -1)
break
case 'PageDown':
target = addMonths(current, event.shiftKey ? 12 : 1)
break
default:
return
}
event.preventDefault()
focusDate(target)
}
</script>
<template>
<div class="er-calendar">
<div class="er-calendar-header">
<button type="button" class="er-calendar-nav" aria-label="Previous month" :disabled="!canGoBack" @click="shiftMonth(-1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<div :id="titleId" class="er-calendar-title" aria-live="polite">{{ title }}</div>
<button type="button" class="er-calendar-nav" aria-label="Next month" :disabled="!canGoForward" @click="shiftMonth(1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
<table ref="grid" role="grid" class="er-calendar-grid" :aria-labelledby="titleId" @keydown="onKeydown">
<thead>
<tr>
<th v-for="(name, index) in weekdays" :key="index" scope="col" :abbr="name.long">{{ name.short }}</th>
</tr>
</thead>
<tbody>
<!-- Keyed by position, not date, so cells are updated in place and focus survives a month change. -->
<tr v-for="(week, row) in weeks" :key="row">
<td v-for="(cell, column) in week" :key="column" role="gridcell" :aria-selected="cell.date === value || cell.inRange">
<button
type="button"
class="er-calendar-day"
:tabindex="cell.date === focused ? 0 : -1"
:aria-label="cell.label"
:aria-current="cell.date === today ? 'date' : undefined"
:aria-disabled="cell.disabled || undefined"
:data-date="cell.date"
:data-selected="cell.date === value ? '' : undefined"
:data-today="cell.date === today ? '' : undefined"
:data-outside="cell.outside ? '' : undefined"
:data-in-range="cell.inRange ? '' : undefined"
:data-range-start="cell.rangeStart ? '' : undefined"
:data-range-end="cell.rangeEnd ? '' : undefined"
@click="choose(cell.date)"
@pointerenter="emit('preview', cell.date)"
@focus="emit('preview', cell.date)"
>
{{ cell.day }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style>
@layer components {
.er-calendar {
width: max-content;
font-size: 0.875rem;
}
.er-calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.er-calendar-title {
font-weight: var(--er-weight-medium);
}
.er-calendar-nav {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: 1px solid var(--er-input);
border-radius: var(--er-radius-sm);
background: var(--er-input-background);
color: var(--er-foreground);
cursor: pointer;
transition: background-color var(--er-duration);
}
.er-calendar-nav:hover {
background: var(--er-accent);
color: var(--er-accent-foreground);
}
.er-calendar-nav:disabled {
opacity: 0.5;
pointer-events: none;
}
.er-calendar-nav svg {
width: 1rem;
height: 1rem;
}
.er-calendar-grid {
border-collapse: collapse;
}
.er-calendar-grid th {
width: 2.25rem;
height: 2rem;
padding: 0;
color: var(--er-muted-foreground);
font-size: 0.75rem;
font-weight: 400;
}
.er-calendar-grid td {
padding: 0;
}
.er-calendar-day {
display: block;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: 0;
border-radius: var(--er-radius-md);
background: transparent;
color: inherit;
font: inherit;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition:
background-color var(--er-duration),
color var(--er-duration);
}
.er-calendar-day:hover {
background: var(--er-accent);
color: var(--er-accent-foreground);
}
.er-calendar-nav:focus-visible,
.er-calendar-day:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 1px;
}
.er-calendar-day[data-outside] {
color: var(--er-muted-foreground);
}
.er-calendar-day[data-today] {
box-shadow: inset 0 0 0 1px var(--er-input);
font-weight: var(--er-weight-semibold);
}
/* A range is one continuous band: square where days join, rounded only at its two ends. */
.er-calendar-day[data-in-range] {
border-radius: 0;
background: var(--er-accent);
color: var(--er-accent-foreground);
}
.er-calendar-day[data-selected],
.er-calendar-day[data-range-start],
.er-calendar-day[data-range-end] {
background: var(--er-primary);
color: var(--er-primary-foreground);
box-shadow: none;
}
.er-calendar-day[data-range-start] {
border-radius: var(--er-radius-md) 0 0 var(--er-radius-md);
}
.er-calendar-day[data-range-end] {
border-radius: 0 var(--er-radius-md) var(--er-radius-md) 0;
}
.er-calendar-day[data-range-start][data-range-end] {
border-radius: var(--er-radius-md);
}
.er-calendar-day[aria-disabled='true'] {
background: transparent;
color: var(--er-muted-foreground);
opacity: 0.45;
cursor: not-allowed;
}
}
</style>dates.ts 59 lines
/**
* Dates are plain 'YYYY-MM-DD' strings, the format <input type="date"> uses: no time
* and no time zone, so a date never shifts by a day between server and browser.
* Strings in this format sort in date order, so they can be compared with < and >.
*/
const DAY = 86_400_000
/** The date as a Date at UTC midnight, or null if the string is not a real date. */
export function parseDate(value: string | null | undefined): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '')
if (!match) return null
const date = new Date(0)
date.setUTCFullYear(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
// Rejects overflow such as 2026-02-31, which Date would roll into March.
return toDateString(date) === value ? date : null
}
export function toDateString(date: Date): string {
return date.toISOString().slice(0, 10)
}
/** Today in the visitor's own time zone. */
export function today(): string {
const now = new Date()
return toDateString(new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())))
}
export function addDays(value: string, days: number): string {
const date = parseDate(value)
return date ? toDateString(new Date(date.getTime() + days * DAY)) : value
}
/** Same day in another month, pulled back to the last day when that month is shorter. */
export function addMonths(value: string, months: number): string {
const date = parseDate(value)
if (!date) return value
const first = new Date(0)
first.setUTCFullYear(date.getUTCFullYear(), date.getUTCMonth() + months, 1)
const daysInMonth = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate()
first.setUTCDate(Math.min(date.getUTCDate(), daysInMonth))
return toDateString(first)
}
export function startOfMonth(value: string): string {
return `${value.slice(0, 7)}-01`
}
/** Day of the week, 0 (Sunday) to 6 (Saturday). */
export function weekday(value: string): number {
return parseDate(value)?.getUTCDay() ?? 0
}
/** Formats a date string for display. Returns '' for anything that is not a date. */
export function formatDate(value: string | null | undefined, locale: string, options: Intl.DateTimeFormatOptions): string {
const date = parseDate(value)
return date ? new Intl.DateTimeFormat(locale, { ...options, timeZone: 'UTC' }).format(date) : ''
}index.ts 4 lines
/** Calendar — a month grid for choosing one date, with full keyboard navigation and min / max limits. */
export { default as Calendar } from './Calendar.vue'
export * from './dates'