Event layout
StableThe calendar’s layout maths: overlapping events side by side, all-day bars stacked into lanes, and the sentence a screen reader hears.
Pure functions, numbers in and numbers out. packColumns places a day’s timed events in columns where they overlap and lets each one widen into free columns beside it. packLanes stacks all-day bars across a week so none collide. eventLabel writes the full name for an event (title, day, time, calendar) for its button’s aria-label.
Updated View source
Columns in a day
import type { CalendarEvent } from '@rchursin/product/data/sample'import { calendarById, eventLabel } from '@rchursin/product/calendar/events'import { packColumns } from '@rchursin/product/calendar/layout'import { PatternFill, patternText } from '@rchursin/product/calendar/patterns'import { at, formatTimeShort } from '@rchursin/product/lib/dates'
/** * packColumns places one day's events side by side where they overlap: col, cols and span are * all a renderer needs (left = col / cols, width = span / cols). Release review starts after * the focus block ends, so it takes its column and widens into the free one next to it. * The list under the picture is what a screen reader gets, written by eventLabel. */const day = new Date(2026, 9, 13)const ev = (id: string, title: string, h: number, m: number, minutes: number, calendar: CalendarEvent['calendar']): CalendarEvent => { const start = at(day, h, m) return { id, title, start, end: new Date(start.getTime() + minutes * 60_000), calendar }}const events = [ ev('standup', 'Standup', 9, 30, 15, 'team'), ev('focus', 'Focus: fix test-fixer', 10, 0, 90, 'focus'),Usage
import { packColumns, packLanes } from '@rchursin/product/calendar/layout'import { eventLabel } from '@rchursin/product/calendar/events'const placed = packColumns(events, (e) => [minutes(e.start), minutes(e.end)])// left = col / cols, width = span / cols<button aria-label={eventLabel(event)}>…</button>Examples
All-day lanes in a week
Four multi-day events in two lanes: each bar takes the first lane that is free when it starts.
import type { CalendarEvent } from '@rchursin/product/data/sample'import { calendarById, eventLabel } from '@rchursin/product/calendar/events'import { packLanes } from '@rchursin/product/calendar/layout'import { PatternFill, patternText } from '@rchursin/product/calendar/patterns'import { addDays, dayDiff, dtf } from '@rchursin/product/lib/dates'
/** * packLanes stacks all-day bars across a week so none overlap: each bar gets the first lane * that is free by its first day. Spans are [first column, column after the last), so an * all-day event that ends at midnight (exclusive, like iCalendar) maps straight across. */const monday = new Date(2026, 9, 12)const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i))const allDay = (id: string, title: string, from: number, to: number, calendar: CalendarEvent['calendar']): CalendarEvent => ({ id, title, start: addDays(monday, from), end: addDays(monday, to), allDay: true, calendar,API
packColumns(items, range, minDuration?)
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | T[] | – | Any events. |
rangerequired | (item: T) => [number, number] | – | Start and end in minutes from the day’s midnight (clamp to the day yourself). |
minDuration | number | 20 | Minutes. For collisions an event counts as at least this long, because that is how tall it is drawn. |
returns | Packed<T>[] | – | { item, start, end, col, cols, span }, sorted by start (longer first on ties). cols is shared by every event in an overlapping cluster. |
packLanes(items, span)
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | T[] | – | All-day events. |
spanrequired | (item: T) => [number, number] | – | First column and the column after the last (0-based, end exclusive). |
returns | Lane<T>[] | – | { item, from, to, lane }, earliest first (longest first on ties). The input array isn’t changed. |
eventLabel(e)
| Prop | Type | Default | Description |
|---|---|---|---|
erequired | CalendarEvent | – | An event from the calendar kit. The calendar’s name comes from the kit’s calendar list (Team, Focus, Personal, Releases). |
returns | string | – | “Weekly skill review, Friday 10 to 11 AM, Team calendar” or “Skill cleanup sprint, Monday to Wednesday, all day, Team calendar”. |
Accessibility
- Position is only visual. Give each event button eventLabel(event) as its name, so the day, time and calendar are heard even though they’re drawn by place.
- Keep the reading order by start time: render placed events in the order packColumns returns them.
- Calendars are told apart by fill pattern (solid, hatched, outline, dashed), never by colour.
Guidelines
Do
- Clamp events to the visible day before packing, so a late event from yesterday doesn’t push today’s columns.
- Draw short events at the minimum height you passed as minDuration.
Don’t
- Don’t sort the result again by column; it would scramble the reading order.
- Don’t pass dates to packColumns; it wants minutes.
In use
Related
- Week and day viewDays across, hours down: a time grid with an all-day row, overlaps side by side, and a line at the current time.
- Event blocksHow an event looks in each calendar view: a block in the week grid, a bar for all-day, a chip in a month cell.
- Fills & patternsSolid, hatched, outline and dashed: the second “colour” of a black-and-white interface, plus the dot-grid canvas.
- CalendarThe whole calendar: week, day, month and agenda views, calendars told apart by pattern, and create, edit and delete.
Source
The component’s own files, exactly as they run on this site.
/** * Pure layout for the time grid. No React, no DOM: numbers in, numbers out, so it is easy to test. * * packColumns timed events in one day column, side by side where they overlap * packLanes all-day bars across a week, stacked so none overlap */
export interface Packed<T> { item: T /** Minutes from midnight, clamped to the day */ start: number end: number /** Column index inside its cluster, the cluster's column count, and how many columns it spans */ col: number cols: number span: number}
interface Row<T> { item: T