@fullcalendar/web-component
Version:
Custom Element for FullCalendar
363 lines (358 loc) • 20 kB
JavaScript
import { R as RefMap, f as DayTableSlicer, e as dayMicroWidth, a as Ruler, d as DayGridHeaderRow, D as DayGridRows, T as TableDateProfileGenerator, g as buildDayTableModel, i as createDayHeaderFormatter, t as buildDateRowConfig, u as buildDayTableRenderRange } from './bd0d9fb1.js';
import { jsx, jsxs } from 'preact/jsx-runtime';
import { createRef, createElement } from 'preact/compat';
import { j as joinClassNames, f as fracToCssDim } from './423b7bc6.js';
import { o as DateComponent, a as memoize, f as afterSize, s as setRef, v as buildNavLinkAttrs, g as generateClassName, w as watchHeight, d as getIsHeightAuto, n as NowTimer, h as ViewContainer, S as Scroller } from './fa7e597f.js';
import { joinDateTimeFormatParts, formatIsoMonthStr, createDuration, intersectRanges } from '@full-ui/headless-calendar';
import { I as computeViewBorderless, F as classNames, c as createFormatter } from './69b11357.js';
class SingleMonth extends DateComponent {
constructor() {
super(...arguments);
this.state = {};
// memo
this.buildDayTableModel = memoize(buildDayTableModel);
this.createDayHeaderFormatter = memoize(createDayHeaderFormatter);
this.buildDateRowConfig = memoize(buildDateRowConfig);
// ref
this.titleElRef = createRef();
this.tableHeaderElRef = createRef();
this.rowHeightRefMap = new RefMap(() => {
afterSize(this.handleHeights);
});
this.slicer = new DayTableSlicer();
this.handleEl = (el) => {
const { options } = this.context;
if (el) {
this.rootEl = el;
options.singleMonthDidMount?.({
el: this.rootEl,
...this.renderProps,
});
}
};
this.handleGridWidth = (gridWidth) => {
if (this._isUnmounting)
return;
this.setState({ gridWidth });
};
this.handleHeights = () => {
if (this._isUnmounting)
return;
setRef(this.props.heightsRef, {
titleHeight: this.titleHeight,
tableHeaderHeight: this.tableHeaderHeight,
rowHeightMap: this.rowHeightRefMap.current,
cellRows: this.cellRows,
});
};
}
get titleId() {
return this.context.baseId + 'month-' + this.props.isoDateStr;
}
render() {
const { props, state, context } = this;
const { dateProfile, forPrint } = props;
const { options, dateEnv } = context;
const { borderlessX, borderlessTop, borderlessBottom } = computeViewBorderless(options);
const dayTableModel = this.buildDayTableModel(dateProfile, context.dateProfileGenerator, dateEnv);
const slicedProps = this.slicer.sliceProps(props, dateProfile, options.nextDayThreshold, context, dayTableModel);
const dayHeaderFormat = this.createDayHeaderFormatter(options.dayHeaderFormat, false, // datesRepDistinctDays
dayTableModel.colCount);
const rowConfig = this.buildDateRowConfig(dayTableModel.headerDates, false, // datesRepDistinctDays
dateProfile, props.todayRange, dayHeaderFormat, context);
this.cellRows = dayTableModel.cellRows;
const isTitleAndHeaderSticky = !forPrint && props.colCount === 1;
const isAspectRatio = !forPrint || props.hasLateralSiblings;
const cellColCnt = dayTableModel.cellRows[0].length;
const colWidth = state.gridWidth != null ? state.gridWidth / cellColCnt : undefined;
const cellIsMicro = colWidth != null && colWidth <= dayMicroWidth;
const cellIsNarrow = cellIsMicro || (colWidth != null && colWidth <= options.dayNarrowWidth);
const rowHeightGuess = state.gridWidth != null
? (1 / options.aspectRatio) * state.gridWidth / 6
: undefined;
const headerStickyBottom = isTitleAndHeaderSticky
? rowHeightGuess
: undefined;
const titleStickyBottom = isTitleAndHeaderSticky && rowHeightGuess != null && state.tableHeaderHeight != null
? rowHeightGuess + state.tableHeaderHeight + 1
: undefined;
const businessHourSegs = forPrint ? [] : slicedProps.businessHourSegs;
const dateSelectionSegs = forPrint ? [] : slicedProps.dateSelectionSegs;
const eventDrag = forPrint ? null : slicedProps.eventDrag;
const eventResize = forPrint ? null : slicedProps.eventResize;
const hasNavLink = options.navLinks && props.colCount > 1;
const headerRenderProps = {
multiMonthColumns: props.colCount || 0,
isSticky: isTitleAndHeaderSticky,
isNarrow: cellIsNarrow,
hasNavLink,
};
const monthStartDate = props.dateProfile.currentRange.start;
const navLinkAttrs = hasNavLink
? buildNavLinkAttrs(context, monthStartDate, 'month', props.isoDateStr)
: {};
return (jsx("div", { role: 'listitem', style: { width: props.width }, children: jsxs("div", { role: 'grid', "aria-labelledby": this.titleId, "data-date": props.isoDateStr, className: joinClassNames(generateClassName(options.singleMonthClass, {
isFirst: props.isFirst,
isLast: props.isLast,
multiMonthColumns: props.colCount || 0,
}), classNames.flexCol, props.hasLateralSiblings && classNames.breakInsideAvoid), children: [jsx(Ruler, { widthRef: this.handleGridWidth }), jsx("div", { id: this.titleId, ref: this.titleElRef, className: joinClassNames(generateClassName(options.singleMonthHeaderClass, headerRenderProps), isTitleAndHeaderSticky && classNames.stickyT, classNames.flexCol), style: {
// HACK to keep zIndex above table-header,
// because in Chrome, something about position:sticky on this title div
// causes its bottom border to no be considered part of its mass,
// and would get overlapped and hidden by the table-header div
zIndex: isTitleAndHeaderSticky ? 3 : undefined, // TODO: className?
marginBottom: titleStickyBottom,
}, children: jsx("div", { ...navLinkAttrs, className: joinClassNames(generateClassName(options.singleMonthHeaderInnerClass, headerRenderProps), navLinkAttrs.className), children: joinDateTimeFormatParts(dateEnv.formatToParts(monthStartDate, props.titleFormat)) }) }), jsxs("div", { className: joinClassNames(generateClassName(options.tableClass, {
borderlessX,
borderlessTop,
borderlessBottom,
multiMonthColumns: props.colCount || 0,
}), classNames.flexCol), style: {
marginTop: titleStickyBottom != null ? -titleStickyBottom : undefined,
}, children: [jsxs("div", { ref: this.tableHeaderElRef, className: joinClassNames(generateClassName(options.tableHeaderClass, {
isSticky: isTitleAndHeaderSticky,
borderlessX,
borderlessTop,
borderlessBottom,
multiMonthColumns: props.colCount || 0,
}), classNames.flexCol, isTitleAndHeaderSticky && classNames.sticky), style: {
zIndex: isTitleAndHeaderSticky ? 2 : undefined, // TODO: className?
top: isTitleAndHeaderSticky ? state.titleHeight : 0,
marginBottom: headerStickyBottom,
}, children: [jsx(DayGridHeaderRow, { ...rowConfig, role: 'row', borderBottom: false, cellIsNarrow: cellIsNarrow, cellIsMicro: cellIsMicro, rowLevel: 0 }), jsx("div", { className: generateClassName(options.dayHeaderDividerClass, {
isSticky: isTitleAndHeaderSticky,
multiMonthColumns: props.colCount || 0,
options: { allDaySlot: Boolean(options.allDaySlot) },
}) })] }), jsx("div", { className: joinClassNames(generateClassName(options.tableBodyClass, {
borderlessX,
borderlessTop,
borderlessBottom,
multiMonthColumns: props.colCount || 0,
}), classNames.flexCol, isAspectRatio && classNames.rel), style: {
zIndex: isTitleAndHeaderSticky ? 1 : undefined, // TODO: className?
marginTop: headerStickyBottom != null ? -headerStickyBottom : undefined,
aspectRatio: isAspectRatio ? String(options.aspectRatio) : undefined,
}, children: jsx(DayGridRows, { dateProfile: props.dateProfile, todayRange: props.todayRange, cellRows: dayTableModel.cellRows, className: isAspectRatio ? classNames.fill : '', forPrint: forPrint && !props.hasLateralSiblings, dayMaxEventRows: (forPrint && props.hasLateralSiblings)
? 1 // for side-by-side multimonths, limit to one row
: true // otherwise, always do +more link, never expand rows
,
// content
fgEventSegs: slicedProps.fgEventSegs, bgEventSegs: slicedProps.bgEventSegs, businessHourSegs: businessHourSegs, dateSelectionSegs: dateSelectionSegs, eventDrag: eventDrag, eventResize: eventResize, eventSelection: slicedProps.eventSelection,
// dimensions
visibleWidth: state.gridWidth, cellIsNarrow: cellIsNarrow, cellIsMicro: cellIsMicro, rowHeightRefMap: this.rowHeightRefMap }) })] })] }) }));
}
componentDidMount() {
this._isUnmounting = false;
this.disconnectTitleHeight = watchHeight(this.titleElRef.current, (height) => {
this.setState({ titleHeight: this.titleHeight = height });
afterSize(this.handleHeights);
});
this.disconnectTableHeaderHeight = watchHeight(this.tableHeaderElRef.current, (height) => {
this.setState({ tableHeaderHeight: this.tableHeaderHeight = height });
afterSize(this.handleHeights);
});
}
componentWillUnmount() {
const { options } = this.context;
this._isUnmounting = true;
this.disconnectTitleHeight();
this.disconnectTableHeaderHeight();
options.singleMonthWillUnmount?.({
el: this.rootEl,
...this.renderProps,
});
}
}
class MultiMonthView extends DateComponent {
constructor() {
super(...arguments);
this.state = {};
// memo
this.splitDateProfileByMonth = memoize(splitDateProfileByMonth);
this.buildMonthFormat = memoize(buildMonthFormat);
// ref
this.scrollerRef = createRef();
this.tilesElRef = createRef();
this.scrollState = {};
// Scrolling
// -----------------------------------------------------------------------------------------------
this.handleInnerWidth = (innerWidth) => {
if (this._isUnmounting)
return;
this.setState({ innerWidth });
};
this.handleScrollStart = () => {
this.scrollState.date = undefined;
this.scrollState.top = undefined;
};
this.handleScrollEnd = (isDevice) => {
const scroller = this.scrollerRef.current;
if (isDevice && scroller) {
this.scrollState.top = scroller.y;
this.scrollState.date = undefined;
}
};
}
render() {
const { context, props, state } = this;
const { options } = context;
const verticalScrolling = !props.forPrint && !getIsHeightAuto(options);
const monthDateProfiles = this.splitDateProfileByMonth(context.dateProfileGenerator, props.dateProfile, context.dateEnv, options.fixedWeekCount, options.showNonCurrentDates);
const monthTitleFormat = this.buildMonthFormat(options.singleMonthTitleFormat, monthDateProfiles);
const { multiMonthMaxColumns, singleMonthMinWidth } = options;
const { innerWidth } = state;
let cols;
let cssMonthWidth;
let hasLateralSiblings = false;
if (innerWidth != null) {
cols = Math.max(1, Math.min(multiMonthMaxColumns, Math.floor(innerWidth / singleMonthMinWidth)));
if (props.forPrint) {
cols = Math.min(cols, 2);
}
cssMonthWidth = fracToCssDim(1 / cols);
hasLateralSiblings = cols > 1;
}
return (jsx(NowTimer, { unit: "day", children: (nowDate, todayRange) => (jsxs(ViewContainer, { viewSpec: context.viewSpec, className: joinClassNames(
// HACK for Safari. Can't do break-inside:avoid with flexbox items, likely b/c it's not standard:
// https://stackoverflow.com/a/60256345
!props.forPrint && classNames.flexCol, props.className), children: [jsx(Scroller, { vertical: verticalScrolling, className: verticalScrolling ? classNames.liquid : '', ref: this.scrollerRef, children: jsx("div", { role: 'list', ref: this.tilesElRef, "aria-labelledby": props.labelId, "aria-label": props.labelStr, className: classNames.safeTiles, children: monthDateProfiles.map((monthDateProfile, i) => {
const monthStr = formatIsoMonthStr(monthDateProfile.currentRange.start);
return (createElement(SingleMonth, { ...props, key: monthStr, todayRange: todayRange, isoDateStr: monthStr, titleFormat: monthTitleFormat, dateProfile: monthDateProfile, width: cssMonthWidth, colCount: cols, isFirst: !i, isLast: i === monthDateProfiles.length - 1, hasLateralSiblings: hasLateralSiblings }));
}) }) }), jsx(Ruler, { widthRef: this.handleInnerWidth })] })) }));
}
// Lifecycle
// -----------------------------------------------------------------------------------------------
componentDidMount() {
this._isUnmounting = false;
this.scrollState.date = this.props.dateProfile.currentDate;
this.scrollerRef.current.addScrollStartListener(this.handleScrollStart);
this.scrollerRef.current.addScrollEndListener(this.handleScrollEnd);
// this.applyScroll() // definitely not ready yet b/c doesn't have state.innerWidth
// workaround for off-by-a-few-pixels on first time when multiMonthMaxColumns=1, not sure why
setTimeout(() => {
this.applyScroll();
}, 0);
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.dateProfile !== this.props.dateProfile) {
if (this.context.options.scrollTimeReset) {
this.resetScroll();
}
else {
this.applyScroll();
}
}
else if (prevState.innerWidth !== this.state.innerWidth) {
this.applyScroll();
}
}
componentWillUnmount() {
this._isUnmounting = true;
this.scrollerRef.current.removeScrollStartListener(this.handleScrollStart);
this.scrollerRef.current.removeScrollEndListener(this.handleScrollEnd);
}
resetScroll() {
this.scrollState.date = this.props.dateProfile.currentDate;
this.scrollState.top = undefined;
this.applyScroll();
}
applyScroll() {
const scroller = this.scrollerRef.current;
const top = this.computeScrollTop();
if (scroller && top != null) {
scroller.scrollTo({ y: top });
}
}
computeScrollTop() {
const { scrollState } = this;
if (scrollState.top != null) {
return scrollState.top;
}
if (scrollState.date != null) {
const tilesEl = this.tilesElRef.current;
const monthEl = tilesEl?.querySelector(`[data-date="${formatIsoMonthStr(scrollState.date)}"]`);
const monthWrapEl = monthEl?.parentElement;
if (tilesEl && monthWrapEl) {
// rounding required for proper alignment
const monthTop = Math.round(monthWrapEl.getBoundingClientRect().top);
const originTop = Math.round(tilesEl.getBoundingClientRect().top);
return monthTop - originTop;
}
}
}
}
// date profile
// -------------------------------------------------------------------------------------------------
const oneMonthDuration = createDuration(1, 'month');
function splitDateProfileByMonth(dateProfileGenerator, dateProfile, dateEnv, fixedWeekCount, showNonCurrentDates) {
const { start, end } = dateProfile.currentRange;
let monthStart = start;
const monthDateProfiles = [];
while (monthStart.valueOf() < end.valueOf()) {
const monthEnd = dateEnv.add(monthStart, oneMonthDuration);
const currentRange = {
// yuck
start: dateProfileGenerator.skipHiddenDays(monthStart),
end: dateProfileGenerator.skipHiddenDays(monthEnd, -1, true),
};
let renderRange = buildDayTableRenderRange({
currentRange,
snapToWeek: true,
fixedWeekCount,
dateEnv,
});
renderRange = {
// yuck
start: dateProfileGenerator.skipHiddenDays(renderRange.start),
end: dateProfileGenerator.skipHiddenDays(renderRange.end, -1, true),
};
const activeRange = dateProfile.activeRange ?
intersectRanges(dateProfile.activeRange, showNonCurrentDates ? renderRange : currentRange) :
null;
monthDateProfiles.push({
currentDate: dateProfile.currentDate,
isValid: dateProfile.isValid,
validRange: dateProfile.validRange,
renderRange,
activeRange,
currentRange,
currentRangeUnit: 'month',
isRangeAllDay: true,
dateIncrement: dateProfile.dateIncrement,
slotMinTime: dateProfile.slotMaxTime,
slotMaxTime: dateProfile.slotMinTime,
});
monthStart = monthEnd;
}
return monthDateProfiles;
}
// date formatting
// -------------------------------------------------------------------------------------------------
const YEAR_MONTH_FORMATTER = createFormatter({ year: 'numeric', month: 'long' });
const YEAR_FORMATTER = createFormatter({ month: 'long' });
function buildMonthFormat(formatOverride, monthDateProfiles) {
return formatOverride ||
((monthDateProfiles[0].currentRange.start.getUTCFullYear() !==
monthDateProfiles[monthDateProfiles.length - 1].currentRange.start.getUTCFullYear())
? YEAR_MONTH_FORMATTER
: YEAR_FORMATTER);
}
var multiMonthPlugin = {
name: 'multimonth',
initialView: 'multiMonthYear',
views: {
multiMonth: {
component: MultiMonthView,
dateProfileGeneratorClass: TableDateProfileGenerator,
multiMonthMaxColumns: 3,
singleMonthMinWidth: 350,
},
multiMonthYear: {
type: 'multiMonth',
duration: { years: 1 },
fixedWeekCount: true, // TODO: apply to all multi-col layouts?
showNonCurrentDates: false, // TODO: looks bad when single-col layout
},
},
};
export { multiMonthPlugin as m };