Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
};
Expand All @@ -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,
},
};
Expand Down Expand Up @@ -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,
]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>;
const dayListSize = days.length;
const usedIndexesByDay: Set<number>[] = [];
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<number>();
usedIndexesByDay.push(usedIndexes);

const needsPosition: SchedulerEventOccurrence[] = [];
const withoutPosition: SchedulerEventOccurrence[] = [];

Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/x-scheduler/src/week-view/tests/WeekView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ describe('<WeekView />', () => {
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');
});
});
Expand Down
Loading