Event Calendar
event-calendaruiFull-featured event calendar primitive supporting month, week, day, work-week, and category/resource views, timed interval grids with collision clustering, all-day header, live current-time indicator, and customizable event templates.
When to use which
Decision GuideCalendar is the always-visible month grid. Date Picker is the form field with popover. Range Calendar manages multi-month spans, and Event Calendar is the multi-view scheduling engine.
| Component | Role | Use when |
|---|---|---|
| Calendar Embedded dashboard views or scheduling pages where the month grid stays continuously visible. | Grid Primitive | |
| RangeCalendar Contiguous multi-month booking ranges, check-in / check-out spans, and date windows. | Range Primitive | |
| DatePicker Forms, filter bars, and dialogs where space is tight and the calendar opens inside a popover. | Form Control | |
| EventCalendarcurrent Full calendar interfaces with month/week/day views, timed hourly intervals, and overlap collision lanes. | Scheduling Engine |
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/event-calendar.json$npx shadcn@latest add https://uipkge.dev/r/lit/event-calendar.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/event-calendar.json$bunx shadcn@latest add https://uipkge.dev/r/lit/event-calendar.jsonhttps://uipkge.dev/r/lit/event-calendar.jsonInstalls to:src/uip/components/event-calendar/Variants
Examples in Use
Full BlockMulti-view scheduling dashboard demonstrating month, week intervals, day view, side-by-side collision handling, and event creation.
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
view | CalendarView | — | optional |
default-view | CalendarView | month | optional |
week-starts-on | 0 | 1 | 0 | optional |
first-interval | number | 0 | optional |
interval-count | number | 24 | optional |
interval-minutes | number | 60 | optional |
interval-height | number | 52 | optional |
time-format | '12h' | '24h' | 12h | optional |
max-events-per-day | number | 3 | optional |
show-now-indicator | boolean | true | optional |
converter | string | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
ClusterItemtype ClusterItem { event: CalendarEvent; startMinutes: number; endMinutes: number; col: number }
const clusters: ClusterItem[][] = []
let currentCluster: ClusterItem[] = []
let clusterEnd = -1
for (const item of timed) {
if (currentCluster.length === 0 || item.startMinutes < clusterEnd) {
currentCluster.push({ ...item, col: 0 })
clusterEnd = Math.max(clusterEnd, item.endMinutes)
} else {
clusters.push(currentCluster)
currentCluster = [{ ...item, col: 0 }]
clusterEnd = item.endMinutes
}
}
if (currentCluster.length > 0) {
clusters.push(currentCluster)
}
const positioned: PositionedEvent[] = []
for (const cluster of clusters) {
const cols: number[] = []
for (const item of cluster) {
let placedCol = -1
for (let c = 0; c < cols.length; c++) {
if (item.startMinutes >= cols[c]) {
placedCol = c
cols[c] = item.endMinutes
break
}
}
if (placedCol === -1) {
placedCol = cols.length
cols.push(item.endMinutes)
}
item.col = placedCol
}
const totalCols = Math.max(1, cols.length)
for (const item of cluster) {
const topPct = Math.max(0, Math.min(100, ((item.startMinutes - startDayMinutes) / totalMinutes) * 100))
const bottomPct = Math.max(0, Math.min(100, ((item.endMinutes - startDayMinutes) / totalMinutes) * 100))
const heightPct = Math.max(1.5, bottomPct - topPct)
const widthPct = 100 / totalCols
const leftPct = item.col * widthPct
positioned.push({
event: item.event,
top: topPct,
height: heightPct,
left: leftPct,
width: widthPct,
startMinutes: item.startMinutes,
endMinutes: item.endMinutes,
})
}
}
return positioned
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-event-calendar': UipEventCalendar
}
}CalendarCategoryinterface CalendarCategory {
id: string
name: string
color?: string
}CalendarEventinterface CalendarEvent {
id?: string
title: string
start: string | Date
end?: string | Date
allDay?: boolean
color?:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'destructive'
| 'info'
| 'purple'
| 'rose'
| string
category?: string
description?: string
location?: string
[key: string]: any
}TimeClickPayloadinterface TimeClickPayload {
date: string
time: string
hour: number
minute: number
category?: string
}PositionedEventinterface PositionedEvent {
event: CalendarEvent
top: number
height: number
left: number
width: number
startMinutes: number
endMinutes: number
}CalendarDayCellinterface CalendarDayCell {
date: Date
dateKey: string
dayNumber: number
inMonth: boolean
isToday: boolean
events: CalendarEvent[]
allDayEvents: CalendarEvent[]
timedEvents: CalendarEvent[]
}npm dependencies
Includes
Files installed (4)
src/uip/components/event-calendar/date-utils.ts7.4 kB
import type { CalendarEvent, PositionedEvent } from './types' export function parseDate(d: string | Date | undefined | null): Date { if (!d) return new Date() if (d instanceof Date) return new Date(d.getTime()) // Format: YYYY-MM-DD if (/^\d{4}-\d{2}-\d{2}$/.test(d)) { const [y, m, day] = d.split('-').map(Number) return new Date(y, m - 1, day, 0, 0, 0) } // Format: YYYY-MM-DD HH:mm or YYYY-MM-DDTHH:mm const matchTime = d.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/) if (matchTime) { const [, y, m, day, h, min] = matchTime.map(Number) return new Date(y, m - 1, day, h, min, 0) } const parsed = new Date(d) return isNaN(parsed.getTime()) ? new Date() : parsed } export function formatDateKey(d: Date): string { const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') return `${y}-${m}-${day}` } export function isSameDay(d1: Date, d2: Date): boolean { return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth() && d1.getDate() === d2.getDate() } export function isToday(d: Date): boolean { return isSameDay(d, new Date()) } export function formatTime(minutes: number, format: '12h' | '24h' = '12h'): string { const totalMin = Math.max(0, Math.min(1439, Math.round(minutes))) const hour = Math.floor(totalMin / 60) const min = totalMin % 60 if (format === '24h') { return `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` } const ampm = hour >= 12 ? 'PM' : 'AM' const h12 = hour % 12 || 12 return `${h12}:${String(min).padStart(2, '0')} ${ampm}` } export function formatHourLabel(hour: number, format: '12h' | '24h' = '12h'): string { const normalizedHour = ((hour % 24) + 24) % 24 if (format === '24h') { return `${String(normalizedHour).padStart(2, '0')}:00` } if (normalizedHour === 0) return '12 AM' if (normalizedHour === 12) return '12 PM' return normalizedHour > 12 ? `${normalizedHour - 12} PM` : `${normalizedHour} AM` } export function getEventMinutes(dateInput: string | Date | undefined, defaultMinutes = 0): number { if (!dateInput) return defaultMinutes if (dateInput instanceof Date) { return dateInput.getHours() * 60 + dateInput.getMinutes() } if (typeof dateInput === 'string') { if (/^\d{1,2}:\d{2}$/.test(dateInput)) { const [h, m] = dateInput.split(':').map(Number) return h * 60 + m } const parsed = parseDate(dateInput) if (/^\d{4}-\d{2}-\d{2}$/.test(dateInput)) { return defaultMinutes } return parsed.getHours() * 60 + parsed.getMinutes() } return defaultMinutes } export function getWeekDays(baseDate: Date, weekStartsOn: 0 | 1 = 0): Date[] { const date = parseDate(baseDate) const day = date.getDay() const diff = (day - weekStartsOn + 7) % 7 const startOfWeek = new Date(date.getFullYear(), date.getMonth(), date.getDate() - diff) const days: Date[] = [] for (let i = 0; i < 7; i++) { const d = new Date(startOfWeek) d.setDate(startOfWeek.getDate() + i) days.push(d) } return days } export function getWorkWeekDays(baseDate: Date): Date[] { const date = parseDate(baseDate) const day = date.getDay() const diff = (day - 1 + 7) % 7 const monday = new Date(date.getFullYear(), date.getMonth(), date.getDate() - diff) const days: Date[] = [] for (let i = 0; i < 5; i++) { const d = new Date(monday) d.setDate(monday.getDate() + i) days.push(d) } return days } export function getMonthDays( cursor: Date, weekStartsOn: 0 | 1 = 0, ): { date: Date; dateKey: string; inMonth: boolean; isToday: boolean }[] { const y = cursor.getFullYear() const m = cursor.getMonth() const firstDay = new Date(y, m, 1) const offset = (firstDay.getDay() - weekStartsOn + 7) % 7 const startDate = new Date(y, m, 1 - offset) const days: { date: Date; dateKey: string; inMonth: boolean; isToday: boolean }[] = [] for (let i = 0; i < 42; i++) { const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate() + i) days.push({ date: d, dateKey: formatDateKey(d), inMonth: d.getMonth() === m, isToday: isToday(d), }) } return days } export function calculateTimedEventPositions( events: CalendarEvent[], dayDate: Date, firstInterval = 0, intervalCount = 24, intervalMinutes = 60, ): PositionedEvent[] { const dayKey = formatDateKey(dayDate) const startDayMinutes = firstInterval * 60 const totalMinutes = intervalCount * intervalMinutes const timed = events .filter((e) => { if (e.allDay) return false const sKey = typeof e.start === 'string' && /^\d{4}-\d{2}-\d{2}/.test(e.start) ? e.start.slice(0, 10) : formatDateKey(parseDate(e.start)) return sKey === dayKey }) .map((event) => { const sMin = getEventMinutes(event.start, 9 * 60) let eMin = getEventMinutes(event.end, sMin + 60) if (eMin <= sMin) eMin = sMin + 30 return { event, startMinutes: sMin, endMinutes: eMin, } }) .sort((a, b) => a.startMinutes - b.startMinutes || b.endMinutes - b.startMinutes - (a.endMinutes - a.startMinutes)) if (timed.length === 0) return [] type ClusterItem = { event: CalendarEvent; startMinutes: number; endMinutes: number; col: number } const clusters: ClusterItem[][] = [] let currentCluster: ClusterItem[] = [] let clusterEnd = -1 for (const item of timed) { if (currentCluster.length === 0 || item.startMinutes < clusterEnd) { currentCluster.push({ ...item, col: 0 }) clusterEnd = Math.max(clusterEnd, item.endMinutes) } else { clusters.push(currentCluster) currentCluster = [{ ...item, col: 0 }] clusterEnd = item.endMinutes } } if (currentCluster.length > 0) { clusters.push(currentCluster) } const positioned: PositionedEvent[] = [] for (const cluster of clusters) { const cols: number[] = [] for (const item of cluster) { let placedCol = -1 for (let c = 0; c < cols.length; c++) { if (item.startMinutes >= cols[c]) { placedCol = c cols[c] = item.endMinutes break } } if (placedCol === -1) { placedCol = cols.length cols.push(item.endMinutes) } item.col = placedCol } const totalCols = Math.max(1, cols.length) for (const item of cluster) { const topPct = Math.max(0, Math.min(100, ((item.startMinutes - startDayMinutes) / totalMinutes) * 100)) const bottomPct = Math.max(0, Math.min(100, ((item.endMinutes - startDayMinutes) / totalMinutes) * 100)) const heightPct = Math.max(1.5, bottomPct - topPct) const widthPct = 100 / totalCols const leftPct = item.col * widthPct positioned.push({ event: item.event, top: topPct, height: heightPct, left: leftPct, width: widthPct, startMinutes: item.startMinutes, endMinutes: item.endMinutes, }) } } return positioned } export function getCurrentTimePosition( firstInterval = 0, intervalCount = 24, intervalMinutes = 60, ): number | null { const now = new Date() const curMinutes = now.getHours() * 60 + now.getMinutes() const startMinutes = firstInterval * 60 const totalMinutes = intervalCount * intervalMinutes if (curMinutes < startMinutes || curMinutes > startMinutes + totalMinutes) { return null } return ((curMinutes - startMinutes) / totalMinutes) * 100 }src/uip/components/event-calendar/event-calendar.ts17.8 kB
import { LitElement, css, html, nothing } from 'lit' import { ChevronLeft, ChevronRight, Clock } from 'lucide' import { cn } from '../../lib/utils' import { icon } from '../../lib/icon' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import type { CalendarCategory, CalendarEvent, CalendarView, PositionedEvent, TimeClickPayload } from './types' import { calculateTimedEventPositions, formatDateKey, formatHourLabel, formatTime, getMonthDays, getWeekDays, getWorkWeekDays, isSameDay, isToday, parseDate, } from './date-utils' import { calendarEventVariants } from './event-calendar.variants' export type { CalendarCategory, CalendarEvent, CalendarView, TimeClickPayload } from './types' /** * <uip-event-calendar> — Full-featured event calendar supporting month, week, * work-week, day, and category/resource views with timed event positioning. */ export class UipEventCalendar extends LitElement { static styles = [ tailwind, css` :host { display: block; width: 100%; } `, ] static properties = { value: { attribute: false }, defaultValue: { attribute: false }, view: { type: String }, defaultView: { type: String, attribute: 'default-view' }, events: { type: Array, attribute: false }, categories: { type: Array, attribute: false }, weekStartsOn: { type: Number, attribute: 'week-starts-on' }, firstInterval: { type: Number, attribute: 'first-interval' }, intervalCount: { type: Number, attribute: 'interval-count' }, intervalMinutes: { type: Number, attribute: 'interval-minutes' }, intervalHeight: { type: Number, attribute: 'interval-height' }, timeFormat: { type: String, attribute: 'time-format' }, maxEventsPerDay: { type: Number, attribute: 'max-events-per-day' }, showNowIndicator: { type: Boolean, attribute: 'show-now-indicator' }, showHeader: { type: Boolean, attribute: 'show-header', converter: { fromAttribute: (v: string | null) => v !== null && v !== 'false' }, }, renderEvent: { attribute: false }, activeDate: { state: true }, activeView: { state: true }, } value?: string | Date defaultValue?: string | Date view?: CalendarView defaultView: CalendarView = 'month' events: CalendarEvent[] = [] categories: (string | CalendarCategory)[] = [] weekStartsOn: 0 | 1 = 0 firstInterval = 0 intervalCount = 24 intervalMinutes = 60 intervalHeight = 52 timeFormat: '12h' | '24h' = '12h' maxEventsPerDay = 3 showNowIndicator = true showHeader = true renderEvent?: (params: { event: CalendarEvent; view: CalendarView; isAllDay: boolean }) => unknown activeDate: Date = new Date() activeView: CalendarView = 'month' constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'event-calendar') this.activeDate = parseDate(this.value ?? this.defaultValue ?? new Date()) this.activeView = this.view ?? this.defaultView } updated(changedProperties: Map<string, unknown>) { if (changedProperties.has('value') && this.value !== undefined) { this.activeDate = parseDate(this.value) } if (changedProperties.has('view') && this.view !== undefined) { this.activeView = this.view } } private setDate(d: Date) { this.activeDate = d this.dispatchEvent(new CustomEvent('date-change', { detail: { date: d }, bubbles: true, composed: true })) this.requestUpdate() } private setView(v: CalendarView) { this.activeView = v this.dispatchEvent(new CustomEvent('view-change', { detail: { view: v }, bubbles: true, composed: true })) this.requestUpdate() } private prev() { const d = new Date(this.activeDate) if (this.activeView === 'month') d.setMonth(d.getMonth() - 1) else if (this.activeView === 'week' || this.activeView === 'work-week') d.setDate(d.getDate() - 7) else d.setDate(d.getDate() - 1) this.setDate(d) } private next() { const d = new Date(this.activeDate) if (this.activeView === 'month') d.setMonth(d.getMonth() + 1) else if (this.activeView === 'week' || this.activeView === 'work-week') d.setDate(d.getDate() + 7) else d.setDate(d.getDate() + 1) this.setDate(d) } private today() { this.setDate(new Date()) } private onEventClick(event: CalendarEvent, e: Event) { e.stopPropagation() this.dispatchEvent(new CustomEvent('event-click', { detail: { event }, bubbles: true, composed: true })) } private onDateClick(dateKey: string) { this.dispatchEvent(new CustomEvent('date-click', { detail: { date: dateKey }, bubbles: true, composed: true })) } private renderCalendarHeader() { const title = (() => { const locale = 'en-US' if (this.activeView === 'month') { return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(this.activeDate) } if (this.activeView === 'week' || this.activeView === 'work-week') { const days = this.activeView === 'week' ? getWeekDays(this.activeDate, this.weekStartsOn) : getWorkWeekDays(this.activeDate) const first = days[0]! const last = days[days.length - 1]! return `${first.toLocaleDateString(locale, { month: 'short', day: 'numeric' })} – ${last.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' })}` } return new Intl.DateTimeFormat(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }).format(this.activeDate) })() const views: CalendarView[] = ['month', 'week', 'day', 'category'] return html` <div class="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-3"> <div class="flex items-center gap-2"> <div class="flex items-center gap-1"> <button type="button" class="border border-input hover:bg-accent text-foreground size-8 inline-flex items-center justify-center rounded-md transition-colors" aria-label="Previous" @click=${this.prev} > ${icon(ChevronLeft, 'chevron-left', 'size-4')} </button> <button type="button" class="border border-input hover:bg-accent text-foreground size-8 inline-flex items-center justify-center rounded-md transition-colors" aria-label="Next" @click=${this.next} > ${icon(ChevronRight, 'chevron-right', 'size-4')} </button> </div> <button type="button" class="border border-input hover:bg-accent text-foreground h-8 px-2.5 rounded-md text-xs font-medium transition-colors" @click=${this.today} > Today </button> <span class="text-base font-semibold tracking-tight ml-2">${title}</span> </div> <div class="flex items-center rounded-md border border-input p-0.5 text-xs font-medium bg-muted/30"> ${views.map( (v) => html` <button type="button" class=${cn( 'rounded px-2.5 py-1 capitalize transition-colors', this.activeView === v ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground', )} @click=${() => this.setView(v)} > ${v} </button> `, )} </div> </div> ` } private renderMonthView() { const days = getMonthDays(this.activeDate, this.weekStartsOn) const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] .slice(this.weekStartsOn) .concat(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].slice(0, this.weekStartsOn)) return html` <div class="flex flex-col"> <!-- Weekday header --> <div class="grid grid-cols-7 border-b border-border bg-muted/40 text-center text-xs font-medium text-muted-foreground"> ${weekdays.map((wd) => html`<div class="py-2">${wd}</div>`)} </div> <!-- Month Grid --> <div class="grid grid-cols-7 border-b border-l border-border"> ${days.map(({ date, dateKey, inMonth, isToday: today }) => { const dayEvents = this.events.filter((e) => { const sKey = typeof e.start === 'string' ? e.start.slice(0, 10) : formatDateKey(parseDate(e.start)) return sKey === dateKey }) const visible = dayEvents.slice(0, this.maxEventsPerDay) const remaining = dayEvents.length - visible.length return html` <div class=${cn( 'min-h-[100px] border-r border-t border-border p-1.5 flex flex-col gap-1 transition-colors', !inMonth && 'bg-muted/20 text-muted-foreground/50', )} @click=${() => this.onDateClick(dateKey)} > <div class="flex items-center justify-between"> <span class=${cn( 'size-6 flex items-center justify-center rounded-full text-xs font-medium select-none', today && 'bg-primary text-primary-foreground font-bold', )} > ${date.getDate()} </span> </div> <div class="flex flex-col gap-1 flex-1"> ${visible.map((event) => { if (this.renderEvent) { return html`<div @click=${(e: Event) => this.onEventClick(event, e)}> ${this.renderEvent({ event, view: 'month', isAllDay: Boolean(event.allDay) })} </div>` } const variant = (event.color as any) ?? 'default' return html` <button type="button" class=${cn(calendarEventVariants({ variant, size: 'sm' }), 'truncate')} @click=${(e: Event) => this.onEventClick(event, e)} > <span class="truncate font-medium">${event.title}</span> </button> ` })} ${remaining > 0 ? html`<span class="text-[10px] text-muted-foreground font-medium pl-1">+${remaining} more</span>` : nothing} </div> </div> ` })} </div> </div> ` } private renderTimeGrid(days: Date[]) { const intervals = Array.from({ length: this.intervalCount }, (_, i) => this.firstInterval + i) return html` <div class="flex flex-col overflow-x-auto"> <!-- Day header row --> <div class="flex border-b border-border bg-muted/40"> <div class="w-16 shrink-0 border-r border-border"></div> ${days.map( (d) => html` <div class="flex-1 py-2 text-center text-xs font-medium border-r border-border last:border-r-0"> <span class=${cn(isToday(d) && 'text-primary font-bold')}> ${d.toLocaleDateString('en-US', { weekday: 'short', month: 'numeric', day: 'numeric' })} </span> </div> `, )} </div> <!-- Interval rows --> <div class="relative flex"> <!-- Time labels column --> <div class="w-16 shrink-0 flex flex-col text-right pr-2 text-xs text-muted-foreground border-r border-border select-none"> ${intervals.map( (hour) => html` <div style="height: ${this.intervalHeight}px;" class="flex items-start justify-end pt-1"> ${formatHourLabel(hour, this.timeFormat)} </div> `, )} </div> <!-- Day columns with positioned events --> ${days.map((day) => { const positioned = calculateTimedEventPositions( this.events, day, this.firstInterval, this.intervalCount, this.intervalMinutes, ) return html` <div class="relative flex-1 border-r border-border last:border-r-0"> <!-- Interval grid lines --> ${intervals.map( () => html` <div style="height: ${this.intervalHeight}px;" class="border-b border-border/50 hover:bg-muted/10 cursor-pointer" ></div> `, )} <!-- Positioned Events --> ${positioned.map(({ event, top, height, left, width }) => { const variant = (event.color as any) ?? 'default' return html` <button type="button" class=${cn( calendarEventVariants({ variant, size: 'sm' }), 'absolute rounded p-1 text-xs transition-opacity', )} style="top: ${top}%; height: ${height}%; left: ${left}%; width: calc(${width}% - 2px);" @click=${(e: Event) => this.onEventClick(event, e)} > <p class="font-semibold truncate">${event.title}</p> ${event.location ? html`<p class="truncate opacity-75 text-[10px]">${event.location}</p>` : nothing} </button> ` })} </div> ` })} </div> </div> ` } private renderCategoryView() { const cats: CalendarCategory[] = this.categories.map((c, idx) => typeof c === 'string' ? { id: c, name: c } : c, ) const intervals = Array.from({ length: this.intervalCount }, (_, i) => this.firstInterval + i) const day = this.activeDate return html` <div class="flex flex-col overflow-x-auto"> <!-- Category header row --> <div class="flex border-b border-border bg-muted/40"> <div class="w-16 shrink-0 border-r border-border"></div> ${cats.map( (cat) => html` <div class="flex-1 py-2 text-center text-xs font-medium border-r border-border last:border-r-0"> <span style="color: ${cat.color ?? 'inherit'};" class="font-bold">${cat.name}</span> </div> `, )} </div> <!-- Interval rows --> <div class="relative flex"> <!-- Time labels --> <div class="w-16 shrink-0 flex flex-col text-right pr-2 text-xs text-muted-foreground border-r border-border select-none"> ${intervals.map( (hour) => html` <div style="height: ${this.intervalHeight}px;" class="flex items-start justify-end pt-1"> ${formatHourLabel(hour, this.timeFormat)} </div> `, )} </div> <!-- Category columns --> ${cats.map((cat) => { const catEvents = this.events.filter((e) => e.category === cat.id) const positioned = calculateTimedEventPositions( catEvents, day, this.firstInterval, this.intervalCount, this.intervalMinutes, ) return html` <div class="relative flex-1 border-r border-border last:border-r-0"> ${intervals.map( () => html` <div style="height: ${this.intervalHeight}px;" class="border-b border-border/50 hover:bg-muted/10 cursor-pointer" ></div> `, )} <!-- Positioned Events --> ${positioned.map(({ event, top, height, left, width }) => { const variant = (event.color as any) ?? 'default' return html` <button type="button" class=${cn( calendarEventVariants({ variant, size: 'sm' }), 'absolute rounded p-1 text-xs transition-opacity', )} style="top: ${top}%; height: ${height}%; left: ${left}%; width: calc(${width}% - 2px);" @click=${(e: Event) => this.onEventClick(event, e)} > <p class="font-semibold truncate">${event.title}</p> ${event.location ? html`<p class="truncate opacity-75 text-[10px]">${event.location}</p>` : nothing} </button> ` })} </div> ` })} </div> </div> ` } render() { return html` <div part="base" class="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 text-card-foreground shadow-xs"> ${this.showHeader ? this.renderCalendarHeader() : nothing} ${this.activeView === 'month' ? this.renderMonthView() : this.activeView === 'category' ? this.renderCategoryView() : this.activeView === 'work-week' ? this.renderTimeGrid(getWorkWeekDays(this.activeDate)) : this.activeView === 'day' ? this.renderTimeGrid([this.activeDate]) : this.renderTimeGrid(getWeekDays(this.activeDate, this.weekStartsOn))} </div> ` } } customElements.get('uip-event-calendar') || customElements.define('uip-event-calendar', UipEventCalendar) declare global { interface HTMLElementTagNameMap { 'uip-event-calendar': UipEventCalendar } }src/uip/components/event-calendar/event-calendar.variants.ts1.6 kB
import { cva, type VariantProps } from 'class-variance-authority' export const calendarEventVariants = cva( 'group relative flex cursor-pointer flex-col overflow-hidden text-left transition-colors select-none border font-normal focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring', { variants: { variant: { default: 'bg-secondary/80 text-secondary-foreground border-border/50 hover:bg-secondary', primary: 'bg-primary/10 text-primary border-primary/25 hover:bg-primary/20', secondary: 'bg-muted/90 text-foreground border-border/60 hover:bg-muted', success: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/20', warning: 'bg-amber-500/10 text-amber-800 dark:text-amber-400 border-amber-500/30 hover:bg-amber-500/20', destructive: 'bg-destructive/10 text-destructive border-destructive/30 hover:bg-destructive/20', info: 'bg-sky-500/10 text-sky-700 dark:text-sky-400 border-sky-500/30 hover:bg-sky-500/20', purple: 'bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/30 hover:bg-purple-500/20', rose: 'bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-500/30 hover:bg-rose-500/20', }, size: { sm: 'rounded-sm px-1.5 py-0.5 text-[11px] leading-tight', default: 'rounded-md px-2 py-1 text-xs leading-normal', lg: 'rounded-md px-2.5 py-1.5 text-sm leading-snug', }, }, defaultVariants: { variant: 'default', size: 'default', }, }, ) export type CalendarEventVariants = VariantProps<typeof calendarEventVariants>src/uip/components/event-calendar/types.ts1 kB
export type CalendarView = 'month' | 'week' | 'day' | 'work-week' | 'category' export interface CalendarCategory { id: string name: string color?: string } export interface CalendarEvent { id?: string title: string start: string | Date end?: string | Date allDay?: boolean color?: | 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'destructive' | 'info' | 'purple' | 'rose' | string category?: string description?: string location?: string [key: string]: any } export interface TimeClickPayload { date: string time: string hour: number minute: number category?: string } export interface PositionedEvent { event: CalendarEvent top: number height: number left: number width: number startMinutes: number endMinutes: number } export interface CalendarDayCell { date: Date dateKey: string dayNumber: number inMonth: boolean isToday: boolean events: CalendarEvent[] allDayEvents: CalendarEvent[] timedEvents: CalendarEvent[] }
Raw manifest:https://uipkge.dev/r/lit/event-calendar.json