Widgets
Week schedule
A week by the hour with events in side-by-side lanes when they overlap, 44 px minimum blocks, a now line in the signal colour, and day tabs when the columns get too narrow.
Preview
Usage
week-schedule-demo.tsx
import { useState } from 'react'
import { BigNumber } from '@/components/chart-kit'
import { DataTile, TileFact } from '@/components/data-tile'
import { WeekSchedule, type ScheduleCategory, type ScheduleEvent } from '@/components/week-schedule'
// An example week for the demo, not a real calendar. "Now" is Thursday 8 October 2026 at 14:20.
const WEEK = '2026-10-05'
const NOW = '2026-10-08T14:20'
const CATEGORIES: ScheduleCategory[] = [
{ id: 'work', label: 'Work', color: 'var(--chart-1)' },
{ id: 'health', label: 'Health', color: 'var(--chart-4)' },
{ id: 'social', label: 'Social', color: 'var(--chart-2)' },
{ id: 'money', label: 'Money', color: 'var(--chart-5)' },
]
const ev = (id: string, day: number, start: string, end: string, title: string, category: string): ScheduleEvent => ({
id,
day,
start,
end,
title,
category,
})
const EVENTS: ScheduleEvent[] = [
ev('e1', 0, '09:00', '09:30', 'Standup', 'work'),
ev('e2', 0, '13:00', '14:30', 'Design review', 'work'),
ev('e3', 0, '18:00', '19:00', 'Climbing', 'health'),
ev('e4', 1, '08:30', '09:30', 'Physio', 'health'),
ev('e5', 1, '12:00', '13:00', 'Lunch with Sam', 'social'),
ev('e6', 1, '15:00', '16:00', 'Card review', 'money'),
ev('e7', 2, '10:00', '12:30', 'Workshop', 'work'),
ev('e8', 2, '11:00', '11:45', 'Dentist', 'health'),
ev('e9', 2, '17:30', '18:30', 'Market run', 'social'),
ev('e10', 3, '08:00', '09:00', 'Yoga', 'health'),
ev('e11', 3, '14:30', '15:30', 'Team sync', 'work'),
ev('e12', 3, '14:30', '15:00', 'Rent transfer', 'money'),
ev('e13', 3, '18:00', '19:00', 'Spin class', 'health'),
ev('e14', 4, '09:00', '10:00', 'Standup', 'work'),
ev('e15', 4, '16:00', '17:00', 'Budget check-in', 'money'),
ev('e16', 4, '19:00', '21:00', 'Dinner, Harbour', 'social'),
ev('e17', 5, '10:00', '11:00', 'Swim', 'health'),
ev('e18', 5, '13:00', '15:00', 'Flea market', 'social'),
ev('e19', 6, '09:00', '10:30', 'Long run', 'health'),
ev('e20', 6, '17:00', '18:00', 'Plan the week', 'money'),
]
const minutes = (t: string) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3))
const BOOKED = EVENTS.reduce((sum, e) => sum + minutes(e.end) - minutes(e.start), 0) / 60
const TO_COME = EVENTS.filter((e) => e.day > 3 || (e.day === 3 && minutes(e.end) > 14 * 60 + 20)).length
export default function WeekScheduleDemo() {
const [picked, setPicked] = useState<ScheduleEvent | null>(null)
return (
<div className="grid w-full gap-4">
<DataTile
title="This week"
footer={<span>Seven days by the hour. Events that overlap sit side by side, and the signal line is now. Example data.</span>}
>
<TileFact
label="Booked this week"
aside={
<span className="rounded-full bg-muted px-2.5 py-1 text-[12.5px] text-muted-foreground tabular-nums">
{TO_COME} of {EVENTS.length} still to come
</span>
}
>
<BigNumber value={BOOKED} format={{ decimals: 2, suffix: ' h' }} />
</TileFact>
<WeekSchedule data={EVENTS} label="This week" weekStart={WEEK} now={NOW} categories={CATEGORIES} />
</DataTile>
<div className="grid gap-4 sm:grid-cols-2">
<DataTile
title="Day view"
density="compact"
footer={<span>{picked ? `Opened ${picked.title}. ` : 'One day at a time, with tabs for the week. '}Example data.</span>}
>
<WeekSchedule
data={EVENTS}
label="This week, by day"
weekStart={WEEK}
now={NOW}
categories={CATEGORIES}
layout="day"
startHour={8}
endHour={20}
legend={false}
onSelect={setPicked}
/>
</DataTile>
<DataTile title="Workdays" density="compact" inverted footer={<span>Five days, work only. Example data.</span>}>
<WeekSchedule
data={EVENTS.filter((e) => e.category === 'work' && e.day < 5)}
label="Work this week"
weekStart={WEEK}
days={5}
now={NOW}
categories={CATEGORIES.slice(0, 1)}
minDayWidth={44}
startHour={8}
endHour={16}
/>
</DataTile>
</div>
</div>
)
}Source
week-schedule.tsx
// A week by the hour: a time rail on the left, one column per day, and the events as coloured blocks with their
// title and time. Events that overlap sit side by side in lanes. Every block is at least 44 px tall, so a
// 15-minute slot stays readable and easy to hit, and the lanes are worked out from that drawn height, so short
// events never cover each other. The "now" line, its dot and its time on the rail are the one thing in --primary.
// When the columns would get narrower than `minDayWidth`, the week turns into day tabs over a single-day grid.
// Screen readers: each day is a list ("Thu 8 Oct, today, now 14:20, 4 events") of events read as "Team sync,
// 14:30 to 15:30, Work". The schedule is one Tab stop: arrow keys walk the events (up and down within a day, left and
// right to the nearest event on the next day), Home and End jump to the first and last, Escape clears. The active
// event is read once through a polite live region and shows the same tooltip as a hover or a tap. With `onSelect`
// the events are buttons in a roving tab order instead.
// Reduced motion: the events are in place at once instead of growing in.
import { useId, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type PointerEvent } from 'react'
import { ChartTooltip } from '@/registry/manniche/chart-kit/chart-kit'
import { EASE_CSS, GRID, WELL, seriesColor } from '@/registry/manniche/chart-kit/chart-utils'
import { useAnnounce, useChartFrame } from '@/registry/manniche/chart-kit/use-chart'
import { TileLegend } from '@/registry/manniche/data-tile/data-tile'
import { cn } from '@/lib/utils'
export type ScheduleEvent = {
/** Unique and stable. */
id: string
title: string
/** The day, counted from `weekStart`: 0 is the first column. */
day: number
/** "HH:MM", 24-hour. */
start: string
/** "HH:MM", 24-hour, after `start`. */
end: string
/** The id of one of `categories`. */
category?: string
/** Overrides the category's colour. */
color?: string
}
export type ScheduleCategory = {
id: string
/** Shown in the legend and read after each event. */
label: string
/** Default the series colours in order. */
color?: string
}
export type WeekScheduleLabels = {
/** The heading of a day, such as "Mon 5 Oct". */
day?: (date: Date, locale: string) => string
today?: string
now?: (time: string) => string
/** The event count after a day's name. */
count?: (n: number) => string
/** One event, read as a sentence. */
event?: (e: ScheduleEvent, parts: { start: string; end: string; category: string }) => string
/** The length in the tooltip. */
duration?: (minutes: number) => string
/** Read after the schedule's name. */
hint?: string
/** The name of the day tabs. */
days?: string
}
export type WeekScheduleProps = Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'> & {
/** The events of the week. */
data: ScheduleEvent[]
/** The accessible name, such as "This week". */
label: string
/** "YYYY-MM-DD" of the first column. */
weekStart: string
/** How many days to show. Default 7. */
days?: number
/** The categories, in legend order. Without them events use their own `color` or the first series colour. */
categories?: ScheduleCategory[]
/** "YYYY-MM-DDTHH:MM". Draws the now line when it falls inside the week and marks that day as today. */
now?: string
/** The first hour on the rail. Default 8. */
startHour?: number
/** The hour the grid ends. Default 21. */
endHour?: number
/** The height of an hour in px. Default 46, or 40 inside a compact tile. */
hourHeight?: number
/** "auto" shows the week while each day gets `minDayWidth`, then day tabs. Default "auto". */
layout?: 'auto' | 'week' | 'day'
/** The narrowest a day column may get before "auto" switches to day tabs. Default 60. */
minDayWidth?: number
/** The day shown in day tabs (controlled). */
day?: number
/** The day shown first in day tabs. Default today, else the first day. */
defaultDay?: number
onDayChange?: (day: number) => void
/** Makes each event a button and is called with it. */
onSelect?: (event: ScheduleEvent) => void
/** Show the category legend under the grid. Default true. */
legend?: boolean
/** Number and date language. Default "en-GB". */
locale?: string
labels?: WeekScheduleLabels
}
const DEFAULT_LABELS: Required<WeekScheduleLabels> = {
day: (d, locale) =>
new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }).format(d).replace(',', ''),
today: 'today',
now: (t) => `Now, ${t}`,
count: (n) => (n === 0 ? 'no events' : n === 1 ? '1 event' : `${n} events`),
event: (e, p) => [e.title, `${p.start} to ${p.end}`, p.category].filter(Boolean).join(', '),
duration: (m) => (m <= 90 ? `${m} min` : m % 60 ? `${Math.floor(m / 60)} h ${m % 60} min` : `${m / 60} h`),
hint: 'Use the arrow keys to move between events.',
days: 'Day',
}
const RAIL = 46
const GAP = 6
const MIN_PX = 44
/** Below this block width a title cannot be read, so the block keeps only its colour bar; the name is in the tooltip. */
const MIN_TEXT_PX = 56
/** Below this block height there is room for the title only, not the time under it. */
const TIME_PX = 58
/** A word's width in em in the semibold title face, guessed from its letters so server and client agree. */
function wordEm(word: string) {
let em = 0
for (const ch of word) em += /[mwMW@]/.test(ch) ? 0.86 : /[A-Z&]/.test(ch) ? 0.68 : /[iljtfr.,'’:;!|]/.test(ch) ? 0.34 : 0.58
return em
}
const toMin = (t: string) => {
const [h = 0, m = 0] = t.split(':').map(Number)
return h * 60 + m
}
const dayNumber = (iso: string) => {
const [y, m, d] = iso.slice(0, 10).split('-').map(Number)
return Math.round(Date.UTC(y, m - 1, d) / 86400000)
}
type Placed = ScheduleEvent & { s: number; e: number; lane: number; lanes: number; color: string; cat: string }
type Tip = { id: string; x: number; y: number; below: boolean; from: 'pointer' | 'key' }
/** Puts overlapping events side by side. Overlap is judged on the drawn block, which is at least 44 px tall. */
function placeDay(events: Placed[], minMinutes: number) {
const sorted = [...events].sort((a, b) => a.s - b.s || b.e - a.e)
let cluster: Placed[] = []
let clusterEnd = -Infinity
const laneEnds: number[] = []
const flush = () => {
const lanes = Math.max(1, laneEnds.length)
for (const p of cluster) p.lanes = lanes
cluster = []
laneEnds.length = 0
}
for (const p of sorted) {
const visualEnd = Math.max(p.e, p.s + minMinutes)
if (p.s >= clusterEnd) flush()
let lane = laneEnds.findIndex((end) => end <= p.s)
if (lane === -1) lane = laneEnds.length
laneEnds[lane] = visualEnd
p.lane = lane
cluster.push(p)
clusterEnd = Math.max(clusterEnd === -Infinity ? 0 : clusterEnd, visualEnd)
}
flush()
return sorted
}
export function WeekSchedule({
data,
label,
weekStart,
days = 7,
categories,
now,
startHour = 8,
endHour = 21,
hourHeight,
layout = 'auto',
minDayWidth = 60,
day: dayProp,
defaultDay,
onDayChange,
onSelect,
legend = true,
locale = 'en-GB',
labels: labelsProp,
className,
style,
...rest
}: WeekScheduleProps) {
const labels = { ...DEFAULT_LABELS, ...labelsProp }
const { ref, width, drawn, reduced } = useChartFrame<HTMLDivElement>()
const { ref: bodyRef, height: bodyHeight } = useChartFrame<HTMLDivElement>()
const { say, region } = useAnnounce()
const uid = useId()
const hours = Math.max(1, endHour - startHour)
const span = hours * 60
const rh = bodyHeight ? bodyHeight / hours : (hourHeight ?? 46)
const minMinutes = ((MIN_PX + 4) / rh) * 60
const startN = dayNumber(weekStart)
const dates = Array.from({ length: days }, (_, i) => new Date((startN + i) * 86400000))
const nowDay = now ? dayNumber(now) - startN : -1
const nowMin = now && now.length > 10 ? toMin(now.slice(11, 16)) : -1
const today = nowDay >= 0 && nowDay < days ? nowDay : -1
const nowInGrid = today >= 0 && nowMin >= startHour * 60 && nowMin <= endHour * 60
const timeFmt = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })
const clock = (min: number) => timeFmt.format(new Date(min * 60000))
const weekdayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' })
const dateFmt = new Intl.DateTimeFormat(locale, { day: 'numeric', timeZone: 'UTC' })
const catIndex = new Map((categories ?? []).map((c, i) => [c.id, { ...c, color: c.color ?? seriesColor(i) }]))
const byDay: Placed[][] = Array.from({ length: days }, () => [])
for (const ev of data) {
if (ev.day < 0 || ev.day >= days) continue
// An event wholly outside the shown hours has no place on the grid; one that overlaps an edge is clamped.
if (toMin(ev.end) <= startHour * 60 || toMin(ev.start) >= endHour * 60) continue
const c = ev.category ? catIndex.get(ev.category) : undefined
byDay[ev.day].push({
...ev,
s: toMin(ev.start),
e: Math.max(toMin(ev.end), toMin(ev.start) + 1),
lane: 0,
lanes: 1,
color: ev.color ?? c?.color ?? seriesColor(0),
cat: c?.label ?? '',
})
}
const placed = byDay.map((list) => placeDay(list, minMinutes))
// Reading order inside a day: by start, then lane.
const ordered = placed.map((list) => [...list].sort((a, b) => a.s - b.s || a.lane - b.lane))
const all = ordered.flat()
// Layout and day tabs ------------------------------------------------------------------------------------------
const colWidth = width ? (width - RAIL - GAP * days) / days : Infinity
const mode = layout === 'auto' ? (colWidth < minDayWidth ? 'day' : 'week') : layout
const [innerDay, setInnerDay] = useState(defaultDay ?? (today >= 0 ? today : 0))
const shownDay = Math.min(days - 1, Math.max(0, dayProp ?? innerDay))
const setDay = (d: number) => {
if (dayProp === undefined) setInnerDay(d)
onDayChange?.(d)
}
const columns = mode === 'week' ? Array.from({ length: days }, (_, i) => i) : [shownDay]
// Active event, tooltip ----------------------------------------------------------------------------------------
const [active, setActive] = useState<string | null>(null)
const [tip, setTip] = useState<Tip | null>(null)
const find = (id: string | null) => all.find((p) => p.id === id)
const sentence = (p: Placed) => labels.event(p, { start: clock(p.s), end: clock(p.e), category: p.cat })
const dayName = (d: number) => labels.day(dates[d], locale)
const tipAt = (el: Element, id: string, from: Tip['from']): Tip | null => {
const root = el.closest('[data-ws-root]')
if (!root) return null
const r = root.getBoundingClientRect()
const b = el.getBoundingClientRect()
const below = b.top - r.top < 96
return { id, x: b.left - r.left + b.width / 2, y: below ? b.bottom - r.top : b.top - r.top, below, from }
}
const activate = (root: Element, id: string, speak: boolean) => {
const p = find(id)
if (!p) return
if (mode === 'day' && p.day !== shownDay) setDay(p.day)
setActive(id)
if (speak) say(sentence(p))
// The block may only exist after the day switch, so measure on the next frame.
requestAnimationFrame(() => {
const el = root.querySelector(`[data-ev="${CSS.escape(id)}"]`)
if (!el) return
setTip(tipAt(el, id, 'key'))
if (onSelect) (el as HTMLElement).focus()
})
}
// The event the keyboard starts from: the next one still to come, else the first.
const startEvent = () => {
const pool = mode === 'day' ? ordered[shownDay] : all
if (today >= 0) {
const upcoming = pool.find((p) => p.day > today || (p.day === today && p.e > nowMin))
if (upcoming) return upcoming
}
return pool[0]
}
const rovingId = (active && find(active) && (mode === 'week' || find(active)?.day === shownDay) ? active : startEvent()?.id) ?? null
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const root = e.currentTarget
if (e.key === 'Escape') {
if (!tip && !active) return
setTip(null)
if (!onSelect) setActive(null)
e.preventDefault()
return
}
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return
e.preventDefault()
const cur = find(onSelect ? rovingId : active)
let to: Placed | undefined
if (!cur) to = startEvent()
else if (e.key === 'Home' || e.key === 'End') {
const pool = mode === 'day' ? ordered[cur.day] : all
to = e.key === 'Home' ? pool[0] : pool[pool.length - 1]
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
const list = ordered[cur.day]
const i = list.indexOf(cur)
to = list[Math.max(0, Math.min(list.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))]
} else {
const dir = e.key === 'ArrowRight' ? 1 : -1
for (let d = cur.day + dir; d >= 0 && d < days; d += dir) {
if (!ordered[d].length) continue
to = ordered[d].reduce((best, p) => (Math.abs(p.s - cur.s) < Math.abs(best.s - cur.s) ? p : best))
break
}
to ??= cur
}
if (to) activate(root, to.id, !onSelect)
}
const onPointerOver = (e: PointerEvent<HTMLElement>, id: string) => {
if (e.pointerType === 'touch') return
setTip(tipAt(e.currentTarget, id, 'pointer'))
}
const onPointerLeave = (e: PointerEvent<HTMLElement>) => {
if (e.pointerType === 'touch') return
setTip((t) => (t?.from === 'pointer' ? null : t))
}
// Touch: a tap shows the tooltip, and sliding the finger across the blocks moves it.
const onTouch = (e: PointerEvent<HTMLDivElement>) => {
if (e.pointerType !== 'touch') return
const el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-ev]')
if (el) setTip(tipAt(el, el.getAttribute('data-ev') ?? '', 'pointer'))
else if (e.type === 'pointerdown') setTip(null)
}
const tipEvent = find(tip?.id ?? null)
const nowPct = ((nowMin - startHour * 60) / span) * 100
const legendItems = [
...(categories ?? []).map((c, i) => ({ label: c.label, color: c.color ?? seriesColor(i), shape: 'square' as const })),
...(nowInGrid ? [{ label: labels.now(clock(nowMin)), color: 'var(--primary)', shape: 'dot' as const }] : []),
]
const gridCols = { gridTemplateColumns: `${RAIL}px repeat(${columns.length}, minmax(0, 1fr))`, columnGap: GAP } as CSSProperties
// The width of one day column in px, to decide per block whether its title fits.
const dayPx = width ? (width - RAIL - GAP * columns.length) / columns.length : Infinity
let stagger = 0
return (
<div
ref={ref}
data-ws-root=""
className={cn(
'relative grid w-full min-w-0 gap-2.5',
hourHeight === undefined && '[--rh:46px] group-data-[density=compact]/tile:[--rh:40px]',
className,
)}
style={{ ...(hourHeight !== undefined ? ({ '--rh': `${hourHeight}px` } as CSSProperties) : null), ...style }}
{...rest}
>
{mode === 'week' ? (
<div aria-hidden className="grid" style={gridCols}>
<span />
{columns.map((d) => (
<span key={d} className="grid min-w-0 justify-items-center gap-0.5 text-center">
<span className="text-[12.5px] leading-[1.3] font-medium text-muted-foreground">{weekdayFmt.format(dates[d])}</span>
<DateMark text={dateFmt.format(dates[d])} on={d === today} />
</span>
))}
</div>
) : (
<DayTabs
days={days}
shown={shownDay}
today={today}
label={`${label}, ${labels.days}`}
tabId={(d) => `${uid}-tab${d}`}
panelId={`${uid}-panel`}
weekday={(d) => weekdayFmt.format(dates[d])}
date={(d) => dateFmt.format(dates[d])}
name={(d) => `${dayName(d)}${d === today ? `, ${labels.today}` : ''}`}
onPick={(d) => {
setDay(d)
setTip(null)
}}
/>
)}
<div
role={mode === 'day' ? 'tabpanel' : 'group'}
id={mode === 'day' ? `${uid}-panel` : undefined}
aria-labelledby={mode === 'day' ? `${uid}-tab${shownDay}` : undefined}
aria-label={mode === 'day' ? undefined : label}
aria-describedby={onSelect ? undefined : `${uid}-hint`}
tabIndex={onSelect || !all.length ? undefined : 0}
onKeyDown={onKeyDown}
onPointerDown={onTouch}
onPointerMove={onTouch}
onBlur={(e) => {
if (e.currentTarget.contains(e.relatedTarget)) return
setTip((t) => (t?.from === 'key' ? null : t))
setActive(null)
}}
className="relative grid touch-pan-y rounded-[14px] outline-offset-4"
style={gridCols}
>
<span id={`${uid}-hint`} className="sr-only">
{labels.hint}
</span>
{/* The rail: hour labels on the lines, and the time of now in the signal colour. */}
<div ref={bodyRef} aria-hidden className="relative" style={{ height: `calc(var(--rh) * ${hours})` }}>
{Array.from({ length: hours - 1 }, (_, i) => {
const h = startHour + i + 1
// The now pill wins over an hour label it would touch.
if (nowInGrid && columns.includes(today) && (Math.abs(nowMin - h * 60) / 60) * rh < 20) return null
return (
<span
key={h}
className="absolute right-2 -translate-y-1/2 font-mono text-[11px] leading-none text-muted-foreground tabular-nums"
style={{ top: `${((i + 1) / hours) * 100}%` }}
>
{clock(h * 60)}
</span>
)
})}
{nowInGrid && columns.includes(today) && (
<span
className="absolute right-0.5 z-10 -translate-y-1/2 rounded-full bg-primary px-1.5 py-[3px] font-mono text-[10.5px] leading-none font-medium text-primary-foreground tabular-nums"
style={{ top: `${nowPct}%` }}
>
{clock(nowMin)}
</span>
)}
</div>
{columns.map((d) => {
const isToday = d === today
const col = isToday ? 'color-mix(in oklab, var(--foreground) 8%, var(--card))' : WELL
const name = [dayName(d), isToday && labels.today, isToday && nowInGrid && labels.now(clock(nowMin)), labels.count(ordered[d].length)]
.filter(Boolean)
.join(', ')
return (
<div key={d} className="relative min-w-0 rounded-[14px]" style={{ background: col, '--col': col } as CSSProperties}>
<svg aria-hidden className="pointer-events-none absolute inset-0 size-full overflow-visible">
{Array.from({ length: hours - 1 }, (_, i) => {
const y = `${((i + 1) / hours) * 100}%`
return <line key={i} x1="0" x2="100%" y1={y} y2={y} stroke={GRID} strokeWidth={1} strokeDasharray="2 4" />
})}
</svg>
<ul aria-label={name} className="absolute inset-0">
{ordered[d].map((p) => {
const top = Math.max(0, (p.s - startHour * 60) / span) * 100
const bottom = Math.min(1, (p.e - startHour * 60) / span) * 100
const k = stagger++
const isActive = (tip?.id === p.id && tip.from === 'key') || active === p.id
const dur = p.e - p.s
const blockW = dayPx / p.lanes - 6
const blockH = Math.max(MIN_PX, (dur / 60) * rh - 4)
// Too narrow for a readable word: only the colour bar shows; the name is read aloud and in the tooltip.
const short = blockH < TIME_PX
const small = blockW < 92
// The longest word must fit on a line whole, so a title is never cut to "Sta…" or broken as "Desig-n".
const room = blockW - (small ? 21 : 23)
const longest = Math.max(0, ...p.title.split(/\s+/).map((w) => wordEm(w))) * (small ? 11.5 : 12.5)
// The guess is kept a tenth wide, so a near miss drops the text rather than break a word.
const bare = blockW < MIN_TEXT_PX || longest * 1.1 > room
const inner = (
<>
<span className="sr-only">{sentence(p)}</span>
<i
aria-hidden
className={cn('absolute left-1.5 w-[3px] rounded-full', short ? 'top-1.5 bottom-1.5' : 'top-2 bottom-2')}
style={{ background: p.color }}
/>
{!bare && (
<span
aria-hidden
className={cn(
'line-clamp-2 block text-[12.5px] leading-[1.2] font-semibold [overflow-wrap:break-word]',
small && 'text-[11.5px] leading-[1.15]',
)}
>
{p.title}
</span>
)}
{!bare && !short && (
<span
aria-hidden
className="mt-0.5 block truncate font-mono text-[10.5px] leading-[1.3] tabular-nums text-[color-mix(in_oklab,var(--foreground)_70%,transparent)]"
>
<span className="hidden @[92px]:inline">
{clock(p.s)}–{clock(p.e)}
</span>
<span className="hidden @[52px]:inline @[92px]:hidden">{clock(p.s)}</span>
</span>
)}
</>
)
const box = cn(
'@container absolute block overflow-hidden rounded-[11px] text-left text-foreground',
bare ? 'p-0' : cn(short ? 'flex flex-col justify-center py-1' : 'py-[7px]', small ? 'pr-1.5 pl-[15px]' : 'pr-2 pl-[15px]'),
'shadow-[0_0_0_2px_var(--col)] [transform-origin:top]',
isActive && 'outline-2 outline-offset-0 outline-ring',
)
const boxStyle = {
top: `min(calc(${top}% + 2px), calc(100% - ${MIN_PX + 2}px))`,
height: `max(${MIN_PX}px, calc(${bottom - top}% - 4px))`,
left: `calc(${(p.lane / p.lanes) * 100}% + 3px)`,
width: `calc(${100 / p.lanes}% - 6px)`,
background: `color-mix(in oklab, ${p.color} 24%, var(--card))`,
opacity: drawn ? 1 : 0,
transform: drawn ? 'none' : 'scaleY(0.6)',
transition: reduced ? 'none' : `opacity 300ms ${EASE_CSS}, transform 300ms ${EASE_CSS}`,
transitionDelay: drawn && !reduced ? `${Math.min(k * 15, 300)}ms` : undefined,
} as CSSProperties
const handlers = {
'data-ev': p.id,
'data-minutes': dur,
onPointerEnter: (e: PointerEvent<HTMLElement>) => onPointerOver(e, p.id),
onPointerLeave,
}
return (
<li key={p.id}>
{onSelect ? (
<button
type="button"
{...handlers}
tabIndex={p.id === rovingId ? 0 : -1}
onFocus={(e) => {
setActive(p.id)
setTip(tipAt(e.currentTarget, p.id, 'key'))
}}
onClick={() => onSelect(p)}
className={cn(box, 'cursor-pointer focus-visible:outline-offset-0')}
style={boxStyle}
>
{inner}
</button>
) : (
<div {...handlers} className={box} style={boxStyle}>
{inner}
</div>
)}
</li>
)
})}
</ul>
{isToday && nowInGrid && (
<span aria-hidden className="pointer-events-none absolute inset-x-0 z-10 h-0.5 -translate-y-1/2 bg-primary" style={{ top: `${nowPct}%` }}>
<i className="absolute top-1/2 -left-[5px] size-2.5 -translate-y-1/2 rounded-full bg-primary shadow-[0_0_0_2px_var(--card)]" />
</span>
)}
</div>
)
})}
</div>
{tipEvent && tip && (
<ChartTooltip
open
x={tip.x}
y={tip.y}
bounds={width}
below={tip.below}
title={[dayName(tipEvent.day), tipEvent.cat].filter(Boolean).join(' · ')}
value={tipEvent.title}
rows={[{ label: `${clock(tipEvent.s)}–${clock(tipEvent.e)}`, value: labels.duration(tipEvent.e - tipEvent.s), color: tipEvent.color }]}
/>
)}
{legend && legendItems.length > 0 && <TileLegend items={legendItems} className="mt-1.5" />}
{region}
</div>
)
}
function DateMark({ text, on, small = false }: { text: string; on: boolean; small?: boolean }) {
return (
<span
className={cn(
'grid place-items-center rounded-full leading-none font-extrabold tabular-nums',
small ? 'size-8 text-[17px]' : 'size-[38px] text-[22px] group-data-[density=compact]/tile:size-8 group-data-[density=compact]/tile:text-[18px]',
on ? 'bg-foreground text-card' : 'text-foreground',
)}
style={{ fontFamily: 'var(--font-display, inherit)', fontStretch: '88%' }}
>
{text}
</span>
)
}
function DayTabs({
days,
shown,
today,
label,
tabId,
panelId,
weekday,
date,
name,
onPick,
}: {
days: number
shown: number
today: number
label: string
tabId: (d: number) => string
panelId: string
weekday: (d: number) => string
date: (d: number) => string
name: (d: number) => string
onPick: (d: number) => void
}) {
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, d: number) => {
const to =
e.key === 'ArrowRight' ? (d + 1) % days : e.key === 'ArrowLeft' ? (d - 1 + days) % days : e.key === 'Home' ? 0 : e.key === 'End' ? days - 1 : null
if (to === null) return
e.preventDefault()
onPick(to)
;(e.currentTarget.parentElement?.children[to] as HTMLElement | undefined)?.focus()
}
return (
<div role="tablist" aria-label={label} className="-mx-1 flex overflow-x-auto px-1 py-1 [scrollbar-width:none]">
{Array.from({ length: days }, (_, d) => (
<button
key={d}
type="button"
role="tab"
id={tabId(d)}
aria-selected={d === shown}
aria-controls={panelId}
aria-label={name(d)}
tabIndex={d === shown ? 0 : -1}
onClick={() => onPick(d)}
onKeyDown={(e) => onKeyDown(e, d)}
className="relative grid min-h-11 min-w-11 flex-1 cursor-pointer justify-items-center gap-0.5 rounded-[14px] py-1 focus-visible:outline-offset-0"
>
<span aria-hidden className="text-[12px] leading-[1.3] font-medium text-muted-foreground">
{weekday(d)}
</span>
<DateMark text={date(d)} on={d === shown} small />
<i aria-hidden className={cn('size-1 rounded-full', d === today ? 'bg-primary' : 'bg-transparent')} />
</button>
))}
</div>
)
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/week-schedule.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/week-schedule