Calendar of events
A month of days, each holding events you can drag to another day, and a filter that narrows them.
Goal
Reach for this shape for a planner, a schedule or a board whose columns are dates. The page imports only from @ecopages/radiant-ui/controllers and @ecopages/radiant-ui/helpers.
Primitives
buildCalendarMonth,getWeekStartsOn,getWeekdayLabels,formatMonthYear,formatDisplayDatebuild the weeks, the week start, the headers and the day names for the locale, so the grid has no date arithmetic of its own.textContainsdecides which events match the filter, ignoring case and accents.SortableControllerwith oneSortableGroupmakes every day a container events move between. It handles the pointer drag, the keyboard path (Space lifts, the arrows move within the day, PageDown and PageUp move to the next and previous day) and the announcements. It never moves a node:onChangeapplies the move to the owner's list of events and renders again.RovingControllerkeeps the days to one tab stop. Withorientation: 'none'it handles no keys, so the script maps the arrows to a step of one day or one week and callsfocus().
The recipe decides three things itself: how an event is stored (an iso date per event), what the filter does to a day (it leaves non-matching events out), and which keys enter and leave a day (Enter focuses its first event, Escape returns to the day).
Example
Drag an event to another day. With the keyboard, move between days with the arrow keys, press Enter to focus the first event of a day, press Space to lift it, use the arrows to reorder it within the day and PageDown or PageUp to move it to the next or previous day, then press Space to drop. Typing in the filter hides the events that do not match.
The three forms below behave the same.
Plain JavaScript
A mount function wires the controllers to the markup and returns the cleanup that undoes it.
Focus a day, press Enter, then Space to lift an event
<div data-example="calendar-events">
<input type="search" placeholder="Filter events" aria-label="Filter events" />
<h3 data-title></h3>
<div data-grid role="grid" aria-label="Events by day"></div>
<p data-status aria-live="polite">Focus a day, press Enter, then Space to lift an event</p>
</div>import { RovingController, SortableController, SortableGroup } from '@ecopages/radiant-ui/controllers';
import {
buildCalendarMonth,
formatDisplayDate,
formatMonthYear,
getWeekdayLabels,
getWeekStartsOn,
textContains,
} from '@ecopages/radiant-ui/helpers';
export function mount(root: HTMLElement): () => void {
const filter = root.querySelector('input')!;
const grid = root.querySelector<HTMLElement>('[data-grid]')!;
const status = root.querySelector('[data-status]')!;
const locale = 'en-US';
const month = new Date(2026, 10, 1);
const events = [
{ id: 'review', iso: '2026-11-03', title: 'Design review' },
{ id: 'standup', iso: '2026-11-03', title: 'Standup' },
{ id: 'launch', iso: '2026-11-12', title: 'Launch day' },
{ id: 'retro', iso: '2026-11-19', title: 'Retro' },
];
root.querySelector('[data-title]')!.textContent = formatMonthYear(month, locale);
const headers = getWeekdayLabels(locale, getWeekStartsOn(locale)).map(
(label) => `<b role="columnheader">${label}</b>`,
);
grid.insertAdjacentHTML('beforeend', `<div role="row">${headers.join('')}</div>`);
const cells: HTMLElement[] = buildCalendarMonth(month.getFullYear(), month.getMonth(), { locale }).flatMap(
(week) => {
const row = grid.appendChild(document.createElement('div'));
row.setAttribute('role', 'row');
return week.map(({ iso, date, inMonth }) => {
const cell = row.appendChild(document.createElement('div'));
cell.setAttribute('role', 'gridcell');
cell.setAttribute('aria-label', formatDisplayDate(date, locale, 'full'));
cell.dataset.iso = iso;
cell.dataset.day = String(date.getDate());
cell.toggleAttribute('data-outside', !inMonth);
return cell;
});
},
);
const visible = (iso: string) =>
events.filter((event) => event.iso === iso && textContains(event.title, filter.value));
function render(): void {
for (const cell of cells) {
const items = visible(cell.dataset.iso!).map(
({ id, title }) => `<div data-event="${id}" tabindex="-1">${title}</div>`,
);
cell.innerHTML = `<span data-day aria-hidden="true">${cell.dataset.day}</span>${items.join('')}`;
}
}
const group = new SortableGroup();
const sortables = cells.map(
(cell) =>
new SortableController({
group,
getContainer: () => cell,
getItems: () => [...cell.querySelectorAll<HTMLElement>('[data-event]')],
announce: (message) => (status.textContent = message),
onChange: ({ item, to, toContainer }) => {
const moved = events.find((event) => event.id === item.dataset.event)!;
events.splice(events.indexOf(moved), 1);
moved.iso = toContainer.dataset.iso!;
const next = visible(moved.iso)[to];
events.splice(next ? events.indexOf(next) : events.length, 0, moved);
render();
grid.querySelector<HTMLElement>(`[data-event="${moved.id}"]`)!.focus();
},
}),
);
for (const sortable of sortables) sortable.attach();
const roving = new RovingController({ getItems: () => cells, getTarget: () => grid, orientation: 'none' });
const steps: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
grid.addEventListener('keydown', (event) => {
const target = event.target as HTMLElement;
if (target.hasAttribute('data-event')) {
if (event.key === 'Escape') target.closest<HTMLElement>('[role="gridcell"]')!.focus();
return;
}
const next = cells[cells.indexOf(target) + (steps[event.key] ?? 0)];
if (event.key === 'Enter') target.querySelector<HTMLElement>('[data-event]')?.focus();
else if (steps[event.key] && next) roving.focus(next);
else return;
event.preventDefault();
});
filter.addEventListener('input', render);
render();
roving.attach();
roving.sync();
return () => {
for (const sortable of sortables) sortable.destroy();
roving.destroy();
};
}mount runs once for each example element on the page, and the function it returns undoes it; see how the examples run.
Radiant element
The same behavior as a RadiantElement. The markup is authored inside the element and the element finds its parts with @query; there is nothing to mount, clean up or run again.
Focus a day, press Enter, then Space to lift an event
<recipe-calendar-events>
<input type="search" placeholder="Filter events" aria-label="Filter events" />
<h3 data-title></h3>
<div data-grid role="grid" aria-label="Events by day"></div>
<p data-status aria-live="polite">Focus a day, press Enter, then Space to lift an event</p>
</recipe-calendar-events>import { RadiantElement, customElement, onEvent, query } from '@ecopages/radiant';
import { SortableGroup, createRovingController, createSortableController } from '@ecopages/radiant-ui/controllers';
import {
buildCalendarMonth,
formatDisplayDate,
formatMonthYear,
getWeekdayLabels,
getWeekStartsOn,
textContains,
} from '@ecopages/radiant-ui/helpers';
const locale = 'en-US';
const month = new Date(2026, 10, 1);
const steps: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
@customElement('recipe-calendar-events')
export class RecipeCalendarEvents extends RadiantElement {
@query({ selector: 'input' }) private filter!: HTMLInputElement;
@query({ selector: '[data-title]' }) private heading!: HTMLElement;
@query({ selector: '[data-grid]' }) private grid!: HTMLElement;
@query({ selector: '[data-status]' }) private status!: HTMLElement;
private cells: HTMLElement[] = [];
private readonly events = [
{ id: 'review', iso: '2026-11-03', title: 'Design review' },
{ id: 'standup', iso: '2026-11-03', title: 'Standup' },
{ id: 'launch', iso: '2026-11-12', title: 'Launch day' },
{ id: 'retro', iso: '2026-11-19', title: 'Retro' },
];
private readonly group = new SortableGroup();
private readonly sortables = buildCalendarMonth(month.getFullYear(), month.getMonth(), { locale })
.flat()
.map(({ iso }) =>
createSortableController(this, {
group: this.group,
getContainer: () => this.cells.find((cell) => cell.dataset.iso === iso) ?? null,
getItems: () => [
...(this.cells
.find((cell) => cell.dataset.iso === iso)
?.querySelectorAll<HTMLElement>('[data-event]') ?? []),
],
announce: (message) => (this.status.textContent = message),
onChange: ({ item, to, toContainer }) => {
const moved = this.events.find((event) => event.id === item.dataset.event)!;
this.events.splice(this.events.indexOf(moved), 1);
moved.iso = toContainer.dataset.iso!;
const next = this.visible(moved.iso)[to];
this.events.splice(next ? this.events.indexOf(next) : this.events.length, 0, moved);
this.paint();
this.grid.querySelector<HTMLElement>(`[data-event="${moved.id}"]`)!.focus();
},
}),
);
private readonly roving = createRovingController(this, {
getItems: () => this.cells,
getTarget: () => this.grid,
orientation: 'none',
});
private visible(iso: string) {
return this.events.filter((event) => event.iso === iso && textContains(event.title, this.filter.value));
}
private paint(): void {
for (const cell of this.cells) {
const items = this.visible(cell.dataset.iso!).map(
({ id, title }) => `<div data-event="${id}" tabindex="-1">${title}</div>`,
);
cell.innerHTML = `<span data-day aria-hidden="true">${cell.dataset.day}</span>${items.join('')}`;
}
}
/**
* Builds the day cells, then attaches the sortables again.
*
* @remarks
* The controllers attach before this runs, when the cells do not exist, so their containers resolve to nothing.
* A second `attach()` resolves them again.
*/
override onConnected(): void {
this.heading.textContent = formatMonthYear(month, locale);
const headers = getWeekdayLabels(locale, getWeekStartsOn(locale)).map(
(label) => `<b role="columnheader">${label}</b>`,
);
this.grid.replaceChildren();
this.grid.insertAdjacentHTML('beforeend', `<div role="row">${headers.join('')}</div>`);
this.cells = buildCalendarMonth(month.getFullYear(), month.getMonth(), { locale }).flatMap((week) => {
const row = this.grid.appendChild(document.createElement('div'));
row.setAttribute('role', 'row');
return week.map(({ iso, date, inMonth }) => {
const cell = row.appendChild(document.createElement('div'));
cell.setAttribute('role', 'gridcell');
cell.setAttribute('aria-label', formatDisplayDate(date, locale, 'full'));
cell.dataset.iso = iso;
cell.dataset.day = String(date.getDate());
cell.toggleAttribute('data-outside', !inMonth);
return cell;
});
});
this.paint();
for (const sortable of this.sortables) sortable.attach();
this.roving.sync();
}
@onEvent({ selector: 'input', type: 'input' })
refilter(): void {
this.paint();
}
@onEvent({ selector: '[data-grid]', type: 'keydown' })
walk(event: KeyboardEvent): void {
const target = event.target as HTMLElement;
if (target.hasAttribute('data-event')) {
if (event.key === 'Escape') target.closest<HTMLElement>('[role="gridcell"]')!.focus();
return;
}
const next = this.cells[this.cells.indexOf(target) + (steps[event.key] ?? 0)];
if (event.key === 'Enter') target.querySelector<HTMLElement>('[data-event]')?.focus();
else if (steps[event.key] && next) this.roving.focus(next);
else return;
event.preventDefault();
}
}createSortableController(this, ...) and createRovingController(this, ...) attach when the element connects and are destroyed when it disconnects, and @onEvent listeners follow the same lifecycle. The element keeps its private methods off the names RadiantElement owns: a method called render would replace the host's own and empty the element. The sortables are created once, one per day of the month, and share a SortableGroup kept in a field. The day cells are built in onConnected, after the controllers attached, so the sortables are attached once more there to resolve their containers; a second attach() is how a controller finds a target that did not exist yet.
What the Radiant form removes: the mount function and its registration, the cleanup that destroys each sortable and the roving controller, and the re-run after a client-side navigation.
Radiant component
The same calendar as one self-contained tag. The element owns its markup: the events and the filter are state, and render() draws the filter field, the heading, the weekday headers, the day cells and the events from buildCalendarMonth and the other helpers, so the page holds only the tag.
<recipe-calendar-events-app></recipe-calendar-events-app>import { RadiantElement, customElement, onEvent, state } from '@ecopages/radiant';
import { SortableGroup, createRovingController, createSortableController } from '@ecopages/radiant-ui/controllers';
import {
buildCalendarMonth,
formatDisplayDate,
formatMonthYear,
getWeekdayLabels,
getWeekStartsOn,
textContains,
} from '@ecopages/radiant-ui/helpers';
type CalendarEvent = { id: string; iso: string; title: string };
const locale = 'en-US';
const month = new Date(2026, 10, 1);
const weeks = buildCalendarMonth(month.getFullYear(), month.getMonth(), { locale });
const steps: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
const initialEvents: CalendarEvent[] = [
{ id: 'review', iso: '2026-11-03', title: 'Design review' },
{ id: 'standup', iso: '2026-11-03', title: 'Standup' },
{ id: 'launch', iso: '2026-11-12', title: 'Launch day' },
{ id: 'retro', iso: '2026-11-19', title: 'Retro' },
];
@customElement('recipe-calendar-events-app')
export class RecipeCalendarEventsApp extends RadiantElement<{ status: string }> {
@state events = initialEvents;
@state filter = '';
@state status = 'Focus a day, press Enter, then Space to lift an event';
private focusEvent: string | null = null;
private readonly group = new SortableGroup();
private readonly sortables = weeks.flat().map(({ iso }) =>
createSortableController(this, {
group: this.group,
getContainer: () => this.querySelector<HTMLElement>(`[data-iso="${iso}"]`),
getItems: () => [...this.querySelectorAll<HTMLElement>(`[data-iso="${iso}"] [data-event]`)],
announce: (message) => (this.status = message),
onChange: ({ item, to, toContainer }) => {
this.focusEvent = item.dataset.event!;
this.events = this.move(item.dataset.event!, toContainer.dataset.iso!, to);
},
}),
);
private readonly roving = createRovingController(this, {
getItems: () => this.cells,
getTarget: () => this.querySelector<HTMLElement>('[data-grid]'),
orientation: 'none',
});
private get cells(): HTMLElement[] {
return [...this.querySelectorAll<HTMLElement>('[role="gridcell"]')];
}
private visible(events: CalendarEvent[], iso: string): CalendarEvent[] {
return events.filter((event) => event.iso === iso && textContains(event.title, this.filter));
}
private move(id: string, iso: string, to: number): CalendarEvent[] {
const moved = { ...this.events.find((event) => event.id === id)!, iso };
const rest = this.events.filter((event) => event.id !== id);
const next = this.visible(rest, iso)[to];
rest.splice(next ? rest.indexOf(next) : rest.length, 0, moved);
return rest;
}
/**
* Attaches the controllers to the grid the render produced, and focuses the event that was just moved.
*
* @remarks
* The controllers attach when the element connects, before the first render, when no day exists yet; a second
* `attach()` resolves each target again. The keyed days keep their cells across renders, so later calls change
* nothing. An event that changed day is a new node, so it is found by its id.
*/
protected override updated(): void {
for (const sortable of this.sortables) sortable.attach();
this.roving.attach();
this.roving.sync();
if (this.focusEvent) {
this.querySelector<HTMLElement>(`[data-event="${this.focusEvent}"]`)?.focus();
this.focusEvent = null;
}
}
@onEvent({ selector: 'input', type: 'input' })
refilter(event: Event): void {
this.filter = (event.target as HTMLInputElement).value;
}
@onEvent({ selector: '[data-grid]', type: 'keydown' })
walk(event: KeyboardEvent): void {
const target = event.target as HTMLElement;
if (target.hasAttribute('data-event')) {
if (event.key === 'Escape') target.closest<HTMLElement>('[role="gridcell"]')!.focus();
return;
}
const cells = this.cells;
const next = cells[cells.indexOf(target) + (steps[event.key] ?? 0)];
if (event.key === 'Enter') target.querySelector<HTMLElement>('[data-event]')?.focus();
else if (steps[event.key] && next) this.roving.focus(next);
else return;
event.preventDefault();
}
override render() {
return (
<>
<input type="search" placeholder="Filter events" aria-label="Filter events" />
<h3 data-title>{formatMonthYear(month, locale)}</h3>
<div data-grid role="grid" aria-label="Events by day">
<div role="row">
{getWeekdayLabels(locale, getWeekStartsOn(locale)).map((label) => (
<b role="columnheader">{label}</b>
))}
</div>
{weeks.map((week) => (
<div key={week[0].iso} role="row">
{week.map(({ iso, date, inMonth }) => (
<div
key={iso}
role="gridcell"
aria-label={formatDisplayDate(date, locale, 'full')}
data-iso={iso}
data-outside={inMonth ? undefined : ''}
>
<span data-day aria-hidden="true">
{date.getDate()}
</span>
{this.visible(this.events, iso).map(({ id, title }) => (
<div key={id} data-event={id} tabindex="-1">
{title}
</div>
))}
</div>
))}
</div>
))}
</div>
<p data-status aria-live="polite">
{this.$.status}
</p>
</>
);
}
}Moving an event writes a new events array and the render draws it; the filter is a second @state that render() reads, so typing leaves out the events that do not match without any code to hide them. The controllers attach when the element connects, before the first render, so updated() attaches them once more when the grid exists, then keeps the roving tab stop in step and focuses the event that moved, found by its id because an event that changed day is a new node. The status line is bound with this.$.status, so announcing during a sort patches one text node and does not call render() again.
Compared with the Radiant element above, there is no day-building code and no innerHTML to keep in step with the state, but the markup lives inside the element, so restyling the structure means editing the class. It also needs the render path on the server to have content before the script runs: this docs build prerenders the tag empty and the client draws it, so the page shows nothing until the script loads, which a page of authored children does not.