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 init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add calendar
pnpm dlx ermineui add calendar
yarn dlx ermineui add calendar
bunx ermineui add calendar

That 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

FileWhere it goes
Calendar.vuesrc/components/ui/calendar/Calendar.vue
dates.tssrc/components/ui/calendar/dates.ts
index.tssrc/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'