/** @jsxImportSource react */ import { StringTemplate } from "../../data/StringTemplate"; import { Culture } from "../../ui/Culture"; import { FocusManager, offFocusOut, oneFocusOut } from "../../ui/FocusManager"; import "../../ui/Format"; import { Localization } from "../../ui/Localization"; import { VDOM, Widget } from "../../ui/Widget"; import { addEventListenerWithOptions, parseDateInvariant } from "../../util"; import { KeyCode } from "../../util/KeyCode"; import { dateDiff } from "../../util/date/dateDiff"; import { lowerBoundCheck } from "../../util/date/lowerBoundCheck"; import { monthStart } from "../../util/date/monthStart"; import { sameDate } from "../../util/date/sameDate"; import { upperBoundCheck } from "../../util/date/upperBoundCheck"; import { zeroTime } from "../../util/date/zeroTime"; import DropdownIcon from "../icons/drop-down"; import ForwardIcon from "../icons/forward"; import { tooltipMouseLeave, tooltipMouseMove, tooltipParentDidMount, tooltipParentWillReceiveProps, tooltipParentWillUnmount, } from "../overlay/tooltip-ops"; import { Field, FieldConfig, getFieldTooltip, FieldInstance } from "./Field"; import type { Instance } from "../../ui/Instance"; import type { RenderingContext } from "../../ui/RenderingContext"; import { BooleanProp, DataRecord, Prop } from "../../ui/Prop"; interface DayInfo { mod?: string; className?: string; style?: DataRecord | string; unselectable?: boolean; disabled?: boolean; } interface DayData { [day: string]: DayInfo; } export interface CalendarConfig extends FieldConfig { /** Selected date. This should be a `Date` object or a valid date string consumable by `Date.parse` function. */ value?: Prop; /** View reference date. If no date is selected, this date is used to determine which month to show in the calendar. */ refDate?: Prop; /** Minimum date value. This should be a `Date` object or a valid date string consumable by `Date.parse` function. */ minValue?: Prop; /** Set to `true` to disallow the `minValue`. Default value is `false`. */ minExclusive?: BooleanProp; /** Maximum date value. This should be a `Date` object or a valid date string consumable by `Date.parse` function. */ maxValue?: Prop; /** Set to `true` to disallow the `maxValue`. Default value is `false`. */ maxExclusive?: BooleanProp; /** Base CSS class to be applied to the calendar. Defaults to `calendar`. */ baseClass?: string; /** Highlight today's date. Default is true. */ highlightToday?: boolean; /** Maximum value error text. */ maxValueErrorText?: string; /** Maximum exclusive value error text. */ maxExclusiveErrorText?: string; /** Minimum value error text. */ minValueErrorText?: string; /** Minimum exclusive value error text. */ minExclusiveErrorText?: string; /** The function that will be used to convert Date objects before writing data to the store. * Default implementation is Date.toISOString. * See also Culture.setDefaultDateEncoding. */ encoding?: (date: Date) => any; /** Set to true to show the button for quickly selecting today's date. */ showTodayButton?: boolean; /** Localizable text for the todayButton. Defaults to `"Today"`. */ todayButtonText?: string; /** Defines which days of week should be displayed as disabled, i.e. `[0, 6]` will make Sunday and Saturday unselectable. */ disabledDaysOfWeek?: number[]; /** Set to true to show weeks starting from Monday. */ startWithMonday?: boolean; /** Map of days to additional day information such as style, className, mod, unselectable and disabled. */ dayData?: Prop; } export class Calendar extends Field { declare public baseClass: string; declare public unfocusable?: boolean; declare public focusable?: boolean; declare public highlightToday?: boolean; declare public maxValueErrorText?: string; declare public maxExclusiveErrorText?: string; declare public minValueErrorText?: string; declare public minExclusiveErrorText?: string; declare public disabledDaysOfWeekErrorText?: string; declare public showTodayButton?: boolean; declare public todayButtonText?: string; declare public startWithMonday?: boolean; declare public onBeforeSelect?: string | ((e: React.MouseEvent, instance: Instance, date: Date) => boolean | void); declare public onSelect?: string | ((e: React.MouseEvent, instance: Instance, date: Date) => void); declare public onBlur?: string | ((e: React.FocusEvent, instance: Instance) => void); declare public onFocusOut?: string | ((instance: Instance) => void); declare public disabledDaysOfWeek?: number[]; declare public partial?: boolean; declare public encoding?: (date: Date) => string; constructor(config?: CalendarConfig) { super(config); } declareData(...args: Record[]) { super.declareData( { value: undefined, refDate: undefined, disabled: undefined, enabled: undefined, minValue: undefined, minExclusive: undefined, maxValue: undefined, maxExclusive: undefined, focusable: undefined, dayData: undefined, }, ...args, ); } init() { if (this.unfocusable) this.focusable = false; super.init(); } prepareData(context: RenderingContext, instance: FieldInstance, ...args: any[]) { const { data } = instance; data.stateMods = { disabled: data.disabled, }; if (data.value) { let d = parseDateInvariant(data.value); if (!isNaN(d.getTime())) { data.date = zeroTime(d); } } if (data.refDate) data.refDate = zeroTime(parseDateInvariant(data.refDate)); if (data.maxValue) data.maxValue = zeroTime(parseDateInvariant(data.maxValue)); if (data.minValue) data.minValue = zeroTime(parseDateInvariant(data.minValue)); super.prepareData(context, instance, ...args); } validate(context: RenderingContext, instance: FieldInstance) { super.validate(context, instance); let { data, widget } = instance; let calendarWidget = widget as Calendar; if (!data.error && data.date) { let d; if (data.maxValue) { d = dateDiff(data.date, data.maxValue); if (d > 0) data.error = StringTemplate.format(this.maxValueErrorText!, data.maxValue); else if (d == 0 && data.maxExclusive) data.error = StringTemplate.format(this.maxExclusiveErrorText!, data.maxValue); } if (data.minValue) { d = dateDiff(data.date, data.minValue); if (d < 0) data.error = StringTemplate.format(this.minValueErrorText!, data.minValue); else if (d == 0 && data.minExclusive) data.error = StringTemplate.format(this.minExclusiveErrorText!, data.minValue); } if (calendarWidget.disabledDaysOfWeek) { if (calendarWidget.disabledDaysOfWeek.includes(data.date.getDay())) data.error = this.disabledDaysOfWeekErrorText; } if (data.dayData) { let date = parseDateInvariant(data.value); let info = data.dayData[date.toDateString()]; if (info && info.disabled) data.error = this.disabledDaysOfWeekErrorText; } } } renderInput(context: RenderingContext, instance: any, key: string): React.ReactElement { return ( this.handleSelect(e, instance, date)} /> ); } handleSelect(e: React.MouseEvent, instance: any, date: Date): void { let { store, data, widget } = instance; let calendarWidget = widget as Calendar; e.stopPropagation(); if (data.disabled) return; if (!validationCheck(date, data, calendarWidget.disabledDaysOfWeek)) return; if (this.onBeforeSelect && instance.invoke("onBeforeSelect", e, instance, date) === false) return; if (calendarWidget.partial) { let mixed = parseDateInvariant(data.value); if (data.value && !isNaN(mixed.getTime())) { mixed.setFullYear(date.getFullYear()); mixed.setMonth(date.getMonth()); mixed.setDate(date.getDate()); date = mixed; } } let encode = calendarWidget.encoding || Culture.getDefaultDateEncoding()!; instance.set("value", encode(date)); if (this.onSelect) instance.invoke("onSelect", e, instance, date); } } Calendar.prototype.baseClass = "calendar"; Calendar.prototype.highlightToday = true; Calendar.prototype.maxValueErrorText = "Select a date not after {0:d}."; Calendar.prototype.maxExclusiveErrorText = "Select a date before {0:d}."; Calendar.prototype.minValueErrorText = "Select a date not before {0:d}."; Calendar.prototype.minExclusiveErrorText = "Select a date after {0:d}."; Calendar.prototype.disabledDaysOfWeekErrorText = "Selected day of week is not allowed."; Calendar.prototype.suppressErrorsUntilVisited = false; Calendar.prototype.showTodayButton = false; Calendar.prototype.todayButtonText = "Today"; Calendar.prototype.startWithMonday = false; Calendar.prototype.focusable = true; Localization.registerPrototype("cx/widgets/Calendar", Calendar); interface CalendarData { maxValue?: Date; maxExclusive?: boolean; minValue?: Date; minExclusive?: boolean; dayData?: Record; } const validationCheck = (date: Date, data: CalendarData, disabledDaysOfWeek?: number[]): boolean => { if (data.maxValue && !upperBoundCheck(date, data.maxValue, data.maxExclusive)) return false; if (data.minValue && !lowerBoundCheck(date, data.minValue, data.minExclusive)) return false; if (disabledDaysOfWeek && disabledDaysOfWeek.includes(date.getDay())) return false; if (data.dayData) { let day = data.dayData[date.toDateString()]; if (day && (day.disabled || day.unselectable)) return false; } return true; }; interface CalendarCmpProps { instance: FieldInstance; handleSelect: (e: React.MouseEvent, date: Date) => void; } interface CalendarState { hover: boolean; focus: boolean; cursor: Date; activeView: string; refDate: Date; startDate: Date; endDate: Date; yearPickerHeight?: number; } export class CalendarCmp extends VDOM.Component { el: HTMLElement | null = null; unsubscribeWheel?: () => void; constructor(props: CalendarCmpProps) { super(props); let { data } = props.instance; let refDate = data.refDate ? data.refDate : data.date || zeroTime(new Date()); this.state = { hover: false, focus: false, cursor: zeroTime(data.date || refDate), activeView: "calendar", ...this.getPage(refDate), }; this.handleMouseMove = this.handleMouseMove.bind(this); this.handleMouseDown = this.handleMouseDown.bind(this); } getPage(refDate: Date): { refDate: Date; startDate: Date; endDate: Date } { refDate = monthStart(refDate); //make a copy let calendarWidget = this.props.instance.widget as Calendar; let startWithMonday = calendarWidget.startWithMonday; let startDay = startWithMonday ? 1 : 0; let startDate = new Date(refDate); while (startDate.getDay() != startDay) startDate.setDate(startDate.getDate() - 1); let endDate = new Date(refDate); endDate.setMonth(refDate.getMonth() + 1); endDate.setDate(endDate.getDate() - 1); let endDay = startWithMonday ? 0 : 6; while (endDate.getDay() != endDay) endDate.setDate(endDate.getDate() + 1); return { refDate, startDate, endDate, }; } moveCursor(e: React.SyntheticEvent | React.KeyboardEvent, date: Date, options: { movePage?: boolean } = {}): void { e.preventDefault(); e.stopPropagation(); date = zeroTime(date); if (date.getTime() == this.state.cursor.getTime()) return; let refDate = this.state.refDate; if (options.movePage || date < this.state.startDate || date > this.state.endDate) refDate = date; this.setState({ ...this.getPage(refDate), cursor: date, }); } move(e: React.MouseEvent, period: string, delta: number): void { e.preventDefault(); e.stopPropagation(); let refDate = new Date(this.state.refDate); switch (period) { case "y": refDate.setFullYear(refDate.getFullYear() + delta); break; case "m": refDate.setMonth(refDate.getMonth() + delta); break; } let page = this.getPage(refDate); let cursor = this.state.cursor; if (cursor < page.startDate) cursor = page.startDate; else if (cursor > page.endDate) cursor = page.endDate; this.setState({ ...page, cursor }); } handleKeyPress(e: React.KeyboardEvent): void { let cursor = new Date(this.state.cursor); switch (e.keyCode) { case KeyCode.enter: this.props.handleSelect(e as unknown as React.MouseEvent, this.state.cursor); break; case KeyCode.left: cursor.setDate(cursor.getDate() - 1); this.moveCursor(e, cursor); break; case KeyCode.right: cursor.setDate(cursor.getDate() + 1); this.moveCursor(e, cursor); break; case KeyCode.up: cursor.setDate(cursor.getDate() - 7); this.moveCursor(e, cursor); break; case KeyCode.down: cursor.setDate(cursor.getDate() + 7); this.moveCursor(e, cursor); break; case KeyCode.pageUp: cursor.setMonth(cursor.getMonth() - 1); this.moveCursor(e, cursor, { movePage: true }); break; case KeyCode.pageDown: cursor.setMonth(cursor.getMonth() + 1); this.moveCursor(e, cursor, { movePage: true }); break; case KeyCode.home: cursor.setDate(1); this.moveCursor(e, cursor, { movePage: true }); break; case KeyCode.end: cursor.setMonth(cursor.getMonth() + 1); cursor.setDate(0); this.moveCursor(e, cursor, { movePage: true }); break; default: let { instance } = this.props; let calendarWidget = instance.widget as Calendar; if (calendarWidget.onKeyDown) instance.invoke("onKeyDown", e, instance); break; } } handleWheel(e: WheelEvent): void { e.preventDefault(); e.stopPropagation(); let cursor = new Date(this.state.cursor); if (e.deltaY < 0) { cursor.setMonth(cursor.getMonth() - 1); this.moveCursor(e as unknown as React.SyntheticEvent, cursor, { movePage: true, }); } else if (e.deltaY > 0) { cursor.setMonth(cursor.getMonth() + 1); this.moveCursor(e as unknown as React.SyntheticEvent, cursor, { movePage: true, }); } } handleBlur(e: React.FocusEvent): void { FocusManager.nudge(); let { instance } = this.props; let calendarWidget = instance.widget as Calendar; if (calendarWidget.onBlur) instance.invoke("onBlur", e, instance); this.setState({ focus: false, }); } handleFocus(e: React.FocusEvent): void { oneFocusOut(this, this.el!, this.handleFocusOut.bind(this)); this.setState({ focus: true, }); } handleFocusOut(): void { let { instance } = this.props; let calendarWidget = instance.widget as Calendar; if (calendarWidget.onFocusOut) instance.invoke("onFocusOut", null, instance); } handleMouseLeave(e: React.MouseEvent): void { tooltipMouseLeave(e, ...getFieldTooltip(this.props.instance)); this.setState({ hover: false, }); } handleMouseEnter(e: React.MouseEvent): void { this.setState({ hover: true, }); } handleMouseMove(e: React.MouseEvent): void { this.moveCursor(e, readDate((e.target as HTMLElement).dataset)); } handleMouseDown(e: React.MouseEvent): void { this.props.handleSelect(e, readDate((e.target as HTMLElement).dataset)); } componentDidMount(): void { //calendar doesn't bring up keyboard so it's ok to focus it even on mobile let calendarWidget = this.props.instance.widget as Calendar; if (calendarWidget.autoFocus && this.el) this.el.focus(); if (this.el) { tooltipParentDidMount(this.el, ...getFieldTooltip(this.props.instance)); this.unsubscribeWheel = addEventListenerWithOptions(this.el, "wheel", (e) => this.handleWheel(e), { passive: false, }); } } UNSAFE_componentWillReceiveProps(props: CalendarCmpProps): void { let { data } = props.instance; if (data.date) this.setState({ ...this.getPage(data.date), }); if (this.el) { tooltipParentWillReceiveProps(this.el, ...getFieldTooltip(props.instance)); } } componentWillUnmount(): void { offFocusOut(this); tooltipParentWillUnmount(this.props.instance); this.unsubscribeWheel?.(); } showYearDropdown(): void { if (this.el && this.el.firstChild) { this.setState({ activeView: "year-picker", yearPickerHeight: (this.el.firstChild as HTMLElement).offsetHeight, }); } } handleYearSelect(e: React.MouseEvent, year: number): void { e.preventDefault(); e.stopPropagation(); let refDate = new Date(this.state.refDate); refDate.setFullYear(year); this.setState({ ...this.getPage(refDate), activeView: "calendar", }); } renderYearPicker(): React.ReactElement { let { data, widget } = this.props.instance; let calendarWidget = widget as Calendar; let minYear: number | undefined = data.minValue?.getFullYear(); let maxYear: number | undefined = data.maxValue?.getFullYear(); let { CSS } = widget; let years: number[] = []; let currentYear = new Date().getFullYear(); let midYear = currentYear - (currentYear % 5); let refYear = new Date(this.state.refDate).getFullYear(); for (let i = midYear - 100; i <= midYear + 100; i++) { years.push(i); } let rows: number[][] = []; for (let i = 0; i < years.length; i += 5) { rows.push(years.slice(i, i + 5)); } return (
{ if (el) { el.addEventListener("wheel", (e) => { e.stopPropagation(); }); let activeYear = el.querySelector("." + CSS.state("selected")); if (activeYear) activeYear.scrollIntoView({ block: "center", behavior: "instant", }); } }} > {rows.map((row: number[], rowIndex: number) => ( {row.map((year: number) => ( ))} ))}
maxYear), selected: year === refYear, active: year === currentYear, })} onClick={(e) => this.handleYearSelect(e, year)} > {year}
); } render(): React.ReactElement { let { data, widget } = this.props.instance; let calendarWidget = widget as Calendar; let { CSS, baseClass, disabledDaysOfWeek, startWithMonday } = calendarWidget; let { refDate, startDate, endDate } = this.getPage(this.state.refDate); let month = refDate.getMonth(); let year = refDate.getFullYear(); let weeks: React.ReactNode[] = []; let date = startDate; let empty: Record = {}; let today = zeroTime(new Date()); while (date >= startDate && date <= endDate) { let days: React.ReactNode[] = []; for (let i = 0; i < 7; i++) { let dayInfo = (data.dayData && data.dayData[date.toDateString()]) || empty; let unselectable = !validationCheck(date, data, disabledDaysOfWeek); let classNames = CSS.expand( CSS.element(baseClass, "day", { outside: month != date.getMonth(), unselectable: unselectable, selected: data.date && sameDate(data.date, date), cursor: (this.state.hover || this.state.focus) && this.state.cursor && sameDate(this.state.cursor, date), today: calendarWidget.highlightToday && sameDate(date, today), }), dayInfo.className, CSS.mod(dayInfo.mod), ); let dateInst = new Date(date); days.push( {date.getDate()} , ); date.setDate(date.getDate() + 1); } weeks.push( {days} , ); } let culture = Culture.getDateTimeCulture(); let monthNames = culture.getMonthNames("long"); let dayNames = culture.getWeekdayNames("short").map((x: string) => x.substr(0, 2)); if (startWithMonday) dayNames = [...dayNames.slice(1), dayNames[0]]; return (
this.handleKeyPress(e)} onMouseDown={(e) => { // prevent losing focus from the input field if (!data.focusable) { e.preventDefault(); } e.stopPropagation(); }} ref={(el) => { this.el = el; }} onMouseMove={(e) => tooltipMouseMove(e, ...getFieldTooltip(this.props.instance))} onMouseLeave={(e) => this.handleMouseLeave(e)} onMouseEnter={(e) => this.handleMouseEnter(e)} // onWheel={(e) => this.handleWheel(e)} onFocus={(e) => this.handleFocus(e)} onBlur={(e) => this.handleBlur(e)} > {this.state.activeView == "calendar" && ( ))} {weeks}
this.move(e, "y", -1)}> this.move(e, "m", -1)}> {monthNames[month]}
this.showYearDropdown()} className={CSS.element(baseClass, "year-name")} > {year}
this.move(e, "m", +1)}> this.move(e, "y", +1)}>
{dayNames.map((name: string, i: number) => ( {name}
)} {this.state.activeView == "calendar" && calendarWidget.showTodayButton && (
)} {this.state.activeView == "year-picker" && this.renderYearPicker()}
); } } const readDate = (ds: DOMStringMap): Date => new Date(Number(ds.year), Number(ds.month) - 1, Number(ds.date)); Widget.alias("calendar", Calendar);