{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"event-calendar-resource-view","type":"registry:ui","title":"Resource-columns day grid for booking scenarios - one time axis, one column per resource, full drag, resize, and drag-create.","description":"Resource-columns day grid for booking scenarios - one time axis, one column per resource, full drag, resize, and drag-create.","dependencies":["date-fns","radix-ui"],"registryDependencies":["@neui/event-calendar","@neui/event-calendar-dnd","@neui/event-calendar-event","@neui/event-calendar-lib","@neui/event-calendar-time-grid","@neui/event-calendar-types","scroll-area"],"files":[{"path":"event-calendar-resource-view.tsx","type":"registry:ui","content":"// Title: Event Calendar Resource View\n// Description: Resource-columns day grid for booking scenarios - one time axis, one column per resource, full drag, resize, and drag-create.\n\n\"use client\"\n\nimport {\n useEffect,\n useMemo,\n useRef,\n useState,\n type CSSProperties,\n type HTMLAttributes,\n} from \"react\"\nimport {\n EventCalendarViewContext,\n useEventCalendar,\n useEventCalendarDay,\n useEventCalendarSelector,\n useEventCalendarSettings,\n useEventCalendarViewConfig,\n useEventCalendarViewSettings,\n} from \"@/components/neui/event-calendar/event-calendar\"\nimport {\n useEventCalendarGestures,\n wasRecentChipPress,\n wasRecentDrag,\n} from \"@/components/neui/event-calendar/event-calendar-dnd\"\nimport {\n EVENT_CALENDAR_GHOST,\n EventCalendarEvent,\n} from \"@/components/neui/event-calendar/event-calendar-event\"\nimport {\n flattenResources,\n getDayKey,\n getDayTotalMinutes,\n packTimedSegments,\n resolveOffDay,\n snapMinutes,\n toZoned,\n zonedStartOfDay,\n} from \"@/components/neui/event-calendar/event-calendar-lib\"\nimport {\n EventCalendarNowIndicator,\n EventCalendarTimeGutter,\n minuteBlockStyle,\n} from \"@/components/neui/event-calendar/event-calendar-time-grid\"\nimport type {\n EventCalendarResource,\n EventCalendarSegment,\n} from \"@/components/neui/event-calendar/event-calendar-types\"\nimport { addDays, addMinutes } from \"date-fns\"\nimport { Slot } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\nimport { ScrollArea } from \"@/components/ui/scroll-area\"\n\nconst EMPTY_ALL_DAY_SEGMENTS: EventCalendarSegment[] = []\n\ninterface EventCalendarResourceViewProps extends HTMLAttributes {\n dayStartHour?: number\n dayEndHour?: number\n showAllDay?: boolean\n /** Gutter/gridline interval in minutes; defaults to the interval view config. */\n interval?: number\n asChild?: boolean\n}\n\n/** Leaf resources become booking columns for the anchor day. */\nfunction EventCalendarResourceView({\n className,\n asChild = false,\n dayStartHour,\n dayEndHour,\n showAllDay = true,\n interval: intervalProp,\n style,\n ...props\n}: EventCalendarResourceViewProps) {\n const instance = useEventCalendar()\n const settings = useEventCalendarSettings()\n const viewConfig = useEventCalendarViewConfig()\n const { effective } = useEventCalendarViewSettings()\n const anchorDate = useEventCalendarSelector((state) => state.date, {\n isEqual: (a, b) => a.getTime() === b.getTime(),\n })\n\n const startHour = dayStartHour ?? settings.dayStartHour\n const endHour = dayEndHour ?? settings.dayEndHour\n const interval = Math.min(\n Math.max(intervalProp ?? viewConfig.interval, 5),\n 240\n )\n const contained = viewConfig.scrollMode !== \"page\"\n const day = zonedStartOfDay(anchorDate, settings.timeZone)\n\n const resources = useMemo(\n () =>\n flattenResources(settings.resources)\n .filter(({ resource }) => !resource.children?.length)\n .map(({ resource }) => resource),\n [settings.resources]\n )\n\n // Initial scroll + api.scrollToTime (same contract as the time grid)\n const scrollRef = useRef(null)\n useEffect(() => {\n if (!contained) return\n const el = scrollRef.current\n if (!el) return\n const viewport = el.querySelector(\n \"[data-slot=scroll-area-viewport]\"\n )\n const slotRow = el.querySelector(\n \"[data-slot=event-calendar-time-gutter] > div\"\n )\n const slotPx = slotRow?.getBoundingClientRect().height || 64\n const pxPerMinute = slotPx / interval\n const scrollTo = (minutes: number) => {\n // keep the hour label above the target line visible (it hangs -top-2)\n viewport?.scrollTo({\n top: Math.max(0, (minutes - startHour * 60) * pxPerMinute - 12),\n })\n }\n scrollTo(viewConfig.scrollToHour * 60)\n instance.internals.registerScrollHandler((time) => {\n const minutes =\n typeof time === \"number\"\n ? time\n : toZoned(time, settings.timeZone).getHours() * 60 +\n toZoned(time, settings.timeZone).getMinutes()\n scrollTo(minutes)\n })\n // Classic (width-consuming) scrollbars squeeze the scrolling track while\n // the header/all-day rows outside keep full width, drifting the column\n // borders. Mirror the measured gutter onto those rows via a CSS var -\n // 0px for overlay scrollbars and the custom ScrollArea, so both modes\n // lay out identically.\n const root = el.closest(\n \"[data-slot=event-calendar-time-grid], [data-slot=event-calendar-resource-view]\"\n )\n const syncScrollbarGutter = () => {\n root?.style.setProperty(\n \"--ec-scrollbar-w\",\n `${viewport ? viewport.offsetWidth - viewport.clientWidth : 0}px`\n )\n }\n syncScrollbarGutter()\n const gutterObserver = viewport\n ? new ResizeObserver(syncScrollbarGutter)\n : null\n if (viewport) gutterObserver?.observe(viewport)\n return () => {\n instance.internals.registerScrollHandler(null)\n gutterObserver?.disconnect()\n }\n }, [\n contained,\n instance,\n settings.timeZone,\n startHour,\n interval,\n viewConfig.scrollToHour,\n // scrollbars custom<->native swaps the scroller DOM: re-bind the\n // viewport, the scroll wiring, and the measured --ec-scrollbar-w\n viewConfig.scrollbars,\n ])\n\n const slots = useMemo(() => {\n const result: number[] = []\n for (let m = startHour * 60; m < endHour * 60; m += interval) {\n result.push(m)\n }\n return result\n }, [startHour, endHour, interval])\n\n // All-day segments for renderAllDaySection - the same index bucket the\n // cells read; inert (stable empty array, so the subscription never\n // re-renders) while the override is unset.\n const allDaySegments = useEventCalendarSelector<\n unknown,\n EventCalendarSegment[]\n >(\n () =>\n viewConfig.renderAllDaySection\n ? (instance.internals\n .getIndex()\n .byDay.get(getDayKey(day, settings.timeZone))?.allDay ??\n EMPTY_ALL_DAY_SEGMENTS)\n : EMPTY_ALL_DAY_SEGMENTS,\n {\n isEqual: (a, b) =>\n a === b ||\n (a.length === b.length && a.every((segment, i) => segment === b[i])),\n }\n )\n\n const gridTemplateColumns = `repeat(${resources.length || 1}, minmax(var(--ec-resource-col-min,8rem), 1fr))`\n\n const track = (\n
\n {/* shared gutter component, so renderTimeGutterSlot and\n classNames.timeGutter customizations apply here too */}\n \n
\n {resources.map((resource) => (\n \n ))}\n
\n {effective.nowIndicator && (\n \n )}\n
\n )\n\n const Comp = asChild ? Slot.Root : \"div\"\n\n return (\n \n \n {/* Resource header row */}\n \n
\n
\n {resources.map((resource) => (\n \n {viewConfig.renderResourceHeader?.({ resource }) ??\n resource.title}\n
\n ))}\n
\n \n {/* All-day row per resource */}\n {showAllDay && (\n \n {viewConfig.renderAllDaySection?.({\n days: [day],\n segments: allDaySegments,\n }) ?? (\n <>\n \n \n {settings.i18n.labels.allDay}\n \n \n \n {resources.map((resource) => (\n \n ))}\n \n \n )}\n \n )}\n {contained ? (\n
\n {viewConfig.scrollbars === \"native\" ? (\n \n {track}\n
\n ) : (\n {track}\n )}\n \n ) : (\n track\n )}\n \n
\n )\n}\n\nfunction EventCalendarResourceAllDayCell({\n resource,\n day,\n}: {\n resource: EventCalendarResource\n day: Date\n}) {\n const settings = useEventCalendarSettings()\n const viewConfig = useEventCalendarViewConfig()\n const { effective } = useEventCalendarViewSettings()\n const gestures = useEventCalendarGestures()\n const { segments } = useEventCalendarDay(day)\n const mine = segments.allDay.filter(\n (segment) => segment.occurrence.event.resourceId === resource.id\n )\n const dayStart = zonedStartOfDay(day, settings.timeZone)\n const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1)\n const isOff = resolveOffDay(\n day,\n settings.timeZone,\n effective.offDays\n ? typeof viewConfig.offDays === \"object\"\n ? viewConfig.offDays\n : true\n : false,\n settings.weekendDays\n )\n const offClassName =\n (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) ||\n \"bg-muted/25\"\n const isDropTarget = useEventCalendarSelector<\n unknown,\n \"valid\" | \"invalid\" | null\n >((state) => {\n const drag = state.drag\n if (!drag || !drag.proposedDayGranular) return null\n const covered = drag.proposedStart < dayEnd && drag.proposedEnd > dayStart\n if (!covered) return null\n return drag.valid ? \"valid\" : \"invalid\"\n })\n // Slot-draft highlight, mirroring the time-grid all-day cell. The dnd\n // layer's all-day create branch does not plumb resourceId into the draft,\n // so every resource cell covering the day highlights together.\n const inDraft = useEventCalendarSelector((state) => {\n const draft = state.slotDraft\n if (!draft || !draft.allDay) return false\n return draft.start < dayEnd && draft.end > dayStart\n })\n return (\n {\n if (e.target === e.currentTarget) gestures.beginCreate(e, day, true)\n }}\n onClick={(e) => {\n if (\n e.target === e.currentTarget &&\n !wasRecentDrag() &&\n !wasRecentChipPress()\n ) {\n settings.onSlotClick?.(\n {\n date: dayStart,\n allDay: true,\n view: \"resource\",\n resourceId: resource.id,\n },\n e\n )\n }\n }}\n >\n {mine.map((segment) => (\n \n ))}\n {isDropTarget && (\n \n )}\n \n )\n}\n\nfunction EventCalendarResourceColumn({\n resource,\n day,\n startHour,\n endHour,\n interval,\n}: {\n resource: EventCalendarResource\n day: Date\n startHour: number\n endHour: number\n interval: number\n}) {\n const settings = useEventCalendarSettings()\n const viewConfig = useEventCalendarViewConfig()\n const { effective } = useEventCalendarViewSettings()\n const gestures = useEventCalendarGestures()\n const { segments, isToday } = useEventCalendarDay(day)\n const isOff = resolveOffDay(\n day,\n settings.timeZone,\n effective.offDays\n ? typeof viewConfig.offDays === \"object\"\n ? viewConfig.offDays\n : true\n : false,\n settings.weekendDays\n )\n const offClassName =\n (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) ||\n \"bg-muted/25\"\n\n const timeZone = settings.timeZone\n const dayStart = zonedStartOfDay(day, timeZone)\n const totalMinutes = getDayTotalMinutes(day, timeZone)\n const boundsStartMin = startHour * 60\n const boundsEndMin = Math.min(endHour * 60, totalMinutes)\n const boundsMinutes = Math.max(60, boundsEndMin - boundsStartMin)\n\n // Filter this resource's timed segments and repack per column.\n // Clones keep the shared index cache untouched. Segments the day bounds clip\n // away are dropped here too, otherwise they hold a column nobody can see and\n // leave a phantom empty half beside the first in-bounds chip.\n const packed = useMemo(() => {\n const mine = segments.timed\n .filter((segment) => {\n if (segment.occurrence.event.resourceId !== resource.id) return false\n const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)\n const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)\n return endMin > boundsStartMin && startMin < boundsEndMin\n })\n .map((segment) => ({ ...segment }) as EventCalendarSegment)\n packTimedSegments(mine)\n return mine\n }, [segments.timed, resource.id, boundsStartMin, boundsEndMin])\n\n const dragGhost = useEventCalendarSelector<\n unknown,\n {\n window: [number, number]\n valid: boolean\n kind: string\n color?: string\n title: string\n occurrence: EventCalendarSegment[\"occurrence\"]\n proposedStart: Date\n proposedEnd: Date\n } | null\n >(\n (state) => {\n const drag = state.drag\n if (!drag || drag.proposedDayGranular) return null\n // Moves carry a proposedResourceId (they can cross columns); resizes stay\n // in place and leave it undefined, so fall back to the event's own\n // resource - otherwise the resize ghost is filtered out of every column.\n const targetResourceId =\n drag.proposedResourceId ?? drag.occurrence.event.resourceId\n if (targetResourceId !== resource.id) return null\n const from = Math.max(\n (drag.proposedStart.getTime() - dayStart.getTime()) / 60000,\n boundsStartMin\n )\n const to = Math.min(\n (drag.proposedEnd.getTime() - dayStart.getTime()) / 60000,\n boundsEndMin\n )\n if (to <= from) return null\n return {\n window: [from, to] as [number, number],\n valid: drag.valid,\n kind: drag.kind,\n color: drag.occurrence.event.color,\n title: drag.occurrence.event.title,\n occurrence: drag.occurrence,\n proposedStart: drag.proposedStart,\n proposedEnd: drag.proposedEnd,\n }\n },\n {\n isEqual: (a, b) =>\n a === b ||\n (a !== null &&\n b !== null &&\n a.window[0] === b.window[0] &&\n a.window[1] === b.window[1] &&\n a.valid === b.valid &&\n a.proposedStart.getTime() === b.proposedStart.getTime() &&\n a.proposedEnd.getTime() === b.proposedEnd.getTime()),\n }\n )\n\n const draftWindow = useEventCalendarSelector<\n unknown,\n [number, number] | null\n >(\n (state) => {\n const draft = state.slotDraft\n if (!draft || draft.allDay || draft.resourceId !== resource.id) {\n return null\n }\n const from = Math.max(\n (draft.start.getTime() - dayStart.getTime()) / 60000,\n boundsStartMin\n )\n const to = Math.min(\n (draft.end.getTime() - dayStart.getTime()) / 60000,\n boundsEndMin\n )\n return to > from ? [from, to] : null\n },\n {\n isEqual: (a, b) =>\n a === b || (a !== null && b !== null && a[0] === b[0] && a[1] === b[1]),\n }\n )\n\n return (\n {\n if (e.target === e.currentTarget) gestures.beginCreate(e, day, false)\n }}\n onClick={(e) => {\n if (\n e.target !== e.currentTarget ||\n wasRecentDrag() ||\n wasRecentChipPress()\n )\n return\n const rect = e.currentTarget.getBoundingClientRect()\n const pxPerMinute = rect.height / boundsMinutes\n const minutes = snapMinutes(\n boundsStartMin + (e.clientY - rect.top) / pxPerMinute,\n settings.snapDuration\n )\n const clamped = Math.min(\n Math.max(minutes, boundsStartMin),\n boundsEndMin - settings.slotDuration\n )\n settings.onSlotClick?.(\n {\n date: addMinutes(dayStart, clamped),\n end: addMinutes(dayStart, clamped + settings.slotDuration),\n allDay: false,\n view: \"resource\",\n resourceId: resource.id,\n },\n e\n )\n }}\n >\n {packed.map((segment) => {\n const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)\n const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)\n if (endMin <= boundsStartMin || startMin >= boundsEndMin) return null\n const columnCount = segment.columnCount ?? 1\n const column = segment.column ?? 0\n const span = segment.columnSpan ?? 1\n const zIndex = segment.occurrence.event.zIndex ?? 10 + column\n // Strict side-by-side columns - no cascade overlap (fade-truncate +\n // hover reveal carry the legibility); the ring separates neighbors.\n const colPct = 100 / columnCount\n return (\n \n 1 && \"ring-background ring-1\",\n // short chips: single centered row, exact-fit line height so\n // the title never slices mid-glyph\n endMin - startMin < viewConfig.compactEventMinutes\n ? \"h-full gap-1 py-0 leading-4\"\n : \"h-full flex-col items-start justify-start gap-0 py-1\",\n viewConfig.classNames?.timedChip\n )}\n />\n \n )\n })}\n {/* Standardized ghost (EVENT_CALENDAR_GHOST): faint drop placeholder\n for moves (the cursor-attached carry clone owns the visual), dashed\n clone for resizes, destructive marking when invalid. */}\n {dragGhost && (\n \n {dragGhost.kind !== \"move\" && (\n \n )}\n \n )}\n {draftWindow && (\n \n )}\n \n )\n}\n\nexport { EventCalendarResourceView }\nexport type { EventCalendarResourceViewProps }","target":"components/neui/event-calendar/event-calendar-resource-view.tsx"}]}