Event Calendar Dashboard
blockproductivityMonth-view event calendar with stats strip, drag/shift range select, side rail (selected day or range summary), upcoming list, and per-cell/per-event context menus. events / onEventsChange binds the source of truth; eventTypes config maps each type key to its label/icon/color theme so swapping the visual palette is one prop. Built on the use-month-grid headless hook.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/event-calendar-dashboard.json$npx shadcn@latest add https://uipkge.dev/r/lit/event-calendar-dashboard.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/event-calendar-dashboard.json$bunx shadcn@latest add https://uipkge.dev/r/lit/event-calendar-dashboard.jsonManifest:
https://uipkge.dev/r/lit/event-calendar-dashboard.jsonInstalls to:src/uip/blocks/event-calendar-dashboard/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
initial-date | string | — | optional |
week-starts-on | 0 | 1 | 0 | optional |
loading | boolean | false | optional |
heading | string | Calendar | optional |
description | string | Schedule, meetings, and deadlines. Click or shift-click to select a range. | optional |
show-stats | boolean | true | optional |
show-side-rail | boolean | true | optional |
show-search | boolean | true | optional |
show-view-select | boolean | true | optional |
show-legend | boolean | true | optional |
show-upcoming | boolean | true | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
DashboardCalendarEventinterface DashboardCalendarEvent {
id: string
title: string
/** YYYY-MM-DD */
date: string
/** HH:mm */
start: string
/** HH:mm */
end: string
type: string
description?: string
location?: string
attendees?: string[]
status?: string
}DashboardEventTypeMetainterface DashboardEventTypeMeta {
label: string
icon: string
chip: string
bar: string
dot: string
glow: string
text: string
ring: string
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-event-calendar-dashboard': UipEventCalendarDashboard
}
}Files installed (1)
src/uip/blocks/event-calendar-dashboard/event-calendar-dashboard.ts46.8 kB
import { LitElement, css, html, nothing } from 'lit' import { AlertCircle, CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Clock, GraduationCap, Headphones, ListFilter, MapPin, MousePointer2, Pencil, Plane, Plus, Search, Trash2, Users, Video, Wrench, X, type IconNode, } from 'lucide' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { icon } from '../../lib/icon' // Side-effect imports: register the composed elements for standalone use // (consumers who install only this block still get working primitives). import '../../components/avatar/avatar' import '../../components/button/button' import '../../components/dialog/dialog' import '../../components/input/input' import '../../components/select/select' import '../../components/separator/separator' import '../../components/skeleton/skeleton' import type { UipInput } from '../../components/input/input' import type { UipSelect } from '../../components/select/select' export interface DashboardCalendarEvent { id: string title: string /** YYYY-MM-DD */ date: string /** HH:mm */ start: string /** HH:mm */ end: string type: string description?: string location?: string attendees?: string[] status?: string } /** * Visual + semantic config per event type. Mirrors React's EventTypeMeta, * except `icon` is a kebab-case icon name (resolved to lucide inside the * shadow root) instead of a Lucide component — components can't cross the * custom-element boundary from plain HTML. */ export interface DashboardEventTypeMeta { label: string icon: string chip: string bar: string dot: string glow: string text: string ring: string } export const defaultDashboardEventTypes: Record<string, DashboardEventTypeMeta> = { meeting: { label: 'Meeting', icon: 'video', chip: 'bg-chart-1/10 text-chart-1 ring-chart-1/20', bar: 'bg-chart-1', dot: 'bg-chart-1', glow: 'bg-chart-1/10', text: 'text-chart-1', ring: 'hover:ring-chart-1/30', }, task: { label: 'Task', icon: 'circle-check', chip: 'bg-success/10 text-success ring-success/20', bar: 'bg-success', dot: 'bg-success', glow: 'bg-success/10', text: 'text-success', ring: 'hover:ring-success/30', }, reminder: { label: 'Reminder', icon: 'circle-alert', chip: 'bg-warning/10 text-warning ring-warning/20', bar: 'bg-warning', dot: 'bg-warning', glow: 'bg-warning/10', text: 'text-warning', ring: 'hover:ring-warning/30', }, travel: { label: 'Travel', icon: 'plane', chip: 'bg-chart-3/10 text-chart-3 ring-chart-3/20', bar: 'bg-chart-3', dot: 'bg-chart-3', glow: 'bg-chart-3/10', text: 'text-chart-3', ring: 'hover:ring-chart-3/30', }, } const NEUTRAL_META: DashboardEventTypeMeta = { label: 'Event', icon: 'calendar-days', chip: 'bg-muted text-muted-foreground ring-muted-foreground/20', bar: 'bg-muted-foreground', dot: 'bg-muted-foreground', glow: 'bg-muted-foreground/10', text: 'text-muted-foreground', ring: 'hover:ring-muted-foreground/30', } function typeIcon(name: string, cls: string) { const map: Record<string, [IconNode, string]> = { video: [Video, 'video'], 'circle-check': [CheckCircle2, 'circle-check-big'], 'circle-alert': [AlertCircle, 'circle-alert'], plane: [Plane, 'plane'], headphones: [Headphones, 'headphones'], wrench: [Wrench, 'wrench'], 'graduation-cap': [GraduationCap, 'graduation-cap'], 'calendar-days': [CalendarDays, 'calendar-days'], } const [node, kebab] = map[name] ?? [CalendarDays, 'calendar-days'] return icon(node, kebab, cls) } function pad(n: number): string { return String(n).padStart(2, '0') } function dateKey(d: Date): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` } function dateFromKey(key: string): Date { const [y, m, d] = key.split('-').map(Number) return new Date(y!, m! - 1, d) } /** * <uip-event-calendar-dashboard> — the registry EventCalendar block as a web component. * * A faithful month-view port of React's EventCalendar: stats strip, day * click / shift-click range select, side rail (selected day or range * summary), upcoming list, search, skeleton loading, show* toggles, and the * event detail dialog. `events` / `eventTypes` are settable properties * (arrays/objects cross the boundary as properties, not attributes). * * Deviations from React (documented): no drag-to-range (click / * shift-click only — pointer drag across shadow-DOM cells is unreliable), * no per-cell/per-event context menus (uip-context-menu trigger-wrapping * per cell is left to consumers), no headerActions / eventDialog / emptyDay * render-prop slots (use light-DOM composition instead), and * `eventTypes[].icon` is a kebab icon-name string rather than a Lucide * component. Callbacks become kebab CustomEvents: `event-click`, * `event-create` (dateKey), `event-edit` / `event-duplicate` / * `event-delete` (event), `range-change` ({ lo, hi }). */ export class UipEventCalendarDashboard extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: block; }`] static properties = { events: { attribute: false }, eventTypes: { attribute: false }, initialDate: { attribute: 'initial-date' }, weekStartsOn: { type: Number, attribute: 'week-starts-on' }, loading: { type: Boolean }, heading: {}, description: {}, showStats: { type: Boolean, attribute: 'show-stats' }, showSideRail: { type: Boolean, attribute: 'show-side-rail' }, showSearch: { type: Boolean, attribute: 'show-search' }, showViewSelect: { type: Boolean, attribute: 'show-view-select' }, showLegend: { type: Boolean, attribute: 'show-legend' }, showUpcoming: { type: Boolean, attribute: 'show-upcoming' }, view: { state: true }, search: { state: true }, cursorYear: { state: true }, cursorMonth: { state: true }, rangeStart: { state: true }, rangeEnd: { state: true }, dialogOpen: { state: true }, selectedEvent: { state: true }, } events: DashboardCalendarEvent[] = [] eventTypes: Record<string, DashboardEventTypeMeta> = defaultDashboardEventTypes initialDate?: string weekStartsOn: 0 | 1 = 0 loading = false /** React's `title` (`title` is taken by HTMLElement). */ heading = 'Calendar' description = 'Schedule, meetings, and deadlines. Click or shift-click to select a range.' showStats = true showSideRail = true showSearch = true showViewSelect = true showLegend = true showUpcoming = true private view: 'month' | 'week' | 'day' = 'month' private search = '' private cursorYear = 0 private cursorMonth = 0 private rangeStart = '' private rangeEnd = '' private dialogOpen = false private selectedEvent: DashboardCalendarEvent | null = null constructor() { super() new ThemeController(this) const now = new Date() this.cursorYear = now.getFullYear() this.cursorMonth = now.getMonth() } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'event-calendar-dashboard') const base = this.initialDate ? dateFromKey(this.initialDate) : new Date() this.cursorYear = base.getFullYear() this.cursorMonth = base.getMonth() const today = dateKey(new Date()) this.rangeStart = today this.rangeEnd = today } private get todayKey(): string { return dateKey(new Date()) } private get monthLabel(): string { return new Date(this.cursorYear, this.cursorMonth, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric', }) } private get weekdays(): string[] { const base = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] return this.weekStartsOn === 1 ? [...base.slice(1), base[0]!] : base } private get gridDays(): Array<{ key: string; date: Date; inMonth: boolean }> { const first = new Date(this.cursorYear, this.cursorMonth, 1) let offset = first.getDay() if (this.weekStartsOn === 1) offset = (offset + 6) % 7 const start = new Date(this.cursorYear, this.cursorMonth, 1 - offset) return Array.from({ length: 42 }, (_, i) => { const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i) return { key: dateKey(date), date, inMonth: date.getMonth() === this.cursorMonth } }) } private get rangeBounds(): { lo: string; hi: string } { return this.rangeStart <= this.rangeEnd ? { lo: this.rangeStart, hi: this.rangeEnd } : { lo: this.rangeEnd, hi: this.rangeStart } } private get isRange(): boolean { return this.rangeBounds.lo !== this.rangeBounds.hi } private get rangeDayCount(): number { const { lo, hi } = this.rangeBounds return Math.round((dateFromKey(hi).getTime() - dateFromKey(lo).getTime()) / 86_400_000) + 1 } private inRange(key: string): boolean { const { lo, hi } = this.rangeBounds return key >= lo && key <= hi } private metaFor(type: string): DashboardEventTypeMeta { return this.eventTypes[type] ?? NEUTRAL_META } private get typeKeys(): string[] { return Object.keys(this.eventTypes) } private get filtered(): DashboardCalendarEvent[] { const q = this.search.trim().toLowerCase() if (!q) return this.events return this.events.filter( (e) => e.title.toLowerCase().includes(q) || (e.description ?? '').toLowerCase().includes(q) || (e.location ?? '').toLowerCase().includes(q), ) } private get eventsByDate(): Map<string, DashboardCalendarEvent[]> { const map = new Map<string, DashboardCalendarEvent[]>() for (const e of this.filtered) { if (!map.has(e.date)) map.set(e.date, []) map.get(e.date)!.push(e) } for (const arr of map.values()) arr.sort((a, b) => a.start.localeCompare(b.start)) return map } private get monthCounts(): Record<string, number> & { total: number } { const out: Record<string, number> & { total: number } = { total: 0 } for (const k of this.typeKeys) out[k] = 0 for (const e of this.events) { const d = new Date(`${e.date}T00:00:00`) if (d.getFullYear() === this.cursorYear && d.getMonth() === this.cursorMonth) { if (out[e.type] !== undefined) out[e.type]!++ out.total++ } } return out } private get typeStats(): Record<string, { weeks: number[]; next: DashboardCalendarEvent | null }> { const out: Record<string, { weeks: number[]; next: DashboardCalendarEvent | null }> = {} for (const k of this.typeKeys) out[k] = { weeks: [0, 0, 0, 0, 0, 0], next: null } const firstOffset = new Date(this.cursorYear, this.cursorMonth, 1).getDay() for (const e of this.events) { const d = new Date(`${e.date}T00:00:00`) if (d.getFullYear() === this.cursorYear && d.getMonth() === this.cursorMonth && out[e.type]) { const week = Math.floor((d.getDate() - 1 + firstOffset) / 7) out[e.type]!.weeks[week]!++ } } for (const t of this.typeKeys) { out[t]!.next = this.events .filter((e) => e.type === t && e.date >= this.todayKey) .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start))[0] ?? null } return out } private fmtNextDate(key: string): string { if (key === this.todayKey) return 'Today' return dateFromKey(key).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) } private get rangeEvents(): DashboardCalendarEvent[] { const { lo, hi } = this.rangeBounds return this.filtered .filter((e) => e.date >= lo && e.date <= hi) .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start)) } private get selectedDayEvents(): DashboardCalendarEvent[] { return this.eventsByDate.get(this.rangeStart) ?? [] } private get upcoming(): DashboardCalendarEvent[] { return this.filtered .filter((e) => e.date > this.todayKey) .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start)) .slice(0, 4) } private openEvent(e: DashboardCalendarEvent) { this.selectedEvent = e this.dialogOpen = true this.dispatchEvent(new CustomEvent('event-click', { detail: e, bubbles: true, composed: true })) } private prevMonth() { const d = new Date(this.cursorYear, this.cursorMonth - 1, 1) this.cursorYear = d.getFullYear() this.cursorMonth = d.getMonth() } private nextMonth() { const d = new Date(this.cursorYear, this.cursorMonth + 1, 1) this.cursorYear = d.getFullYear() this.cursorMonth = d.getMonth() } private goToToday() { const now = new Date() this.cursorYear = now.getFullYear() this.cursorMonth = now.getMonth() const key = dateKey(now) this.rangeStart = key this.rangeEnd = key this.emitRange() } private clearRange() { this.rangeEnd = this.rangeStart this.emitRange() } private onCellClick(key: string, e: MouseEvent) { if (e.shiftKey) { this.rangeEnd = key } else { this.rangeStart = key this.rangeEnd = key } this.emitRange() } private emitRange() { this.dispatchEvent( new CustomEvent('range-change', { detail: this.rangeBounds, bubbles: true, composed: true }), ) } private fmtDayLong(key: string): string { return dateFromKey(key).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }) } private fmtDayShort(key: string): string { return dateFromKey(key).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) } private initials(name: string): string { return name .split(' ') .map((n) => n[0]) .join('') .slice(0, 2) } private cellRangeClass(key: string, inMonth: boolean): string { const { lo, hi } = this.rangeBounds if (!this.inRange(key)) return inMonth ? 'bg-background hover:bg-accent/40' : 'bg-muted/20 hover:bg-muted/30' if (key === lo && key === hi) return 'bg-accent/30 ring-1 ring-inset ring-primary/60' let cls = 'bg-primary/10 hover:bg-primary/15' if (key === lo) cls += ' ring-1 ring-inset ring-primary/60' if (key === hi) cls += ' ring-1 ring-inset ring-primary/60' return cls } render() { const monthCounts = this.monthCounts const typeStats = this.typeStats const gridDays = this.gridDays const eventsByDate = this.eventsByDate const todayKey = this.todayKey const rangeEvents = this.rangeEvents const selectedDayEvents = this.selectedDayEvents const upcoming = this.upcoming const rangeTypeCounts: Record<string, number> = {} for (const k of this.typeKeys) rangeTypeCounts[k] = 0 for (const e of rangeEvents) { if (rangeTypeCounts[e.type] !== undefined) rangeTypeCounts[e.type]!++ } return html` <div data-slot="event-calendar-dashboard" class="flex flex-col gap-5"> <header class="flex flex-wrap items-end justify-between gap-4"> <div> <h1 class="text-2xl font-semibold tracking-tight">${this.heading}</h1> ${this.description ? html`<p class="text-muted-foreground mt-1 text-sm">${this.description}</p>` : nothing} </div> <div class="flex items-center gap-2"> ${this.showSearch ? html`<div class="relative"> <uip-input .value=${this.search} placeholder="Search events" class="h-8 w-56 text-xs" @input=${(e: Event) => { this.search = (e.target as UipInput).value }} > <span slot="prefix">${icon(Search, 'search', 'size-3.5')}</span> </uip-input> </div>` : nothing} ${this.showViewSelect ? html`<uip-select value=${this.view} class="h-8 w-24 text-xs" @change=${(e: Event) => { this.view = (e.target as UipSelect).value as 'month' | 'week' | 'day' }} > <option value="month">Month</option> <option value="week">Week</option> <option value="day">Day</option> </uip-select>` : nothing} <uip-button size="sm" class="gap-1.5" @click=${() => this.dispatchEvent(new CustomEvent('event-create', { detail: this.rangeStart, bubbles: true, composed: true }))} > ${icon(Plus, 'plus', 'size-3.5')} New event </uip-button> </div> </header> ${this.showStats ? html` <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"> ${this.typeKeys.map((key) => { const meta = this.eventTypes[key]! const searched = meta.label.toLowerCase() === this.search.toLowerCase() return html` <button type="button" class=${[ 'group bg-card hover:bg-muted/40 relative isolate flex flex-col overflow-hidden rounded-xl border p-4 text-left ring-1 ring-transparent transition-[colors,box-shadow,transform] duration-150 ring-inset hover:shadow-xs active:scale-[0.98]', meta.ring, ].join(' ')} @click=${() => { this.search = searched ? '' : meta.label.toLowerCase() }} > ${typeIcon( meta.icon, [ 'pointer-events-none absolute -top-4 -right-4 size-24 opacity-[0.06] transition-opacity group-hover:opacity-[0.12]', meta.text, ].join(' '), )} <div class="flex items-center justify-between gap-2"> <div class="flex items-center gap-1.5"> <span class=${['size-1.5 rounded-full', meta.dot].join(' ')}></span> <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">${meta.label}</p> </div> <div class=${['flex size-7 items-center justify-center rounded-md ring-1 ring-inset', meta.chip].join(' ')} > ${typeIcon(meta.icon, 'size-3.5')} </div> </div> <div class="mt-2 flex items-baseline gap-1.5"> <p class="text-3xl leading-none font-semibold tabular-nums">${monthCounts[key] ?? 0}</p> <p class="text-muted-foreground text-xs tracking-wider uppercase">this month</p> </div> <div class="mt-3 flex h-6 items-end gap-1"> ${typeStats[key]!.weeks.slice(0, 6).map( (n, i) => html` <span class=${[ 'flex-1 rounded-sm transition-colors', n > 0 ? meta.bar : 'bg-muted/40', n > 0 ? 'opacity-70 group-hover:opacity-100' : '', ].join(' ')} style="height: ${n > 0 ? Math.min(100, 30 + n * 35) : 20}%" title="Week ${i + 1}: ${n} ${meta.label.toLowerCase()}${n === 1 ? '' : 's'}" ></span> `, )} </div> <div class="border-border/50 mt-3 min-h-[2.25rem] border-t pt-2"> ${typeStats[key]!.next ? html` <p class="text-muted-foreground text-xs tracking-wider uppercase"> Next <span class=${meta.text}>${this.fmtNextDate(typeStats[key]!.next!.date)}</span> </p> <p class="mt-0.5 truncate text-xs font-medium">${typeStats[key]!.next!.title}</p> ` : html` <p class="text-muted-foreground text-xs tracking-wider uppercase">No upcoming</p> <p class="text-muted-foreground/70 mt-0.5 text-xs">All clear</p> `} </div> </button> ` })} </div> ` : nothing} <div class=${['grid gap-4', this.showSideRail ? 'lg:grid-cols-[minmax(0,1fr)_320px]' : ''].join(' ')}> <div class="bg-card/40 overflow-hidden rounded-xl border"> <div class="bg-muted/30 flex flex-wrap items-center justify-between gap-3 border-b px-4 py-2.5"> <div class="flex items-center gap-2"> <uip-button variant="outline" size="icon" class="size-7" aria-label="Previous month" @click=${this.prevMonth}> ${icon(ChevronLeft, 'chevron-left', 'size-4')} </uip-button> <uip-button variant="outline" size="icon" class="size-7" aria-label="Next month" @click=${this.nextMonth}> ${icon(ChevronRight, 'chevron-right', 'size-4')} </uip-button> <uip-button variant="ghost" size="sm" class="h-7 text-xs" @click=${this.goToToday}>Today</uip-button> <h2 class="ml-2 text-sm font-semibold">${this.monthLabel}</h2> </div> <div class="text-muted-foreground flex items-center gap-3 text-xs"> ${this.isRange ? html` <div class="bg-primary/10 text-primary ring-primary/20 flex items-center gap-1.5 rounded-full px-2 py-0.5 ring-1 ring-inset" > ${icon(MousePointer2, 'mouse-pointer-2', 'size-3')} <span> ${this.rangeDayCount} days, ${rangeEvents.length} events </span> <button class="hover:text-foreground ml-0.5" aria-label="Clear date range" @click=${this.clearRange}> ${icon(X, 'x', 'size-3')} </button> </div> ` : nothing} <div class="flex items-center gap-1.5"> ${icon(CalendarDays, 'calendar-days', 'size-3')} <span> ${monthCounts.total} event${monthCounts.total === 1 ? '' : 's'} this month </span> </div> </div> </div> <div class="bg-muted/10 text-muted-foreground grid grid-cols-7 border-b text-xs tracking-wider uppercase"> ${this.weekdays.map((w) => html`<div class="px-2 py-2 font-medium">${w}</div>`)} </div> <div class="grid grid-cols-7 select-none"> ${this.loading ? Array.from({ length: 42 }).map( () => html` <div class="h-24 border-r border-b p-1.5 last:border-r-0"> <uip-skeleton class="h-3 w-6"></uip-skeleton> <uip-skeleton class="mt-2 h-3 w-full"></uip-skeleton> </div> `, ) : gridDays.map( (d, i) => html` <button type="button" class=${[ 'group focus-visible:ring-ring relative flex h-24 flex-col gap-1 border-r border-b p-1.5 text-left transition-colors focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none', (i + 1) % 7 === 0 ? 'border-r-0' : '', i >= 35 ? 'border-b-0' : '', this.cellRangeClass(d.key, d.inMonth), !d.inMonth && !this.inRange(d.key) ? 'text-muted-foreground/60' : '', ].join(' ')} @click=${(e: MouseEvent) => this.onCellClick(d.key, e)} > <div class="flex items-center justify-between"> <span class=${[ 'inline-flex size-5 items-center justify-center rounded-full text-xs tabular-nums', d.key === todayKey ? 'bg-primary text-primary-foreground font-semibold' : '', d.key !== todayKey && d.inMonth ? 'text-foreground' : '', !d.inMonth ? 'text-muted-foreground/60' : '', ].join(' ')} > ${d.date.getDate()} </span> ${(eventsByDate.get(d.key)?.length ?? 0) > 0 ? html`<span class="text-muted-foreground text-xs tabular-nums" >${eventsByDate.get(d.key)!.length}</span >` : nothing} </div> <div class="flex flex-col gap-0.5"> ${(eventsByDate.get(d.key) ?? []).slice(0, 2).map( (e) => html` <span class=${[ 'flex cursor-pointer items-center gap-1 truncate rounded px-1 py-0.5 text-xs ring-1 ring-inset', this.metaFor(e.type).chip, ].join(' ')} @click=${(ev: Event) => { ev.stopPropagation() this.openEvent(e) }} > <span class="tabular-nums opacity-70">${e.start}</span> <span class="truncate">${e.title}</span> </span> `, )} ${(eventsByDate.get(d.key)?.length ?? 0) > 2 ? html`<span class="text-muted-foreground px-1 text-xs" >+${eventsByDate.get(d.key)!.length - 2} more</span >` : nothing} </div> </button> `, )} </div> ${this.showLegend ? html` <div class="bg-muted/20 text-muted-foreground flex flex-wrap items-center gap-3 border-t px-4 py-2 text-xs"> ${icon(ListFilter, 'list-filter', 'size-3')} ${this.typeKeys.map( (key) => html` <div class="flex items-center gap-1.5"> <span class=${['size-2 rounded-full', this.eventTypes[key]!.dot].join(' ')}></span> ${this.eventTypes[key]!.label} </div> `, )} <span class="ml-auto text-xs">Tip: click or shift-click to range. Click an event for details.</span> </div> ` : nothing} </div> ${this.showSideRail ? html` <aside class="flex flex-col gap-4"> ${!this.isRange ? html` <div class="bg-card/40 overflow-hidden rounded-xl border"> <div class="bg-muted/30 border-b px-4 py-3"> <div class="flex items-center justify-between"> <div> <p class="text-muted-foreground text-xs tracking-wider uppercase"> ${this.rangeStart === todayKey ? 'Today' : 'Selected'} </p> <p class="mt-0.5 text-sm font-semibold">${this.fmtDayLong(this.rangeStart)}</p> </div> <div class="text-right"> <p class="text-muted-foreground text-xs tracking-wider uppercase">Events</p> <p class="text-sm font-semibold tabular-nums">${selectedDayEvents.length}</p> </div> </div> </div> <div class="max-h-[420px] overflow-y-auto p-3"> ${this.loading ? Array.from({ length: 3 }).map( () => html` <div class="mb-2 space-y-2 rounded-lg border p-3"> <uip-skeleton class="h-3 w-32"></uip-skeleton> <uip-skeleton class="h-2 w-20"></uip-skeleton> </div> `, ) : selectedDayEvents.length === 0 ? html` <div class="flex flex-col items-center justify-center gap-2 py-8 text-center"> <div class="bg-muted flex size-10 items-center justify-center rounded-full"> ${icon(CalendarDays, 'calendar-days', 'text-muted-foreground size-5')} </div> <p class="text-sm font-medium">Nothing scheduled</p> <p class="text-muted-foreground text-xs">Click a date or add a new event.</p> <uip-button size="sm" variant="outline" class="mt-1 h-7 gap-1.5 text-xs" @click=${() => this.dispatchEvent( new CustomEvent('event-create', { detail: this.rangeStart, bubbles: true, composed: true }), )} > ${icon(Plus, 'plus', 'size-3')} New event </uip-button> </div> ` : selectedDayEvents.map( (e) => html` <div class="group hover:bg-accent/50 relative flex cursor-pointer gap-3 rounded-lg p-2.5 transition-colors" @click=${() => this.openEvent(e)} > <div class=${['w-0.5 shrink-0 rounded-full', this.metaFor(e.type).bar].join(' ')}></div> <div class="min-w-0 flex-1 space-y-1"> <div class="flex items-start justify-between gap-2"> <p class="text-sm leading-tight font-medium">${e.title}</p> <span class=${[ 'shrink-0 rounded px-1.5 py-0.5 text-xs tracking-wider uppercase ring-1 ring-inset', this.metaFor(e.type).chip, ].join(' ')} > ${this.metaFor(e.type).label} </span> </div> <div class="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs"> <span class="inline-flex items-center gap-1"> ${icon(Clock, 'clock', 'size-3')} ${e.start}-${e.end} </span> ${e.location ? html`<span class="inline-flex items-center gap-1"> ${icon(MapPin, 'map-pin', 'size-3')} ${e.location} </span>` : nothing} </div> ${e.attendees?.length ? html`<div class="flex items-center -space-x-1.5 pt-0.5"> ${e.attendees.slice(0, 4).map( (a) => html` <uip-avatar class="border-background size-5 border-2"> <uip-avatar-fallback class="bg-primary/10 text-primary text-xs"> ${this.initials(a)} </uip-avatar-fallback> </uip-avatar> `, )} ${e.attendees.length > 4 ? html`<span class="text-muted-foreground pl-2 text-xs"> +${e.attendees.length - 4} </span>` : nothing} </div>` : nothing} </div> </div> `, )} </div> </div> ` : html` <div class="bg-card/40 overflow-hidden rounded-xl border"> <div class="from-primary/10 border-b bg-gradient-to-r to-transparent px-4 py-3"> <div class="flex items-center justify-between gap-3"> <div class="min-w-0"> <p class="text-muted-foreground text-xs tracking-wider uppercase"> Range, ${this.rangeDayCount} days </p> <p class="mt-0.5 truncate text-sm font-semibold"> ${this.fmtDayShort(this.rangeBounds.lo)} -> ${this.fmtDayShort(this.rangeBounds.hi)} </p> </div> <uip-button variant="ghost" size="icon" class="size-7" aria-label="Clear date range" @click=${this.clearRange} > ${icon(X, 'x', 'size-3.5')} </uip-button> </div> <div class="mt-3 grid gap-2 grid-cols-4"> ${this.typeKeys.map( (key) => html` <div class="bg-background/40 rounded-md border px-2 py-1.5"> <div class="text-muted-foreground flex items-center gap-1 text-xs tracking-wider uppercase"> <span class=${['size-1.5 rounded-full', this.eventTypes[key]!.dot].join(' ')}></span> ${this.eventTypes[key]!.label} </div> <p class="mt-0.5 text-sm font-semibold tabular-nums">${rangeTypeCounts[key] ?? 0}</p> </div> `, )} </div> </div> <div class="max-h-[420px] overflow-y-auto"> ${rangeEvents.length === 0 ? html`<p class="text-muted-foreground px-4 py-8 text-center text-xs"> No events in range. </p>` : rangeEvents.map( (e) => html` <div class="hover:bg-accent/40 flex cursor-pointer items-start gap-3 border-b px-3 py-2.5 transition-colors last:border-b-0" @click=${() => this.openEvent(e)} > <div class="bg-background/60 flex w-10 shrink-0 flex-col items-center rounded-md border px-1 py-1 text-center" > <span class="text-muted-foreground text-xs uppercase"> ${new Date(`${e.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short' })} </span> <span class="text-sm leading-none font-semibold tabular-nums"> ${new Date(`${e.date}T00:00:00`).getDate()} </span> </div> <div class=${['w-0.5 shrink-0 self-stretch rounded-full', this.metaFor(e.type).bar].join(' ')} ></div> <div class="min-w-0 flex-1"> <p class="truncate text-xs font-medium">${e.title}</p> <p class="text-muted-foreground mt-0.5 text-xs tabular-nums"> ${e.start}-${e.end}${e.location ? html`<span> ${e.location}</span>` : nothing} </p> </div> <span class=${[ 'shrink-0 rounded px-1.5 py-0.5 text-xs tracking-wider uppercase ring-1 ring-inset', this.metaFor(e.type).chip, ].join(' ')} > ${this.metaFor(e.type).label} </span> </div> `, )} </div> </div> `} ${this.showUpcoming ? html` <div class="bg-card/40 overflow-hidden rounded-xl border"> <div class="bg-muted/30 border-b px-4 py-2.5"> <p class="text-sm font-semibold">Up next</p> <p class="text-muted-foreground text-xs">After today</p> </div> <div class="divide-y"> ${this.loading ? Array.from({ length: 3 }).map( () => html` <div class="flex items-center gap-3 p-3"> <uip-skeleton class="size-9 rounded-md"></uip-skeleton> <div class="flex-1 space-y-1"> <uip-skeleton class="h-3 w-32"></uip-skeleton> <uip-skeleton class="h-2 w-20"></uip-skeleton> </div> </div> `, ) : upcoming.length === 0 ? html`<p class="text-muted-foreground px-4 py-6 text-center text-xs"> Nothing on the horizon. </p>` : upcoming.map( (e) => html` <button type="button" class="group hover:bg-accent/40 flex w-full items-center gap-3 p-3 text-left transition-colors" @click=${() => this.openEvent(e)} > <div class="bg-background flex size-10 shrink-0 flex-col items-center justify-center rounded-md border text-center" > <span class="text-muted-foreground text-xs uppercase"> ${new Date(`${e.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short' })} </span> <span class="text-sm leading-none font-semibold tabular-nums"> ${new Date(`${e.date}T00:00:00`).getDate()} </span> </div> <div class="min-w-0 flex-1"> <div class="flex items-center gap-1.5"> <span class=${['size-1.5 rounded-full', this.metaFor(e.type).dot].join(' ')}></span> <p class="truncate text-xs font-medium">${e.title}</p> </div> <p class="text-muted-foreground mt-0.5 text-xs tabular-nums"> ${e.start}-${e.end}${e.location ? html`<span> ${e.location}</span>` : nothing} </p> </div> </button> `, )} </div> </div> ` : nothing} </aside> ` : nothing} </div> <uip-dialog ?open=${this.dialogOpen} heading=${this.selectedEvent?.title ?? ''} description=${this.selectedEvent ? `${this.metaFor(this.selectedEvent.type).label}${this.selectedEvent.status ? ` ${this.selectedEvent.status}` : ''}` : ''} @open-change=${(e: CustomEvent<{ open: boolean }>) => { this.dialogOpen = e.detail.open }} > ${this.selectedEvent ? html` <div class="space-y-3 py-2"> ${this.selectedEvent.description ? html`<p class="text-muted-foreground text-sm">${this.selectedEvent.description}</p>` : nothing} <uip-separator></uip-separator> <div class="grid gap-2 text-sm"> <div class="flex items-center gap-2"> ${icon(Clock, 'clock', 'text-muted-foreground size-4')} <span> ${this.selectedEvent.date} ${this.selectedEvent.start} - ${this.selectedEvent.end} </span> </div> ${this.selectedEvent.location ? html`<div class="flex items-center gap-2"> ${icon(MapPin, 'map-pin', 'text-muted-foreground size-4')} <span>${this.selectedEvent.location}</span> </div>` : nothing} ${this.selectedEvent.attendees?.length ? html`<div class="flex items-start gap-2"> ${icon(Users, 'users', 'text-muted-foreground mt-0.5 size-4')} <div class="flex flex-wrap items-center gap-1.5"> ${this.selectedEvent.attendees.map( (a) => html` <div class="bg-muted flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs"> <uip-avatar class="size-4"> <uip-avatar-fallback class="bg-primary/10 text-primary text-xs"> ${this.initials(a)} </uip-avatar-fallback> </uip-avatar> <span>${a}</span> </div> `, )} </div> </div>` : nothing} </div> </div> <div slot="footer"> <uip-button variant="outline" size="sm" @click=${() => { this.dispatchEvent( new CustomEvent('event-edit', { detail: this.selectedEvent, bubbles: true, composed: true }), ) this.dialogOpen = false }} > ${icon(Pencil, 'pencil', 'size-3.5')} Edit </uip-button> <uip-button size="sm" variant="destructive" @click=${() => { this.dispatchEvent( new CustomEvent('event-delete', { detail: this.selectedEvent, bubbles: true, composed: true }), ) this.dialogOpen = false }} > ${icon(Trash2, 'trash-2', 'size-3.5')} Cancel event </uip-button> </div> ` : nothing} </uip-dialog> </div> ` } } customElements.get('uip-event-calendar-dashboard') || customElements.define('uip-event-calendar-dashboard', UipEventCalendarDashboard) declare global { interface HTMLElementTagNameMap { 'uip-event-calendar-dashboard': UipEventCalendarDashboard } }
Raw manifest:https://uipkge.dev/r/lit/event-calendar-dashboard.json