diff --git a/homedocs/src/pages/changelog.mdx b/homedocs/src/pages/changelog.mdx index a3f98d097..ee6da0477 100644 --- a/homedocs/src/pages/changelog.mdx +++ b/homedocs/src/pages/changelog.mdx @@ -4,6 +4,24 @@ title: Changelog description: Version history and release notes --- +## cx\@26.5.1 + +**Features** + +- Added a `daybefore` date format that renders a date shifted back by one calendar day, using the same pattern argument as `datetime` — handy for displaying the exclusive end of a date range as an inclusive value. Also added the `dayBefore` date utility ([#1290](https://github.com/codaxy/cxjs/issues/1290)) +- Added a `quarter` date format that renders the calendar quarter of a date from a string-template pattern (`{q}`, `{yyyy}`, `{yy}`), with an `exclusive` flag for displaying the end of a half-open range. Also added the `dateQuarter` date utility +- Added a `hideClippedLabels` option to chart axes that drops a first/last label, and its tick, when it would be clipped at the chart edge — enabled by default on `TimeAxis` ([#1291](https://github.com/codaxy/cxjs/issues/1291)) +- `DateTimePicker` date and time wheels now scroll endlessly, so picking values across large ranges no longer hits hard list boundaries + +**Fixes** + +- `Grid`'s `onCreateFilter` callback may now return `null` to apply no filter +- Fixed the `ValidationGroup` `visited` flag not propagating to nested fields when a field's own bound data was unchanged ([#1276](https://github.com/codaxy/cxjs/issues/1276)) +- Added the missing SCSS import for the `RangeMarker` chart component — its styles were not being generated +- Added missing `@use` declarations in `RangeMarker.scss` for `besm`, `include`, and `variables` + +--- + ## cx\@26.4.4 **Fixes** diff --git a/packages/cx/package.json b/packages/cx/package.json index ba3fa9f8a..dcb7068ab 100644 --- a/packages/cx/package.json +++ b/packages/cx/package.json @@ -1,6 +1,6 @@ { "name": "cx", - "version": "26.4.4", + "version": "26.5.1", "description": "Advanced JavaScript UI framework for admin and dashboard applications with ready to use grid, form and chart components.", "exports": { "./data": { diff --git a/packages/cx/src/widgets/form/DateTimePicker.tsx b/packages/cx/src/widgets/form/DateTimePicker.tsx index c0eacc8ab..71ebee537 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"; @@ -47,383 +53,438 @@ DateTimePicker.prototype.autoFocus = false; DateTimePicker.prototype.segment = "datetime"; DateTimePicker.prototype.showSeconds = false; +// 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 }, (_, j) => ( + {String(j + startAt).padStart(2, "0")} + )); +} + 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; } -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!; +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 firstYear: 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, + }; + 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"); + + // 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; + + // 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(); + if (!this.days || this.numberOfDaysInMonth !== numberOfDaysInMonth) { + this.numberOfDaysInMonth = numberOfDaysInMonth; + this.days = buildNumberWheel(numberOfDaysInMonth, 1); + } + let days = this.days; + + if (!this.hours) this.hours = buildNumberWheel(24, 0); + let hours = this.hours; + + if (!this.minutes) this.minutes = buildNumberWheel(60, 0); + let 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( + state.date, + "year", + newIndex + this.firstYear, + ), + }), + this.handleChange, + ); }} - 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} - - )} -
- ); - } - - 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, - }); + 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(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 && ( + { + let day = rawIndex % this.numberOfDaysInMonth; + this.setState( + (state) => ({ + date: this.setDateComponent(state.date, "date", day + 1), + }), + this.handleChange, + ); + }} + onPipeKeyDown={(kd) => { + this.keyDownPipes["date"] = kd; + }} + onMouseDown={() => { + this.setState({ activeWheel: "date" }); + }} + > + {days} + + )} + {this.wheels.hours && this.wheels.year && ( + + )} + {this.wheels.hours && ( + { + let hour = rawIndex % 24; + this.setState( + (state) => ({ + date: this.setDateComponent(state.date, "hours", hour), + }), + this.handleChange, + ); + }} + onPipeKeyDown={(kd) => { + this.keyDownPipes["hours"] = kd; + }} + onMouseDown={() => { + this.setState({ activeWheel: "hours" }); + }} + > + {hours} + + )} + {this.wheels.hours && this.wheels.minutes && :} + {this.wheels.minutes && ( + { + let minute = rawIndex % 60; + this.setState( + (state) => ({ + date: this.setDateComponent(state.date, "minutes", minute), + }), + this.handleChange, + ); + }} + onPipeKeyDown={(kd) => { + this.keyDownPipes["minutes"] = kd; + }} + onMouseDown={() => { + this.setState({ activeWheel: "minutes" }); + }} + > + {minutes} + + )} + {this.wheels.minutes && this.wheels.seconds && :} + {this.wheels.seconds && ( + { + let second = rawIndex % 60; + this.setState( + (state) => ({ + date: this.setDateComponent(state.date, "seconds", second), + }), + 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; + } + } } diff --git a/packages/cx/src/widgets/form/Wheel.tsx b/packages/cx/src/widgets/form/Wheel.tsx index 789535b5f..0141aaaa8 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; @@ -109,23 +118,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, @@ -228,7 +244,19 @@ export class WheelComponent extends VDOM.Component