diff --git a/packages/x-scheduler-internals/src/calendar-grid/use-placeholder-in-day/useCalendarGridPlaceholderInDay.ts b/packages/x-scheduler-internals/src/calendar-grid/use-placeholder-in-day/useCalendarGridPlaceholderInDay.ts index cd06f7f05a2f8..5cbd5eddecc34 100644 --- a/packages/x-scheduler-internals/src/calendar-grid/use-placeholder-in-day/useCalendarGridPlaceholderInDay.ts +++ b/packages/x-scheduler-internals/src/calendar-grid/use-placeholder-in-day/useCalendarGridPlaceholderInDay.ts @@ -19,6 +19,28 @@ export function useCalendarGridPlaceholderInDay( const store = useEventCalendarStoreContext(); const { start: rowStart, end: rowEnd } = useCalendarGridDayRowContext(); + const findAvailableIndex = React.useCallback( + (excludeKey?: string): number => { + let positionIndex = 1; + const targetDay = row.days.find((rowDay) => adapter.isSameDay(rowDay.value, day)); + if (targetDay) { + const usedIndexes = new Set( + targetDay.withPosition + .filter((occ) => occ.key !== excludeKey) + .map((occ) => occ.position.index), + ); + while (usedIndexes.has(positionIndex)) { + positionIndex += 1; + } + } + if (maxEvents != null && positionIndex > maxEvents) { + positionIndex = maxEvents; + } + return positionIndex; + }, + [adapter, day, maxEvents, row.days], + ); + const rawPlaceholder = useStore( store, eventCalendarOccurrencePlaceholderSelectors.placeholderInDayCell, @@ -54,13 +76,9 @@ export function useCalendarGridPlaceholderInDay( ...sharedProperties, title: '', allDay: true, - displayTimezone: { - start: startProcessed, - end: endProcessed, - timezone, - }, + displayTimezone: { start: startProcessed, end: endProcessed, timezone }, position: { - index: 1, + index: findAvailableIndex(), daySpan: adapter.differenceInDays(rawPlaceholder.end, day) + 1, }, }; @@ -74,13 +92,9 @@ export function useCalendarGridPlaceholderInDay( return { ...sharedProperties, title: rawPlaceholder.eventData.title ?? '', - displayTimezone: { - start: startProcessed, - end: endProcessed, - timezone, - }, + displayTimezone: { start: startProcessed, end: endProcessed, timezone }, position: { - index: 1, + index: findAvailableIndex(), daySpan: adapter.differenceInDays(rawPlaceholder.end, day) + 1, }, }; @@ -111,9 +125,19 @@ export function useCalendarGridPlaceholderInDay( end: processDate(rawPlaceholder.end, adapter), displayTimezone: { ...originalEvent!.displayTimezone }, position: { - index: positionIndex, + index: findAvailableIndex(rawPlaceholder.occurrenceKey), daySpan: adapter.differenceInDays(rawPlaceholder.end, day) + 1, }, }; - }, [adapter, day, maxEvents, originalEvent, originalEventId, rawPlaceholder, row.days, rowEnd]); + }, [ + adapter, + day, + maxEvents, + originalEvent, + originalEventId, + rawPlaceholder, + row.days, + rowEnd, + findAvailableIndex, + ]); } diff --git a/packages/x-scheduler-internals/src/use-event-occurrences-with-day-grid-position/useEventOccurrencesWithDayGridPosition.test.ts b/packages/x-scheduler-internals/src/use-event-occurrences-with-day-grid-position/useEventOccurrencesWithDayGridPosition.test.ts index a144c6f2921f0..4f73ab2e9333c 100644 --- a/packages/x-scheduler-internals/src/use-event-occurrences-with-day-grid-position/useEventOccurrencesWithDayGridPosition.test.ts +++ b/packages/x-scheduler-internals/src/use-event-occurrences-with-day-grid-position/useEventOccurrencesWithDayGridPosition.test.ts @@ -62,6 +62,7 @@ describe('useDayListEventOccurrencesWithPosition', () => { expect(result.days[1].withPosition[1].id).to.equal('B'); expect(result.days[1].withPosition[1].position).to.deep.equal({ index: 2, daySpan: 2 }); expect(result.days[2].withPosition[0].id).to.equal('B'); + // Event B was visible on day 2, so it stays in row 2 — no compaction to row 1 expect(result.days[2].withPosition[0].position).to.deep.equal({ index: 2, daySpan: 1, @@ -80,5 +81,11 @@ describe('useDayListEventOccurrencesWithPosition', () => { expect(result.maxIndex).to.equal(2); expect(result.days[2].withPosition[0].id).to.equal('C'); expect(result.days[2].withPosition[0].position).to.deep.equal({ index: 1, daySpan: 1 }); + expect(result.days[2].withPosition[1].id).to.equal('B'); + expect(result.days[2].withPosition[1].position).to.deep.equal({ + index: 2, + daySpan: 1, + isInvisible: true, + }); }); }); diff --git a/packages/x-scheduler-internals/src/use-event-occurrences-with-day-grid-position/useEventOccurrencesWithDayGridPosition.ts b/packages/x-scheduler-internals/src/use-event-occurrences-with-day-grid-position/useEventOccurrencesWithDayGridPosition.ts index af26ae935a693..a4bdac9e40140 100644 --- a/packages/x-scheduler-internals/src/use-event-occurrences-with-day-grid-position/useEventOccurrencesWithDayGridPosition.ts +++ b/packages/x-scheduler-internals/src/use-event-occurrences-with-day-grid-position/useEventOccurrencesWithDayGridPosition.ts @@ -14,20 +14,24 @@ import { sortEventOccurrences } from '../sort-event-occurrences'; export function useEventOccurrencesWithDayGridPosition( parameters: useEventOccurrencesWithDayGridPosition.Parameters, ): useEventOccurrencesWithDayGridPosition.ReturnValue { - const { days, occurrencesMap, shouldAddPosition } = parameters; + const { days, occurrencesMap, shouldAddPosition, maxEvents } = parameters; const adapter = useAdapterContext(); return React.useMemo(() => { - const indexLookup: { - [dayKey: string]: { - occurrencesIndex: { [occurrenceKey: string]: number }; - usedIndexes: Set; + const dayListSize = days.length; + const usedIndexesByDay: Set[] = []; + const activeSegments: { + [occurrenceKey: string]: { + position: useEventOccurrencesWithDayGridPosition.EventOccurrencePosition; + startDayIndex: number; + wasHidden: boolean; }; } = {}; - const dayListSize = days.length; const processedDays = days.map((day, dayIndex) => { - indexLookup[day.key] = { occurrencesIndex: {}, usedIndexes: new Set() }; + const usedIndexes = new Set(); + usedIndexesByDay.push(usedIndexes); + const needsPosition: SchedulerEventOccurrence[] = []; const withoutPosition: SchedulerEventOccurrence[] = []; @@ -41,61 +45,98 @@ export function useEventOccurrencesWithDayGridPosition( } } - // 2. Sort the withPosition occurrences by start and end date - const sortedNeedsPosition = sortEventOccurrences(needsPosition); + // 2. Sort: multi-day events first (so they claim top rows), then by start date + const sortedNeedsPosition = sortEventOccurrences(needsPosition).sort((a, b) => { + const aIsActive = activeSegments[a.key] != null ? 0 : 1; + const bIsActive = activeSegments[b.key] != null ? 0 : 1; + if (aIsActive !== bIsActive) { + return aIsActive - bIsActive; + } + const aSpan = adapter.differenceInDays( + a.displayTimezone.end.value, + a.displayTimezone.start.value, + ); + const bSpan = adapter.differenceInDays( + b.displayTimezone.end.value, + b.displayTimezone.start.value, + ); + return bSpan - aSpan; + }); // 3. Assign position to each occurrence const withPosition: useEventOccurrencesWithDayGridPosition.EventOccurrenceWithPosition[] = []; + for (const occurrence of sortedNeedsPosition) { - let position: useEventOccurrencesWithDayGridPosition.EventOccurrencePosition; + let smallestAvailableIndex = 1; + while (usedIndexes.has(smallestAvailableIndex)) { + smallestAvailableIndex += 1; + } - const occurrenceIndexInPreviousDay = - dayIndex === 0 - ? null - : indexLookup[days[dayIndex - 1].key].occurrencesIndex[occurrence.key]; + const active = activeSegments[occurrence.key]; + let position: useEventOccurrencesWithDayGridPosition.EventOccurrencePosition; - // If the event is present in the previous day, we keep the same index - if (occurrenceIndexInPreviousDay != null) { - position = { index: occurrenceIndexInPreviousDay, daySpan: 1, isInvisible: true }; - } - // Otherwise, we find the smallest available index - else { - const usedIndexes = indexLookup[day.key].usedIndexes; - let i = 1; - while (usedIndexes.has(i)) { - i += 1; + if (active != null) { + const currentIndex = active.position.index; + const isCurrentlyVisible = maxEvents == null || currentIndex <= maxEvents; + const wouldNewIndexBeVisible = maxEvents == null || smallestAvailableIndex <= maxEvents; + + if (isCurrentlyVisible) { + // Event is visible in its current row — never split it, keep the bar continuous. + position = { index: currentIndex, daySpan: 1, isInvisible: true }; + } else if (wouldNewIndexBeVisible) { + // Event was hidden (overflowed) and a visible row just opened up — start a new + // segment with a continuation arrow so the user knows it started earlier. + active.position.daySpan = dayIndex - active.startDayIndex; + position = { + index: smallestAvailableIndex, + daySpan: Math.min( + adapter.differenceInDays(occurrence.displayTimezone.end.value, day.value) + 1, + dayListSize - dayIndex, + ), + isContinuation: true, + }; + activeSegments[occurrence.key] = { + position, + startDayIndex: dayIndex, + wasHidden: false, + }; + } else { + // Still hidden even after checking — stay invisible in the overflow row. + position = { index: currentIndex, daySpan: 1, isInvisible: true }; } - + } else { + // First time we're seeing this occurrence — assign the smallest available index. const durationInDays = adapter.differenceInDays(occurrence.displayTimezone.end.value, day.value) + 1; + const isHidden = maxEvents != null && smallestAvailableIndex > maxEvents; position = { - index: i, - daySpan: Math.min(durationInDays, dayListSize - dayIndex), // Don't go past the day list end + index: smallestAvailableIndex, + daySpan: Math.min(durationInDays, dayListSize - dayIndex), + }; + activeSegments[occurrence.key] = { + position, + startDayIndex: dayIndex, + wasHidden: isHidden, }; } - indexLookup[day.key].occurrencesIndex[occurrence.key] = position.index; - indexLookup[day.key].usedIndexes.add(position.index); + usedIndexes.add(position.index); withPosition.push({ ...occurrence, position }); } - // Sort the occurrences by their index to make sure they are in the order they should be rendered in. + // Sort by index so they render in the right visual order withPosition.sort((a, b) => a.position.index - b.position.index); - return { - ...day, - withPosition, - withoutPosition, - }; + return { ...day, withPosition, withoutPosition }; }); - const usedIndexes = Object.values(indexLookup).flatMap((day) => Array.from(day.usedIndexes)); + const usedIndexesFlat = usedIndexesByDay.flatMap((set) => Array.from(set)); return { days: processedDays, - maxIndex: usedIndexes.length === 0 ? 1 : Math.max(...usedIndexes), + maxIndex: usedIndexesFlat.length === 0 ? 1 : Math.max(...usedIndexesFlat), }; - }, [adapter, days, occurrencesMap, shouldAddPosition]); + }, [adapter, days, maxEvents, occurrencesMap, shouldAddPosition]); } export namespace useEventOccurrencesWithDayGridPosition { @@ -114,6 +155,12 @@ export namespace useEventOccurrencesWithDayGridPosition { * @default () => true */ shouldAddPosition?: (occurrence: SchedulerEventOccurrence) => boolean; + /** + * The maximum number of events to show before collapsing into a "+N more" overflow. + * When provided, events beyond this threshold are tracked so they can resurface with + * a continuation arrow on later days where a visible row becomes available. + */ + maxEvents?: number | null; } export interface EventOccurrencePosition { @@ -130,6 +177,11 @@ export namespace useEventOccurrencesWithDayGridPosition { * Invisible events are used to reserve space for events that started on a previous day. */ isInvisible?: boolean; + /** + * Whether this segment is a continuation of an event that was previously hidden in overflow. + * When true, the renderer should show a left-pointing arrow to indicate the event started earlier. + */ + isContinuation?: boolean; } export interface EventOccurrenceWithPosition extends SchedulerEventOccurrence { diff --git a/packages/x-scheduler/src/week-view/tests/WeekView.test.tsx b/packages/x-scheduler/src/week-view/tests/WeekView.test.tsx index df98b8730d662..5999d0bf2f732 100644 --- a/packages/x-scheduler/src/week-view/tests/WeekView.test.tsx +++ b/packages/x-scheduler/src/week-view/tests/WeekView.test.tsx @@ -172,7 +172,7 @@ describe('', () => { const eventStyle = mainEvent?.getAttribute('style') || ''; const gridColumnSpan = eventStyle.match(/--grid-column-span:\s*(\d+)/)?.[1]; - // Should span 4 columns (4 days) + // Should span 3 columns — event is split at the overflow boundary, resuming on day 4 expect(gridColumnSpan).to.equal('4'); }); });