UNPKG

@fullcalendar/web-component

Version:
1,066 lines (1,052 loc) 101 kB
import { addDays, addMs, intersectRanges, formatDayString, joinDateTimeFormatParts, diffDays, buildIsoString, addWeeks, diffWeeks } from '@full-ui/headless-calendar'; import { g as guid, c as createFormatter, F as classNames, $ as expandRecurring, s as sliceEventStore, ao as fabricateEventRange, ak as sortEventSegs, al as getEventRangeMeta, an as getEventKey, v as EventImpl, S as setElEventRange, D as formatWithOrdinals, Q as createAriaClickAttrs, ah as getEventTargetViaRoot, R as computeElIsRtl, aa as getAppendableRoot, ad as applyStyle } from './69b11357.js'; import { B as BaseComponent, p as watchWidth, s as setRef, o as DateComponent, a as memoize, d as getIsHeightAuto, f as afterSize, q as getDateMeta, t as buildDateStr, v as buildNavLinkAttrs, W as WEEKDAY_ONLY_FORMAT, C as ContentContainer, u as renderText$1, x as StandardEvent, w as watchHeight, r as watchSize, g as generateClassName, y as findWeekdayText, z as findDayNumberText, b as memoizeObjArg, A as isDimsEqual, E as findMonthText, V as ViewContextType } from './fa7e597f.js'; import { c as computeMajorUnit, i as isMajorUnit, D as DateProfileGenerator } from './3d9e7be7.js'; import { jsx, jsxs, Fragment } from 'preact/jsx-runtime'; import { createRef, Component, createPortal } from 'preact/compat'; import { j as joinClassNames, f as fracToCssDim } from './423b7bc6.js'; import { f as computeClippedClientRect } from './cf1edde2.js'; class Slicer { constructor() { this.sliceBusinessHours = memoize(this._sliceBusinessHours); this.sliceDateSelection = memoize(this._sliceDateSpan); this.sliceEventStore = memoize(this._sliceEventStore); this.sliceEventDrag = memoize(this._sliceInteraction); this.sliceEventResize = memoize(this._sliceInteraction); this.forceDayIfListItem = false; // hack } sliceProps(props, dateProfile, nextDayThreshold, context, ...extraArgs) { let { eventUiBases } = props; let eventSegs = this.sliceEventStore(props.eventStore, eventUiBases, dateProfile, nextDayThreshold, ...extraArgs); return { dateSelectionSegs: this.sliceDateSelection(props.dateSelection, dateProfile, nextDayThreshold, eventUiBases, context, ...extraArgs), businessHourSegs: this.sliceBusinessHours(props.businessHours, dateProfile, nextDayThreshold, context, ...extraArgs), fgEventSegs: eventSegs.fg, bgEventSegs: eventSegs.bg, eventDrag: this.sliceEventDrag(props.eventDrag, eventUiBases, dateProfile, nextDayThreshold, ...extraArgs), eventResize: this.sliceEventResize(props.eventResize, eventUiBases, dateProfile, nextDayThreshold, ...extraArgs), eventSelection: props.eventSelection, }; // TODO: give interactionSegs? } sliceNowDate(// does not memoize date, dateProfile, nextDayThreshold, context, ...extraArgs) { return this._sliceDateSpan({ range: { start: date, end: addMs(date, 1) }, allDay: false }, // add 1 ms, protect against null range dateProfile, nextDayThreshold, {}, context, ...extraArgs); } _sliceBusinessHours(businessHours, dateProfile, nextDayThreshold, context, ...extraArgs) { if (!businessHours) { return []; } return this._sliceEventStore(expandRecurring(businessHours, computeActiveRange(dateProfile, Boolean(nextDayThreshold)), context), {}, dateProfile, nextDayThreshold, ...extraArgs).bg; } _sliceEventStore(eventStore, eventUiBases, dateProfile, nextDayThreshold, ...extraArgs) { if (eventStore) { let rangeRes = sliceEventStore(eventStore, eventUiBases, computeActiveRange(dateProfile, Boolean(nextDayThreshold)), nextDayThreshold); return { bg: this.sliceEventRanges(rangeRes.bg, extraArgs), fg: this.sliceEventRanges(rangeRes.fg, extraArgs), }; } return { bg: [], fg: [] }; } _sliceInteraction(interaction, eventUiBases, dateProfile, nextDayThreshold, ...extraArgs) { if (!interaction) { return null; } let rangeRes = sliceEventStore(interaction.mutatedEvents, eventUiBases, computeActiveRange(dateProfile, Boolean(nextDayThreshold)), nextDayThreshold); return { segs: this.sliceEventRanges(rangeRes.fg, extraArgs), affectedInstances: interaction.affectedEvents.instances, isEvent: interaction.isEvent, }; } _sliceDateSpan(dateSpan, dateProfile, nextDayThreshold, eventUiBases, context, ...extraArgs) { if (!dateSpan) { return []; } let activeRange = computeActiveRange(dateProfile, Boolean(nextDayThreshold)); let activeDateSpanRange = intersectRanges(dateSpan.range, activeRange); if (activeDateSpanRange) { dateSpan = { ...dateSpan, range: activeDateSpanRange }; let eventRange = fabricateEventRange(dateSpan, eventUiBases, context); let segs = this.sliceRange(dateSpan.range, ...extraArgs); for (let seg of segs) { seg.eventRange = eventRange; } return segs; } return []; } /* "complete" seg means it has component and eventRange */ sliceEventRanges(eventRanges, extraArgs) { let segs = []; for (let eventRange of eventRanges) { segs.push(...this.sliceEventRange(eventRange, extraArgs)); } return segs; } /* "complete" seg means it has component and eventRange */ sliceEventRange(eventRange, extraArgs) { let dateRange = eventRange.range; // hack to make multi-day events that are being force-displayed as list-items to take up only one day if (this.forceDayIfListItem && eventRange.ui.display === 'list-item') { dateRange = { start: dateRange.start, end: addDays(dateRange.start, 1), }; } let segs = this.sliceRange(dateRange, ...extraArgs); // !!! for (let seg of segs) { seg.eventRange = eventRange; seg.isStart = eventRange.isStart && seg.isStart; seg.isEnd = eventRange.isEnd && seg.isEnd; } return segs; } } /* for incorporating slotMinTime/slotMaxTime if appropriate TODO: should be part of DateProfile! TimelineDateProfile already does this btw */ function computeActiveRange(dateProfile, isComponentAllDay) { let range = dateProfile.activeRange; if (isComponentAllDay) { return range; } return { start: addMs(range.start, dateProfile.slotMinTime.milliseconds), end: addMs(range.end, dateProfile.slotMaxTime.milliseconds - 864e5), // 864e5 = ms in a day }; } class DayTableSlicer extends Slicer { constructor() { super(...arguments); this.forceDayIfListItem = true; } sliceRange(dateRange, dayTableModel) { return dayTableModel.sliceRange(dateRange); } } // TODO: converge types with DayTableCell and DayCellContainer (the component) and refineRenderProps // the generation of DayTableCell will be distinct (for the BODY cells) // but can share some of the same types/utils // Date Cells // ------------------------------------------------------------------------------------------------- const firstSunday = new Date(259200000); function buildDateRowConfigs(dates, datesRepDistinctDays, dateProfile, todayRange, dayHeaderFormat, // TODO: rename to dateHeaderFormat? context) { const rowConfig = buildDateRowConfig(dates, datesRepDistinctDays, dateProfile, todayRange, dayHeaderFormat, context); const majorUnit = computeMajorUnit(dateProfile, context.dateEnv); // HACK mutate isMajor // Skip 'day' majorUnit: when each header cell IS a day, every cell would match, // so there's no meaningful boundary to highlight (unlike timeline slots which can be sub-day). if (datesRepDistinctDays && majorUnit !== 'day') { for (const dataConfig of rowConfig.dataConfigs) { if (isMajorUnit(dataConfig.dateMarker, majorUnit, context.dateEnv)) { dataConfig.renderProps.isMajor = true; } } } return [rowConfig]; } /* Should this receive resource data attributes? Or ResourceApi object itself? */ function buildDateRowConfig(dateMarkers, datesRepDistinctDays, dateProfile, todayRange, dayHeaderFormat, // TODO: rename to dateHeaderFormat? context, colSpan, isMajorMod) { return { isDateRow: true, renderConfig: buildDateRenderConfig(dayHeaderFormat, datesRepDistinctDays, context), dataConfigs: buildDateDataConfigs(dateMarkers, datesRepDistinctDays, dateProfile, todayRange, dayHeaderFormat, context, colSpan, undefined, undefined, undefined, undefined, isMajorMod) }; } /* For header cells: how to connect w/ custom rendering Applies to all cells in a row */ function buildDateRenderConfig(dayHeaderFormat, datesRepDistinctDays, context) { const { options } = context; return { generatorName: 'dayHeaderContent', customGenerator: options.dayHeaderContent, classNameGenerator: options.dayHeaderClass, innerClassNameGenerator: options.dayHeaderInnerClass, didMount: options.dayHeaderDidMount, willUnmount: options.dayHeaderWillUnmount, align: options.dayHeaderAlign, sticky: options._dayHeaderSticky, dayHeaderFormat, datesRepDistinctDays, }; } const dowDates = []; for (let dow = 0; dow < 7; dow++) { dowDates.push(addDays(new Date(259200000), dow)); // start with Sun, 04 Jan 1970 00:00:00 GMT) } /* For header cells: data */ function buildDateDataConfigs(dateMarkers, datesRepDistinctDays, dateProfile, todayRange, dayHeaderFormat, // TODO: rename to dateHeaderFormat? context, colSpan = 1, keyPrefix = '', extraRenderProps = {}, // TODO extraAttrs = {}, // TODO className = '', isMajorMod) { const { dateEnv, viewApi, options } = context; return datesRepDistinctDays ? dateMarkers.map((dateMarker, i) => { const dateMeta = getDateMeta(dateMarker, dateEnv, dateProfile, todayRange); const isMajor = isMajorMod != null && !(i % isMajorMod); const hasNavLink = options.navLinks && !dateMeta.isDisabled && dateMarkers.length > 1; // don't show navlink to day if only one day const renderProps = { ...dateMeta, ...extraRenderProps, isMajor, isSticky: false, // HACK. gets overridden inPopover: false, hasNavLink, view: viewApi, }; const fullDateStr = buildDateStr(context, dateMarker); // for DayGridHeaderCell return { key: keyPrefix + dateMarker.toUTCString(), dateMarker, renderProps, attrs: { 'aria-label': fullDateStr, ...(dateMeta.isToday ? { 'aria-current': 'date' } : {}), // TODO: assign undefined for nonexistent 'data-date': formatDayString(dateMarker), ...extraAttrs, }, // for navlink innerAttrs: hasNavLink ? buildNavLinkAttrs(context, dateMarker, undefined, fullDateStr) : { 'aria-hidden': true }, // label already on cell colSpan, hasNavLink, className, }; }) : dateMarkers.map((dateMarker, i) => { const dow = dateMarker.getUTCDay(); const normDate = addDays(firstSunday, dow); const dateMeta = { date: dateEnv.toDate(dateMarker), dow, isDisabled: false, isFuture: false, isPast: false, isToday: false, isOther: false, }; const isMajor = isMajorMod != null && !(i % isMajorMod); const renderProps = { ...dateMeta, date: dowDates[dow], isMajor, isSticky: false, // HACK. gets overridden inPopover: false, hasNavLink: false, view: viewApi, ...extraRenderProps, }; const fullWeekDayStr = joinDateTimeFormatParts(dateEnv.formatToParts(normDate, WEEKDAY_ONLY_FORMAT)); // for DayGridHeaderCell return { key: keyPrefix + String(dow), dateMarker, renderProps, attrs: { 'aria-label': fullWeekDayStr, ...extraAttrs, }, // NOT a navlink innerAttrs: { 'aria-hidden': true, // label already on cell }, colSpan, className, }; }); } /* TODO: make API where createRefMap() called */ class RefMap { constructor(masterCallback, ignoreDeletes = false) { this.masterCallback = masterCallback; this.ignoreDeletes = ignoreDeletes; this.rev = ''; this.current = new Map(); this.callbacks = new Map; this.handleValue = (val, key) => { let { current, callbacks } = this; if (val === null) { if (!this.ignoreDeletes) { current.delete(key); callbacks.delete(key); } } else { current.set(key, val); } this.rev = guid(); if (this.masterCallback) { this.masterCallback(val, key); } }; } createRef(key) { let refCallback = this.callbacks.get(key); if (!refCallback) { refCallback = (val) => { this.handleValue(val, key); }; this.callbacks.set(key, refCallback); } return refCallback; } } class Ruler extends BaseComponent { constructor() { super(...arguments); this.elRef = createRef(); } render() { return (jsx("div", { ref: this.elRef })); } componentDidMount() { this._isUnmounting = false; const { props } = this; const el = this.elRef.current; this.disconnectWidth = watchWidth(el, (width) => { if (this._isUnmounting) return; setRef(props.widthRef, width); }); } componentWillUnmount() { this._isUnmounting = true; this.disconnectWidth(); const { props } = this; if (props.widthRef) { setRef(props.widthRef, null); } } } /* We need really specific keys because RefMap::createRef() which is then given to heightRef unable to change key! As a result, we cannot reuse elements between normal/slice/standin types, but that's okay since they render quite differently */ function getEventPartKey(seg) { return getEventKey(seg) + ':' + seg.start + (seg.standinFor ? ':standin' : seg.isSlice ? ':slice' : ''); } // DayGridRange utils (TODO: move) // ------------------------------------------------------------------------------------------------- function splitSegsByRow(segs, rowCount) { const byRow = []; for (let row = 0; row < rowCount; row++) { byRow[row] = []; } for (const seg of segs) { byRow[seg.row].push(seg); } return byRow; } function splitInteractionByRow(ui, rowCount) { const byRow = []; if (!ui) { for (let row = 0; row < rowCount; row++) { byRow[row] = null; } } else { for (let row = 0; row < rowCount; row++) { byRow[row] = { affectedInstances: ui.affectedInstances, isEvent: ui.isEvent, segs: [], }; } for (const seg of ui.segs) { byRow[seg.row].segs.push(seg); } } return byRow; } function sliceSegForCol(seg, col) { return { ...seg, start: col, end: col + 1, isStart: seg.isStart && seg.start === col, isEnd: seg.isEnd && seg.end - 1 === col, standinFor: seg, }; } class BgEvent extends BaseComponent { constructor() { super(...arguments); // memo this.buildPublicEvent = memoize((context, eventDef, eventInstance) => new EventImpl(context, eventDef, eventInstance)); this.handleEl = (el) => { this.el = el; if (el) { setElEventRange(el, this.props.eventRange); } }; } render() { const { props, context } = this; const { eventRange } = props; const { options } = context; const eventUi = eventRange.ui; const eventApi = this.buildPublicEvent(context, eventRange.def, eventRange.instance); const subcontentRenderProps = { event: eventApi, isNarrow: props.isNarrow || false, isShort: props.isShort || false, }; const renderProps = { event: eventApi, view: context.viewApi, timeText: '', // never display time color: eventUi.color || options.backgroundEventColor, contrastColor: eventUi.contrastColor, isDraggable: false, isStartResizable: false, isEndResizable: false, isMirror: false, isStart: props.isStart, isEnd: props.isEnd, isFirst: false, isLast: false, isPast: props.isPast, isFuture: props.isFuture, isToday: props.isToday, isSelected: false, isDragging: false, isResizing: false, isInteractive: false, level: 0, isNarrow: props.isNarrow || false, isShort: props.isShort || false, timeClass: '', // never display time titleClass: generateClassName(options.backgroundEventTitleClass, subcontentRenderProps), options: { eventOverlap: Boolean(options.eventOverlap) }, }; // does not include backgroundEventClass.. added below const outerClassName = joinClassNames(eventUi.className, classNames.fill, classNames.internalEvent, classNames.internalBgEvent, props.isVertical ? classNames.flexCol : classNames.flexRow); const innerClassName = joinClassNames(generateClassName(options.backgroundEventInnerClass, renderProps), classNames.liquid); return (jsx(ContentContainer, { tag: 'div', className: outerClassName, style: { '--fc-event-color': renderProps.color, '--fc-event-contrast-color': renderProps.contrastColor, }, defaultGenerator: renderInnerContent, elRef: this.handleEl, renderProps: renderProps, generatorName: "backgroundEventContent", customGenerator: options.backgroundEventContent, classNameGenerator: options.backgroundEventClass, didMount: options.backgroundEventDidMount, willUnmount: options.backgroundEventWillUnmount, children: (InnerContent) => (jsx(InnerContent, { tag: 'div', className: innerClassName })) })); } componentDidUpdate(prevProps) { if (this.el && this.props.eventRange !== prevProps.eventRange) { setElEventRange(this.el, this.props.eventRange); } } } function renderInnerContent(props) { let { title } = props.event; return title && (jsx("div", { className: props.titleClass, children: props.event.title })); } // Other types of fills // ------------------------------------------------------------------------------------------------- function renderFill(fillType, options) { return (jsx("div", { className: joinClassNames(fillType === 'non-business' ? options.nonBusinessHoursClass : fillType === 'highlight' ? options.highlightClass : undefined, classNames.fill) })); } const SPACE_FROM_VIEWPORT = 10; const ROW_BORDER_WIDTH = 1; class MorePopover extends DateComponent { constructor() { super(...arguments); // memo this.getDateMeta = memoize(getDateMeta); this.closeRef = createRef(); this.focusStartRef = createRef(); this.focusEndRef = createRef(); this.handleRootEl = (rootEl) => { this.rootEl = rootEl; if (rootEl) { this.context.registerInteractiveComponent(this, { el: rootEl, useEventCenter: false, }); } else { this.context.unregisterInteractiveComponent(this); } }; // Triggered when the user clicks *anywhere* in the document, for the autoHide feature this.handleDocumentMouseDown = (ev) => { // only hide the popover if the click happened outside the popover const target = getEventTargetViaRoot(ev); if (!this.rootEl.contains(target)) { this.handleClose(); } }; this.handleDocumentKeyDown = (ev) => { if (ev.key === 'Escape') { this.handleClose(); } }; // for many different close techniques // cannot accept params because might receive a browser Event this.handleClose = () => { let { onClose } = this.props; if (onClose) { onClose(); } }; } render() { let { props, context } = this; let { options, dateEnv, viewApi } = context; let { startDate, todayRange, dateProfile } = props; let dateMeta = this.getDateMeta(startDate, dateEnv, dateProfile, todayRange); let textParts = dateEnv.formatToParts(startDate, options.popoverFormat); let text = joinDateTimeFormatParts(textParts); const dayHeaderRenderProps = { ...dateMeta, isMajor: false, isNarrow: false, isSticky: false, inPopover: true, level: 0, hasNavLink: false, text, textParts, get weekdayText() { return findWeekdayText(textParts); }, get dayNumberText() { return findDayNumberText(textParts); }, view: viewApi, // TODO: should know about the resource! }; const dayCellRenderProps = { ...dateMeta, isMajor: false, isNarrow: false, inPopover: true, hasNavLink: false, get weekdayText() { return findWeekdayText(textParts); }, get dayNumberText() { return findDayNumberText(textParts); }, get monthText() { return findMonthText(textParts); }, view: viewApi, text: '', textParts: [], options: { businessHours: Boolean(options.businessHours) }, }; const fullDateStr = formatDayString(startDate); /* TODO: DRY with TimelineHeaderCell */ const { dayHeaderAlign } = options; const align = typeof dayHeaderAlign === 'function' ? dayHeaderAlign({ level: 0, inPopover: true, isNarrow: false }) : dayHeaderAlign; const isRtl = computeElIsRtl(props.alignEl); return createPortal(jsxs("div", { "data-date": fullDateStr, id: props.id, role: 'dialog', "aria-labelledby": props.titleId, className: joinClassNames(options.popoverClass, classNames.flexCol, classNames.popoverZ, classNames.abs, classNames.borderBoxRoot, classNames.internalPopover), style: { // positioning is mutated directly in updateSize, HOWEVER, we don't want popover to start // low on screen because might cause unnecessary scrollbars top: 0, left: 0, }, // HACK because of portal dir: isRtl ? 'rtl' : undefined, "data-color-scheme": options.colorScheme || undefined, ref: this.handleRootEl, children: [jsx("div", { tabIndex: 0, style: { outline: 'none' }, ref: this.focusStartRef }), jsxs("div", { className: joinClassNames(generateClassName(options.dayHeaderClass, dayHeaderRenderProps), classNames.flexCol, classNames.borderOnlyB, align === 'center' ? classNames.alignCenter : align === 'end' ? classNames.alignEnd : classNames.alignStart), children: [jsx("div", { children: jsx(ContentContainer, { tag: "div", attrs: { id: props.titleId, // NOTE: more-popover never has nav-links }, generatorName: "dayHeaderContent", renderProps: dayHeaderRenderProps, customGenerator: options.dayHeaderContent, defaultGenerator: renderText, classNameGenerator: options.dayHeaderInnerClass, didMount: options.dayHeaderDidMount, willUnmount: options.dayHeaderWillUnmount }) }), jsx(ContentContainer, { tag: 'button', attrs: { 'aria-label': options.closeHint, ...createAriaClickAttrs(this.handleClose) }, elRef: this.closeRef, className: joinClassNames(options.popoverCloseClass, classNames.flexRow, classNames.cursorPointer), renderProps: {}, customGenerator: options.popoverCloseContent, generatorName: 'popoverCloseContent' })] }), jsx("div", { className: joinClassNames(generateClassName(options.dayCellClass, dayCellRenderProps), classNames.flexCol, classNames.borderNone), children: jsx("div", { className: generateClassName(options.dayCellInnerClass, dayCellRenderProps), children: props.children }) }), jsx("div", { tabIndex: 0, style: { outline: 'none' }, ref: this.focusEndRef })] }), getAppendableRoot(props.alignEl)); } queryHit(isRtl, positionLeft, positionTop, elWidth, elHeight) { let { rootEl, props } = this; if (positionLeft >= 0 && positionLeft < elWidth && positionTop >= 0 && positionTop < elHeight) { return { dateProfile: props.dateProfile, dateSpan: { allDay: !props.forceTimed, range: { start: props.startDate, end: props.endDate, }, ...props.dateSpanProps, }, getDayEl: () => rootEl, rect: { left: 0, top: 0, right: elWidth, bottom: elHeight, }, layer: 1, // important when comparing with hits from other components }; } return null; } componentDidMount() { document.addEventListener('mousedown', this.handleDocumentMouseDown); document.addEventListener('keydown', this.handleDocumentKeyDown); this.focusStartRef.current.addEventListener('focus', this.handleClose); this.focusEndRef.current.addEventListener('focus', this.handleClose); this.closeRef.current.focus({ preventScroll: true }); this.updateSize(); } componentWillUnmount() { document.removeEventListener('mousedown', this.handleDocumentMouseDown); document.removeEventListener('keydown', this.handleDocumentKeyDown); this.focusStartRef.current.removeEventListener('focus', this.handleClose); this.focusEndRef.current.removeEventListener('focus', this.handleClose); } updateSize() { let { alignEl, alignParentTop } = this.props; let { rootEl: popoverEl } = this; const isRtl = computeElIsRtl(alignEl); // position relative to viewport const alignmentRect = computeClippedClientRect(alignEl); if (alignmentRect) { let popoverDims = popoverEl.getBoundingClientRect(); // position relative to viewport let popoverVPTop = alignParentTop // HACK: subtract 1 for DayGrid, which has borders on row-bottom. Only view that uses alignParentTop ? alignEl.closest(alignParentTop).getBoundingClientRect().top - ROW_BORDER_WIDTH : alignmentRect.top; let popoverVPLeft = isRtl ? alignmentRect.right - popoverDims.width : alignmentRect.left; // constrain popoverVPTop = Math.max(popoverVPTop, SPACE_FROM_VIEWPORT); popoverVPLeft = Math.min(popoverVPLeft, document.documentElement.clientWidth - SPACE_FROM_VIEWPORT - popoverDims.width); popoverVPLeft = Math.max(popoverVPLeft, SPACE_FROM_VIEWPORT); const { offsetParent } = popoverEl; // final popover position, relative to offsetParent let top; let left; // TODO: account for RTL if (!offsetParent || offsetParent === document.body) { top = popoverVPTop + window.scrollY; left = popoverVPLeft + window.scrollX; } else { const offsetParentRect = offsetParent.getBoundingClientRect(); top = popoverVPTop - offsetParentRect.top + offsetParent.scrollTop; left = popoverVPLeft - offsetParentRect.left + offsetParent.scrollLeft; } applyStyle(popoverEl, { top, left }); } } } // TODO: DRY function renderText(renderProps) { return renderProps.text; } function doCoordRangesIntersect(r0, r1) { return r0.end > r1.start && r0.start < r1.end; } function intersectCoordRanges(r0, r1) { const start = Math.max(r0.start, r1.start); const end = Math.min(r0.end, r1.end); if (start < end) { return { start, end, isStart: r0.isStart && start === r0.start, isEnd: r0.isEnd && end === r0.end, }; } } function joinCoordRanges(r0, r1) { return { start: Math.min(r0.start, r1.start), end: Math.max(r0.end, r1.end), }; } function getCoordRangeEnd(r) { return r.end; } // { eventRange } // ------------------------------------------------------------------------------------------------- function computeEarliestStart(segs) { return segs.reduce(pickEarliestStart).eventRange.range.start; } function computeLatestEnd(segs) { return segs.reduce(pickLatestEnd).eventRange.range.end; } function pickEarliestStart(r0, r1) { return r0.eventRange.range.start < r1.eventRange.range.start ? r0 : r1; } function pickLatestEnd(r0, r1) { return r0.eventRange.range.end > r1.eventRange.range.end ? r0 : r1; } /* IMPORTANT: caller is responsible for injecting moreLinkInnerClass, either on root `classNames` or within inner element */ class MoreLinkContainer extends BaseComponent { constructor() { super(...arguments); this.state = { isPopoverOpen: false, }; this.handleLinkEl = (linkEl) => { this.linkEl = linkEl; if (this.props.elRef) { setRef(this.props.elRef, linkEl); } }; this.handleClick = (ev) => { let { props, context } = this; let { dateEnv, options } = context; let { moreLinkClick } = options; let date = computeRange(props).start; function buildPublicSeg(seg) { let { def, instance, range } = seg.eventRange; return { event: new EventImpl(context, def, instance), start: dateEnv.toDate(range.start), end: dateEnv.toDate(range.end), isStart: seg.isStart, isEnd: seg.isEnd, }; } if (typeof moreLinkClick === 'function') { moreLinkClick = moreLinkClick({ date: dateEnv.toDate(date), allDay: Boolean(props.allDayDate), allSegs: props.segs.map(buildPublicSeg), hiddenSegs: props.hiddenSegs.map(buildPublicSeg), jsEvent: ev, view: context.viewApi, }); } if (!moreLinkClick || moreLinkClick === 'popover') { this.setState({ isPopoverOpen: true }); } else if (typeof moreLinkClick === 'string') { // a view name context.calendarApi.zoomTo(date, moreLinkClick); } }; this.handlePopoverClose = () => { if (this.linkEl) { // was null sometimes when initiating drag-n-drop would hide the popover this.linkEl.focus(); } this.setState({ isPopoverOpen: false }); }; } render() { let { props, state } = this; return (jsx(ViewContextType.Consumer, { children: (context) => { let { viewApi, options, calendarApi, baseId } = context; let { moreLinkText } = options; let moreCnt = props.hiddenSegs.length; let range = computeRange(props); let popoverId = baseId + 'popover-' + range.start.toISOString(); let numericText = `+${moreCnt}`; // TODO: offer hook or i18n? let longText = typeof moreLinkText === 'function' // TODO: eventually use formatWithOrdinals ? moreLinkText.call(calendarApi, moreCnt) : `${numericText} ${moreLinkText}`; let hint = formatWithOrdinals(options.moreLinkHint, [moreCnt], longText); let renderProps = { num: moreCnt, numericText, longText, text: (props.isMicro || props.display === 'column') ? numericText : longText, isNarrow: props.isNarrow, view: viewApi, }; return (jsxs(Fragment, { children: [Boolean(moreCnt) && (jsx(ContentContainer, { tag: 'div', elRef: this.handleLinkEl, className: joinClassNames(generateClassName(// will added to moreLinkClass props.display === 'row' ? options.rowMoreLinkClass // row : options.columnMoreLinkClass, // column renderProps), props.className, props.display === 'row' ? classNames.flexRow : classNames.flexCol, classNames.internalMoreLink, classNames.cursorPointer), style: props.style, attrs: { ...props.attrs, ...createAriaClickAttrs(this.handleClick), title: hint, 'role': 'button', 'aria-haspopup': 'dialog', 'aria-expanded': state.isPopoverOpen, 'aria-controls': state.isPopoverOpen ? popoverId : undefined, }, renderProps: renderProps, generatorName: "moreLinkContent", customGenerator: options.moreLinkContent, defaultGenerator: renderMoreLinkText, classNameGenerator: options.moreLinkClass, didMount: options.moreLinkDidMount, willUnmount: options.moreLinkWillUnmount, children: (InnerContent) => (jsx(InnerContent, { tag: 'div', className: joinClassNames(generateClassName(options.moreLinkInnerClass, renderProps), generateClassName(props.display === 'row' ? options.rowMoreLinkInnerClass // row : options.columnMoreLinkInnerClass, // column renderProps), props.display === 'row' ? classNames.stickyS : classNames.stickyT) })) })), state.isPopoverOpen && (jsx(MorePopover, { id: popoverId, titleId: popoverId + '-title', startDate: range.start, endDate: range.end, dateProfile: props.dateProfile, todayRange: props.todayRange, dateSpanProps: props.dateSpanProps, alignEl: props.alignElRef ? props.alignElRef.current : this.linkEl, alignParentTop: props.alignParentTop, forceTimed: props.forceTimed, onClose: this.handlePopoverClose, children: props.popoverContent() }))] })); } })); } } function renderMoreLinkText(props) { return props.text; } function computeRange(props) { if (props.allDayDate) { return { start: props.allDayDate, end: addDays(props.allDayDate, 1), }; } return { start: computeEarliestStart(props.hiddenSegs), end: computeLatestEnd(props.hiddenSegs), }; } const DEFAULT_TABLE_EVENT_TIME_FORMAT = createFormatter({ hour: 'numeric', minute: '2-digit', omitZeroMinute: true, meridiem: 'narrow', }); function hasListItemDisplay(seg) { let { display } = seg.eventRange.ui; return display === 'list-item' || (display === 'auto' && !seg.eventRange.def.allDay && (seg.end - seg.start) === 1 && // single-day seg.isStart && // " seg.isEnd // " ); } class DayGridMoreLink extends BaseComponent { render() { let { props } = this; return (jsx(MoreLinkContainer, { display: 'row', className: props.className, isNarrow: props.isNarrow, isMicro: props.isMicro, dateProfile: props.dateProfile, todayRange: props.todayRange, allDayDate: props.allDayDate, segs: props.segs, hiddenSegs: props.hiddenSegs, alignElRef: props.alignElRef, alignParentTop: props.alignParentTop, dateSpanProps: props.dateSpanProps, popoverContent: () => (jsx(Fragment, { children: props.segs.map((seg) => { let { eventRange } = seg; let { instanceId } = eventRange.instance; let isDragging = Boolean(props.eventDrag && props.eventDrag.affectedInstances[instanceId]); let isResizing = Boolean(props.eventResize && props.eventResize.affectedInstances[instanceId]); let isInvisible = isDragging || isResizing; return (jsx("div", { style: { visibility: isInvisible ? 'hidden' : undefined, }, children: jsx(StandardEvent, { display: hasListItemDisplay(seg) ? 'list-item' : 'row', eventRange: eventRange, isStart: seg.isStart, isEnd: seg.isEnd, isDragging: isDragging, isResizing: isResizing, isMirror: false, isSelected: instanceId === props.eventSelection, defaultTimeFormat: DEFAULT_TABLE_EVENT_TIME_FORMAT, defaultDisplayEventEnd: false, ...getEventRangeMeta(eventRange, props.todayRange) }) }, instanceId)); }) })) })); } } class DayGridCell extends DateComponent { constructor() { super(...arguments); // memo this.getDateMeta = memoize(getDateMeta); this.refineRenderProps = memoizeObjArg(refineRenderProps); // ref this.rootElRef = createRef(); this.handleBodyEl = (bodyEl) => { if (this.disconnectBodyHeight) { this.disconnectBodyHeight(); this.disconnectBodyHeight = undefined; setRef(this.props.headerHeightRef, null); setRef(this.props.mainHeightRef, null); } if (bodyEl) { // we want to fire on ANY size change, because we do more advanced stuff this.disconnectBodyHeight = watchSize(bodyEl, (_bodyWidth, bodyHeight) => { if (this._isUnmounting) return; const { props } = this; const mainRect = bodyEl.getBoundingClientRect(); const rootRect = this.rootElRef.current.getBoundingClientRect(); const headerHeight = mainRect.top - rootRect.top; if (!isDimsEqual(this.headerHeight, headerHeight)) { this.headerHeight = headerHeight; setRef(props.headerHeightRef, headerHeight); } if (props.fgLiquidHeight) { setRef(props.mainHeightRef, bodyHeight); } }); } }; } render() { let { props, context } = this; let { options, dateEnv } = context; // TODO: memoize this const isMonthStart = props.showDayNumber && shouldDisplayMonthStart(props.date, props.dateProfile.currentRange, dateEnv); const dateMeta = this.getDateMeta(props.date, dateEnv, props.dateProfile, props.todayRange); const baseClassName = joinClassNames(props.borderStart ? classNames.borderOnlyS : classNames.borderNone, props.width != null ? '' : classNames.liquid, classNames.flexCol, classNames.noMargin, classNames.noPadding); const hasNavLink = options.navLinks; const renderProps = this.refineRenderProps({ date: props.date, isMajor: props.isMajor, isNarrow: props.isNarrow, dateMeta: dateMeta, hasLabel: props.showDayNumber, hasMonthLabel: isMonthStart, hasNavLink, renderProps: props.renderProps, viewApi: context.viewApi, dateEnv: context.dateEnv, monthStartFormat: options.monthStartFormat, dayCellFormat: options.dayCellFormat, businessHours: Boolean(options.businessHours), }); if (dateMeta.isDisabled) { return (jsx("div", { role: 'gridcell', "aria-disabled": true, className: joinClassNames(generateClassName(options.dayCellClass, renderProps), props.className, baseClassName), style: { width: props.width } })); } const fullDateStr = buildDateStr(context, props.date); return (jsx(ContentContainer, { tag: "div", elRef: this.rootElRef, className: joinClassNames(props.className, baseClassName), attrs: { ...props.attrs, role: 'gridcell', 'aria-label': fullDateStr, ...(renderProps.isToday ? { 'aria-current': 'date' } : {}), 'data-date': formatDayString(props.date), }, style: { width: props.width, }, renderProps: renderProps, generatorName: "dayCellTopContent" // !!! for top , customGenerator: options.dayCellTopContent /* !!! for top */, defaultGenerator: renderTopInner, classNameGenerator: options.dayCellClass, didMount: options.dayCellDidMount, willUnmount: options.dayCellWillUnmount, children: (InnerContent) => (jsxs(Fragment, { children: [jsx("div", { className: joinClassNames(classNames.rel, // puts it above bg-fills, which are positioned on TOP of this component :| generateClassName(options.dayCellTopClass, renderProps)), children: props.showDayNumber && (jsx(InnerContent // the dayCellTopContent , { tag: 'div', attrs: hasNavLink ? buildNavLinkAttrs(context, props.date, undefined, fullDateStr) : { 'aria-hidden': true } // label already on cell , className: generateClassName(options.dayCellTopInnerClass, renderProps) })) }), jsxs("div", { className: joinClassNames(classNames.flexCol, props.fgLiquidHeight ? classNames.liquid : classNames.grow), ref: this.handleBodyEl, children: [jsx("div", { className: generateClassName(options.dayCellInnerClass, renderProps), style: { minHeight: props.fgHeight }, children: props.fg }), jsx(DayGridMoreLink, { className: classNames.rel, allDayDate: props.date, segs: props.segs, hiddenSegs: props.hiddenSegs, alignElRef: this.rootElRef, alignParentTop: props.showDayNumber ? '[role=row]' : `.${classNames.internalView}`, dateSpanProps: props.dateSpanProps, dateProfile: props.dateProfile, eventSelection: props.eventSelection, eventDrag: props.eventDrag, eventResize: props.eventResize, todayRange: props.todayRange, isNarrow: props.isNarrow, isMicro: props.isMicro })] }), jsx("div", { className: joinClassNames(classNames.rel, // puts it above bg-fills generateClassName(options.dayCellBottomClass, renderProps)) })] })) })); } componentDidMount() { this._isUnmounting = false; } componentWillUnmount() { this._isUnmounting = true; } } // Utils // ------------------------------------------------------------------------------------------------- function renderTopInner(props) { return props.text || jsx(Fragment, { children: "\u00A0" }); // TODO: DRY? } function shouldDisplayMonthStart(date, currentRange, dateEnv) { const { start: currentStart, end: currentEnd } = currentRange; const currentEndIncl = addMs(currentEnd, -1); const currentFirstYear = dateEnv.getYear(currentStart); const currentFirstMonth = dateEnv.getMonth(currentStart); const currentLastYear = dateEnv.getYear(currentEndIncl); const currentLastMonth = dateEnv.getMonth(currentEndIncl); // spans more than one month? return !(currentFirstYear === currentLastYear && currentFirstMonth === currentLastMonth) && Boolean( // first date in current view? date.valueOf() === currentStart.valueOf() || // a month-start that's within the current range? (dateEnv.getDay(date) === 1 && date.valueOf() < currentEnd.valueOf())); } function refineRenderProps(raw) { let { date, dateEnv, hasLabel, hasMonthLabel, hasNavLink, businessHours } = raw; let textParts = []; let text = ''; if (hasLabel) { textParts = dateEnv.formatToParts(date, hasMonthLabel ? raw.monthStartFormat : raw.dayCellFormat); text = joinDateTimeFormatParts(textParts); } return { ...raw.dateMeta, ...raw.renderProps, text, textParts, isMajor: raw.isMajor, isNarrow: raw.isNarrow, inPopover: false, hasNavLink, get weekdayText() { return findWeekdayText(textParts); }, get dayNumberText() { return findDayNumberText(textParts); }, get monthText() { return findMonthText(textParts); }, options: { businessHours }, view: raw.viewApi, }; } class SegHierarchy { constructor(segs, getSegThickness = (seg) => { return 1; }, strictOrder = false, // HACK maxCoord, maxDepth, hiddenConsumes = false, // hidden segs also hide the touchingPlacement? allowSlicing = false) { this.getSegThickness = getSegThickness; this.strictOrder = strictOrder; this.maxCoord = maxCoord; this.maxDepth = maxDepth; this.hiddenConsumes = hiddenConsumes; this.allowSlicing = allowSlicing; this.placementsByLevel = []; this.levelCoords = []; // parallel with placementsByLevel this.hiddenSegs = []; for (const seg of segs) { this.insertSeg(seg, this.getSegThickness(seg)); } } insertSeg(seg, segThickness, isSlice) { if (segThickness != null) { const insertion = this.findInsertion(seg, segThickness); if (this.isInsertionValid(insertion, segThickness)) { this.insertSegAt(seg, insertion, segThickness, isSlice); } else { const { touchingPlacement } = insertion; // is there a touching-seg? if (touchingPlacement) { // should we hide or reslice touchingPlacement? if (this.hiddenConsumes && !touchingPlacement.isZombie) { touchingPlacement.isZombie = true; // edit in-place this.hiddenSegs.push(touchingPlacement); if (this.allowSlicing) { const newSeg = Object.assign({}, touchingPlacement); // copy // slice touchingPlacement in-place Object.assign(touchingPlacement, intersectCoordRanges(touchingPlacement, seg)); touchingPlacement.isSlice = true; // try to reinsert touchingPlacement's seg this.splitSeg(newSeg, touchingPlacement.thickness, touchingPlacement); } } // record seg as hidden, potentially split by touchingPlacement if (this.allowSlicing) { this.hiddenSegs.push({ ...seg, ...intersectCoordRanges(seg, touchingPlacement), }); this.splitSeg(seg, segThickness, touchingPlacement); } else { this.hiddenSegs.push(seg); } // not touching anything } else { this.hiddenSegs.push(seg); } } } } /* TODO: inline? */ isInsertionValid(insertion, thickness) { return (this.maxCoord == null || insertion.levelCoord + thickness <= this.maxCoord) && (this.maxDepth == null || insertion.depth < this.maxDepth); } /* Does not add the portion that intersects with barrier to hiddenSegs */ splitSeg(seg, segThickness, barrier) { // any leftover seg on the start-side of the barrier? if (seg.start < barrier.start) { this.insertSeg({ ...seg, end: barrier.start, isEnd: false }, segThickness, /* isSlice = */ true); } // any leftover seg on the end-side of the bar