/**@jsxImportSource react */ import { Widget, VDOM } from "../../ui/Widget"; import { Field, getFieldTooltip, FieldInstance, FieldConfig } from "./Field"; import { Culture } from "../../ui/Culture"; import { FocusManager, oneFocusOut, offFocusOut, preventFocusOnTouch, } from "../../ui/FocusManager"; import { StringTemplate } from "../../data/StringTemplate"; import { monthStart } from "../../util/date/monthStart"; import { dateDiff } from "../../util/date/dateDiff"; import { minDate } from "../../util/date/minDate"; import { maxDate } from "../../util/date/maxDate"; import { lowerBoundCheck } from "../../util/date/lowerBoundCheck"; import { upperBoundCheck } from "../../util/date/upperBoundCheck"; import { Console } from "../../util/Console"; import { KeyCode } from "../../util/KeyCode"; import { tooltipParentWillReceiveProps, tooltipParentWillUnmount, tooltipMouseMove, tooltipMouseLeave, tooltipParentDidMount, } from "../overlay/tooltip-ops"; import { Localization } from "../../ui/Localization"; import { scrollElementIntoView } from "../../util/scrollElementIntoView"; import { stopPropagation } from "../../util/eventCallbacks"; import { isString } from "../../util/isString"; import { isTouchEvent } from "../../util/isTouchEvent"; import { getCursorPos } from "../overlay/captureMouse"; import type { RenderingContext } from "../../ui/RenderingContext"; import type { Instance } from "../../ui/Instance"; import type { Prop, BooleanProp } from "../../ui/Prop"; import { enableCultureSensitiveFormatting } from "../../ui/Format"; import { parseDateInvariant } from "../../util"; import { HtmlElement } from "../HtmlElement"; enableCultureSensitiveFormatting(); export class MonthPickerInstance< F extends MonthPicker = MonthPicker, > extends FieldInstance { isMonthDateSelectable?: (monthDate: Date) => boolean; } export interface MonthPickerConfig extends FieldConfig { range?: BooleanProp; from?: Prop; to?: Prop; value?: Prop; refDate?: Prop; minValue?: Prop; minExclusive?: BooleanProp; maxValue?: Prop; maxExclusive?: BooleanProp; startYear?: number; endYear?: number; bufferSize?: number; maxValueErrorText?: string; maxExclusiveErrorText?: string; minValueErrorText?: string; minExclusiveErrorText?: string; encoding?: (date: Date) => any; inclusiveTo?: boolean; onBeforeSelect?: ( e: Event, instance: MonthPickerInstance, dateFrom?: Date, dateTo?: Date, ) => boolean; onSelect?: ( instance: MonthPickerInstance, dateFrom?: Date, dateTo?: Date, ) => void; hideQuarters?: boolean; onCreateIsMonthDateSelectable?: ( validationParams: Record, instance: MonthPickerInstance, ) => (monthDate: Date) => boolean; handleSelect?: ( e: React.MouseEvent, instance: MonthPickerInstance, dateFrom?: Date, dateTo?: Date, ) => void; onBlur?: | string | ((e: React.FocusEvent, instance: MonthPickerInstance) => void); onFocusOut?: | string | ((e: React.FocusEvent, instance: MonthPickerInstance) => void); autoFocus?: boolean; } export class MonthPicker< Config extends MonthPickerConfig = MonthPickerConfig, > extends Field { declare public baseClass: string; declare public mode?: string; declare public range?: BooleanProp; declare public from?: Prop; declare public to?: Prop; declare public value?: Prop; declare public refDate?: Prop; declare public minValue?: Prop; declare public minExclusive?: BooleanProp; declare public maxValue?: Prop; declare public maxExclusive?: BooleanProp; declare public startYear: number; declare public endYear: number; declare public bufferSize: number; declare public maxValueErrorText: string; declare public maxExclusiveErrorText: string; declare public minValueErrorText: string; declare public minExclusiveErrorText: string; declare public encoding?: (date: Date) => any; declare public inclusiveTo?: boolean; declare public onBeforeSelect?: ( e: Event, instance: MonthPickerInstance, dateFrom?: Date, dateTo?: Date, ) => boolean; declare public onSelect?: ( instance: MonthPickerInstance, dateFrom?: Date, dateTo?: Date, ) => void; declare public hideQuarters?: boolean; declare public onCreateIsMonthDateSelectable?: ( validationParams: Record, instance: MonthPickerInstance, ) => (monthDate: Date) => boolean; declare public onBlur?: | string | ((e: React.FocusEvent, instance: MonthPickerInstance) => void); declare public onFocusOut?: | string | ((e: React.FocusEvent, instance: MonthPickerInstance) => void); declareData(...args: Record[]): void { let values: Record = {}; if (this.mode == "range") { this.range = true; this.mode = "edit"; Console.warn( 'Please use the range flag on MonthPickers. Syntax mode="range" is deprecated.', this, ); } if (this.range) { values = { from: null, to: null, }; } else { values = { value: this.emptyValue, }; } super.declareData( values, { refDate: undefined, disabled: undefined, minValue: undefined, minExclusive: undefined, maxValue: undefined, maxExclusive: undefined, }, ...args, ); } init(): void { super.init(); } prepareData(context: RenderingContext, instance: MonthPickerInstance): void { let { data } = instance; data.stateMods = { disabled: data.disabled, }; if (!this.range && data.value) data.date = monthStart(parseDateInvariant(data.value)); if (this.range) { if (data.from) data.from = monthStart(parseDateInvariant(data.from)); if (data.to) { let date = parseDateInvariant(data.to); if (this.inclusiveTo) date.setDate(date.getDate() + 1); data.to = monthStart(date); } } if (data.refDate) data.refDate = monthStart(parseDateInvariant(data.refDate)); if (data.maxValue) data.maxValue = monthStart(parseDateInvariant(data.maxValue)); if (data.minValue) data.minValue = monthStart(parseDateInvariant(data.minValue)); if (this.onCreateIsMonthDateSelectable) { instance.isMonthDateSelectable = instance.invoke( "onCreateIsMonthDateSelectable", data.validationParams, instance, ); } super.prepareData(context, instance); } validate(context: RenderingContext, instance: MonthPickerInstance): void { super.validate(context, instance); let { data } = instance; 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, ); } } } renderInput( context: RenderingContext, instance: MonthPickerInstance, key: string, ): React.ReactNode { return ( ); } handleSelect( e: React.KeyboardEvent | React.MouseEvent | React.TouchEvent, instance: MonthPickerInstance, date1: Date, date2: Date, ): void { let { data, widget, isMonthDateSelectable } = instance; let encode = widget.encoding || Culture.getDefaultDateEncoding(); if (data.disabled) return; if (isMonthDateSelectable && !isMonthDateSelectable(date1)) return; if (!dateSelectableCheck(date1, data)) return; if ( this.onBeforeSelect && instance.invoke("onBeforeSelect", e, instance, date1, date2) === false ) return; if (this.range) { instance.set("from", encode(date1)); let toDate = new Date(date2); if (this.inclusiveTo) toDate.setDate(toDate.getDate() - 1); instance.set("to", encode(toDate)); } else instance.set("value", encode(date1)); if (this.onSelect) instance.invoke("onSelect", instance, date1, date2); } } MonthPicker.prototype.baseClass = "monthpicker"; MonthPicker.prototype.range = false; MonthPicker.prototype.startYear = 1980; MonthPicker.prototype.endYear = 2050; MonthPicker.prototype.bufferSize = 15; MonthPicker.prototype.hideQuarters = false; // Localization MonthPicker.prototype.maxValueErrorText = "Select {0:d} or before."; MonthPicker.prototype.maxExclusiveErrorText = "Select a date before {0:d}."; MonthPicker.prototype.minValueErrorText = "Select {0:d} or later."; MonthPicker.prototype.minExclusiveErrorText = "Select a date after {0:d}."; MonthPicker.prototype.inclusiveTo = false; Localization.registerPrototype("cx/widgets/MonthPicker", MonthPicker); Widget.alias("month-picker", MonthPicker); const dateSelectableCheck = ( date: Date, data: Record, ): boolean => { if ( data.maxValue && !upperBoundCheck(date, data.maxValue as Date, data.maxExclusive) ) return false; if ( data.minValue && !lowerBoundCheck(date, data.minValue as Date, data.minExclusive) ) return false; return true; }; const monthNumber = (date: Date): number => { return date.getFullYear() * 12 + date.getMonth(); }; interface MonthPickerComponentProps { instance: MonthPickerInstance; onBlur?: | string | ((e: React.FocusEvent, instance: MonthPickerInstance) => void); onFocusOut?: | string | ((e: React.FocusEvent, instance: MonthPickerInstance) => void); onKeyDown?: | string | (( e: React.KeyboardEvent, instance: MonthPickerInstance, ) => boolean | void); autoFocus?: boolean; } interface MonthPickerComponentState { cursorYear: number; cursorMonth: number; cursorQuarter: number; column: string; start: number; end: number; state?: string; focused?: boolean; yearHeight?: number; } interface CursorInfo { column: string; cursorYear: number; cursorMonth: number; cursorQuarter: number; } export class MonthPickerComponent extends VDOM.Component< MonthPickerComponentProps, MonthPickerComponentState > { dom: { el?: HTMLDivElement | null; table?: HTMLTableElement | null; } = {}; dragStartDates?: [Date, Date]; constructor(props: MonthPickerComponentProps) { super(props); let { data, widget } = props.instance; let cursor = monthStart( data.refDate ? data.refDate : data.date || data.from || new Date(), ); this.dom = {}; this.state = { cursorYear: cursor.getFullYear(), cursorMonth: cursor.getMonth() + 1, cursorQuarter: cursor.getMonth() / 3, column: "M", start: widget.startYear, end: Math.min(widget.startYear + widget.bufferSize, widget.endYear), }; this.handleMouseDown = this.handleMouseDown.bind(this); this.handleMouseUp = this.handleMouseUp.bind(this); this.handleMouseEnter = this.handleMouseEnter.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this); this.handleTouchMove = this.handleTouchMove.bind(this); this.handleTouchEnd = this.handleTouchEnd.bind(this); } extractCursorInfo(el: HTMLElement): CursorInfo | false { const dataPoint = el.getAttribute("data-point"); if (!dataPoint) return false; let parts = dataPoint.split("-"); if (parts[0] != "Y") return false; let cursor: CursorInfo = { column: "Y", cursorYear: Number(parts[1]), cursorMonth: 1, cursorQuarter: 1, }; if (parts.length == 4) { cursor.column = parts[2]; if (cursor.column == "M") cursor.cursorMonth = Number(parts[3]); else cursor.cursorQuarter = Number(parts[3]); } return cursor; } moveCursor( e: React.KeyboardEvent | React.MouseEvent | React.TouchEvent, data: Pick, options: { ensureVisible?: boolean; select?: boolean } = {}, ): void { //e.preventDefault(); e.stopPropagation(); if ("cursorYear" in data && data.cursorYear !== undefined) { let { startYear, endYear } = this.props.instance.widget; (data as any).cursorYear = Math.max( startYear, Math.min(endYear, data.cursorYear as number), ); } if (Object.keys(data).every((k) => this.state[k as K] == (data as any)[k])) return; this.setState(data, () => { if (options.ensureVisible) { let index = this.state.cursorYear - this.state.start; let tbody = this.dom.table?.children?.[index]; if (tbody) scrollElementIntoView(tbody); } if (options.select) { let { instance } = this.props; let { widget, data } = instance; let [cursorFromDate, cursorToDate] = this.getCursorDates(); let originFromDate = cursorFromDate, originToDate = cursorToDate; if (widget.range) { if (data.from) originFromDate = data.from; if (data.to) originToDate = data.to; } widget.handleSelect( e, instance, minDate(originFromDate, cursorFromDate), maxDate(originToDate, cursorToDate), ); } }); } handleKeyPress(e: React.KeyboardEvent): void { let { instance } = this.props; let { widget } = this.props.instance; let { cursorMonth, cursorYear, cursorQuarter, column } = this.state; let select = widget.range && e.shiftKey; switch (e.keyCode) { case KeyCode.enter: // if (widget.range && e.shiftKey && !this.dragStartDates) { // this.handleMouseDown(e, {}, false); // } else { // this.handleMouseUp(e); // } this.handleMouseUp(e); e.preventDefault(); e.stopPropagation(); break; case KeyCode.left: if (column == "Y") this.moveCursor( e, { cursorQuarter: 3, cursorYear: cursorYear - 1, column: "Q", }, { select }, ); else if (column == "Q") this.moveCursor( e, { cursorMonth: cursorQuarter * 4, column: "M" }, { select }, ); else if (column == "M" && (cursorMonth - 1) % 3 == 0) this.moveCursor(e, { column: "Y" }); else this.moveCursor(e, { cursorMonth: cursorMonth - 1 }, { select }); break; case KeyCode.right: if (column == "Y") this.moveCursor(e, { cursorMonth: 1, column: "M" }, { select }); else if (column == "Q") this.moveCursor( e, { column: "Y", cursorYear: cursorQuarter == 3 ? cursorYear + 1 : cursorYear, }, { select }, ); else if (column == "M" && (cursorMonth - 1) % 3 == 2) this.moveCursor( e, { column: "Q", cursorQuarter: Math.floor((cursorMonth - 1) / 3), }, { select }, ); else this.moveCursor(e, { cursorMonth: cursorMonth + 1 }, { select }); break; case KeyCode.up: e.preventDefault(); // prevent scroll if (column == "Y") this.moveCursor( e, { cursorYear: cursorYear - 1 }, { ensureVisible: true, select }, ); else if (column == "Q") this.moveCursor( e, { cursorQuarter: (cursorQuarter + 3) % 4, cursorYear: cursorQuarter == 0 ? cursorYear - 1 : cursorYear, }, { ensureVisible: true, select }, ); else if (column == "M") if (cursorMonth > 3) this.moveCursor( e, { cursorMonth: cursorMonth - 3 }, { ensureVisible: true, select }, ); else this.moveCursor( e, { cursorMonth: cursorMonth + 9, cursorYear: cursorYear - 1 }, { ensureVisible: true, select }, ); break; case KeyCode.down: e.preventDefault(); // prevent scroll if (column == "Y") this.moveCursor( e, { cursorYear: cursorYear + 1 }, { ensureVisible: true, select }, ); else if (column == "Q") this.moveCursor( e, { cursorQuarter: (cursorQuarter + 1) % 4, cursorYear: cursorQuarter == 3 ? cursorYear + 1 : cursorYear, }, { ensureVisible: true, select }, ); else if (column == "M") if (cursorMonth < 10) this.moveCursor( e, { cursorMonth: cursorMonth + 3 }, { ensureVisible: true, select }, ); else this.moveCursor( e, { cursorMonth: cursorMonth - 9, cursorYear: cursorYear + 1 }, { ensureVisible: true, select }, ); break; case KeyCode.pageUp: this.moveCursor( e, { cursorYear: this.state.cursorYear - 1 }, { ensureVisible: true, select }, ); break; case KeyCode.pageDown: this.moveCursor( e, { cursorYear: this.state.cursorYear + 1 }, { ensureVisible: true, select }, ); break; default: if (widget.onKeyDown) instance.invoke("onKeyDown", e, instance); break; } } handleBlur(e: React.FocusEvent): void { FocusManager.nudge(); let { instance } = this.props; let { widget } = instance; if (widget.onBlur) instance.invoke("onBlur", e, instance); this.setState({ focused: false, }); } handleFocus(e: React.FocusEvent): void { this.setState({ focused: true, }); if (this.props.onFocusOut && this.dom.el) oneFocusOut(this, this.dom.el, this.handleFocusOut.bind(this)); } handleFocusOut(e: React.FocusEvent): void { let { instance } = this.props; let { widget } = instance; if (widget.onFocusOut) instance.invoke("onFocusOut", e, instance); } getCursorDates(cursor?: CursorInfo): [Date, Date] { let { cursorMonth, cursorYear, cursorQuarter, column } = cursor ?? this.state; switch (column) { case "M": return [ new Date(cursorYear, cursorMonth - 1, 1), new Date(cursorYear, cursorMonth, 1), ]; case "Q": return [ new Date(cursorYear, cursorQuarter * 3, 1), new Date(cursorYear, cursorQuarter * 3 + 3, 1), ]; default: case "Y": return [new Date(cursorYear, 0, 1), new Date(cursorYear + 1, 0, 1)]; } } handleTouchMove(e: React.TouchEvent): void { let cursorPos = getCursorPos(e); let el = document.elementFromPoint(cursorPos.clientX, cursorPos.clientY); if ( this.dom.table && el && this.dom.table.contains(el) && el instanceof HTMLElement && isString(el.dataset.point) ) { let cursor = this.extractCursorInfo(el); if (cursor) this.moveCursor(e, cursor); } } handleTouchEnd(e: React.TouchEvent): void { if (this.state.state == "drag") this.handleMouseUp(e); } handleMouseEnter(e: React.MouseEvent): void { if (this.state.state != "drag") return; let cursor = this.extractCursorInfo(e.target as HTMLElement); if (cursor) { this.moveCursor(e, cursor); } } handleMouseDown( e: React.MouseEvent | React.TouchEvent, cursor?: CursorInfo | false, drag: boolean = true, ): void { let { instance } = this.props; let { widget } = instance; if (!cursor) { cursor = this.extractCursorInfo(e.currentTarget as HTMLElement); if (!cursor) return; this.moveCursor(e, cursor, { select: !widget.range }); } e.stopPropagation(); preventFocusOnTouch(e); this.dragStartDates = this.getCursorDates(cursor); if (drag) { this.setState({ state: "drag", ...cursor, }); } } handleMouseUp( e: React.KeyboardEvent | React.MouseEvent | React.TouchEvent, ): void { let { instance } = this.props; let { widget, data } = instance; e.stopPropagation(); e.preventDefault(); let [cursorFromDate, cursorToDate] = this.getCursorDates(); let originFromDate = cursorFromDate, originToDate = cursorToDate; if (widget.range && e.shiftKey) { if (data.from) originFromDate = data.from; if (data.to) originToDate = data.to; } else if (this.state.state == "drag") { if (widget.range) { [originFromDate, originToDate] = this.dragStartDates!; } this.setState({ state: "normal" }); } else { //skip mouse events originated somewhere else if (e.type != "keydown") return; } widget.handleSelect( e, instance, minDate(originFromDate, cursorFromDate), maxDate(originToDate, cursorToDate), ); } render(): React.ReactNode { let { instance } = this.props; let { data, widget, isMonthDateSelectable } = instance; let { CSS, baseClass, startYear, endYear, hideQuarters } = widget; let years = []; let { start, end } = this.state; let from = 10000, to = 0, a, b; if (data.date && !widget.range) { from = monthNumber(data.date); to = from + 0.1; } else if (widget.range) { if (this.state.state == "drag") { let [originFromDate, originToDate] = this.dragStartDates!; let [cursorFromDate, cursorToDate] = this.getCursorDates(); a = Math.min(monthNumber(originFromDate), monthNumber(cursorFromDate)); b = Math.max(monthNumber(originToDate), monthNumber(cursorToDate)); from = Math.min(a, b); to = Math.max(a, b); } else if (data.from && data.to) { a = monthNumber(data.from); b = monthNumber(data.to); from = Math.min(a, b); to = Math.max(a, b); } } let monthNames = Culture.getDateTimeCulture().getMonthNames("short"); let showCursor = this.state.focused; for (let y = start; y <= end; y++) { let selectableMonths = 0b111111111111; // Loop through the months in a year to check if all months are unselectable for (let i = 0; i < 12; i++) { if ( (isMonthDateSelectable && !isMonthDateSelectable(new Date(y, i, 1))) || !dateSelectableCheck(new Date(y, i, 1), data) ) { // Set month as unselectable at specified bit selectableMonths &= ~(1 << i); } } // All bits are 0 - all months are unselectable const unselectableYear = selectableMonths === 0; let rows = []; for (let q = 0; q < 4; q++) { let row = []; if (q == 0) { row.push( {y} , ); } for (let i = 0; i < 3; i++) { let m = q * 3 + i + 1; const unselectableMonth = (selectableMonths & (1 << (m - 1))) === 0; let mno = y * 12 + m - 1; let handle = true; //isTouchDevice(); //mno === from || mno === to - 1; row.push( = from && mno < to, unselectable: unselectableMonth, })} data-point={`Y-${y}-M-${m}`} onMouseEnter={ unselectableMonth ? undefined : this.handleMouseEnter } onMouseDown={unselectableMonth ? undefined : this.handleMouseDown} onMouseUp={unselectableMonth ? undefined : this.handleMouseUp} onTouchStart={ unselectableMonth ? undefined : this.handleMouseDown } onTouchMove={unselectableMonth ? undefined : this.handleTouchMove} onTouchEnd={this.handleMouseUp} > {monthNames[m - 1].substr(0, 3)} , ); } if (!hideQuarters) { let unselectableQuarter = true; const start = q * 3; for (let i = start; i < start + 3; i++) { if ((selectableMonths & (1 << i)) !== 0) { // found a selectable month in a quarter unselectableQuarter = false; break; } } row.push( {`Q${q + 1}`} , ); } rows.push(row); } years.push(rows); } return (
{ this.dom.el = el; }} className={data.classNames} style={data.style} tabIndex={data.disabled ? null : data.tabIndex || 0} onKeyDown={this.handleKeyPress} onMouseDown={stopPropagation} onMouseMove={(e) => tooltipMouseMove(e, ...getFieldTooltip(this.props.instance)) } onMouseLeave={this.handleMouseLeave.bind(this)} onFocus={(e) => this.handleFocus(e)} onBlur={this.handleBlur.bind(this)} onScroll={this.onScroll.bind(this)} > {this.state.yearHeight && (
)} { this.dom.table = el; }} > {years.map((rows, y) => ( {rows.map((cells, i) => ( {cells} ))} ))}
{this.state.yearHeight && (
)}
); } onScroll(): void { if (!this.dom.el || !this.state.yearHeight) return; let { startYear, endYear, bufferSize } = this.props.instance.widget; let visibleItems = ceil5( Math.ceil(this.dom.el.offsetHeight / this.state.yearHeight), ); let start = Math.max( startYear, startYear + floor5(Math.floor(this.dom.el.scrollTop / this.state.yearHeight)) - visibleItems, ); if (start != this.state.start) { this.setState({ start, end: Math.min(start + bufferSize, endYear), }); } } handleMouseLeave(e: React.MouseEvent): void { tooltipMouseLeave(e, ...getFieldTooltip(this.props.instance)); } componentDidMount(): void { //non-input, ok to focus on mobile if (this.props.autoFocus && this.dom.el) this.dom.el.focus(); if (this.dom.el && this.dom.table) { tooltipParentDidMount( this.dom.el, ...getFieldTooltip(this.props.instance), ); let yearHeight = this.dom.table.scrollHeight / (this.props.instance.widget.bufferSize + 1); this.setState( { yearHeight: yearHeight, }, () => { let { widget, data } = this.props.instance; let { startYear } = widget; let yearCount = 1; if (widget.range && data.from && data.to) { yearCount = data.to.getFullYear() - data.from.getFullYear() + 1; if (data.to.getMonth() == 0 && data.to.getDate() == 1) yearCount--; } if (this.dom.el && this.state.yearHeight) { this.dom.el.scrollTop = (this.state.cursorYear - startYear + yearCount / 2) * this.state.yearHeight - this.dom.el.offsetHeight / 2; } }, ); } } UNSAFE_componentWillReceiveProps(props: MonthPickerComponentProps): void { this.setState({ state: "normal", }); if (this.dom.el) { tooltipParentWillReceiveProps( this.dom.el, ...getFieldTooltip(props.instance), ); } } componentWillUnmount(): void { offFocusOut(this); tooltipParentWillUnmount(this.props.instance); } } function ceil5(x: number): number { return Math.ceil(x / 5) * 5; } function floor5(x: number): number { return Math.floor(x / 5) * 5; }