Week Schedule
A calendar week: seven days on an hour grid, with events placed and sized by their times, an all-day row and a line at the current time.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/week-schedule.jsonUsage
import { WeekSchedule, type WeekScheduleEvent } from "@/components/ui/week-schedule"Examples
Default
Click an event for its details in a popover beside it. Pass onWeekChange for Today, previous and next. Below 768px of its own width it becomes an agenda list by day.
October 2026
Sunday, October 4
Monday, October 5
Tuesday, October 6
TodayWednesday, October 7
Thursday, October 8
Friday, October 9
Saturday, October 10
Sun4
Mon5
Tue6
Wed7
Thu8
Fri9
Sat10
All day
1 AM2 AM3 AM4 AM5 AM6 AM7 AM8 AM9 AM10 AM11 AM12 PM1 PM2 PM3 PM4 PM5 PM6 PM7 PM8 PM9 PM10 PM11 PM
Working hours
A Monday start and an 8am to 7pm grid, which fits without scrolling. Events outside the hours are cut at the edge.
October 2026
Monday, October 5
Tuesday, October 6
TodayWednesday, October 7
Thursday, October 8
Friday, October 9
Saturday, October 10
Sunday, October 11
Nothing scheduled
Mon5
Tue6
Wed7
Thu8
Fri9
Sat10
Sun11
All day
9 AM10 AM11 AM12 PM1 PM2 PM3 PM4 PM5 PM6 PM
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| week* | Date | - | Any date inside the week to show. |
| events* | WeekScheduleEvent[] | - | { id, title, start, end?, allDay?, meta?, description?, tone? }. description shows only in the event's popover. A timed event's end defaults to 30 minutes after start; an all-day event's end is its last day, inclusive. tone is a category tint (accent, success, warning, destructive, neutral), so put any meaning it carries in the title or meta too. |
| weekStartsOn | 0 to 6 | 0 | First day of the week. 0 is Sunday, 1 is Monday. |
| startHour | number | 0 | First hour on the grid. |
| endHour | number | 24 | Hour the grid stops at. |
| scrollToHour | number | 8 | Hour scrolled to the top when the grid opens. |
| now | Date | - | Pins the current time. Leave it out to follow the clock; the today marker and now line appear after load. |
| locale | string | "en-US" | Formats day names and times. |
| timeZone | string | - | The zone to draw the week in, e.g. "America/New_York". Pass it on any server-rendered page: without it the server draws in its own zone and the browser in the viewer's, and the first render is thrown away. |
| selectedId | string | - | Marks the event whose detail you have open elsewhere, such as your own Sheet. An accent ring, so selecting moves nothing. |
| onEventClick | (event) => void | - | Called with an event when it is clicked, alongside its popover. |
| eventDetails | boolean | true | Clicking an event opens a popover beside it with its time, meta and description. Set it to false to open your own Sheet from onEventClick; with neither, events are plain content. |
| onWeekChange | (week: Date) => void | - | Adds Today, previous and next to the header, and is called with a date in the new week. |
| label | string | - | Names the week for screen readers. Defaults to "Week of" and the first day. |
| labels | Partial<{ today, allDay, empty, previousWeek, nextWeek, weekOf, to }> | - | The component's own words, for a page in another language. Pair with locale, which formats the dates and times. |
"use client"
import * as React from "react"
import { IconChevronLeft as ChevronLeft, IconChevronRight as ChevronRight } from "@tabler/icons-react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/registry/lib/utils"
import { Badge } from "@/registry/ui/badge"
import { Button } from "@/registry/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/registry/ui/popover"
import { ScrollFade } from "@/registry/ui/scroll-fade"
type WeekScheduleTone = "accent" | "success" | "warning" | "destructive" | "neutral"
type WeekScheduleEvent = {
id: string
title: string
start: Date
/** Timed events: when it ends (defaults to 30 minutes after `start`).
* All-day events: the LAST day it covers, inclusive (defaults to `start`). */
end?: Date
allDay?: boolean
/** One quiet line under the title, e.g. a place or a channel. */
meta?: React.ReactNode
/** Longer detail, shown in the event's popover: an agenda, notes, a link. */
description?: React.ReactNode
tone?: WeekScheduleTone
}
// The same tone pairs as Badge, so an event and a status badge of one tone
// read as the same colour, and each clears 4.5:1 for its own text.
const TONE: Record<WeekScheduleTone, string> = {
accent: "bg-accent-100 dark:bg-accent-800 text-accent-700 dark:text-accent-300",
success: "bg-success-100 dark:bg-success-900 text-success-700 dark:text-success-300",
warning: "bg-warning-100 dark:bg-warning-900 text-warning-800 dark:text-warning-300",
destructive: "bg-destructive-100 dark:bg-destructive-900 text-destructive-700 dark:text-destructive-300",
// inset-ring, not shadow-ring-flat: an outer ring paints 1px past the box,
// into the gap that separates the block from the hour line above it.
neutral: "bg-muted text-foreground inset-ring inset-ring-(--shadow-ring)",
}
// In the grid an event is a filled block, so hover darkens the fill and
// selected is an accent ring on its edge.
const GRID_STATES =
"data-[interactive]:hover:brightness-95 dark:data-[interactive]:hover:brightness-110 data-[selected]:ring-2 data-[selected]:ring-accent data-[popup-open]:ring-2 data-[popup-open]:ring-accent"
const DOT: Record<WeekScheduleTone, string> = {
accent: "bg-accent-500",
success: "bg-success-500",
warning: "bg-warning-500",
destructive: "bg-destructive-500",
neutral: "bg-subtle-foreground",
}
// One hour of grid, in rem. Every vertical position is a fraction of the day's
// height, so a 30 minute event is half an hour row at any root size.
const HOUR_REM = 3
const MINUTE = 60_000
const DAY = 24 * 60 * MINUTE
// Every date is first turned into WALL time: what a clock in the chosen zone
// reads at that instant, stored as if that reading were UTC. Day boundaries,
// weekdays and positions are then plain UTC arithmetic, which has no DST, and
// the labels are formatted with timeZone "UTC" so they print the same reading.
// That is what lets a server in UTC and a browser in New York draw the same
// week: both read the clock of the zone they were given, not their own.
function makeClock(timeZone: string | undefined) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
})
function wall(d: Date) {
const p: Record<string, string> = {}
for (const part of parts.formatToParts(d)) p[part.type] = part.value
return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second)
}
return {
wall,
/** Wall time back to a real instant, through the zone's offset at that time. */
instant: (w: number) => new Date(w - (wall(new Date(w)) - w)),
}
}
function dayOf(w: number) {
return Math.floor(w / DAY) * DAY
}
function eventEnd(e: WeekScheduleEvent) {
return e.end ?? new Date(e.start.getTime() + 30 * MINUTE)
}
type Placed = { event: WeekScheduleEvent; top: number; bottom: number; lane: number; lanes: number; left: number; width: number }
// Lays out one day's timed events, in minutes from midnight. Events that
// overlap, directly or through a chain, form a cluster; each takes the first
// lane free at its start. Then they cascade the way a desktop calendar does:
// an event starts at its lane but runs to the right edge, and a later one is
// drawn on top of it, so the earlier title stays readable above the overlap
// instead of being squeezed into a sliver. Two events that start within half
// an hour of each other would cover each other's titles, so those keep to
// their own lane. An event crossing midnight is clipped into each day it touches.
function placeDay(
events: { event: WeekScheduleEvent; start: number; end: number }[],
dayStart: number,
startHour: number,
endHour: number
): Placed[] {
const from = startHour * 60
const to = endHour * 60
const items: Placed[] = events
.map(({ event, start, end }) => ({
event,
top: Math.max((start - dayStart) / MINUTE, from),
end: Math.min((end - dayStart) / MINUTE, to),
}))
.filter((p) => p.top < to && p.end > from && p.top < p.end)
// A very short event still needs a line of text, so it draws at 30 minutes.
.map(({ event, top, end }) => ({ event, top, bottom: Math.min(Math.max(end, top + 30), to), lane: 0, lanes: 1, left: 0, width: 100 }))
.sort((a, b) => a.top - b.top || b.bottom - a.bottom)
let cluster: Placed[] = []
let laneEnds: number[] = []
let clusterEnd = -Infinity
const close = () => {
const lanes = laneEnds.length
for (const p of cluster) {
p.lanes = lanes
p.left = (p.lane / lanes) * 100
const crowded = cluster.some(
(o) => o.lane > p.lane && o.top < p.bottom && Math.abs(o.top - p.top) < 30
)
p.width = crowded ? 100 / lanes : 100 - p.left
}
cluster = []
laneEnds = []
}
for (const p of items) {
if (p.top >= clusterEnd) close()
let lane = laneEnds.findIndex((end) => end <= p.top)
if (lane === -1) lane = laneEnds.push(0) - 1
laneEnds[lane] = p.bottom
p.lane = lane
cluster.push(p)
clusterEnd = Math.max(clusterEnd, p.bottom)
}
close()
return items
}
// The live clock, ticking each minute. Undefined until mount so the server and
// the first client render agree; the today marker and now line arrive after.
function useLiveNow(enabled: boolean) {
const [now, setNow] = React.useState<Date>()
React.useEffect(() => {
if (!enabled) return
setNow(new Date())
const id = window.setInterval(() => setNow(new Date()), MINUTE)
return () => window.clearInterval(id)
}, [enabled])
return now
}
const DEFAULT_LABELS = {
today: "Today",
allDay: "All day",
empty: "Nothing scheduled",
previousWeek: "Previous week",
nextWeek: "Next week",
weekOf: "Week of",
to: "to",
}
// @use-when a calendar week: seven days side by side on an hour grid, with
// events placed and sized by their times, an all-day row and a now line. A
// meeting calendar, a content calendar, bookings or shifts.
function WeekSchedule({
week,
events,
weekStartsOn = 0,
startHour = 0,
endHour = 24,
scrollToHour = 8,
now: nowProp,
locale = "en-US",
timeZone,
selectedId,
onEventClick,
eventDetails = true,
onWeekChange,
label,
labels,
className,
...props
}: Omit<React.ComponentProps<"section">, "children"> & {
/** Any date inside the week to show. */
week: Date
events: WeekScheduleEvent[]
/** 0 is Sunday, 1 is Monday. */
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
/** First hour on the grid. */
startHour?: number
/** Hour the grid stops at. */
endHour?: number
/** Scrolled to the top on mount, so a 24 hour grid opens on the working day. */
scrollToHour?: number
/** Pins "now" for docs and tests. Leave it out to follow the clock. */
now?: Date
/** Formats day names and times. */
locale?: string
/** The IANA zone to draw the week in, e.g. "America/New_York". Pass it on any
* server-rendered page: without it the server draws in its own zone and the
* browser in the viewer's, and the two disagree. */
timeZone?: string
/** Marks an event as the one whose detail is open elsewhere. */
selectedId?: string
/** Called with an event when it is clicked, alongside its popover. */
onEventClick?: (event: WeekScheduleEvent) => void
/** Clicking an event opens a popover beside it with its time, meta and
* description. Turn it off to open your own Sheet from `onEventClick`. */
eventDetails?: boolean
/** Pass it and the header gets Today, previous and next. Called with a date in the new week. */
onWeekChange?: (week: Date) => void
/** Names the week for screen readers. Defaults to "Week of" and the first day. */
label?: string
/** The component's own words, for a page in another language. */
labels?: Partial<typeof DEFAULT_LABELS>
}) {
const t = { ...DEFAULT_LABELS, ...labels }
const liveNow = useLiveNow(nowProp == null)
const now = nowProp ?? liveNow
const gridRef = React.useRef<HTMLDivElement>(null)
const markRef = React.useRef<HTMLDivElement>(null)
const interactive = eventDetails || onEventClick != null
// The popover for one event: what it is, when, and anything else it carries.
// Contextual detail tied to one thing is a popover anchored to that thing,
// not a page-level dialog: the calendar stays in view and usable around it.
const detail = (e: WeekScheduleEvent) =>
eventDetails ? (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<PopoverPrimitive.Title className="text-sm leading-snug font-semibold text-pretty">{e.title}</PopoverPrimitive.Title>
<p className="text-xs text-muted-foreground">{range(e)}</p>
</div>
{e.meta != null && (
<p className="flex items-center gap-2 text-xs text-subtle-foreground">
<span aria-hidden className={cn("size-2 shrink-0 rounded-full", DOT[e.tone ?? "accent"])} />
{e.meta}
</p>
)}
{e.description != null && (
<PopoverPrimitive.Description render={<div />} className="text-xs text-muted-foreground text-pretty">
{e.description}
</PopoverPrimitive.Description>
)}
</div>
) : undefined
const clock = React.useMemo(() => makeClock(timeZone), [timeZone])
const weekDay = dayOf(clock.wall(week))
const first = weekDay - ((new Date(weekDay).getUTCDay() - weekStartsOn + 7) % 7) * DAY
const days = Array.from({ length: 7 }, (_, i) => first + i * DAY)
const last = days[6]
const today = now == null ? undefined : dayOf(clock.wall(now))
const hours = Array.from({ length: endHour - startHour }, (_, i) => startHour + i)
const span = (endHour - startHour) * 60
// Every formatter reads the wall time back in UTC, see makeClock.
const fmt = React.useMemo(() => {
const make = (o: Intl.DateTimeFormatOptions) => {
const f = new Intl.DateTimeFormat(locale, { ...o, timeZone: "UTC" })
return { format: (w: number) => f.format(new Date(w)) }
}
return {
weekday: make({ weekday: "short" }),
dayNumber: make({ day: "numeric" }),
fullDay: make({ weekday: "long", month: "long", day: "numeric" }),
month: make({ month: "long", year: "numeric" }),
shortMonth: make({ month: "short" }),
hour: make({ hour: "numeric" }),
time: make({ hour: "numeric", minute: "2-digit" }),
}
}, [locale])
// All-day events as column ranges inside this week, clipped at its edges.
const allDay = events
.filter((e) => e.allDay)
.map((event) => ({
event,
from: (dayOf(clock.wall(event.start)) - first) / DAY,
to: (dayOf(clock.wall(event.end ?? event.start)) - first) / DAY,
}))
.filter((a) => a.from <= 6 && a.to >= 0 && a.from <= a.to)
.map((a) => ({ ...a, from: Math.max(a.from, 0), to: Math.min(a.to, 6) }))
.sort((a, b) => a.from - b.from || b.to - a.to)
const timed = events
.filter((e) => !e.allDay)
.map((event) => ({ event, start: clock.wall(event.start), end: clock.wall(eventEnd(event)) }))
const byDay = days.map((day, i) => ({
day,
today: day === today,
allDay: allDay.filter((a) => a.from <= i && a.to >= i).map((a) => a.event),
placed: placeDay(timed, day, startHour, endHour),
}))
// What a screen reader hears after the title. Read in full here because the
// block itself only has room to show the start.
const range = (e: WeekScheduleEvent) => {
const start = clock.wall(e.start)
if (!e.allDay) return `${fmt.fullDay.format(start)}, ${fmt.time.format(start)} ${t.to} ${fmt.time.format(clock.wall(eventEnd(e)))}`
const end = clock.wall(e.end ?? e.start)
return dayOf(start) === dayOf(end)
? `${fmt.fullDay.format(start)}, ${t.allDay}`
: `${fmt.fullDay.format(start)} ${t.to} ${fmt.fullDay.format(end)}, ${t.allDay}`
}
// Opens the grid on the working day. The sticky header keeps its place in
// the flow, so the hour line's offset inside the body is already the scroll
// that puts it just under the header; the half rung up clears its label.
React.useLayoutEffect(() => {
const el = gridRef.current?.querySelector<HTMLElement>('[data-slot="scroll-fade-viewport"]')
const mark = markRef.current
if (!el || !mark) return
el.scrollTop = Math.max(mark.offsetTop - 8, 0)
}, [scrollToHour, startHour])
// A week across two months names both.
const title =
new Date(first).getUTCMonth() === new Date(last).getUTCMonth()
? fmt.month.format(first)
: `${fmt.shortMonth.format(first)} ${t.to} ${fmt.month.format(last)}`
// The hour column is 14 spacing units, the same `start-14` the hour lines
// are inset by, so the two cannot drift; the days share what is left.
const columns = { gridTemplateColumns: "calc(var(--spacing) * 14) repeat(7, minmax(0, 1fr))" }
return (
<section
data-slot="week-schedule"
aria-label={label ?? `${t.weekOf} ${fmt.fullDay.format(first)}`}
className={cn(
"@container/week-schedule flex w-full min-w-0 flex-col overflow-hidden rounded-lg bg-card text-card-foreground shadow-ring-md",
className
)}
{...props}
>
{/* flex-wrap: in a narrow card the buttons drop under the title instead
of running past the card's edge, where overflow-hidden would cut them. */}
<div className="flex min-h-14 flex-wrap items-center justify-between gap-x-3 gap-y-2 border-b border-border px-4 py-2">
<h2 className="text-sm leading-snug font-semibold text-pretty">{title}</h2>
{onWeekChange && (
<div className="flex items-center gap-1">
{/* One set of peers, so one size and one variant. */}
<Button variant="outline" onClick={() => onWeekChange(now ?? new Date())}>
{t.today}
</Button>
<Button variant="outline" size="icon" aria-label={t.previousWeek} onClick={() => onWeekChange(clock.instant(first - 7 * DAY + 12 * 60 * MINUTE))}>
<ChevronLeft />
</Button>
<Button variant="outline" size="icon" aria-label={t.nextWeek} onClick={() => onWeekChange(clock.instant(first + 7 * DAY + 12 * 60 * MINUTE))}>
<ChevronRight />
</Button>
</div>
)}
</div>
{/* Narrow: an agenda, one block per day. Seven hour columns cannot fit a
phone, and a list answers the same question in reading order. */}
<ol className="flex flex-col divide-y divide-border @3xl/week-schedule:hidden">
{byDay.map(({ day, today, allDay: allDayEvents, placed }) => {
const list = [...allDayEvents, ...placed.map((p) => p.event)]
return (
<li key={day} aria-current={today ? "date" : undefined} className="flex flex-col gap-2 px-4 py-3">
<div className="flex min-h-6 items-center gap-2">
<h3 className="text-xs leading-snug font-semibold">
{fmt.fullDay.format(day)}
</h3>
{today && <Badge size="sm">{t.today}</Badge>}
</div>
{list.length === 0 ? (
<p className="text-1xs text-subtle-foreground">{t.empty}</p>
) : (
// -mx-2 outdents the rows by their own padding, so the text
// lines up with the day heading and only the hover fill overhangs.
<ul className="-mx-2 flex flex-col gap-1">
{list.map((e) => (
<li key={e.id}>
<EventShell
event={e}
selected={selectedId === e.id}
interactive={interactive}
onClick={onEventClick}
detail={detail(e)}
side="bottom"
className="items-start gap-3 rounded-md px-2 py-1.5 data-[interactive]:pointer-coarse:min-h-11 data-[interactive]:hover:bg-accent-50 dark:data-[interactive]:hover:bg-accent-950 data-[selected]:bg-accent-100 dark:data-[selected]:bg-accent-800 data-[popup-open]:bg-accent-100 dark:data-[popup-open]:bg-accent-800"
>
<span aria-hidden className="w-16 shrink-0 text-1xs leading-5 tabular-nums text-subtle-foreground">
{e.allDay ? t.allDay : fmt.time.format(clock.wall(e.start))}
</span>
<span aria-hidden className={cn("mt-1.5 size-2 shrink-0 rounded-full", DOT[e.tone ?? "accent"])} />
<span className="flex min-w-0 flex-col">
<span className="text-xs leading-5 font-medium text-foreground text-pretty">{e.title}</span>
<span className="sr-only">, {range(e)}</span>
{e.meta != null && <span className="truncate text-1xs text-subtle-foreground">{e.meta}</span>}
</span>
</EventShell>
</li>
))}
</ul>
)}
</li>
)
})}
</ol>
{/* Wide: the hour grid. The day header and the all-day row are sticky
INSIDE the scroller rather than above it, so they share its scrollbar
gutter and their columns cannot drift from the grid's. */}
{/* ScrollFade gives the kit's own overlay scrollbar. `fade="bottom"`
because the day header is frozen at the top. */}
<div ref={gridRef} className="hidden @3xl/week-schedule:block">
<ScrollFade fade="bottom" viewportClassName="max-h-144">
<div className="sticky top-0 z-20 border-b border-border bg-card">
<div aria-hidden className="grid" style={columns}>
<div />
{byDay.map(({ day, today }) => (
<div key={day} className="flex flex-col items-center gap-1 border-s border-border py-2">
<span className={cn("text-2xs font-medium", today ? "text-accent-600 dark:text-accent-400" : "text-muted-foreground")}>
{fmt.weekday.format(day)}
</span>
<span
className={cn(
"flex size-7 items-center justify-center rounded-full text-sm font-semibold tabular-nums",
today ? "bg-accent text-accent-foreground" : "text-foreground"
)}
>
{fmt.dayNumber.format(day)}
</span>
</div>
))}
</div>
{allDay.length > 0 && (
<div className="grid border-t border-border" style={columns}>
<div aria-hidden className="px-2 py-1.5 text-end text-2xs text-subtle-foreground">{t.allDay}</div>
{/* One grid across the seven days, so an event covering three days
is one bar over three columns. `dense` packs short bars into
the gaps a long one leaves. */}
<ul aria-label={t.allDay} className="col-span-7 grid grid-flow-row-dense gap-y-1 py-1" style={{ gridTemplateColumns: "repeat(7, minmax(0, 1fr))" }}>
{allDay.map(({ event: e, from, to }) => (
<li key={e.id} className="min-w-0 px-0.5" style={{ gridColumn: `${from + 1} / ${to + 2}` }}>
<EventShell event={e} selected={selectedId === e.id} interactive={interactive} onClick={onEventClick} detail={detail(e)} className={cn("h-5 items-center rounded-sm px-2", TONE[e.tone ?? "accent"], GRID_STATES)}>
<span className="truncate text-1xs font-medium">{e.title}</span>
<span className="sr-only">, {range(e)}</span>
</EventShell>
</li>
))}
</ul>
</div>
)}
</div>
<div className="relative grid" style={columns}>
<div aria-hidden className="relative" style={{ height: `${hours.length * HOUR_REM}rem` }}>
{hours.map((h, i) =>
i === 0 ? null : (
<span
key={h}
className="absolute end-2 -translate-y-1/2 text-2xs tabular-nums text-subtle-foreground"
style={{ top: `${i * HOUR_REM}rem` }}
>
{fmt.hour.format(h * 60 * MINUTE)}
</span>
)
)}
</div>
{/* Hour lines sit under the seven columns. */}
<div aria-hidden className="pointer-events-none absolute inset-y-0 end-0 start-14">
{hours.map((h, i) => (
<div
key={h}
ref={h === Math.max(scrollToHour, startHour) ? markRef : undefined}
className={cn("absolute inset-x-0", i > 0 && "border-t border-border")}
style={{ top: `${i * HOUR_REM}rem` }}
/>
))}
</div>
{byDay.map(({ day, today, placed }) => {
const nowMin = today && now ? (clock.wall(now) - day) / MINUTE : -1
return (
<ol
key={day}
aria-label={fmt.fullDay.format(day)}
aria-current={today ? "date" : undefined}
className="relative border-s border-border"
>
{placed.map((p) => {
// What fits is set by height: under 50 minutes one row (title
// then time), under 90 a line each, longer two title lines
// and the meta line once there is room for it.
const minutes = p.bottom - p.top
const compact = minutes < 50
const roomy = minutes >= 90
return (
<li
key={p.event.id}
className="absolute px-0.5 py-0.5"
style={{
top: `${((p.top - startHour * 60) / span) * 100}%`,
height: `${(minutes / span) * 100}%`,
insetInlineStart: `${p.left}%`,
width: `${p.width}%`,
zIndex: p.lane,
}}
>
<EventShell
event={p.event}
selected={selectedId === p.event.id}
interactive={interactive}
onClick={onEventClick}
detail={detail(p.event)}
className={cn(
"h-full rounded-sm px-2",
TONE[p.event.tone ?? "accent"],
GRID_STATES,
p.lane > 0 && "ring-1 ring-card",
// A compact row wraps, and the button clips its second line: when
// the column is too narrow for both, the time drops out of view
// and the title keeps the room.
compact ? "flex-wrap content-start items-baseline gap-x-1.5 py-0.5" : "flex-col py-1"
)}
>
<span className={cn("min-w-0 text-1xs leading-snug font-medium", roomy ? "line-clamp-2 hyphens-auto wrap-break-word" : "truncate")}>
{p.event.title}
</span>
<span aria-hidden className="shrink-0 truncate text-2xs leading-snug tabular-nums">{fmt.time.format(clock.wall(p.event.start))}</span>
<span className="sr-only">, {range(p.event)}</span>
{roomy && p.event.meta != null && (
<span className="truncate text-2xs">{p.event.meta}</span>
)}
</EventShell>
</li>
)
})}
{nowMin >= startHour * 60 && nowMin < endHour * 60 && (
<li
aria-hidden
className="pointer-events-none absolute inset-x-0 z-10"
style={{ top: `${((nowMin - startHour * 60) / span) * 100}%` }}
>
<span className="absolute -top-1 -start-1 size-2 rounded-full bg-destructive-500" />
<span className="absolute inset-x-0 -top-px border-t-2 border-destructive-500" />
</li>
)}
</ol>
)
})}
</div>
</ScrollFade>
</div>
</section>
)
}
// One event, in the grid or the agenda. A button when it does something on
// press (a popover, the page's own handler), otherwise plain content: a control
// that does nothing is a lie to the keyboard and to a screen reader. Its
// accessible name is its own text plus a screen-reader range, so a truncated
// title still reads in full. Open and selected are a ring (grid) or a fill
// (agenda), so nothing moves; colour and filter only, so no motion guard.
function EventShell({
event,
selected,
interactive,
onClick,
detail,
side = "right",
className,
children,
}: {
event: WeekScheduleEvent
selected: boolean
interactive: boolean
onClick?: (event: WeekScheduleEvent) => void
detail?: React.ReactNode
side?: "right" | "bottom"
className?: string
children: React.ReactNode
}) {
const base = cn("flex w-full min-w-0 overflow-hidden text-start", className)
if (!interactive) {
return (
<div title={event.title} className={base}>
{children}
</div>
)
}
const button = (
<button
type="button"
data-interactive=""
data-selected={selected ? "" : undefined}
aria-current={selected ? "true" : undefined}
title={event.title}
onClick={() => onClick?.(event)}
className={cn(
"cursor-pointer outline-none transition-[background-color,filter,box-shadow] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring-accent",
base
)}
>
{children}
</button>
)
if (detail == null) return button
return (
<Popover>
<PopoverTrigger render={button} />
{/* Beside the event in the grid, so the column it sits in stays visible;
under the row in the agenda, where there is no room beside it. Both
flip when they would leave the screen. */}
<PopoverContent side={side} align="start" sideOffset={8}>
{detail}
</PopoverContent>
</Popover>
)
}
export { WeekSchedule }
export type { WeekScheduleEvent, WeekScheduleTone }