DatesEvent calendar
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callEvent calendar
A week view where the agent finds a free slot for you. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add event-calendarReact
npx designme add event-calendar --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
A work-week calendar where people drag events to a new time or day, click free time to add one, and open any event for its details. An agent button in the header asks your agent to find a slot, and the slots it suggests appear as outlined blocks with Book and Other. It shows scheduling help from an agent right inside the calendar people already use.
- When an agent schedules meetings or finds free time for a team.
- When people need to move and add events in a simple week view without a full calendar app.
- When the agent can prepare something for a meeting, such as prep notes, from the event itself.
- Shows
dayscolumns fromweek(5 by default) andhoursrows fromstartHour(9am to 5pm by default), with a today mark and a now line whennowis set. - Dragging an event moves it to another time or day in 15-minute steps; a click without moving opens its details card.
- Hovering free time shows a "+ New event" slot, and clicking it adds an event, from
newEvent, with a box to name it; Save keeps it and Discard removes it. - The details card shows the time and the people invited, plus Prep notes when
onPrepNotesis set ("Writing" shows until a returned promise settles) and Join whenonJoinis set. - With
onAsk, the header gets the agent's button; whilefindingis true it shows a thinking mark and a scan line sweeps the week. suggestionsdraw as outlined blocks; the first is the pick, with Book (onBook) and, while there are more, Other (onSkip). The header line reports the last move or addition, or your ownstatus.- Under 520px wide, on a phone or in a narrow card, it shows one day at a time, picked from a row of days, so every event has room for its whole title and time. It opens on today, and on the day of the agent's pick when
suggestionsarrive; control the day withdayandonDayChange.
- The details card is a role="dialog" labelled "Event", and it is inert while closed.
- Escape closes the details card; in a new event's title box, Enter saves and Escape discards.
- Enter or Space on an event opens its details card with the focus in it, and closing the card gives the focus back to the event.
- The title box is labelled "Event title", and the card's close button is labelled "Close".
- On a narrow calendar the row of days is a group labelled "Day"; each day is a button with
aria-pressedand its full date as its label.
Usage
Import it and drive it from your own state.
Import
import { EventCalendar } from '@/components/designme/event-calendar/EventCalendar';Example
The demo above as a React component, 56 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useCallback, useRef, useState } from 'react';
import { EventCalendar, type EventCalendarEvent, type EventCalendarPerson, type EventCalendarSuggestion, type EventCalendarTone } from './EventCalendar';
import { useStory, useWait } from '../lib/useScript';
const WEEK = new Date(2026, 9, 19);
const at = (day: number, hour: number) => new Date(2026, 9, 19 + day, Math.floor(hour), Math.round((hour % 1) * 60));
const WHO: EventCalendarPerson[] = [{ initials: 'MK', color: 'var(--dm-av-l)' }, { initials: 'JO', color: 'var(--dm-av-m)' }, { initials: 'DA', color: 'var(--dm-av-b)' }, { initials: 'AR', color: 'var(--dm-av-p)' }];
// two to four people, the way the site picks them
const people = (title: string) => WHO.slice(0, 2 + title.length % 3);
const EVENTS: [number, number, number, string, EventCalendarTone][] = [
[0, 9, 1, 'Standup', 'blue'], [0, 13, 1.5, 'Pricing workshop', 'orange'], [1, 10, 1, '1:1 with Maya', 'purple'], [1, 14, 2, 'Focus: checkout', 'teal'],
[2, 9, 1, 'Standup', 'blue'], [2, 11.5, 1, 'Agent demo', 'yellow'], [3, 9, 1, 'Standup', 'blue'], [3, 12, 1, 'Lunch & learn', 'green'],
[3, 15, 1.5, 'Launch review', 'red'], [4, 10, 1.5, 'Planning', 'orange']
];
const START: EventCalendarEvent[] = EVENTS.map(([d, h, len, title, tone], i) => ({ id: 'e' + i, title, start: at(d, h), end: at(d, h + len), tone, people: people(title) }));
const SLOTS: EventCalendarSuggestion[] = [
{ id: 'wed', title: 'Design review', start: at(2, 13.5), end: at(2, 14.25) },
{ id: 'fri', title: 'Design review', start: at(4, 13), end: at(4, 13.75) }
];
export default function EventCalendarDemo() {
const [events, setEvents] = useState(START);
const [suggestions, setSuggestions] = useState<EventCalendarSuggestion[]>([]);
const [finding, setFinding] = useState(false);
const [label, setLabel] = useState('Find 45 min for a design review');
const skipped = useRef(false);
const wait = useWait();
// the agent checks everyone's calendar, then suggests the best slot with one more behind it
const ask = useCallback(async () => {
setSuggestions([]); skipped.current = false;
setFinding(true); setLabel('Checking 4 calendars');
await wait(1300);
setSuggestions(SLOTS); setFinding(false); setLabel('Everyone is free Wed 1:30');
}, [wait]);
// the preview asks the agent for a slot once on its own, unless you take over first
const ref = useRef<HTMLDivElement>(null);
useStory(ref, async s => { await s.wait(1200); await ask(); });
return (
<div ref={ref} style={{ width: '100%' }}>
<EventCalendar week={WEEK} now={new Date(2026, 9, 19, 10, 42)} events={events} onEventsChange={setEvents}
newEvent={{ tone: 'pink', people: people('New event') }}
onPrepNotes={() => wait(1200).then(() => "Notes ready: last week's decisions and 3 open questions.")} onJoin={() => {}}
askLabel={label} onAsk={ask} finding={finding} suggestions={suggestions}
onBook={s => {
setEvents(es => [...es, { id: s.id + '-booked', title: s.title, start: s.start, end: s.end, tone: 'blue', people: people(s.title) }]);
setSuggestions([]); setLabel(skipped.current ? 'Booked Fri 1:00, invites sent' : 'Booked, invites sent to 4 people');
}}
onSkip={() => { skipped.current = true; setSuggestions(ss => ss.slice(1)); setLabel('Next best: Fri 1:00'); }} />
</div>
);
}
Props
Everything EventCalendar takes.
| Prop | Type | What it does |
|---|---|---|
events | EventCalendarEvent[] | The events. Dragging moves them, clicking free time adds one, the details card names or removes it |
defaultEvents | EventCalendarEvent[] | |
onEventsChange | (events: EventCalendarEvent[]) => void | |
weekrequired | Date | The first day shown, like Monday Oct 19. Its time of day is ignored |
days | number | How many days to show, from `week`. Defaults to 5 |
startHour | number | The first hour on the grid, 9 for 9am |
hours | number | How many hours the grid shows. Defaults to 8 (9am to 5pm) |
title | string | The heading. Defaults to the dates shown, like "Oct 19 – 23, 2026" |
now | Date | Marks today and draws the now line. Leave it out for neither |
selected | string | null | The event whose details card is open |
defaultSelected | string | null | |
onSelectedChange | (id: string | null) => void | |
newEvent | { tone?: EventCalendarTone; people?: EventCalendarPerson[]; hours?: number } | What an event added by clicking free time starts with. Defaults to a pink, one hour event |
onPrepNotes | (event: EventCalendarEvent) => ReactNode | Promise<ReactNode> | Adds Prep notes to the details card. Return the notes, or a promise of them; "Writing" shows until it settles |
onJoin | (event: EventCalendarEvent) => void | Adds Join to the details card |
askLabel | string | Adds the agent's button to the header, like "Find 45 min for a design review" |
onAsk | () => void | |
finding | boolean | The agent is looking: its button thinks and a scan line sweeps the week |
suggestions | EventCalendarSuggestion[] | Slots the agent suggests. The first is the pick, the rest wait dimmed behind it |
onBook | (suggestion: EventCalendarSuggestion) => void | Book on the picked suggestion. Add the event and clear the suggestions |
onSkip | (suggestion: EventCalendarSuggestion) => void | Other on the picked suggestion, shown while there are more. Drop it and the next one becomes the pick |
status | string | The quiet line in the header. Defaults to what the person last did, like "Moved Standup to Wed 1:15" |
day | number | The day a narrow calendar shows, as its place in the week (0 for the first day). A calendar under 520px wide shows one day at a time, picked from a row of days; it opens on today, and moves to the day of the agent's pick when one comes |
defaultDay | number | |
onDayChange | (day: number) => void | |
className | string |
Code
The full source, as the CLI installs it.
EventCalendar.tsxReact · 361 lines
'use client';
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Avatar, enter, Swap, Think, Tip } from '../lib/motion';
import '../designme.css';
import './event-calendar.css';
export type EventCalendarTone = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'yellow' | 'pink' | 'teal';
export interface EventCalendarPerson {
/** Like "MK" */
initials: string;
/** The avatar's colour, like 'var(--dm-av-l)' */
color: string;
}
export interface EventCalendarEvent {
id: string;
/** Like "Pricing workshop". An empty title reads "New event" and opens with a box to name it */
title: string;
/** Read in the browser's local time */
start: Date;
end: Date;
/** The block's colour. Defaults to blue */
tone?: EventCalendarTone;
/** Who is invited, shown in the details card */
people?: EventCalendarPerson[];
}
/** A free slot the agent found, drawn as an outlined block with Book (and Other while there are more) */
export interface EventCalendarSuggestion {
id: string;
title: string;
start: Date;
end: Date;
}
export interface EventCalendarProps {
/** The events. Dragging moves them, clicking free time adds one, the details card names or removes it */
events?: EventCalendarEvent[];
defaultEvents?: EventCalendarEvent[];
onEventsChange?: (events: EventCalendarEvent[]) => void;
/** The first day shown, like Monday Oct 19. Its time of day is ignored */
week: Date;
/** How many days to show, from `week`. Defaults to 5 */
days?: number;
/** The first hour on the grid, 9 for 9am */
startHour?: number;
/** How many hours the grid shows. Defaults to 8 (9am to 5pm) */
hours?: number;
/** The heading. Defaults to the dates shown, like "Oct 19 – 23, 2026" */
title?: string;
/** Marks today and draws the now line. Leave it out for neither */
now?: Date;
/** The event whose details card is open */
selected?: string | null;
defaultSelected?: string | null;
onSelectedChange?: (id: string | null) => void;
/** What an event added by clicking free time starts with. Defaults to a pink, one hour event */
newEvent?: { tone?: EventCalendarTone; people?: EventCalendarPerson[]; hours?: number };
/** Adds Prep notes to the details card. Return the notes, or a promise of them; "Writing" shows until it settles */
onPrepNotes?: (event: EventCalendarEvent) => ReactNode | Promise<ReactNode>;
/** Adds Join to the details card */
onJoin?: (event: EventCalendarEvent) => void;
/** Adds the agent's button to the header, like "Find 45 min for a design review" */
askLabel?: string;
onAsk?: () => void;
/** The agent is looking: its button thinks and a scan line sweeps the week */
finding?: boolean;
/** Slots the agent suggests. The first is the pick, the rest wait dimmed behind it */
suggestions?: EventCalendarSuggestion[];
/** Book on the picked suggestion. Add the event and clear the suggestions */
onBook?: (suggestion: EventCalendarSuggestion) => void;
/** Other on the picked suggestion, shown while there are more. Drop it and the next one becomes the pick */
onSkip?: (suggestion: EventCalendarSuggestion) => void;
/** The quiet line in the header. Defaults to what the person last did, like "Moved Standup to Wed 1:15" */
status?: string;
/** The day a narrow calendar shows, as its place in the week (0 for the first day). A calendar under 520px wide shows one
day at a time, picked from a row of days; it opens on today, and moves to the day of the agent's pick when one comes */
day?: number;
defaultDay?: number;
onDayChange?: (day: number) => void;
className?: string;
}
const PX = 44;
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const DAYN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const TONE: Record<EventCalendarTone, string> = { blue: 'pb', purple: 'pl', green: 'pm', orange: 'pp', red: 'pr', yellow: 'py', pink: 'pk', teal: 'pt' };
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
const dayIndex = (week: Date, d: Date) => Math.round((addDays(d, 0).getTime() - addDays(week, 0).getTime()) / 864e5);
const hourOf = (d: Date) => d.getHours() + d.getMinutes() / 60;
const at = (week: Date, day: number, hour: number) => new Date(week.getFullYear(), week.getMonth(), week.getDate() + day, Math.floor(hour), Math.round((hour % 1) * 60));
/** 13.5 → "1:30", the way the site writes times */
const tm = (h: number) => { const hh = Math.floor(h), mm = Math.round((h - hh) * 60); return `${hh > 12 ? hh - 12 : hh}:${String(mm).padStart(2, '0')}`; };
const tint = (tone: EventCalendarTone = 'blue'): CSSProperties => ({ background: `var(--dm-${TONE[tone]}-bg)`, color: `var(--dm-${TONE[tone]})` });
const named = (e: EventCalendarEvent) => e.title || 'New event';
/** The details card: who is coming and what to do, or a box to name a new event */
function Details({ event, onClose, onSave, onDiscard, onPrepNotes, onJoin }: {
event: EventCalendarEvent; onClose: () => void; onSave: (title: string) => void; onDiscard: () => void;
onPrepNotes?: EventCalendarProps['onPrepNotes']; onJoin?: EventCalendarProps['onJoin'];
}) {
const fresh = !event.title;
const input = useRef<HTMLInputElement>(null);
const [prep, setPrep] = useState<'idle' | 'writing' | 'done'>('idle');
const [note, setNote] = useState<ReactNode>(null);
useEffect(() => { if (fresh) input.current?.focus({ preventScroll: true }); }, [fresh]);
const write = async () => {
if (!onPrepNotes || prep !== 'idle') return;
setPrep('writing');
const n = await onPrepNotes(event);
setNote(n); setPrep('done');
};
const key = (k: KeyboardEvent<HTMLInputElement>) => { if (k.key === 'Enter') onSave(k.currentTarget.value); if (k.key === 'Escape') onDiscard(); };
const h = hourOf(event.start), end = hourOf(event.end), people = event.people ?? [];
return (
<>
<div className="dm-ecx-ph">
<i style={{ background: `var(--dm-${TONE[event.tone ?? 'blue']})` }} />
{fresh ? <input ref={input} className="dm-ecx-ti" placeholder="Add a title" aria-label="Event title" onKeyDown={key} /> : <b>{event.title}</b>}
<Tip label="Close"><button type="button" className="dm-ib" aria-label="Close" onClick={onClose}><Icon name="x" size="xs" /></button></Tip>
</div>
<div className="dm-ecx-pt">{DAYN[event.start.getDay()]} {event.start.getDate()} {MONTHS[event.start.getMonth()]} · {tm(h)} – {tm(end)}</div>
{people.length > 0 && (
<div className="dm-ecx-pw">
{people.map((p, i) => <Avatar key={i} initials={p.initials} color={p.color} />)}
<span className="dm-muted">{people.length === 1 ? '1 person' : `${people.length} people`}</span>
</div>
)}
{prep === 'done' && <div className={cx('dm-ecx-note', enter().className)}><Icon name="spark" size="xs" /><span>{note}</span></div>}
{fresh ? (
<div className="dm-ecx-pa">
<button type="button" className="dm-pill dm-ghost dm-sm" onClick={onDiscard}>Discard</button>
<button type="button" className="dm-pill dm-primary dm-sm" onClick={() => onSave(input.current?.value ?? '')}>Save</button>
</div>
) : (onPrepNotes || onJoin) && (
<div className="dm-ecx-pa">
{onPrepNotes && prep !== 'done' && <button type="button" className="dm-pill dm-soft dm-sm" onClick={write}>{prep === 'writing' ? <><Think />Writing</> : <><Icon name="spark" size="xs" />Prep notes</>}</button>}
{onJoin && <button type="button" className="dm-pill dm-primary dm-sm" onClick={() => onJoin(event)}>Join</button>}
</div>
)}
</>
);
}
/** A work week of events you can drag to another time or day, add to by clicking free time, and open for details.
The agent can look for a free slot and suggest it. */
export function EventCalendar({
events, defaultEvents = [], onEventsChange, week, days = 5, startHour = 9, hours = 8, title, now,
selected, defaultSelected = null, onSelectedChange, newEvent, onPrepNotes, onJoin,
askLabel = 'Find a time', onAsk, finding = false, suggestions = [], onBook, onSkip, status, day, defaultDay, onDayChange, className
}: EventCalendarProps) {
const [list, setList] = useControllable(events, defaultEvents, onEventsChange);
const [openId, setOpenId] = useControllable(selected, defaultSelected, onSelectedChange);
const [msg, setMsg] = useState('');
const uid = useId();
const made = useRef(0);
const root = useRef<HTMLDivElement>(null);
const pop = useRef<HTMLDivElement>(null);
const cols = useRef<(HTMLDivElement | null)[]>([]);
const blocks = useRef(new Map<string, HTMLElement>());
// the first events stagger in; an event added later, or dropped after a drag, plays the entrance again
const delays = useRef<Map<string, number> | null>(null);
if (!delays.current) delays.current = new Map(list.map((e, i) => [e.id, i * 30]));
// a dropped event goes last in its day, so it sits on top, as on the site
const [moves, setMoves] = useState<Record<string, number>>({});
const drops = useRef(0);
const end = startHour + hours;
const inView = (d: Date) => { const i = dayIndex(week, d); return i >= 0 && i < days; };
const shown = list.filter(e => inView(e.start));
const open = openId != null ? list.find(e => e.id === openId) ?? null : null;
// the card keeps what it showed while it fades out, and starts afresh each time it opens
const [card, setCard] = useState(open);
if (open && open !== card) setCard(open);
const [runs, setRuns] = useState(0);
const select = (id: string | null) => { if (id !== null) setRuns(n => n + 1); setOpenId(id); };
const close = () => setOpenId(null);
// opened with Enter or Space, the card takes the focus; closed with the focus in it, its event gets it back
const keyOpen = useRef(false), inPop = useRef(false), lastOpen = useRef<string | null>(null);
useIsoLayoutEffect(() => {
if (openId != null) {
lastOpen.current = openId;
if (keyOpen.current) { keyOpen.current = false; pop.current?.querySelector<HTMLElement>('input,button')?.focus({ preventScroll: true }); }
return;
}
if (inPop.current) { inPop.current = false; const b = lastOpen.current && blocks.current.get(lastOpen.current); if (b && b.isConnected) b.focus({ preventScroll: true }); }
}, [openId]);
/* ---------- the details card: beside its event, inside the calendar ---------- */
const [place, setPlace] = useState<CSSProperties>({});
useIsoLayoutEffect(() => {
const el = open && blocks.current.get(open.id), p = pop.current, panel = root.current;
if (!el || !p || !panel) return;
const pr = panel.getBoundingClientRect(), er = el.getBoundingClientRect(), w = 248;
let x = er.right - pr.left + 8; if (x + w > pr.width - 8) x = er.left - pr.left - w - 8; x = Math.max(8, x);
const y = Math.max(8, Math.min(er.top - pr.top, pr.height - p.offsetHeight - 8));
setPlace({ left: x, top: y });
}, [open?.id, runs]);
useEffect(() => {
if (!open) return;
const down = (e: globalThis.PointerEvent) => { const t = e.target as Node; if (!pop.current?.contains(t) && !blocks.current.get(open.id)?.contains(t)) close(); };
const key = (e: globalThis.KeyboardEvent) => { if (e.key === 'Escape') close(); };
document.addEventListener('pointerdown', down); document.addEventListener('keydown', key);
return () => { document.removeEventListener('pointerdown', down); document.removeEventListener('keydown', key); };
}); // eslint-disable-line react-hooks/exhaustive-deps
const update = (id: string, change: Partial<EventCalendarEvent>) => setList(list.map(e => e.id === id ? { ...e, ...change } : e));
const remove = (id: string) => { setList(list.filter(e => e.id !== id)); close(); };
const save = (e: EventCalendarEvent, value: string) => {
const v = value.trim() || 'Untitled';
update(e.id, { title: v }); close();
setMsg(`Added ${v}, ${DAYN[e.start.getDay()]} ${tm(hourOf(e.start))}`);
};
/* ---------- dragging: to another time or day, in 15 minute steps; a click without moving opens the card ---------- */
const drag = useRef<{ id: string; x: number; y: number; moved: boolean; day: number; hour: number } | null>(null);
const [dragging, setDragging] = useState<{ id: string; day: number; hour: number; dx: number } | null>(null);
const colX = (i: number) => cols.current[i]?.getBoundingClientRect().left ?? 0;
const down = (p: PointerEvent<HTMLElement>, e: EventCalendarEvent) => {
if (p.button) return;
p.currentTarget.setPointerCapture(p.pointerId);
drag.current = { id: e.id, x: p.clientX, y: p.clientY, moved: false, day: dayIndex(week, e.start), hour: hourOf(e.start) };
};
const move = (p: PointerEvent<HTMLElement>, e: EventCalendarEvent) => {
const d = drag.current; if (!d || d.id !== e.id) return;
const dx = p.clientX - d.x, dy = p.clientY - d.y;
if (!d.moved && Math.hypot(dx, dy) < 5) return;
if (!d.moved) { d.moved = true; if (openId != null) close(); setGhost(null); }
const from = dayIndex(week, e.start), len = hourOf(e.end) - hourOf(e.start);
d.hour = Math.max(startHour, Math.min(end - len, hourOf(e.start) + Math.round(dy / PX * 4) / 4));
const under = document.elementsFromPoint(p.clientX, p.clientY).find(x => x.classList?.contains('dm-ecx-col')) as HTMLElement | undefined;
if (under) d.day = +(under.dataset.d ?? d.day);
setDragging({ id: e.id, day: d.day, hour: d.hour, dx: colX(d.day) - colX(from) });
};
const up = (e: EventCalendarEvent) => {
const d = drag.current; drag.current = null; if (!d || d.id !== e.id) return;
if (!d.moved) { select(openId === e.id ? null : e.id); return; }
setDragging(null);
const n = ++drops.current; setMoves(m => ({ ...m, [e.id]: n }));
const from = dayIndex(week, e.start), h = hourOf(e.start), len = hourOf(e.end) - h;
if (d.day === from && d.hour === h) return;
update(e.id, { start: at(week, d.day, d.hour), end: at(week, d.day, d.hour + len) });
setMsg(`Moved ${named(e)} to ${DAYN[addDays(week, d.day).getDay()]} ${tm(d.hour)}`);
};
/* ---------- free time: a dashed slot follows the pointer, a click adds an event there ---------- */
const [ghost, setGhost] = useState<{ day: number; hour: number } | null>(null);
const hover = (p: PointerEvent<HTMLDivElement>, day: number) => {
if (p.target !== p.currentTarget || dragging) { if (ghost) setGhost(null); return; }
const hour = startHour + Math.min(hours * 2 - 1, Math.floor((p.clientY - p.currentTarget.getBoundingClientRect().top) / PX * 2)) / 2;
if (ghost?.day !== day || ghost.hour !== hour) setGhost({ day, hour });
};
const add = (p: MouseEvent<HTMLDivElement>, day: number) => {
if (p.target !== p.currentTarget || !ghost || ghost.day !== day) return;
const len = newEvent?.hours ?? 1, id = `${uid}-new-${++made.current}`;
setList([...list, { id, title: '', start: at(week, day, ghost.hour), end: at(week, day, ghost.hour + len), tone: newEvent?.tone ?? 'pink', people: newEvent?.people }]);
setGhost(null); select(id);
};
/* ---------- the agent's scan ---------- */
const [scans, setScans] = useState(0);
useEffect(() => { if (finding) setScans(n => n + 1); }, [finding]);
const heading = title ?? (() => { const a = week, b = addDays(week, days - 1); return `${MONTHS[a.getMonth()]} ${a.getDate()} – ${b.getMonth() !== a.getMonth() ? MONTHS[b.getMonth()] + ' ' : ''}${b.getDate()}, ${b.getFullYear()}`; })();
const today = now && inView(now) ? dayIndex(week, now) : -1;
const sizes: CSSProperties | undefined = days !== 5 ? { ['--dm-days' as string]: days } : undefined;
/* ---------- a narrow calendar: one day at a time ---------- */
const [shownDay, setShownDay] = useControllable(day, defaultDay ?? Math.max(0, today), onDayChange);
const daysRow = useRef<HTMLDivElement>(null);
const narrow = () => !!daysRow.current?.getClientRects().length;
const [turns, setTurns] = useState(0);
const pickDay = (d: number) => { if (d === shownDay) return; if (narrow()) { close(); setTurns(n => n + 1); } setShownDay(d); };
// the agent's pick opens on its day
const pick = suggestions[0], pickDayOf = pick && inView(pick.start) ? dayIndex(week, pick.start) : -1;
useEffect(() => { if (pickDayOf >= 0) pickDay(pickDayOf); }, [pick?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const tall: CSSProperties | undefined = hours !== 8 ? { height: hours * PX } : undefined;
const top = (h: number) => (h - startHour) * PX + 1;
return (
<div ref={root} data-dm="" className={cx('dm-ecx dm-panel', className)}>
<div className="dm-ecx-h">
<b>{heading}</b><span className="dm-ecx-sp" />
<Swap className="dm-ecx-msg dm-muted">{status ?? msg}</Swap>
{onAsk && <button type="button" className="dm-pill dm-soft dm-sm dm-ecx-ask" onClick={onAsk}>{finding ? <Think /> : <Icon name="spark" size="xs" />}<Swap key={finding ? 'f' : 'i'}>{askLabel}</Swap></button>}
</div>
<div ref={daysRow} className="dm-ecx-days" role="group" aria-label="Day">
{Array.from({ length: days }, (_, d) => {
const date = addDays(week, d);
return (
<button key={d} type="button" className={d === today ? 'dm-t' : undefined} aria-pressed={d === shownDay} onClick={() => pickDay(d)}
aria-label={date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}>
<span>{DAYN[date.getDay()]}</span><b>{date.getDate()}</b>
</button>
);
})}
</div>
<div className="dm-ecx-sc">
<div className="dm-ecx-grid" style={sizes}>
<div className="dm-ecx-tm">{Array.from({ length: hours }, (_, i) => { const h = startHour + i; return <span key={h}>{h > 12 ? h - 12 : h}{h >= 12 ? 'pm' : 'am'}</span>; })}</div>
{Array.from({ length: days }, (_, d) => {
const date = addDays(week, d);
return (
<div key={d === shownDay ? `${d}:${turns}` : d} className={cx('dm-ecx-day', d === shownDay && 'dm-on', d === shownDay && turns > 0 && 'dm-enter')}>
<div className={cx('dm-ecx-dh', d === today && 'dm-t')}>{DAYN[date.getDay()]} <b>{date.getDate()}</b></div>
<div ref={el => { cols.current[d] = el; }} className="dm-ecx-col" data-d={d} style={tall}
onPointerMove={p => hover(p, d)} onPointerLeave={() => setGhost(null)} onClick={p => add(p, d)}>
{shown.filter(e => dayIndex(week, e.start) === d).sort((a, b) => (moves[a.id] ?? 0) - (moves[b.id] ?? 0)).map(e => {
const h = hourOf(e.start), len = hourOf(e.end) - h, live = dragging?.id === e.id ? dragging : null, at_ = live?.hour ?? h;
if (h + len <= startHour || h >= end) return null;
// an hour or more keeps its exact height; anything shorter grows to show its time, like a booked suggestion on the site
return (
<Tip key={`${e.id}:${moves[e.id] ?? 0}`} label={`${named(e)}, ${tm(at_)}`} off={!!dragging || openId === e.id}>
<button type="button" ref={el => { if (el) blocks.current.set(e.id, el); else blocks.current.delete(e.id); }}
className={cx('dm-ecx-e dm-enter', live && 'dm-drag')}
style={{ ...tint(e.tone), top: top(at_), [len < 1 ? 'minHeight' : 'height']: len * PX - 3, transform: live ? `translateX(${live.dx}px)` : undefined, animationDelay: (delays.current!.get(e.id) ?? 0) + 'ms' }}
onPointerDown={p => down(p, e)} onPointerMove={p => move(p, e)} onPointerUp={() => up(e)} onPointerCancel={() => up(e)}
onClick={c => { if (c.detail !== 0) return; keyOpen.current = openId !== e.id; select(openId === e.id ? null : e.id); }}>
<b>{named(e)}</b><span>{tm(at_)} – {tm(at_ + len)}</span>
</button>
</Tip>
);
})}
{suggestions.filter(s => dayIndex(week, s.start) === d).map(s => {
const i = suggestions.indexOf(s), h = hourOf(s.start), len = hourOf(s.end) - h;
return (
<div key={s.id} className={cx('dm-ecx-e dm-sug', i > 0 && 'dm-alt')} style={{ ...tint('blue'), top: top(h), height: 'auto', minHeight: len * PX - 3, animationDelay: i * 120 + 'ms' }}>
<b>{s.title}</b><span>{tm(h)} – {tm(h + len)}</span>
{i === 0 && (onBook || onSkip) && (
<div className="dm-ecx-sg">
{onBook && <button type="button" onClick={() => onBook(s)}>Book</button>}
{onSkip && suggestions.length > 1 && <button type="button" className="dm-n" onClick={() => onSkip(s)}>Other</button>}
</div>
)}
</div>
);
})}
{d === today && now && <Tip label={`Now, ${tm(hourOf(now))}`}><div className="dm-ecx-now" style={{ top: (hourOf(now) - startHour) * PX }} /></Tip>}
{ghost?.day === d && <div className="dm-ecx-ghost" style={{ top: top(ghost.hour) }}>+ New event</div>}
</div>
</div>
);
})}
<div key={scans} className={cx('dm-ecx-scan', scans > 0 && 'dm-go')} />
</div>
</div>
<div ref={pop} className={cx('dm-ecx-pop', open && 'dm-open')} role="dialog" aria-label="Event" inert={!open} style={place}
onFocus={() => { inPop.current = true; }} onBlur={f => { if (!pop.current?.contains(f.relatedTarget as Node)) inPop.current = false; }}>
{card && <Details key={`${card.id}:${runs}`} event={card} onClose={close} onSave={v => save(card, v)} onDiscard={() => remove(card.id)} onPrepNotes={onPrepNotes} onJoin={onJoin} />}
</div>
</div>
);
}
event-calendar.cssReact · 79 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Event calendar ---------- */
.dm-ecx{overflow:hidden;position:relative;container:ecx/inline-size}
/* the Ask pill crossfades its words but stays a flex row, so the spark keeps its gap */
.dm-ecx-ask.dm-sw{display:inline-flex}
/* the Ask pill is an accent tint, like a picked preset in Date range picker */
.dm-pill.dm-soft.dm-ecx-ask{background:var(--dm-sel);color:var(--dm-accent-fg,var(--dm-accent));box-shadow:none}
@media (hover:hover) and (pointer:fine){.dm-pill.dm-soft.dm-ecx-ask:hover{background:color-mix(in srgb,var(--dm-accent) 14%,transparent)}}
.dm-ecx-h{display:flex;align-items:center;gap:var(--dm-s-2);padding:10px var(--dm-s-3);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13.5px;flex-wrap:wrap}
.dm-ecx-h b{font-weight:500}
.dm-ecx-sp{flex:1}
.dm-ecx-sc{overflow-x:auto}
.dm-ecx-grid{display:grid;grid-template-columns:44px repeat(var(--dm-days,5),minmax(76px,1fr));min-width:424px;position:relative}
.dm-ecx-tm{grid-row:1/3;display:flex;flex-direction:column;padding-top:var(--dm-s-8)}
.dm-ecx-tm span{height:44px;font-size:10.5px;color:var(--dm-muted);text-align:right;padding-right:var(--dm-s-2);transform:translateY(-6px)}
.dm-ecx-day{border-left:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-ecx-dh{height:32px;display:flex;align-items:center;justify-content:center;gap:5px;font-size:12px;color:var(--dm-fg2);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-ecx-dh b{font-weight:500;color:var(--dm-fg)}
.dm-ecx-dh.dm-t b{width:22px;height:22px;border-radius:50%;background:var(--dm-accent);color:var(--dm-on-accent);display:grid;place-items:center;font-size:11.5px}
.dm-ecx-col{position:relative;height:352px;background-image:linear-gradient(color-mix(in srgb,var(--dm-line) 50%,transparent) 1px,transparent 1px);background-size:100% 44px}
.dm-ecx-e{position:absolute;left:3px;right:3px;border-radius:var(--dm-r-sm);padding:var(--dm-s-1) 7px;font-size:11.5px;line-height:1.3;text-align:left;overflow:hidden;transition:transform .25s var(--dm-ease),box-shadow .25s}
@media (hover:hover) and (pointer:fine){.dm-ecx-e:hover{transform:translateY(-1px);box-shadow:var(--dm-shadow)}}
/* events sit in the scrolling grid, which would cut an outside ring: theirs is drawn inside */
.dm-ecx-e:focus-visible{outline-offset:-2px}
.dm-ecx-e{cursor:grab;touch-action:none}
.dm-ecx-e.dm-drag{transition:none;z-index:6;box-shadow:var(--dm-shadow-pop);cursor:grabbing}
.dm-ecx-col{cursor:copy}
.dm-ecx-ghost{position:absolute;left:3px;right:3px;height:41px;border-radius:var(--dm-r-sm);border:1.5px dashed color-mix(in srgb,var(--dm-accent-mark,var(--dm-accent)) 45%,transparent);background:color-mix(in srgb,var(--dm-accent) 5%,transparent);color:var(--dm-accent-fg,var(--dm-accent));font-size:11px;font-weight:500;padding:var(--dm-s-1) 7px;pointer-events:none}
.dm-ecx-now{position:absolute;left:0;right:0;height:2px;margin-top:-1px;background:var(--dm-bad);z-index:3}
.dm-ecx-now::before{content:"";position:absolute;left:-4px;top:-3px;width:8px;height:8px;border-radius:50%;background:var(--dm-bad)}
.dm-ecx-msg{font-size:12.5px;margin-right:var(--dm-s-2)}
.dm-ecx-pop{position:absolute;z-index:20;width:248px;padding:var(--dm-s-3);background:var(--dm-raised);border-radius:14px;box-shadow:var(--dm-shadow-pop);opacity:0;visibility:hidden;transform:translateY(6px);filter:blur(3px);pointer-events:none;transition:opacity .2s var(--dm-ease),transform .35s var(--dm-ease),filter .2s,visibility 0s .2s}
.dm-ecx-pop.dm-open{opacity:1;visibility:visible;transform:none;filter:none;pointer-events:auto;transition-property:opacity,transform,filter}
.dm-ecx-ph{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13.5px}
.dm-ecx-ph>i{width:10px;height:10px;border-radius:3px;flex:none}
.dm-ecx-ph b{font-weight:500;flex:1;min-width:0}
.dm-ecx-ph .dm-ib{width:24px;height:24px;margin:-4px -4px -4px auto}
.dm-ecx-ti{flex:1;min-width:0;border:0;outline:0;background:none;font:inherit;font-weight:500;color:var(--dm-fg)}
.dm-ecx-ti::placeholder{color:var(--dm-muted)}
.dm-ecx-ti:focus{box-shadow:0 1px 0 color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-ecx-pt{font-size:12.5px;color:var(--dm-fg2);margin:var(--dm-s-1) 0 0 18px}
.dm-ecx-pw{display:flex;align-items:center;margin:10px 0 0 18px}
.dm-ecx-pw .dm-av{width:22px;height:22px;font-size:9px;margin-right:-4px;box-shadow:0 0 0 2px var(--dm-raised)}
.dm-ecx-pw .dm-muted{margin-left:10px;font-size:12px}
.dm-ecx-note{display:flex;gap:6px;align-items:flex-start;margin:10px 0 0 18px;font-size:12px;line-height:1.45;color:var(--dm-fg2)}
.dm-ecx-note .dm-i{color:var(--dm-accent-fg,var(--dm-accent));margin-top:2px}
.dm-ecx-pa{display:flex;justify-content:flex-end;gap:6px;margin-top:var(--dm-s-3)}
.dm-ecx-e b{display:block;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-ecx-e span{font-size:10.5px}
.dm-ecx-e.dm-sug{background:var(--dm-surface)!important;color:var(--dm-accent-fg,var(--dm-accent))!important;box-shadow:inset 0 0 0 1.5px var(--dm-accent-mark,var(--dm-accent));animation:dm-ecs .6s var(--dm-ease) both}
.dm-ecx-e.dm-sug.dm-alt{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-accent-mark,var(--dm-accent)) 40%,transparent)}
@keyframes dm-ecs{from{opacity:0;transform:scale(.92);filter:blur(3px)}}
.dm-ecx-sg{display:flex;gap:var(--dm-s-1);margin-top:var(--dm-s-1)}
.dm-ecx-sg button{font-size:10.5px;padding:2px 6px;border-radius:5px;background:var(--dm-accent);color:var(--dm-on-accent)}
.dm-ecx-sg button.dm-n{background:transparent;color:var(--dm-accent-fg,var(--dm-accent))}
.dm-ecx-scan{position:absolute;left:44px;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--dm-accent),transparent);top:32px;opacity:0;pointer-events:none}
.dm-ecx-scan.dm-go{animation:dm-ecsc 1.2s var(--dm-ease-io) both}
@keyframes dm-ecsc{0%{opacity:0;top:32px}20%{opacity:1}100%{opacity:0;top:384px}}
/* a narrow calendar, on a phone or in a narrow card: one day at a time, picked from a row of days, so every event has room
for its whole title and time */
.dm-ecx-days{display:none}
@container ecx (max-width:519px){
.dm-ecx-days{display:flex;gap:var(--dm-s-1);padding:var(--dm-s-2) var(--dm-s-3);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-ecx-days button{flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;gap:2px;padding:var(--dm-s-1) 0;border-radius:var(--dm-r-sm);font-size:11.5px;color:var(--dm-fg2);transition:background var(--dm-t-fast),color var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-ecx-days button:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-ecx-days button[aria-pressed="true"]{background:var(--dm-surface);color:var(--dm-fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-pick-on) 50%,transparent)}
.dm-ecx-days b{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;font-size:13px;font-weight:500;color:var(--dm-fg)}
.dm-ecx-days .dm-t b{background:var(--dm-accent);color:var(--dm-on-accent)}
.dm-ecx-sc{overflow:visible}
.dm-ecx-grid{grid-template-columns:44px minmax(0,1fr);min-width:0;padding-top:var(--dm-s-2)}
.dm-ecx-day:not(.dm-on){display:none}
.dm-ecx-dh{display:none}
.dm-ecx-tm{grid-row:auto;padding-top:0}
.dm-ecx-scan{top:8px}
.dm-ecx-scan.dm-go{animation-name:dm-ecscn}
.dm-ecx-pop{max-width:calc(100% - 16px)}
}
@keyframes dm-ecscn{0%{opacity:0;top:8px}20%{opacity:1}100%{opacity:0;top:360px}}
event-calendar.htmlHTML · 10 lines
<div class="card wide" data-c="event-calendar">
<div class="stage-in">
<div class="ecx panel">
<div class="ecx-h"><b>Oct 19 – 23, 2026</b><span class="ecx-sp"></span><span class="ecx-msg muted"></span><button class="pill soft sm ecx-ask"><svg class="i xs"><use href="#i-spark"/></svg>Find 45 min for a design review</button></div>
<div class="ecx-days" role="group" aria-label="Day"></div>
<div class="ecx-sc"><div class="ecx-grid"><div class="ecx-tm"></div></div></div>
</div>
</div>
</div>
event-calendar.cssHTML · 78 lines
/* ---------- Event calendar ---------- */
.ecx{overflow:hidden;position:relative;container:ecx/inline-size}
/* the Ask pill crossfades its words but stays a flex row, so the spark keeps its gap */
.ecx-ask.sw{display:inline-flex}
/* the Ask pill is an accent tint, like a picked preset in Date range picker */
.pill.soft.ecx-ask{background:var(--sel);color:var(--accent-fg,var(--accent));box-shadow:none}
@media (hover:hover) and (pointer:fine){.pill.soft.ecx-ask:hover{background:color-mix(in srgb,var(--accent) 14%,transparent)}}
.ecx-h{display:flex;align-items:center;gap:var(--s-2);padding:10px var(--s-3);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent);font-size:13.5px;flex-wrap:wrap}
.ecx-h b{font-weight:500}
.ecx-sp{flex:1}
.ecx-sc{overflow-x:auto}
.ecx-grid{display:grid;grid-template-columns:44px repeat(var(--days,5),minmax(76px,1fr));min-width:424px;position:relative}
.ecx-tm{grid-row:1/3;display:flex;flex-direction:column;padding-top:var(--s-8)}
.ecx-tm span{height:44px;font-size:10.5px;color:var(--muted);text-align:right;padding-right:var(--s-2);transform:translateY(-6px)}
.ecx-day{border-left:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.ecx-dh{height:32px;display:flex;align-items:center;justify-content:center;gap:5px;font-size:12px;color:var(--fg2);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.ecx-dh b{font-weight:500;color:var(--fg)}
.ecx-dh.t b{width:22px;height:22px;border-radius:50%;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;font-size:11.5px}
.ecx-col{position:relative;height:352px;background-image:linear-gradient(color-mix(in srgb,var(--line) 50%,transparent) 1px,transparent 1px);background-size:100% 44px}
.ecx-e{position:absolute;left:3px;right:3px;border-radius:var(--r-sm);padding:var(--s-1) 7px;font-size:11.5px;line-height:1.3;text-align:left;overflow:hidden;transition:transform .25s var(--ease),box-shadow .25s}
@media (hover:hover) and (pointer:fine){.ecx-e:hover{transform:translateY(-1px);box-shadow:var(--shadow)}}
/* events sit in the scrolling grid, which would cut an outside ring: theirs is drawn inside */
.ecx-e:focus-visible{outline-offset:-2px}
.ecx-e{cursor:grab;touch-action:none}
.ecx-e.drag{transition:none;z-index:6;box-shadow:var(--shadow-pop);cursor:grabbing}
.ecx-col{cursor:copy}
.ecx-ghost{position:absolute;left:3px;right:3px;height:41px;border-radius:var(--r-sm);border:1.5px dashed color-mix(in srgb,var(--accent-mark,var(--accent)) 45%,transparent);background:color-mix(in srgb,var(--accent) 5%,transparent);color:var(--accent-fg,var(--accent));font-size:11px;font-weight:500;padding:var(--s-1) 7px;pointer-events:none}
.ecx-now{position:absolute;left:0;right:0;height:2px;margin-top:-1px;background:var(--bad);z-index:3}
.ecx-now::before{content:"";position:absolute;left:-4px;top:-3px;width:8px;height:8px;border-radius:50%;background:var(--bad)}
.ecx-msg{font-size:12.5px;margin-right:var(--s-2)}
.ecx-pop{position:absolute;z-index:20;width:248px;padding:var(--s-3);background:var(--raised);border-radius:14px;box-shadow:var(--shadow-pop);opacity:0;visibility:hidden;transform:translateY(6px);filter:blur(3px);pointer-events:none;transition:opacity .2s var(--ease),transform .35s var(--ease),filter .2s,visibility 0s .2s}
.ecx-pop.open{opacity:1;visibility:visible;transform:none;filter:none;pointer-events:auto;transition-property:opacity,transform,filter}
.ecx-ph{display:flex;align-items:center;gap:var(--s-2);font-size:13.5px}
.ecx-ph>i{width:10px;height:10px;border-radius:3px;flex:none}
.ecx-ph b{font-weight:500;flex:1;min-width:0}
.ecx-ph .ib{width:24px;height:24px;margin:-4px -4px -4px auto}
.ecx-ti{flex:1;min-width:0;border:0;outline:0;background:none;font:inherit;font-weight:500;color:var(--fg)}
.ecx-ti::placeholder{color:var(--muted)}
.ecx-ti:focus{box-shadow:0 1px 0 color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.ecx-pt{font-size:12.5px;color:var(--fg2);margin:var(--s-1) 0 0 18px}
.ecx-pw{display:flex;align-items:center;margin:10px 0 0 18px}
.ecx-pw .av{width:22px;height:22px;font-size:9px;margin-right:-4px;box-shadow:0 0 0 2px var(--raised)}
.ecx-pw .muted{margin-left:10px;font-size:12px}
.ecx-note{display:flex;gap:6px;align-items:flex-start;margin:10px 0 0 18px;font-size:12px;line-height:1.45;color:var(--fg2)}
.ecx-note .i{color:var(--accent-fg,var(--accent));margin-top:2px}
.ecx-pa{display:flex;justify-content:flex-end;gap:6px;margin-top:var(--s-3)}
.ecx-e b{display:block;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ecx-e span{font-size:10.5px}
.ecx-e.sug{background:var(--surface)!important;color:var(--accent-fg,var(--accent))!important;box-shadow:inset 0 0 0 1.5px var(--accent-mark,var(--accent));animation:ecs .6s var(--ease) both}
.ecx-e.sug.alt{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent-mark,var(--accent)) 40%,transparent)}
@keyframes ecs{from{opacity:0;transform:scale(.92);filter:blur(3px)}}
.ecx-sg{display:flex;gap:var(--s-1);margin-top:var(--s-1)}
.ecx-sg button{font-size:10.5px;padding:2px 6px;border-radius:5px;background:var(--accent);color:var(--on-accent)}
.ecx-sg button.n{background:transparent;color:var(--accent-fg,var(--accent))}
.ecx-scan{position:absolute;left:44px;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--accent),transparent);top:32px;opacity:0;pointer-events:none}
.ecx-scan.go{animation:ecsc 1.2s var(--ease-io) both}
@keyframes ecsc{0%{opacity:0;top:32px}20%{opacity:1}100%{opacity:0;top:384px}}
/* a narrow calendar, on a phone or in a narrow card: one day at a time, picked from a row of days, so every event has room
for its whole title and time */
.ecx-days{display:none}
@container ecx (max-width:519px){
.ecx-days{display:flex;gap:var(--s-1);padding:var(--s-2) var(--s-3);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.ecx-days button{flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;gap:2px;padding:var(--s-1) 0;border-radius:var(--r-sm);font-size:11.5px;color:var(--fg2);transition:background var(--t-fast),color var(--t-fast)}
@media (hover:hover) and (pointer:fine){.ecx-days button:hover{background:var(--row);color:var(--fg)}}
.ecx-days button[aria-pressed="true"]{background:var(--surface);color:var(--fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--pick-on) 50%,transparent)}
.ecx-days b{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;font-size:13px;font-weight:500;color:var(--fg)}
.ecx-days .t b{background:var(--accent);color:var(--on-accent)}
.ecx-sc{overflow:visible}
.ecx-grid{grid-template-columns:44px minmax(0,1fr);min-width:0;padding-top:var(--s-2)}
.ecx-day:not(.on){display:none}
.ecx-dh{display:none}
.ecx-tm{grid-row:auto;padding-top:0}
.ecx-scan{top:8px}
.ecx-scan.go{animation-name:ecscn}
.ecx-pop{max-width:calc(100% - 16px)}
}
@keyframes ecscn{0%{opacity:0;top:8px}20%{opacity:1}100%{opacity:0;top:360px}}
event-calendar.jsHTML · 89 lines
DM.def('event-calendar', (r, c) => {
r.innerHTML = r.innerHTML.replace('{spark}', I('spark','xs'));
const DAYS = ['Mon 19','Tue 20','Wed 21','Thu 22','Fri 23'], H0 = 9, PX = 44;
const EV = [[0,9,1,'Standup','b'],[0,13,1.5,'Pricing workshop','p'],[1,10,1,'1:1 with Maya','l'],[1,14,2,'Focus: checkout','t'],[2,9,1,'Standup','b'],[2,11.5,1,'Agent demo','y'],[3,9,1,'Standup','b'],[3,12,1,'Lunch & learn','m'],[3,15,1.5,'Launch review','r'],[4,10,1.5,'Planning','p']];
const grid = $('.ecx-grid', r);
$('.ecx-tm', r).innerHTML = Array.from({ length:8 }, (_, i) => { const h = H0 + i; return `<span>${h > 12 ? h - 12 : h}${h >= 12 ? 'pm' : 'am'}</span>`; }).join('');
grid.insertAdjacentHTML('beforeend', DAYS.map((d, i) => `<div class="ecx-day"><div class="ecx-dh${i === 0 ? ' t' : ''}">${d.split(' ')[0]} <b>${d.split(' ')[1]}</b></div><div class="ecx-col" data-d="${i}"></div></div>`).join('') + '<div class="ecx-scan"></div>');
const col = i => $(`.ecx-col[data-d="${i}"]`, grid);
/* a narrow calendar shows one day: the row of days picks it, and an answer from the agent opens the day it found */
const days = $('.ecx-days', r);
days.innerHTML = DAYS.map((d, i) => `<button aria-pressed="false" data-d="${i}"${i === 0 ? ' class="t"' : ''} aria-label="${['Monday','Tuesday','Wednesday','Thursday','Friday'][i]} ${d.split(' ')[1]} October"><span>${d.split(' ')[0]}</span><b>${d.split(' ')[1]}</b></button>`).join('');
let day = -1;
const narrow = () => !!days.getClientRects().length;
function showDay(i){
if (i === day) return; const was = day; day = i;
$$('button', days).forEach(b => b.setAttribute('aria-pressed', +b.dataset.d === i));
$$('.ecx-day', grid).forEach((d, k) => d.classList.toggle('on', k === i));
if (was >= 0 && narrow()){ closePop(); enter(col(i).parentElement); }
}
$$('button', days).forEach(b => b.onclick = () => showDay(+b.dataset.d));
const tm = h => { const hh = Math.floor(h), mm = Math.round((h - hh) * 60); return `${hh > 12 ? hh - 12 : hh}:${String(mm).padStart(2, '0')}`; };
const ev = (e, cls = '') => html(`<button class="ecx-e ${cls}" style="top:${(e[1] - H0) * PX + 1}px;height:${e[2] * PX - 3}px;background:var(--p${e[4]}-bg);color:var(--p${e[4]})"><b>${e[3]}</b><span>${tm(e[1])} – ${tm(e[1] + e[2])}</span></button>`);
const panel = $('.ecx', r), msg = $('.ecx-msg', r), pop = html('<div class="ecx-pop" role="dialog" aria-label="Event"></div>'); panel.appendChild(pop);
const WHO = [['MK','var(--av-l)'],['JO','var(--av-m)'],['DA','var(--av-b)'],['AR','var(--av-p)']], DAYN = ['Mon','Tue','Wed','Thu','Fri'];
let openFor = null;
const say = t => swap(msg, t);
// closing gives the focus back to the event when it was in the card
const closePop = () => { const back = pop.contains(document.activeElement) && openFor; pop.classList.remove('open'); openFor = null; if (back && back.isConnected) back.focus({ preventScroll:true }); };
/* the details card opens beside the event and stays inside the calendar */
function openPop(el){
const e = el._e; if (openFor === el){ closePop(); return; } openFor = el; const n = 2 + e[3].length % 3, fresh = e[3] === 'New event';
pop.innerHTML = `<div class="ecx-ph"><i style="background:var(--p${e[4]})"></i>${fresh ? '<input class="ecx-ti" placeholder="Add a title" aria-label="Event title">' : `<b>${e[3]}</b>`}<button class="ib ecx-px" aria-label="Close" data-tip="Close">${I('x','xs')}</button></div><div class="ecx-pt">${DAYN[e[0]]} ${DAYS[e[0]].split(' ')[1]} Oct · ${tm(e[1])} – ${tm(e[1] + e[2])}</div><div class="ecx-pw">${WHO.slice(0, n).map(w => AV(w[0], w[1])).join('')}<span class="muted">${n} people</span></div><div class="ecx-pa">${fresh ? '<button class="pill ghost sm ecx-del">Discard</button><button class="pill primary sm ecx-save">Save</button>' : `<button class="pill soft sm ecx-prep">${I('spark','xs')}Prep notes</button><button class="pill primary sm">Join</button>`}</div>`;
const pr = panel.getBoundingClientRect(), er = el.getBoundingClientRect(), w = 248;
let x = er.right - pr.left + 8; if (x + w > pr.width - 8) x = er.left - pr.left - w - 8; x = Math.max(8, x);
let y = er.top - pr.top; y = Math.max(8, Math.min(y, pr.height - pop.offsetHeight - 8));
pop.style.left = x + 'px'; pop.style.top = y + 'px'; pop.classList.remove('open'); void pop.offsetWidth; pop.classList.add('open'); hideTip();
$('.ecx-px', pop).onclick = closePop;
const prep = $('.ecx-prep', pop); if (prep) prep.onclick = () => { prep.innerHTML = '<span class="think on"><i></i><i></i><i></i><i></i></span>Writing'; c.later(1200, () => { if (!prep.isConnected) return; const nt = html(`<div class="ecx-note">${I('spark','xs')}<span>Notes ready: last week's decisions and 3 open questions.</span></div>`); prep.closest('.ecx-pa').before(nt); enter(nt); prep.remove(); }); };
const ti = $('.ecx-ti', pop); if (ti){ ti.focus({ preventScroll:true });
const save = () => { const v = ti.value.trim() || 'Untitled'; e[3] = v; $('b', el).textContent = v; el.dataset.tip = v + ', ' + tm(e[1]); closePop(); say('Added ' + v + ', ' + DAYN[e[0]] + ' ' + tm(e[1])); };
ti.onkeydown = k => { if (k.key === 'Enter') save(); if (k.key === 'Escape'){ el.remove(); closePop(); } };
$('.ecx-save', pop).onclick = save; $('.ecx-del', pop).onclick = () => { el.remove(); closePop(); }; }
}
/* drag to another time or day, snapping to 15 minutes; a click without moving opens the details */
const colX = i => col(i).getBoundingClientRect().left;
function bind(el, e){ el._e = e; el.dataset.tip = e[3] + ', ' + tm(e[1]);
// Enter or Space opens its card with the focus in it (a pointer opens it on release, below)
el.onclick = k => { if (!k.isTrusted || k.detail) return; openPop(el); if (openFor === el){ const f = $('.ecx-ti', pop) || $('button', pop); if (f) f.focus({ preventScroll:true }); } };
el.onpointerdown = d => { if (d.button || d.target.closest('.ecx-sg')) return; const sx = d.clientX, sy = d.clientY; let moved = false, nd = e[0], nh = e[1]; el.setPointerCapture(d.pointerId);
el.onpointermove = m => { const dx = m.clientX - sx, dy = m.clientY - sy; if (!moved && Math.hypot(dx, dy) < 5) return; if (!moved){ moved = true; el.classList.add('drag'); closePop(); hideTip(); }
nh = Math.max(H0, Math.min(H0 + 8 - e[2], e[1] + Math.round(dy / PX * 4) / 4));
const under = document.elementsFromPoint(m.clientX, m.clientY).find(x => x.classList && x.classList.contains('ecx-col')); if (under) nd = +under.dataset.d;
el.style.transform = `translateX(${colX(nd) - colX(e[0])}px)`; el.style.top = (nh - H0) * PX + 1 + 'px'; $('span', el).textContent = `${tm(nh)} – ${tm(nh + e[2])}`; };
el.onpointerup = el.onpointercancel = () => { el.onpointermove = el.onpointerup = el.onpointercancel = null; if (!moved){ openPop(el); return; }
el.classList.remove('drag'); el.style.transform = ''; const was = e[0] !== nd || e[1] !== nh; e[0] = nd; e[1] = nh; col(nd).appendChild(el); el.dataset.tip = e[3] + ', ' + tm(nh);
if (was) say(`Moved ${e[3]} to ${DAYN[nd]} ${tm(nh)}`); }; }; }
EV.forEach((e, i) => { const el = ev(e); col(e[0]).appendChild(el); enter(el, i * 30); bind(el, e); });
showDay(0);
/* now line on today's column */
col(0).insertAdjacentHTML('beforeend', `<div class="ecx-now" style="top:${(10.7 - H0) * PX}px" data-tip="Now, 10:42"></div>`);
/* a dashed slot follows the pointer over free time; click it to add an event there */
const ghost = html('<div class="ecx-ghost">+ New event</div>');
$$('.ecx-col', grid).forEach(cl => {
cl.onpointermove = m => { if (m.target !== cl || $('.ecx-e.drag', grid)){ ghost.remove(); return; } const h = H0 + Math.min(15, Math.floor((m.clientY - cl.getBoundingClientRect().top) / PX * 2)) / 2;
ghost.style.top = (h - H0) * PX + 1 + 'px'; ghost._h = h; if (ghost.parentElement !== cl) cl.appendChild(ghost); };
cl.onpointerleave = () => ghost.remove();
cl.onclick = m => { if (m.target !== cl || ghost.parentElement !== cl) return; const e = [+cl.dataset.d, ghost._h, 1, 'New event', 'k']; ghost.remove(); const el = ev(e); cl.appendChild(el); enter(el); bind(el, e); openPop(el); };
});
c.on(document, 'pointerdown', d => { if (openFor && !pop.contains(d.target) && !openFor.contains(d.target)) closePop(); });
c.on(document, 'keydown', k => { if (k.key === 'Escape') closePop(); });
const ask = $('.ecx-ask', r);
// the button keeps its spark, only the words crossfade
const tell = t => swap(ask, I('spark','xs') + t, true);
ask.onclick = () => c.run(async () => {
$$('.ecx-e.sug', grid).forEach(x => x.remove());
ask.innerHTML = '<span class="think on"><i></i><i></i><i></i><i></i></span>Checking 4 calendars'; const sc = $('.ecx-scan', grid); sc.classList.remove('go'); void sc.offsetWidth; sc.classList.add('go');
await c.wait(1300);
const S = [[2,13.5,.75,'Design review','b'],[4,13,.75,'Design review','b']];
S.forEach((s, i) => { const el = ev(s, 'sug' + (i ? ' alt' : '')); if (!i) el.insertAdjacentHTML('beforeend', '<div class="ecx-sg"><button class="y">Book</button><button class="n">Other</button></div>'); col(s[0]).appendChild(el); el.style.animationDelay = i * 120 + 'ms'; el.style.height = 'auto'; el.style.minHeight = s[2] * PX - 3 + 'px'; });
ask.innerHTML = I('spark','xs') + 'Everyone is free Wed 1:30';
showDay(S[0][0]);
const best = $('.ecx-e.sug:not(.alt)', grid);
$('.y', best).onclick = e => { e.stopPropagation(); best.classList.remove('sug'); best.style.background = 'var(--pb-bg)'; best.style.color = 'var(--pb)'; best.style.boxShadow = ''; $('.ecx-sg', best).remove(); enter(best); bind(best, S[0]); $$('.ecx-e.sug', grid).forEach(x => x.remove()); tell('Booked, invites sent to 4 people'); };
$('.n', best).onclick = e => { e.stopPropagation(); const alt = $('.ecx-e.sug.alt', grid); best.remove(); alt.classList.remove('alt'); alt.insertAdjacentHTML('beforeend', '<div class="ecx-sg"><button class="y">Book</button></div>'); $('.y', alt).onclick = ev2 => { ev2.stopPropagation(); alt.classList.remove('sug'); alt.style.background = 'var(--pb-bg)'; alt.style.color = 'var(--pb)'; $('.ecx-sg', alt).remove(); bind(alt, S[1]); tell('Booked Fri 1:00, invites sent'); }; tell('Next best: Fri 1:00'); showDay(S[1][0]); };
});
// the preview asks the agent for a slot once on its own, unless you take over first
c.auto(1200, () => ask.click());
});
Questions
What people ask about it.
- Does the calendar find free slots by itself?
- No.
onAsktells you the person pressed the agent's button. Setfindingto true while your agent looks, then pass what it found assuggestionsand setfindingback to false. - What should Book and Other do?
onBookgets the picked suggestion: add it toeventsand clearsuggestions.onSkipgets the one being skipped: drop it fromsuggestionsand the next one becomes the pick.- How do I save events after someone drags or adds one?
- Pass
eventsandonEventsChange. Every move, new event, rename and removal comes back as the full updated list, with times as local Dates. - Does it work on a phone?
- Yes. Below 520px wide the week turns into a day view with a row of days to switch between, and dragging an event moves it within that day. The width of the calendar decides, not the screen, so a narrow card on a wide screen gets the day view too.
- Can I show a full week or longer hours?
- Yes. Set
daysto 7 for a full week, andstartHourandhoursfor the time range, likestartHour={8}andhours={10}for 8am to 6pm.
More in Dates
Pickers for single dates and date ranges.