From df1c5603365140922ed2c4680ef1822a1e9da8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D1=83=D1=88=D0=BA=D0=BE?= Date: Mon, 2 Feb 2026 09:38:38 +0100 Subject: [PATCH 1/4] DateTimePicker: Infinite scroll for faster date/time selection --- litmus-ts/bugs/DateTimePickerInfinite.tsx | 18 + litmus-ts/index.tsx | 21 +- .../cx/src/widgets/form/DateTimePicker.tsx | 912 ++++++++++-------- 3 files changed, 548 insertions(+), 403 deletions(-) create mode 100644 litmus-ts/bugs/DateTimePickerInfinite.tsx diff --git a/litmus-ts/bugs/DateTimePickerInfinite.tsx b/litmus-ts/bugs/DateTimePickerInfinite.tsx new file mode 100644 index 000000000..48178df2d --- /dev/null +++ b/litmus-ts/bugs/DateTimePickerInfinite.tsx @@ -0,0 +1,18 @@ +import { bind, createFunctionalComponent } from "cx/ui"; +import { + DateField, + DateTimeField, + DateTimePicker, + TimeField, +} from "cx/widgets"; + +export default createFunctionalComponent(() => { + return ( + + + + + + + ); +}); diff --git a/litmus-ts/index.tsx b/litmus-ts/index.tsx index 1dbaaf725..7aa1dde69 100644 --- a/litmus-ts/index.tsx +++ b/litmus-ts/index.tsx @@ -1,10 +1,15 @@ import { Store } from "cx/data"; import "cx/locale/de-de.js"; -import { History, Widget } from "cx/ui"; +import { + Culture, + enableCultureSensitiveFormatting, + History, + Widget, +} from "cx/ui"; import { startHotAppLoop } from "cx/ui/app/startHotAppLoop.js"; import { Debug, Timing } from "cx/util"; import { enableMsgBoxAlerts, enableTooltips } from "cx/widgets"; -import Demo from "./bugs/GridOnFetchRecords"; +import test from "./bugs/DateTimePickerInfinite"; let store = new Store(); @@ -14,18 +19,14 @@ Widget.resetCounter(); //Timing.enable('vdom-render'); Timing.enable("app-loop"); Debug.enable("app-data"); +enableCultureSensitiveFormatting(); enableTooltips(); enableMsgBoxAlerts(); History.connect(store, "url"); -startHotAppLoop( - module, - document.getElementById("app"), - store, - +Culture.setCulture("sr-latn"); - - , -); +// @ts-expect-error +startHotAppLoop(module, document.getElementById("app"), store, test); diff --git a/packages/cx/src/widgets/form/DateTimePicker.tsx b/packages/cx/src/widgets/form/DateTimePicker.tsx index c0eacc8ab..a2cddb84c 100644 --- a/packages/cx/src/widgets/form/DateTimePicker.tsx +++ b/packages/cx/src/widgets/form/DateTimePicker.tsx @@ -12,32 +12,38 @@ import { WheelComponent } from "./Wheel"; enableCultureSensitiveFormatting(); export class DateTimePicker extends Widget { - declare public size: number; - declare public segment: string; - declare public autoFocus?: boolean; - declare public showSeconds?: boolean; - declare public encoding?: (date: Date) => string; - declare public onFocusOut?: string | ((instance: Instance) => void); - declare public onSelect?: string | ((e: React.KeyboardEvent, instance: Instance, date: Date) => void); - declare baseClass: string; - - declareData(...args: Record[]): void { - return super.declareData(...args, { - value: undefined, - }); - } - - render(context: RenderingContext, instance: Instance, key: string): React.ReactNode { - return ( - - ); - } + declare public size: number; + declare public segment: string; + declare public autoFocus?: boolean; + declare public showSeconds?: boolean; + declare public encoding?: (date: Date) => string; + declare public onFocusOut?: string | ((instance: Instance) => void); + declare public onSelect?: + | string + | ((e: React.KeyboardEvent, instance: Instance, date: Date) => void); + declare baseClass: string; + + declareData(...args: Record[]): void { + return super.declareData(...args, { + value: undefined, + }); + } + + render( + context: RenderingContext, + instance: Instance, + key: string, + ): React.ReactNode { + return ( + + ); + } } DateTimePicker.prototype.baseClass = "datetimepicker"; @@ -45,385 +51,505 @@ DateTimePicker.prototype.styled = true; DateTimePicker.prototype.size = 3; DateTimePicker.prototype.autoFocus = false; DateTimePicker.prototype.segment = "datetime"; -DateTimePicker.prototype.showSeconds = false; - +DateTimePicker.prototype.showSeconds = true; interface DateTimePickerComponentProps { - instance: Instance; - data: Record; - size: number; - segment: string; + instance: Instance; + data: Record; + size: number; + segment: string; } interface DateTimePickerComponentState { - date: Date; - activeWheel: string | null; + date: Date; + activeWheel: string | null; + daysResetKey: number; + hoursResetKey: number; + minutesResetKey: number; + secondsResetKey: number; } -class DateTimePickerComponent extends VDOM.Component { - el!: HTMLDivElement; - declare wheels: Record; - keyDownPipes: Record void>; - - constructor(props: DateTimePickerComponentProps) { - super(props); - let date = props.data.value ? parseDateInvariant(props.data.value as string | number | Date) : new Date(); - if (isNaN(date.getTime())) date = new Date(); - this.state = { - date: date, - activeWheel: null, - }; - - let { widget } = props.instance; - let pickerWidget = widget as DateTimePicker; - - this.handleChange = this.handleChange.bind(this); - this.onFocus = this.onFocus.bind(this); - this.onBlur = this.onBlur.bind(this); - this.onKeyDown = this.onKeyDown.bind(this); - - let showDate = props.segment.indexOf("date") !== -1; - let showTime = props.segment.indexOf("time") !== -1; - - this.wheels = { - year: showDate, - month: showDate, - date: showDate, - hours: showTime, - minutes: showTime, - seconds: showTime && !!pickerWidget.showSeconds, - }; - - this.keyDownPipes = {}; - } - - UNSAFE_componentWillReceiveProps(props: DateTimePickerComponentProps): void { - let date = props.data.value ? parseDateInvariant(props.data.value as string | number | Date) : new Date(); - if (isNaN(date.getTime())) date = new Date(); - this.setState({ date }); - } - - setDateComponent(date: Date, component: string, value: number): Date { - let v = new Date(date); - switch (component) { - case "year": - v.setFullYear(value); - break; - - case "month": - v.setMonth(value); - break; - - case "date": - v.setDate(value); - break; - - case "hours": - v.setHours(value); - break; - - case "minutes": - v.setMinutes(value); - break; - - case "seconds": - v.setSeconds(value); - break; - } - return v; - } - - handleChange(): void { - let { widget } = this.props.instance; - let pickerWidget = widget as DateTimePicker; - let encode = pickerWidget.encoding || Culture.getDefaultDateEncoding(); - this.props.instance.set("value", encode!(this.state.date)); - } - - render(): React.ReactNode { - let { instance, data, size } = this.props; - let { widget } = instance; - let { CSS, baseClass } = widget; - let pickerWidget = widget as DateTimePicker; - let date = this.state.date; - - let culture = Culture.getDateTimeCulture(); - let monthNames = culture.getMonthNames("short"); - - let years = []; - for (let y = 1970; y <= 2050; y++) years.push({y}); - - let days = []; - let start = new Date(date.getFullYear(), date.getMonth(), 1); - while (start.getMonth() === date.getMonth()) { - let day = start.getDate(); - days.push({day < 10 ? "0" + day : day}); - start.setDate(start.getDate() + 1); - } - - let hours = []; - for (let h = 0; h < 24; h++) { - hours.push({h < 10 ? "0" + h : h}); - } - - let minutes = []; - for (let m = 0; m < 60; m++) { - minutes.push({m < 10 ? "0" + m : m}); - } - - return ( -
{ - this.el = el!; - }} - className={data.classNames as string} - onFocus={this.onFocus} - onBlur={this.onBlur} - onKeyDown={this.onKeyDown} - > - {this.wheels.year && ( - { - this.setState( - (state) => ({ - date: this.setDateComponent(this.state.date, "year", newIndex + 1970), - }), - this.handleChange, - ); - }} - onPipeKeyDown={(kd) => { - this.keyDownPipes["year"] = kd; - }} - onMouseDown={() => { - this.setState({ activeWheel: "year" }); - }} - > - {years} - - )} - {this.wheels.year && this.wheels.month && -} - {this.wheels.month && ( - { - this.setState( - (state) => ({ - date: this.setDateComponent(this.state.date, "month", newIndex), - }), - this.handleChange, - ); - }} - onPipeKeyDown={(kd) => { - this.keyDownPipes["month"] = kd; - }} - onMouseDown={() => { - this.setState({ activeWheel: "month" }); - }} - > - {monthNames.map((m: string, i: number) => ( - {m} - ))} - - )} - {this.wheels.month && this.wheels.date && -} - {this.wheels.date && ( - { - this.setState( - (state) => ({ - date: this.setDateComponent(this.state.date, "date", newIndex + 1), - }), - this.handleChange, - ); - }} - onPipeKeyDown={(kd) => { - this.keyDownPipes["date"] = kd; - }} - onMouseDown={() => { - this.setState({ activeWheel: "date" }); - }} - > - {days} - - )} - {this.wheels.hours && this.wheels.year && } - {this.wheels.hours && ( - { - this.setState( - (state) => ({ - date: this.setDateComponent(this.state.date, "hours", newIndex), - }), - this.handleChange, - ); - }} - onPipeKeyDown={(kd) => { - this.keyDownPipes["hours"] = kd; - }} - onMouseDown={() => { - this.setState({ activeWheel: "hours" }); - }} - > - {hours} - - )} - {this.wheels.hours && this.wheels.minutes && :} - {this.wheels.minutes && ( - { - this.setState( - (state) => ({ - date: this.setDateComponent(this.state.date, "minutes", newIndex), - }), - this.handleChange, - ); - }} - onPipeKeyDown={(kd) => { - this.keyDownPipes["minutes"] = kd; - }} - onMouseDown={() => { - this.setState({ activeWheel: "minutes" }); - }} - > - {minutes} - - )} - {this.wheels.minutes && this.wheels.seconds && :} - {this.wheels.seconds && ( - { - this.setState( - (state) => ({ - date: this.setDateComponent(this.state.date, "seconds", newIndex), - }), - this.handleChange, - ); - }} - onPipeKeyDown={(kd) => { - this.keyDownPipes["seconds"] = kd; - }} - onMouseDown={() => { - this.setState({ activeWheel: "seconds" }); - }} - > - {minutes} - - )} -
+class DateTimePickerComponent extends VDOM.Component< + DateTimePickerComponentProps, + DateTimePickerComponentState +> { + el!: HTMLDivElement; + declare wheels: Record; + keyDownPipes: Record void>; + declare years: any[]; + declare days: any[]; + declare hours: any[]; + declare minutes: any[]; + declare century: number; + declare numberOfDaysInMonth: number; + + constructor(props: DateTimePickerComponentProps) { + super(props); + let date = props.data.value + ? parseDateInvariant(props.data.value as string | number | Date) + : new Date(); + if (isNaN(date.getTime())) date = new Date(); + this.state = { + date: date, + activeWheel: null, + daysResetKey: 0, + hoursResetKey: 0, + minutesResetKey: 0, + secondsResetKey: 0, + }; + this.century = (date.getFullYear() / 100) | 0; + + let { widget } = props.instance; + let pickerWidget = widget as DateTimePicker; + + this.handleChange = this.handleChange.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onBlur = this.onBlur.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + + let showDate = props.segment.indexOf("date") !== -1; + let showTime = props.segment.indexOf("time") !== -1; + + this.wheels = { + year: showDate, + month: showDate, + date: showDate, + hours: showTime, + minutes: showTime, + seconds: showTime && !!pickerWidget.showSeconds, + }; + + this.keyDownPipes = {}; + } + + UNSAFE_componentWillReceiveProps(props: DateTimePickerComponentProps): void { + let date = props.data.value + ? parseDateInvariant(props.data.value as string | number | Date) + : new Date(); + if (isNaN(date.getTime())) date = new Date(); + this.setState({ date }); + } + + setDateComponent(date: Date, component: string, value: number): Date { + let v = new Date(date); + switch (component) { + case "year": + v.setFullYear(value); + break; + + case "month": + v.setMonth(value); + break; + + case "date": + v.setDate(value); + break; + + case "hours": + v.setHours(value); + break; + + case "minutes": + v.setMinutes(value); + break; + + case "seconds": + v.setSeconds(value); + break; + } + return v; + } + + handleChange(): void { + let { widget } = this.props.instance; + let pickerWidget = widget as DateTimePicker; + let encode = pickerWidget.encoding || Culture.getDefaultDateEncoding(); + this.props.instance.set("value", encode!(this.state.date)); + } + + render(): React.ReactNode { + let { instance, data, size } = this.props; + let { widget } = instance; + let { CSS, baseClass } = widget; + let pickerWidget = widget as DateTimePicker; + let date = this.state.date; + + let culture = Culture.getDateTimeCulture(); + let monthNames = culture.getMonthNames("short"); + + let years = []; + if (!this.years || this.century !== ((date.getFullYear() / 100) | 0)) { + this.century = (date.getFullYear() / 100) | 0; + + for ( + let y = this.century * 100 - 3; + y <= (this.century + 1) * 100 + 5; + y++ + ) + years.push({y}); + this.years = years; + } else { + years = this.years; + } + + let days = []; + const daysInThisMonth = new Date( + date.getFullYear(), + date.getMonth() + 1, + 0, + ).getDate(); + this.numberOfDaysInMonth ??= daysInThisMonth; + + if (!this.days || this.numberOfDaysInMonth !== daysInThisMonth) { + days = Array.from({ length: 5 }, (_, d) => ( + + {daysInThisMonth - 4 + d < 10 + ? "0" + (daysInThisMonth - 4 + d) + : daysInThisMonth - 4 + d} + + )); + days.push( + ...Array.from({ length: 36 }, (_, d) => ( + + {(d % daysInThisMonth) + 1 < 10 + ? "0" + ((d % daysInThisMonth) + 1) + : (d % daysInThisMonth) + 1} + + )), ); - } - - componentDidMount(): void { - let { widget } = this.props.instance; - let pickerWidget = widget as DateTimePicker; - if (pickerWidget.autoFocus) this.el.focus(); - } - - componentWillUnmount(): void { - offFocusOut(this); - } - - onFocus(): void { - oneFocusOut(this, this.el, this.onFocusOut.bind(this)); - - if (!this.state.activeWheel) { - let firstWheel: string | null = null; - for (let wheel in this.wheels) { - if (this.wheels[wheel]) { - firstWheel = wheel; - break; - } - } - this.setState({ - activeWheel: firstWheel, - }); + this.days = days; + this.numberOfDaysInMonth = daysInThisMonth; + } else { + days = this.days; + } + + let hours = []; + if (!this.hours) { + hours = Array.from({ length: 52 }, (_, h) => ( + {h % 24 < 10 ? "0" + (h % 24) : h % 24} + )); + this.hours = hours; + } else { + hours = this.hours; + } + + let minutes = []; + if (!this.minutes) { + minutes = Array.from({ length: 130 }, (_, h) => ( + {h % 60 < 10 ? "0" + (h % 60) : h % 60} + )); + this.minutes = minutes; + } else { + minutes = this.minutes; + } + + return ( +
{ + this.el = el!; + }} + className={data.classNames as string} + onFocus={this.onFocus} + onBlur={this.onBlur} + onKeyDown={this.onKeyDown} + > + {this.wheels.year && ( + { + this.setState( + (state) => ({ + date: this.setDateComponent( + this.state.date, + "year", + newIndex + Number(this.years[0].key), + ), + }), + this.handleChange, + ); + }} + onPipeKeyDown={(kd) => { + this.keyDownPipes["year"] = kd; + }} + onMouseDown={() => { + this.setState({ activeWheel: "year" }); + }} + > + {years} + + )} + {this.wheels.year && this.wheels.month && -} + {this.wheels.month && ( + { + this.setState( + (state) => ({ + date: this.setDateComponent( + this.state.date, + "month", + newIndex, + ), + }), + this.handleChange, + ); + }} + onPipeKeyDown={(kd) => { + this.keyDownPipes["month"] = kd; + }} + onMouseDown={() => { + this.setState({ activeWheel: "month" }); + }} + > + {monthNames.map((m: string, i: number) => ( + {m} + ))} + + )} + {this.wheels.month && this.wheels.date && -} + {this.wheels.date && ( + { + newDate -= 5; + if (newDate < 0) { + newDate += this.numberOfDaysInMonth; + } else { + newDate = newDate % this.numberOfDaysInMonth; + } + + this.setState( + (state) => ({ + date: this.setDateComponent(state.date, "date", newDate + 1), + daysResetKey: + ((state.date.getDate() - 1) ^ newDate) === + this.numberOfDaysInMonth - 1 + ? state.daysResetKey + 1 + : state.daysResetKey, + }), + this.handleChange, + ); + }} + onPipeKeyDown={(kd) => { + this.keyDownPipes["date"] = kd; + }} + onMouseDown={() => { + this.setState({ activeWheel: "date" }); + }} + > + {days} + + )} + {this.wheels.hours && this.wheels.year && ( + + )} + {this.wheels.hours && ( + { + const newHour = newIndex % 24; + + this.setState( + (s) => ({ + date: this.setDateComponent(s.date, "hours", newHour), + hoursResetKey: + (s.date.getHours() ^ newHour) == 23 + ? s.hoursResetKey + 1 + : s.hoursResetKey, + }), + this.handleChange, + ); + }} + onPipeKeyDown={(kd) => { + this.keyDownPipes["hours"] = kd; + }} + onMouseDown={() => { + this.setState({ activeWheel: "hours" }); + }} + > + {hours} + + )} + {this.wheels.hours && this.wheels.minutes && :} + {this.wheels.minutes && ( + { + const newMinutes = newIndex % 60; + this.setState( + (state) => ({ + date: this.setDateComponent( + state.date, + "minutes", + newMinutes, + ), + minutesResetKey: + (state.date.getMinutes() ^ newMinutes) == 59 + ? state.minutesResetKey + 1 + : state.minutesResetKey, + }), + this.handleChange, + ); + }} + onPipeKeyDown={(kd) => { + this.keyDownPipes["minutes"] = kd; + }} + onMouseDown={() => { + this.setState({ activeWheel: "minutes" }); + }} + > + {minutes} + + )} + {this.wheels.minutes && this.wheels.seconds && :} + {this.wheels.seconds && ( + { + const newSeconds = newIndex % 60; + this.setState( + (state) => ({ + date: this.setDateComponent( + state.date, + "seconds", + newSeconds, + ), + secondsResetKey: + (state.date.getSeconds() ^ newSeconds) == 59 + ? state.secondsResetKey + 1 + : state.secondsResetKey, + }), + this.handleChange, + ); + }} + onPipeKeyDown={(kd) => { + this.keyDownPipes["seconds"] = kd; + }} + onMouseDown={() => { + this.setState({ activeWheel: "seconds" }); + }} + > + {minutes} + + )} +
+ ); + } + + componentDidMount(): void { + let { widget } = this.props.instance; + let pickerWidget = widget as DateTimePicker; + if (pickerWidget.autoFocus) this.el.focus(); + } + + componentWillUnmount(): void { + offFocusOut(this); + } + + onFocus(): void { + oneFocusOut(this, this.el, this.onFocusOut.bind(this)); + + if (!this.state.activeWheel) { + let firstWheel: string | null = null; + for (let wheel in this.wheels) { + if (this.wheels[wheel]) { + firstWheel = wheel; + break; + } } - } - onFocusOut(): void { - let { instance } = this.props; - let { widget } = instance; - let pickerWidget = widget as DateTimePicker; - if (pickerWidget.onFocusOut) instance.invoke("onFocusOut", null, instance); - } - - onBlur(): void { this.setState({ - activeWheel: null, + activeWheel: firstWheel, }); - } - - onKeyDown(e: React.KeyboardEvent): void { - let tmp: string | null = null; - let { instance } = this.props; - let { widget } = instance; - let pickerWidget = widget as DateTimePicker; - - switch (e.keyCode) { - case KeyCode.right: - e.preventDefault(); - for (let wheel in this.wheels) { - if (this.wheels[wheel]) { - if (tmp === this.state.activeWheel) { - this.setState({ activeWheel: wheel }); - break; - } - tmp = wheel; - } + } + } + + onFocusOut(): void { + let { instance } = this.props; + let { widget } = instance; + let pickerWidget = widget as DateTimePicker; + if (pickerWidget.onFocusOut) instance.invoke("onFocusOut", null, instance); + } + + onBlur(): void { + this.setState({ + activeWheel: null, + }); + } + + onKeyDown(e: React.KeyboardEvent): void { + let tmp: string | null = null; + let { instance } = this.props; + let { widget } = instance; + let pickerWidget = widget as DateTimePicker; + + switch (e.keyCode) { + case KeyCode.right: + e.preventDefault(); + for (let wheel in this.wheels) { + if (this.wheels[wheel]) { + if (tmp === this.state.activeWheel) { + this.setState({ activeWheel: wheel }); + break; } - break; - - case KeyCode.left: - e.preventDefault(); - for (let wheel in this.wheels) { - if (this.wheels[wheel]) { - if (wheel === this.state.activeWheel && tmp) { - this.setState({ activeWheel: tmp }); - break; - } - tmp = wheel; - } + tmp = wheel; + } + } + break; + + case KeyCode.left: + e.preventDefault(); + for (let wheel in this.wheels) { + if (this.wheels[wheel]) { + if (wheel === this.state.activeWheel && tmp) { + this.setState({ activeWheel: tmp }); + break; } - break; - - case KeyCode.enter: - e.preventDefault(); - if (pickerWidget.onSelect) instance.invoke("onSelect", e, instance, this.state.date); - break; - - default: let kdp = this.keyDownPipes[this.state.activeWheel!]; - if (kdp) kdp(e); - break; - } - } + tmp = wheel; + } + } + break; + + case KeyCode.enter: + e.preventDefault(); + if (pickerWidget.onSelect) + instance.invoke("onSelect", e, instance, this.state.date); + break; + + default: + let kdp = this.keyDownPipes[this.state.activeWheel!]; + if (kdp) kdp(e); + break; + } + } } From c7a5d10725f534c48ce9c6d4ce119ec005d4bfe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D1=83=D1=88=D0=BA=D0=BE?= Date: Mon, 2 Feb 2026 16:14:25 +0100 Subject: [PATCH 2/4] fix: return default value of showSeconds to false --- packages/cx/src/widgets/form/DateTimePicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cx/src/widgets/form/DateTimePicker.tsx b/packages/cx/src/widgets/form/DateTimePicker.tsx index a2cddb84c..9af3b0f8e 100644 --- a/packages/cx/src/widgets/form/DateTimePicker.tsx +++ b/packages/cx/src/widgets/form/DateTimePicker.tsx @@ -51,7 +51,7 @@ DateTimePicker.prototype.styled = true; DateTimePicker.prototype.size = 3; DateTimePicker.prototype.autoFocus = false; DateTimePicker.prototype.segment = "datetime"; -DateTimePicker.prototype.showSeconds = true; +DateTimePicker.prototype.showSeconds = false; interface DateTimePickerComponentProps { instance: Instance; data: Record; From c8e186b17d11d9e30f4696110a1807557184186a Mon Sep 17 00:00:00 2001 From: Marko Stijak Date: Tue, 19 May 2026 12:46:11 +0200 Subject: [PATCH 3/4] DateTimePicker: robust wheel recentring, uniform buffers, cleanup Addresses review feedback on the infinite-scroll wheels: - Replace the XOR wrap-detection (only valid for single-step scrolls, misfires on multi-step touch jumps) with a direct check: recentre the wheel when a scroll lands outside the centre range of the 3x buffer. - buildNumberWheel() builds uniform, symmetric 3x buffers for the day, hour and minute wheels (previously 41/52/130 with thin trailing room). - year/month onChange now read the setState updater's `state` argument instead of `this.state`, consistent with the other wheels. - Track the first year in a `firstYear` field instead of reading it back from a rendered span's React key. - Pad option labels with String.padStart; remove the dead `??=` on numberOfDaysInMonth. --- .../cx/src/widgets/form/DateTimePicker.tsx | 174 +++++++----------- 1 file changed, 67 insertions(+), 107 deletions(-) diff --git a/packages/cx/src/widgets/form/DateTimePicker.tsx b/packages/cx/src/widgets/form/DateTimePicker.tsx index 9af3b0f8e..dea96d12f 100644 --- a/packages/cx/src/widgets/form/DateTimePicker.tsx +++ b/packages/cx/src/widgets/form/DateTimePicker.tsx @@ -52,6 +52,18 @@ DateTimePicker.prototype.size = 3; DateTimePicker.prototype.autoFocus = false; DateTimePicker.prototype.segment = "datetime"; DateTimePicker.prototype.showSeconds = false; + +// Builds the option spans for a numeric wheel. The list is three times the +// value range — a lead range, the visible range, and a trail range — so a +// scroll keeps a full range of headroom on each side before it must recentre. +// The current value is centred by passing `index = value + range`; a scroll +// that lands outside the centre range [range, 2*range) recentres the wheel. +function buildNumberWheel(range: number, startAt: number): React.ReactNode[] { + return Array.from({ length: range * 3 }, (_, j) => ( + {String((j % range) + startAt).padStart(2, "0")} + )); +} + interface DateTimePickerComponentProps { instance: Instance; data: Record; @@ -80,6 +92,7 @@ class DateTimePickerComponent extends VDOM.Component< declare hours: any[]; declare minutes: any[]; declare century: number; + declare firstYear: number; declare numberOfDaysInMonth: number; constructor(props: DateTimePickerComponentProps) { @@ -176,72 +189,36 @@ class DateTimePickerComponent extends VDOM.Component< let culture = Culture.getDateTimeCulture(); let monthNames = culture.getMonthNames("short"); - let years = []; - if (!this.years || this.century !== ((date.getFullYear() / 100) | 0)) { - this.century = (date.getFullYear() / 100) | 0; - - for ( - let y = this.century * 100 - 3; - y <= (this.century + 1) * 100 + 5; - y++ - ) - years.push({y}); - this.years = years; - } else { - years = this.years; + // Years: a window spanning the current century, rebuilt when it changes. + let currentCentury = (date.getFullYear() / 100) | 0; + if (!this.years || this.century !== currentCentury) { + this.century = currentCentury; + this.firstYear = currentCentury * 100 - 3; + let lastYear = (currentCentury + 1) * 100 + 5; + this.years = []; + for (let y = this.firstYear; y <= lastYear; y++) + this.years.push({y}); } + let years = this.years; - let days = []; - const daysInThisMonth = new Date( + // Day/hour/minute wheels use a 3x buffer (see buildNumberWheel). The day + // buffer depends on the month length, so it is rebuilt when that changes. + const numberOfDaysInMonth = new Date( date.getFullYear(), date.getMonth() + 1, 0, ).getDate(); - this.numberOfDaysInMonth ??= daysInThisMonth; - - if (!this.days || this.numberOfDaysInMonth !== daysInThisMonth) { - days = Array.from({ length: 5 }, (_, d) => ( - - {daysInThisMonth - 4 + d < 10 - ? "0" + (daysInThisMonth - 4 + d) - : daysInThisMonth - 4 + d} - - )); - days.push( - ...Array.from({ length: 36 }, (_, d) => ( - - {(d % daysInThisMonth) + 1 < 10 - ? "0" + ((d % daysInThisMonth) + 1) - : (d % daysInThisMonth) + 1} - - )), - ); - - this.days = days; - this.numberOfDaysInMonth = daysInThisMonth; - } else { - days = this.days; + if (!this.days || this.numberOfDaysInMonth !== numberOfDaysInMonth) { + this.numberOfDaysInMonth = numberOfDaysInMonth; + this.days = buildNumberWheel(numberOfDaysInMonth, 1); } + let days = this.days; - let hours = []; - if (!this.hours) { - hours = Array.from({ length: 52 }, (_, h) => ( - {h % 24 < 10 ? "0" + (h % 24) : h % 24} - )); - this.hours = hours; - } else { - hours = this.hours; - } + if (!this.hours) this.hours = buildNumberWheel(24, 0); + let hours = this.hours; - let minutes = []; - if (!this.minutes) { - minutes = Array.from({ length: 130 }, (_, h) => ( - {h % 60 < 10 ? "0" + (h % 60) : h % 60} - )); - this.minutes = minutes; - } else { - minutes = this.minutes; - } + if (!this.minutes) this.minutes = buildNumberWheel(60, 0); + let minutes = this.minutes; return (
{ this.setState( (state) => ({ date: this.setDateComponent( - this.state.date, + state.date, "year", - newIndex + Number(this.years[0].key), + newIndex + this.firstYear, ), }), this.handleChange, @@ -295,11 +272,7 @@ class DateTimePickerComponent extends VDOM.Component< onChange={(newIndex) => { this.setState( (state) => ({ - date: this.setDateComponent( - this.state.date, - "month", - newIndex, - ), + date: this.setDateComponent(state.date, "month", newIndex), }), this.handleChange, ); @@ -324,23 +297,19 @@ class DateTimePickerComponent extends VDOM.Component< CSS={CSS} active={this.state.activeWheel === "date"} baseClass={baseClass + "-wheel"} - index={date.getDate() + 4} - onChange={(newDate) => { - newDate -= 5; - if (newDate < 0) { - newDate += this.numberOfDaysInMonth; - } else { - newDate = newDate % this.numberOfDaysInMonth; - } - + index={date.getDate() - 1 + this.numberOfDaysInMonth} + onChange={(rawIndex) => { + let range = this.numberOfDaysInMonth; + let day = rawIndex % range; this.setState( (state) => ({ - date: this.setDateComponent(state.date, "date", newDate + 1), + date: this.setDateComponent(state.date, "date", day + 1), + // Recentre the wheel when the scroll lands outside the + // centre range [range, 2*range) of the 3x buffer. daysResetKey: - ((state.date.getDate() - 1) ^ newDate) === - this.numberOfDaysInMonth - 1 - ? state.daysResetKey + 1 - : state.daysResetKey, + rawIndex === day + range + ? state.daysResetKey + : state.daysResetKey + 1, }), this.handleChange, ); @@ -366,16 +335,15 @@ class DateTimePickerComponent extends VDOM.Component< active={this.state.activeWheel === "hours"} baseClass={baseClass + "-wheel"} index={date.getHours() + 24} - onChange={(newIndex) => { - const newHour = newIndex % 24; - + onChange={(rawIndex) => { + let hour = rawIndex % 24; this.setState( - (s) => ({ - date: this.setDateComponent(s.date, "hours", newHour), + (state) => ({ + date: this.setDateComponent(state.date, "hours", hour), hoursResetKey: - (s.date.getHours() ^ newHour) == 23 - ? s.hoursResetKey + 1 - : s.hoursResetKey, + rawIndex === hour + 24 + ? state.hoursResetKey + : state.hoursResetKey + 1, }), this.handleChange, ); @@ -399,19 +367,15 @@ class DateTimePickerComponent extends VDOM.Component< baseClass={baseClass + "-wheel"} active={this.state.activeWheel === "minutes"} index={date.getMinutes() + 60} - onChange={(newIndex) => { - const newMinutes = newIndex % 60; + onChange={(rawIndex) => { + let minute = rawIndex % 60; this.setState( (state) => ({ - date: this.setDateComponent( - state.date, - "minutes", - newMinutes, - ), + date: this.setDateComponent(state.date, "minutes", minute), minutesResetKey: - (state.date.getMinutes() ^ newMinutes) == 59 - ? state.minutesResetKey + 1 - : state.minutesResetKey, + rawIndex === minute + 60 + ? state.minutesResetKey + : state.minutesResetKey + 1, }), this.handleChange, ); @@ -435,19 +399,15 @@ class DateTimePickerComponent extends VDOM.Component< baseClass={baseClass + "-wheel"} active={this.state.activeWheel === "seconds"} index={date.getSeconds() + 60} - onChange={(newIndex) => { - const newSeconds = newIndex % 60; + onChange={(rawIndex) => { + let second = rawIndex % 60; this.setState( (state) => ({ - date: this.setDateComponent( - state.date, - "seconds", - newSeconds, - ), + date: this.setDateComponent(state.date, "seconds", second), secondsResetKey: - (state.date.getSeconds() ^ newSeconds) == 59 - ? state.secondsResetKey + 1 - : state.secondsResetKey, + rawIndex === second + 60 + ? state.secondsResetKey + : state.secondsResetKey + 1, }), this.handleChange, ); From 2131c6ce8ecd209264930250eda0c55156867b47 Mon Sep 17 00:00:00 2001 From: Marko Stijak Date: Tue, 19 May 2026 13:32:50 +0200 Subject: [PATCH 4/4] DateTimePicker: seamless infinite wheel scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-wrap remount, which interrupted the scroll at every boundary, with seamless wrapping. WheelComponent gains a `cycle` flag: when set it renders the option list WHEEL_BUFFER_COPIES (3) times and, on prop updates, snaps to whichever copy of the value is nearest its current position. A wrap then scrolls one item into the adjacent identical copy — no remount and no scrollTop adjustment, so the motion is never interrupted. DateTimePicker passes the natural single option list plus the `cycle` flag, and drops the resetKey state entirely. --- .../cx/src/widgets/form/DateTimePicker.tsx | 53 +++++-------------- packages/cx/src/widgets/form/Wheel.tsx | 43 ++++++++++++--- 2 files changed, 50 insertions(+), 46 deletions(-) diff --git a/packages/cx/src/widgets/form/DateTimePicker.tsx b/packages/cx/src/widgets/form/DateTimePicker.tsx index dea96d12f..71ebee537 100644 --- a/packages/cx/src/widgets/form/DateTimePicker.tsx +++ b/packages/cx/src/widgets/form/DateTimePicker.tsx @@ -53,14 +53,12 @@ DateTimePicker.prototype.autoFocus = false; DateTimePicker.prototype.segment = "datetime"; DateTimePicker.prototype.showSeconds = false; -// Builds the option spans for a numeric wheel. The list is three times the -// value range — a lead range, the visible range, and a trail range — so a -// scroll keeps a full range of headroom on each side before it must recentre. -// The current value is centred by passing `index = value + range`; a scroll -// that lands outside the centre range [range, 2*range) recentres the wheel. +// Builds the option spans for a numeric wheel — one zero-padded span per value. +// Pass the result to a WheelComponent with `cycle` set to make it scroll +// endlessly; centre the current value by passing `index = value + range`. function buildNumberWheel(range: number, startAt: number): React.ReactNode[] { - return Array.from({ length: range * 3 }, (_, j) => ( - {String((j % range) + startAt).padStart(2, "0")} + return Array.from({ length: range }, (_, j) => ( + {String(j + startAt).padStart(2, "0")} )); } @@ -74,10 +72,6 @@ interface DateTimePickerComponentProps { interface DateTimePickerComponentState { date: Date; activeWheel: string | null; - daysResetKey: number; - hoursResetKey: number; - minutesResetKey: number; - secondsResetKey: number; } class DateTimePickerComponent extends VDOM.Component< @@ -104,10 +98,6 @@ class DateTimePickerComponent extends VDOM.Component< this.state = { date: date, activeWheel: null, - daysResetKey: 0, - hoursResetKey: 0, - minutesResetKey: 0, - secondsResetKey: 0, }; this.century = (date.getFullYear() / 100) | 0; @@ -292,24 +282,18 @@ class DateTimePickerComponent extends VDOM.Component< {this.wheels.month && this.wheels.date && -} {this.wheels.date && ( { - let range = this.numberOfDaysInMonth; - let day = rawIndex % range; + let day = rawIndex % this.numberOfDaysInMonth; this.setState( (state) => ({ date: this.setDateComponent(state.date, "date", day + 1), - // Recentre the wheel when the scroll lands outside the - // centre range [range, 2*range) of the 3x buffer. - daysResetKey: - rawIndex === day + range - ? state.daysResetKey - : state.daysResetKey + 1, }), this.handleChange, ); @@ -329,9 +313,10 @@ class DateTimePickerComponent extends VDOM.Component< )} {this.wheels.hours && ( ({ date: this.setDateComponent(state.date, "hours", hour), - hoursResetKey: - rawIndex === hour + 24 - ? state.hoursResetKey - : state.hoursResetKey + 1, }), this.handleChange, ); @@ -361,9 +342,10 @@ class DateTimePickerComponent extends VDOM.Component< {this.wheels.hours && this.wheels.minutes && :} {this.wheels.minutes && ( ({ date: this.setDateComponent(state.date, "minutes", minute), - minutesResetKey: - rawIndex === minute + 60 - ? state.minutesResetKey - : state.minutesResetKey + 1, }), this.handleChange, ); @@ -393,9 +371,10 @@ class DateTimePickerComponent extends VDOM.Component< {this.wheels.minutes && this.wheels.seconds && :} {this.wheels.seconds && ( ({ date: this.setDateComponent(state.date, "seconds", second), - secondsResetKey: - rawIndex === second + 60 - ? state.secondsResetKey - : state.secondsResetKey + 1, }), this.handleChange, ); diff --git a/packages/cx/src/widgets/form/Wheel.tsx b/packages/cx/src/widgets/form/Wheel.tsx index 56252dfac..77b5ebfcf 100644 --- a/packages/cx/src/widgets/form/Wheel.tsx +++ b/packages/cx/src/widgets/form/Wheel.tsx @@ -65,6 +65,10 @@ Wheel.prototype.baseClass = "wheel"; Wheel.prototype.size = 3; Wheel.prototype.styled = true; +/** A cyclic wheel renders its option list this many times, leaving scroll + * headroom on both sides of the centre copy. */ +export const WHEEL_BUFFER_COPIES = 3; + export interface WheelComponentProps { size: number; children: React.ReactNode[]; @@ -74,6 +78,11 @@ export interface WheelComponentProps { className?: string; style?: React.CSSProperties; index?: number; + /** Set to render the option list as an endlessly scrolling wheel. The list + * is rendered `WHEEL_BUFFER_COPIES` times and the wheel snaps to whichever + * copy of the selected value is nearest its current position, so a wrap + * scrolls seamlessly into the adjacent identical copy. */ + cycle?: boolean; onChange: (newIndex: number) => void; onPipeKeyDown?: (fn: (e: React.KeyboardEvent) => void) => void; onMouseDown?: () => void; @@ -110,23 +119,30 @@ export class WheelComponent extends VDOM.Component void; render(): React.ReactNode { - let { size, children, CSS, baseClass, active, className, style, onMouseDown } = this.props; + let { size, children, CSS, baseClass, active, className, style, onMouseDown, cycle } = this.props; let optionClass = CSS.element(baseClass, "option"); let dummyClass = CSS.element(baseClass, "option", { dummy: true }); + // A cyclic wheel repeats the option list so it can scroll past either end. + let options = children; + if (cycle) { + options = []; + for (let i = 0; i < WHEEL_BUFFER_COPIES; i++) options.push(...children); + } + let tpad = [], bpad = [], padSize = 0; for (let i = 0; i < (size - 1) / 2; i++) { - tpad.push({ key: -1 - i, child: children[0], cls: dummyClass }); - bpad.push({ key: -100 - i, child: children[0], cls: dummyClass }); + tpad.push({ key: -1 - i, child: options[0], cls: dummyClass }); + bpad.push({ key: -100 - i, child: options[0], cls: dummyClass }); padSize++; } let displayedOptions = [ ...tpad, - ...children.map((c, i) => ({ + ...options.map((c, i) => ({ key: i, child: c, cls: optionClass, @@ -233,7 +249,19 @@ export class WheelComponent extends VDOM.Component