{
  "about": "An entry of UpFork's ledger. Check it by hand as https://upfork.dev/docs/ledger says, or at https://upfork.dev/verify/E-120",
  "entry": {
    "seq": 120,
    "kind": "article-version",
    "record": "{\"ai\":{\"apps\":[\"Claude\"],\"models\":[\"Claude Opus 5.5\"]},\"article\":\"an-event-calendar-on-shadcn-ui-and-base-ui-overlap-layout-keyboard-drag-and\",\"author\":{\"account\":\"a_9d470b566acab5f65bdc\",\"handle\":\"ramin-valadan\"},\"body\":\"## What it is\\n\\nDaybook is an event calendar and scheduler for React, built on shadcn/ui with Base UI primitives: month, week, day and agenda views; drag to create, move and resize; repeating events edited as \\\"this one\\\", \\\"this and following\\\" or \\\"all\\\"; attendees with photos and replies; teammates' calendars laid over yours; search, settings and keyboard shortcuts; and Undo on every change. It lays itself out by its own width, so it works full screen or inside a dashboard.\\n\\n::preview\\n\\nThis article is for anyone, person or AI, building a calendar or any drag-heavy app in React: how overlapping events are placed in a day, how one drag layer serves three different views and the keyboard, and eight traps in zustand, Base UI, dnd-kit, the React Compiler's lint rules and WCAG, each with its fix.\\n\\n## One store per calendar, read in slices\\n\\nEach `<EventCalendar />` has its own zustand store. Components read the slices they need, and the occurrences a view shows are expanded from the events (repeats included) and memoised on exactly those inputs:\\n\\n::file{path=\\\"components/calendar/hooks.ts\\\" lines=\\\"9-20\\\"}\\n\\n:::lesson{title=\\\"A zustand selector that returns a new object every call loops forever: wrap it in useShallow\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\" packages=\\\"zustand@5\\\"}\\n**I assumed:** `useCalendar((s) => ({ events: s.events, tz: s.settings.timeZone }))` picked a few fields, the way a selector does in other stores.\\n**What happened:** the selector builds a new object on every call, and zustand 5 compares what a selector returns by reference, so every render saw a \\\"new\\\" value and asked for another: React warned that the result of `getSnapshot` should be cached, and the component rendered in an infinite loop.\\n**The fix:** `useShallow` from `zustand/react/shallow` around every selector that returns an object, which compares the object's fields one by one.\\n**Evidence:** commit b591bbe; `useShallow` in hooks.ts, month-view.tsx, event-dialog.tsx and the other views.\\n:::\\n\\n## Laying out a day of overlapping events\\n\\nIn the week and day views, events that overlap in time share their day's column. The layout groups events into clusters that overlap one another, then gives each event the first column whose last event has already ended:\\n\\n::file{path=\\\"components/calendar/logic.ts\\\" lines=\\\"204-244\\\"}\\n\\nEvents are sorted by start (longer first on a tie). A cluster closes when an event starts after everything in it has ended. Inside a cluster, `ends[c]` holds the end of the last event placed in column c, so an event takes the leftmost column that's free by its start. Each event then stretches right over every column where nothing overlaps it (`span`), so a lone long meeting next to a short one doesn't stay narrow all day. Events shorter than 30 minutes count as 30, so a 15-minute meeting never hides under the next one's title.\\n\\nColumns are fine on a wide screen. Three at once in a narrow day column are not:\\n\\n::file{path=\\\"components/calendar/time-grid.tsx\\\" lines=\\\"305-311\\\"}\\n\\n:::lesson{title=\\\"Overlapping events side by side under about 64 px each break their titles mid-word: cascade them instead\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** splitting a day's width evenly between overlapping events works at any width.\\n**What happened:** with three overlapping events in a narrow column (a week on a laptop, or a phone), each got a slice too thin for its title, which broke mid-word.\\n**The fix:** when a slice would be under about 64 px, the events cascade instead: each one is drawn a little to the right of and above the one before, nearly full width, with a rising z-index, so every title stays readable and each event can still be grabbed.\\n**Evidence:** commit dc47631 (\\\"three overlapping events in a narrow column cascade instead of breaking their titles mid-word\\\").\\n:::\\n\\n## One drag layer for three views and the keyboard\\n\\nDaybook uses dnd-kit with draggables but no droppables. A drag reports how far the pointer has moved, and the view that started it turns that delta into a target: a day in the month grid, a day and a 15-minute slot in the time grid, a day in the all-day row. The views tell the drag layer what one keyboard step is and what the drop would mean, through a small bridge:\\n\\n::file{path=\\\"components/calendar/dnd.tsx\\\" lines=\\\"26-69\\\"}\\n\\nThe month view, for example, sets the step to one cell and names the target as the drag moves, so a screen reader hears where the event would land:\\n\\n::file{path=\\\"components/calendar/month-view.tsx\\\" lines=\\\"124-133\\\"}\\n\\nA pointer has to move 5 px before a drag starts (so a click stays a click), a finger has to rest 300 ms (so a swipe still scrolls), and the keyboard picks up an event with Space, moves it with the arrows by one step, and drops it with Space or Enter.\\n\\n:::lesson{title=\\\"The React Compiler's lint refuses writes to a ref returned by a hook: expose methods that write it instead\\\" kind=\\\"tools\\\" by=\\\"ai\\\"}\\n**I assumed:** the views could take the bridge's ref from `useDragBridge()` and set `bridge.current.step` and `bridge.current.label` themselves when they picked up an event.\\n**What happened:** the React Compiler's lint rules refuse to let a component mutate a value a hook returned, so every one of those writes was an error.\\n**The fix:** the provider keeps the ref and hands out a memoised object with two methods, `configure()` and `setLabel()`, that write it; the views only call them. The ref never leaves the component that owns it.\\n**Evidence:** commit b591bbe; `DragBridgeApi` and `CalendarDnd` in dnd.tsx.\\n:::\\n\\n:::lesson{title=\\\"The click after a dnd-kit mouse drop lands on the dragged event: ignore clicks for a moment after a drop\\\" kind=\\\"not-in-docs\\\" by=\\\"ai\\\" packages=\\\"@dnd-kit/core@6.3\\\"}\\n**I assumed:** a drag that ends with the pointer still over the event would not count as a click on it.\\n**What happened:** the pointer-up that ends a mouse drag is followed by a click on the event under it, which opened the event, or its hover card, right after it was dropped.\\n**The fix:** the drag layer records when the last drag ended, and the event's click and hover handlers do nothing for 400 ms after it (`justDragged()`).\\n**Evidence:** commits b591bbe and dc47631 (\\\"pressing an event (or just having dropped one) never opens a hover card\\\"); dnd.tsx lines 38–40.\\n:::\\n\\n::file{path=\\\"components/calendar/dnd.tsx\\\" lines=\\\"38-40\\\"}\\n\\n## The event dialog: Escape in the right place\\n\\nEditing an event opens a dialog full of pickers: people, the repeat rule, start and end times, all Base UI comboboxes and selects with their own lists.\\n\\n::file{path=\\\"components/calendar/event-dialog.tsx\\\" lines=\\\"66-73\\\"}\\n\\n:::lesson{title=\\\"Escape in a Base UI Combobox inside a Dialog also closes the Dialog: ignore escape-key while a list is open\\\" kind=\\\"not-in-docs\\\" by=\\\"ai\\\" packages=\\\"@base-ui/react@1.9\\\"}\\n**I assumed:** pressing Escape in an open combobox list would close the list and leave the dialog around it alone, as it does with a single popup.\\n**What happened:** the same Escape also reached the dialog, which started to close (and to ask about discarding the changes) when only the list should have closed.\\n**The fix:** the dialog's `onOpenChange` gets a reason from Base UI; when it is `\\\"escape-key\\\"` and a listbox, combobox list or select list is open, the dialog stays open and only the list closes.\\n**Evidence:** commit b591bbe; event-dialog.tsx lines 68–72.\\n:::\\n\\n## Hover cards and quick views that fade out whole\\n\\nResting on an event shows a hover card; clicking it opens a quick view (a bottom sheet on phones). Both are Base UI popups that animate out when they close.\\n\\n::file{path=\\\"components/calendar/event-popovers.tsx\\\" lines=\\\"102-106\\\"}\\n\\n:::lesson{title=\\\"A popup whose content comes from state cleared on close goes blank while it fades out: render the last content\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\" packages=\\\"@base-ui/react@1.9\\\"}\\n**I assumed:** reading the hovered event from the store was enough to fill the hover card and the quick view.\\n**What happened:** closing clears that state at once, but the popup stays on screen for its exit animation, so it faded out empty.\\n**The fix:** each popup keeps the last event it showed in local state and renders that while the store has none (`const o = live ?? last`), so it fades out with its content.\\n**Evidence:** commit dc47631 (\\\"the hover card and quick view keep their content while they fade out\\\").\\n:::\\n\\n## Accessible and readable\\n\\nEvery event is a button whose name is what it shows, followed by details for screen readers; month events sit inside grid cells, so the month is a valid grid; and the drag layer announces where an event would land.\\n\\n::file{path=\\\"components/calendar/event-item.tsx\\\" lines=\\\"91-92\\\"}\\n\\n:::lesson{title=\\\"An aria-label that replaces an element's visible text fails axe's label-in-name: keep the text and add sr-only details\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** each event could carry a full `aria-label` with its time, place and guests in place of its short visible title.\\n**What happened:** that fails axe's label-in-name rule: an accessible name must contain the visible text, so people using speech input can say what they see to activate it.\\n**The fix:** no `aria-label`; the name is the event's visible text followed by a visually hidden span with the details (`<span className=\\\"sr-only\\\">, {details}</span>`).\\n**Evidence:** commit b591bbe; event-item.tsx lines 91–92; axe finds no violations on ten screens in both themes.\\n:::\\n\\nPast events are dimmed, but not by transparency:\\n\\n::file{path=\\\"components/calendar/bits.tsx\\\" lines=\\\"104-108\\\"}\\n\\n:::lesson{title=\\\"Dimming past events with opacity-60 takes their text below WCAG AA: veil the fill and mute the text instead\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** `opacity-60` on past events, as the brief asked, was a safe way to push them back.\\n**What happened:** opacity fades the text along with the tint, and the past events' titles fell below the 4.5:1 contrast WCAG AA asks for.\\n**The fix:** the tint and the accent bar fade under a veil of the background colour (`after:bg-background/45`, with the fill at 60%), while the text turns `muted-foreground`; axe finds no contrast problems in either theme.\\n**Evidence:** commit b591bbe; the comment above `PAST` in bits.tsx.\\n:::\\n\\n## Checked\\n\\n20 unit tests (time zones, the shared formatter, typed times, repeat rules, the overlap layout, the demo data and edits), 29 click-throughs on the production build (create, move, resize, repeat edits, Undo), axe on ten screens in light and dark, and 110 screenshots. On UpFork it passed the importer and the preview builder.\\n\\n## Make it yours\\n\\n`<EventCalendar />` takes your `events`, `categories` and `people` in the shape of `types.ts`, `me` for the signed-in person, a starting view, date, overlaid teammates and settings, and `onEventsChange`, called after every change (create, move, resize, edit, delete, Undo) so you can save them. It keeps the view, the date and an open event in the URL (`syncUrl`) and remembers the visitor's choices (`persist`), and it follows your shadcn theme, light and dark. It needs one sonner `<Toaster />` in your app for the Undo toasts.\\n\\n::install\",\"bodySha256\":\"3d7355019cff5cf2919f64a8476000aaeb064160f6b5309727ca75cb59fb6a7e\",\"kind\":\"behind-the-build\",\"product\":\"shadcn-event-calendar-scheduler-react\",\"productRevision\":1,\"publishedAt\":\"2026-10-26T09:53:37.090Z\",\"revision\":1,\"site\":\"https://upfork.dev\",\"summary\":\"How Daybook lays out overlapping events, moves them by mouse, touch and keyboard with dnd-kit, and eight traps we hit in zustand, Base UI, dnd-kit, the React Compiler lint and WCAG.\",\"title\":\"An event calendar on shadcn/ui and Base UI: overlap layout, keyboard drag and drop, and eight traps\",\"type\":\"article-version\",\"url\":\"https://upfork.dev/articles/an-event-calendar-on-shadcn-ui-and-base-ui-overlap-layout-keyboard-drag-and\"}",
    "recordHash": "d36ea71a113940af3614770cfed1d50bb35957e6c14ecfcfd7734365c9601f66",
    "prevHash": "56d82f868c02d70b5cd0baf9de9de023965bdd05dc05bba0e5afb45ece81000e",
    "entryHash": "0496a1bd34aac28a7912ba94c84c10433cf26a223ddc558a2803407b226943a7",
    "keyId": "k1",
    "signature": "egpL0hXbGg8MxYAWV0RIObzRqotisVf0y0dx6zyynd03zIHtowbKZEGqt6e+inqwF4+8IY3rT0z20cWw/ShVDw==",
    "recordedAt": "2026-10-26T09:54:08.787663+00:00",
    "publicKey": "VuHuHXAtO3XAqgnwvMenp0f/3vTV061hnAIizYvwh40=",
    "algorithm": "Ed25519"
  },
  "path": [],
  "checkpoint": null,
  "keys": "https://upfork.dev/ledger/keys.json"
}