Schedule calendar
A reusable Monthly/Weekly/Daily scheduling grid. It owns navigation, view switching, and click reporting, every day cell's content, every header extra action, and the side panel's content (if you use one) are entirely yours via templates. Distinct from bk-custom-calendar (a date picker), this one displays a schedule of events across time.
Preview
A working preview of Schedule calendar in the Brickclay style. Try it.
Playground
Dynamic grid, week-start, min/max navigation, selection, all three views. Monthly's day-cell content is templated (below); Weekly/Daily's event positioning is its own deep-dive further down, since it needs event data with actual start/end times to show anything.
- Monthly
- Weekly
- Daily
- dateRangeChanged, Mon Oct 26 2026 → Sun Dec 06 2026
- dateRangeChanged, fires once on load too, see below
Header actions slot
The "Filters"/"+ Create" buttons in the Playground above are headerActionsTemplate, not a built-in feature, and not sized around any specific button count or label: supply one button, ten, a whole custom toolbar, or nothing at all (no extra action shows). Resize the window narrow to see them wrap, then stack full-width below a phone-width breakpoint, a DSM-owned wrapper around this slot handles that generically, without assuming what's inside it.
<ng-template #headerActions> <bk-button variant="secondary" size="sm" label="Filters" (clicked)="openFilters()"></bk-button> <bk-button variant="primary" size="sm" label="+ Create" (clicked)="openCreate()"></bk-button> <!-- any number of elements works - DSM never assumes how many --> </ng-template> <bk-schedule-calendar [headerActionsTemplate]="headerActions" ...></bk-schedule-calendar> // bk-schedule-calendar renders whatever this slot contains and does nothing // else with it - no Filters feature lives inside the calendar itself.
Side panel (sidePanel: 'inline' | 'popup')
Click a day cell below, the calendar opens a panel and renders sidePanelTemplate inside it. 'popup' floats it over the grid like a drawer (backdrop, slide animation, the ×), calendar width stays the same; 'inline' lays the same panel out as a static column alongside the grid instead, no backdrop, no animation. Default is sidePanel="none": the calendar never does this unless you opt in.
No events on this day.
Weekly & Daily, event positioning
Same visual language as Monthly, at slotIntervalMinutes resolution, but here each event's start/end places it in the grid, and events sharing a time range lay out side-by-side automatically. An empty slot is still clickable and reports slotSelected with only the events actually overlapping that slot. Today's column also shows a live current-time indicator line, and each day column's footer shows how many events it holds. The day column body itself is blank by default (no horizontal lines), flip showSlotBorders below to turn it into a traditional bordered hour grid instead. In Weekly, today's and the selected day's whole column, header, body, empty slots, footer, carry that state continuously (the Time column never does); toggle to Daily and back, or click a day header, to see it. Time labels follow timeFormat (12h/24h below).
Last daySelected:, click a day header or an event
Last slotSelected:, click an empty cell
Last eventClicked:, click an event
API reference
Inputs
| Name | Type | Default | Note |
|---|---|---|---|
events | CalendarEvent<T>[] | [] | Flat array, { id, start, end, data }. data is entirely yours. |
view | 'monthly' | 'weekly' | 'daily' | 'monthly' | Controlled, rebind from viewChanged for the built-in tabs to move (same pattern as bk-tabs' activeTabId/change). |
availableViews | ('monthly' | 'weekly' | 'daily')[] | all three | Which view tabs the header offers. |
weekStartsOn | 'monday' | 'sunday' | 'monday' | First column of the Monthly and Weekly grids. |
minDate / maxDate | Date | none | Every view now. Cells/day-columns/slots outside range render disabled (no click emissions); Prev/Next stop navigating once the next step would leave the range entirely off-screen. |
dayHeaderTemplate / dayBodyTemplate | TemplateRef<CalendarDayCellContext<T>> | none | Monthly day-cell content. dayHeaderTemplate is extra content only, composed next to the calendar's own built-in date indicator (day number + today/selected/outside-month/disabled styling), never instead of it. dayBodyTemplate has no built-in content, full consumer control. Context: { date, dayOfMonth, events, isToday, isCurrentMonth, isSelected, isDisabled, onEventClick }. |
timeSlotTemplate | TemplateRef<CalendarTimeSlotContext<T>> | none | Weekly/Daily event content. The calendar positions the box (top/height from start/end, side-by-side for overlaps, layoutDayEvents) and wires its click straight to eventClicked (and now daySelected too); this template only supplies what's inside. Context: { event }. |
weeklyDayHeaderTemplate | CalendarDaySummaryTemplateInput<T> (a TemplateRef, or a (context) => TemplateRef | null resolver) | none | Weekly only. Extra content next to Weekly's own built-in day name/date, never instead of it, same split Monthly's dayHeaderTemplate uses for its date badge. Same resolver shape as weeklyDaySummaryTemplate below. Context: { date, events, isToday, isSelected, isDisabled }. |
weeklyDaySummaryTemplate / dailyDaySummaryTemplate | CalendarDaySummaryTemplateInput<T> (a TemplateRef, or a (context) => TemplateRef | null resolver) | none | Independent per view, so Weekly and Daily can look completely different. A plain TemplateRef renders the same for every day; a resolver function is called per day (weekends styled differently, isToday gets a badge, etc., return null to skip a day). Rendered in a small footer row under each day column, below the hour grid. Context: { date, events, isToday, isSelected, isDisabled }. |
slotIntervalMinutes | number | 60 | Weekly/Daily row resolution, sizes the grid and drives the event-positioning math. Also drives the current-time indicator line shown on today's column. |
timeFormat | '12h' | '24h' | '12h' | Weekly/Daily time-column label format, '12h' gives "02:00 PM", '24h' gives "14:00". Also what slotSelected's own time payload is formatted as, since both come from the same rows. |
showSlotBorders | boolean | false | Weekly/Daily only, off by default, the day column body draws no horizontal lines at all. true adds a border under every slotIntervalMinutes row, turning the blank column into a traditional bordered hour grid. |
minColumnWidth | string (CSS length) | '11.5rem' | Floor for a day column's width (Monthly's 7, Weekly's 7, Daily always fills). Columns stretch to fill extra space above this; below it the grid scrolls horizontally instead of shrinking further. |
headerActionsTemplate | TemplateRef<void> | none | Free-form header slot (Filters, Create, …), DSM renders it and does nothing else. |
sidePanel | 'none' | 'inline' | 'popup' | 'none' | 'popup' floats a DSM-owned panel container over the grid (position/backdrop/slide/close ×), this was the only opt-in mode before, then called 'wrapper'. 'inline' renders the same container as a static column alongside the grid instead, no backdrop, no animation. 'none' means the calendar never opens anything on its own. |
sidePanelTemplate / sidePanelPosition | TemplateRef<...> / 'left' | 'right' | none / 'right' | Only relevant when sidePanel is "inline" or "popup". |
sidePanelOpen | boolean | null | null | null (uncontrolled, default): a day/slot click opens the panel, its own close affordance closes it, the original behavior. true/false controls it from the host instead, e.g. a persistent panel that's always open and never toggled by a click (same "controlled" pattern as view/viewChanged): the calendar still emits sidePanelOpened/sidePanelClosed so the host can react, but visibility follows this input, not the click. |
sidePanelClosable | boolean | true | false hides the built-in ×, for a non-dismissible panel, or one that supplies its own close control inside sidePanelTemplate (via the context's close). Independent of sidePanelOpen; 'popup' mode's backdrop click-to-close still works either way. |
sidePanelWidth | string (CSS length) | none | Overrides either mode's own default width ('inline': min(22rem, 100%); 'popup': min(24rem, 88vw)), e.g. a wider persistent 'inline' panel that needs more room than the default floating popup did. |
Outputs
| Name | Payload | Note |
|---|---|---|
viewChanged | { view } | User clicked a header tab. Rebind view to actually switch (see the input above). |
daySelected | { date, events } | A day box click (Monthly), a day header click (Weekly), or clicking any event inside any view's grid (all three), an event click always selects its own day too, same as clicking the box/header itself. Never fires for a disabled (out-of-range) day. |
eventClicked | { event } | Monthly: fired via CalendarDayCellContext.onEventClick(event, domEvent), call it from your own dayBodyTemplate markup; it stops the click from also bubbling into the cell's own click handler. Weekly/Daily: fired directly by the calendar when a positioned event is clicked, no callback needed. Every case also emits daySelected (see above). |
dateRangeChanged | { start, end } | Fires on load and after every Prev/Today/Next/view-switch, your cue to (re)fetch events for what's now visible. The calendar never fetches anything itself. |
slotSelected | { date, time, events } | Weekly/Daily empty-cell click, events is only what actually overlaps that slot, not the whole day. |
sidePanelOpened / sidePanelClosed | { context } | Only relevant when sidePanel is "inline" or "popup". |