From 1c5fba3be03ec35bbed3e1fc17587d0178f6430b Mon Sep 17 00:00:00 2001 From: Todor Stoyanov Date: Tue, 11 Aug 2026 16:19:27 +0300 Subject: [PATCH 1/6] =?UTF-8?q?feat(zoom):=20Step=2001-04=20=E2=80=94=2020?= =?UTF-8?q?0%=20zoom=20support=20for=20DatePicker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DateComponentBase: _highZoom property, _isHighZoom(), zoom watch lifecycle - TimePicker: same zoom detection (standalone, no shared base) - DateHighZoomInputs: new internal component with Year/Month/Day selects, YearPicker dialog, validate(), syncStartDate/EndDate(), full public API - YearPicker: _showHeader, _rowSize, _pageSize properties; CalendarHeader CSS included for standalone use - DatePicker: _highZoom integration — DateHighZoomInputs replaces Calendar in popover content, OK/Cancel footer, input click handling at zoom --- packages/main/src/DateComponentBase.ts | 52 ++ packages/main/src/DateHighZoomInputs.ts | 558 ++++++++++++++++++ .../main/src/DateHighZoomInputsTemplate.tsx | 155 +++++ packages/main/src/DatePicker.ts | 74 ++- packages/main/src/DatePickerInputTemplate.tsx | 9 +- .../main/src/DatePickerPopoverTemplate.tsx | 28 +- packages/main/src/DateTimePicker.ts | 2 + packages/main/src/TimePicker.ts | 52 ++ packages/main/src/YearPicker.ts | 125 +++- packages/main/src/YearPickerTemplate.tsx | 23 +- packages/main/src/bundle.esm.ts | 1 + .../main/src/i18n/messagebundle.properties | 16 + .../main/src/i18n/messagebundle_en.properties | 16 + .../main/src/themes/DateHighZoomInputs.css | 83 +++ .../main/src/themes/DatePickerPopover.css | 7 + packages/main/src/themes/YearPicker.css | 12 +- .../main/src/types/DateHighZoomInputsTypes.ts | 12 + .../test/pages/HighZoomDateTimeControls.html | 182 ++++++ 18 files changed, 1389 insertions(+), 18 deletions(-) create mode 100644 packages/main/src/DateHighZoomInputs.ts create mode 100644 packages/main/src/DateHighZoomInputsTemplate.tsx create mode 100644 packages/main/src/themes/DateHighZoomInputs.css create mode 100644 packages/main/src/types/DateHighZoomInputsTypes.ts create mode 100644 packages/main/test/pages/HighZoomDateTimeControls.html diff --git a/packages/main/src/DateComponentBase.ts b/packages/main/src/DateComponentBase.ts index 91be57d5c0a45..783ad30771b8e 100644 --- a/packages/main/src/DateComponentBase.ts +++ b/packages/main/src/DateComponentBase.ts @@ -124,10 +124,62 @@ class DateComponentBase extends UI5Element { _cachedMinDate?: { key: string, value: CalendarDate }; _cachedMaxDate?: { key: string, value: CalendarDate }; + /** + * True when the effective viewport width is ≤ 320 px (corresponds to ~200% browser zoom on a phone). + * @private + */ + @property({ type: Boolean, noAttribute: true }) + _highZoom = false; + + _fnZoomResizeHandler?: () => void; + constructor() { super(); } + onEnterDOM() { + this._highZoom = this._isHighZoom(); + this._startZoomWatch(); + } + + onExitDOM() { + this._stopZoomWatch(); + } + + _isHighZoom(): boolean { + return ((window.visualViewport?.width) ?? window.innerWidth) <= 320; + } + + _startZoomWatch() { + if (this._fnZoomResizeHandler) { + window.removeEventListener("resize", this._fnZoomResizeHandler); + window.visualViewport?.removeEventListener("resize", this._fnZoomResizeHandler); + } + + this._fnZoomResizeHandler = () => { + if (!this.isConnected) { return; } + const bHighZoom = this._isHighZoom(); + if (bHighZoom !== this._highZoom) { + this._highZoom = bHighZoom; + this._onZoomChange(bHighZoom); + } + }; + + window.visualViewport?.addEventListener("resize", this._fnZoomResizeHandler); + window.addEventListener("resize", this._fnZoomResizeHandler); + } + + _stopZoomWatch() { + if (this._fnZoomResizeHandler) { + window.removeEventListener("resize", this._fnZoomResizeHandler); + window.visualViewport?.removeEventListener("resize", this._fnZoomResizeHandler); + this._fnZoomResizeHandler = undefined; + } + } + + // noop — override per subclass + _onZoomChange(_bHighZoom: boolean): void {} + get _primaryCalendarType() { const localeData = getCachedLocaleDataInstance(getLocale()); return this.primaryCalendarType || getCalendarType() || localeData.getPreferredCalendarType(); diff --git a/packages/main/src/DateHighZoomInputs.ts b/packages/main/src/DateHighZoomInputs.ts new file mode 100644 index 0000000000000..cfda256919012 --- /dev/null +++ b/packages/main/src/DateHighZoomInputs.ts @@ -0,0 +1,558 @@ +import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; +import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; +import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; +import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js"; +import "@ui5/webcomponents-localization/dist/features/calendar/Gregorian.js"; +import type CalendarType from "@ui5/webcomponents-base/dist/types/CalendarType.js"; + +import { + DATEPICKER_HZ_YEAR_LABEL, + DATEPICKER_HZ_MONTH_LABEL, + DATEPICKER_HZ_DAY_LABEL, + DATEPICKER_HZ_FROM_LABEL, + DATEPICKER_HZ_TO_LABEL, + DATEPICKER_HZ_YEAR_OUT_OF_RANGE, + DATEPICKER_HZ_MONTH_OUT_OF_RANGE, + DATEPICKER_HZ_DAY_OUT_OF_RANGE, + CALENDAR_FOOTER_OK_BUTTON, + CALENDAR_FOOTER_CANCEL_BUTTON, +} from "./generated/i18n/i18n-defaults.js"; + +import DateHighZoomInputsTemplate from "./DateHighZoomInputsTemplate.js"; +import DateHighZoomInputsCss from "./generated/themes/DateHighZoomInputs.css.js"; +import { DateHighZoomInputsMode, DateHighZoomInputsField } from "./types/DateHighZoomInputsTypes.js"; +import type { YearPickerChangeEventDetail } from "./YearPicker.js"; + +type DateHighZoomInputsChangeEventDetail = { + field: `${DateHighZoomInputsField}`; + isEndDate: boolean; +}; + +/** + * @class + * Internal component used by date pickers at high zoom (≤320px viewport). + * Renders Year/Month/Day selects instead of a calendar grid. + * @constructor + * @extends UI5Element + * @private + */ +@customElement({ + tag: "ui5-date-high-zoom-inputs", + renderer: jsxRenderer, + styles: DateHighZoomInputsCss, + template: DateHighZoomInputsTemplate, +}) +@event("change") +class DateHighZoomInputs extends UI5Element { + eventDetails!: { + change: DateHighZoomInputsChangeEventDetail; + }; + + /** + * Selected start date + * @private + */ + @property({ type: Object, noAttribute: true }) + dateValue: Date | null = null; + + /** + * Selected end date (Range mode only) + * @private + */ + @property({ type: Object, noAttribute: true }) + secondDateValue: Date | null = null; + + /** + * Minimum selectable date + * @private + */ + @property({ type: Object, noAttribute: true }) + minDate: Date | null = null; + + /** + * Maximum selectable date + * @private + */ + @property({ type: Object, noAttribute: true }) + maxDate: Date | null = null; + + /** + * Single or Range mode + * @private + */ + @property() + mode: `${DateHighZoomInputsMode}` = DateHighZoomInputsMode.Single; + + // --- Start date field states --- + + @property({ noAttribute: true }) + _yearValue = ""; + + @property({ type: Number, noAttribute: true }) + _monthValue = 0; + + @property({ type: Number, noAttribute: true }) + _dayValue = 1; + + @property({ noAttribute: true }) + _yearValueState: `${ValueState}` = ValueState.None; + + @property({ noAttribute: true }) + _yearValueStateMessage = ""; + + @property({ noAttribute: true }) + _monthValueState: `${ValueState}` = ValueState.None; + + @property({ noAttribute: true }) + _monthValueStateMessage = ""; + + @property({ noAttribute: true }) + _dayValueState: `${ValueState}` = ValueState.None; + + @property({ noAttribute: true }) + _dayValueStateMessage = ""; + + // --- End date field states (Range mode) --- + + @property({ noAttribute: true }) + _endYearValue = ""; + + @property({ type: Number, noAttribute: true }) + _endMonthValue = 0; + + @property({ type: Number, noAttribute: true }) + _endDayValue = 1; + + @property({ noAttribute: true }) + _endYearValueState: `${ValueState}` = ValueState.None; + + @property({ noAttribute: true }) + _endYearValueStateMessage = ""; + + @property({ noAttribute: true }) + _endMonthValueState: `${ValueState}` = ValueState.None; + + @property({ noAttribute: true }) + _endMonthValueStateMessage = ""; + + @property({ noAttribute: true }) + _endDayValueState: `${ValueState}` = ValueState.None; + + @property({ noAttribute: true }) + _endDayValueStateMessage = ""; + + // --- Year picker dialog state --- + + /** Whether the year-picker dialog is open for the start date year field */ + @property({ type: Boolean, noAttribute: true }) + _yearPickerOpen = false; + + /** Whether the year-picker dialog is open for the end date year field (Range mode) */ + @property({ type: Boolean, noAttribute: true }) + _endYearPickerOpen = false; + + /** Primary calendar type forwarded from the parent picker */ + @property({ noAttribute: true }) + primaryCalendarType?: `${CalendarType}`; + + // Plain instance vars — no @property to avoid re-render resetting the input + _yearPickerTimestamp = 0; + _endYearPickerTimestamp = 0; + _pendingYear: number | null = null; + _endPendingYear: number | null = null; + + @i18n("@ui5/webcomponents") + static i18nBundle: I18nBundle; + + // ---- Labels ---- + + get _yearLabel() { return DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_YEAR_LABEL); } + get _monthLabel() { return DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_MONTH_LABEL); } + get _dayLabel() { return DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_DAY_LABEL); } + get _fromLabel() { return DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_FROM_LABEL); } + get _toLabel() { return DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_TO_LABEL); } + get _okLabel() { return DateHighZoomInputs.i18nBundle.getText(CALENDAR_FOOTER_OK_BUTTON); } + get _cancelLabel() { return DateHighZoomInputs.i18nBundle.getText(CALENDAR_FOOTER_CANCEL_BUTTON); } + + get _isRange() { + return this.mode === DateHighZoomInputsMode.Range; + } + + // ---- Month options ---- + + get _monthOptions() { + const months = []; + for (let i = 0; i < 12; i++) { + const d = new Date(2000, i, 1); + months.push({ + value: i, + text: d.toLocaleString("default", { month: "long" }), + }); + } + return months; + } + + // ---- Day options (computed from current year + month) ---- + + _getDaysInMonth(year: number, month: number) { + const y = isNaN(year) ? 2000 : year; + return new Date(y, month + 1, 0).getDate(); + } + + get _dayOptions() { + const count = this._getDaysInMonth(parseInt(this._yearValue), this._monthValue); + return Array.from({ length: count }, (_, i) => i + 1); + } + + get _endDayOptions() { + const count = this._getDaysInMonth(parseInt(this._endYearValue), this._endMonthValue); + return Array.from({ length: count }, (_, i) => i + 1); + } + + // ---- Public API ---- + + syncStartDate() { + if (!this.dateValue) { return; } + const year = this.dateValue.getFullYear(); + this._yearValue = String(year); + this._yearPickerTimestamp = Date.UTC(year, 0, 1, 12, 0, 0) / 1000; + this._monthValue = this.dateValue.getMonth(); + this._dayValue = this.dateValue.getDate(); + } + + syncEndDate(date: Date | null) { + this.secondDateValue = date; + if (!date) { return; } + const year = date.getFullYear(); + this._endYearValue = String(year); + this._endYearPickerTimestamp = Date.UTC(year, 0, 1, 12, 0, 0) / 1000; + this._endMonthValue = date.getMonth(); + this._endDayValue = date.getDate(); + } + + getSelectedDate(): { year: number; month: number; day: number } { + return { + year: parseInt(this._yearValue), + month: this._monthValue, + day: this._dayValue, + }; + } + + getSelectedSecondDate(): { year: number; month: number; day: number } | null { + if (!this._isRange) { return null; } + return { + year: parseInt(this._endYearValue), + month: this._endMonthValue, + day: this._endDayValue, + }; + } + + getDateObject(): Date | null { + const { year, month, day } = this.getSelectedDate(); + if (isNaN(year) || isNaN(month) || isNaN(day)) { return null; } + const d = new Date(year, month, day); + d.setFullYear(year); // guard for years 0-99 + return d; + } + + validate(): boolean { + return this._doValidate(false); + } + + validateEndDate(): boolean { + if (!this._isRange) { return true; } + return this._doValidate(true); + } + + resetValueState() { + this._yearValueState = ValueState.None; + this._yearValueStateMessage = ""; + this._monthValueState = ValueState.None; + this._monthValueStateMessage = ""; + this._dayValueState = ValueState.None; + this._dayValueStateMessage = ""; + + if (this._isRange) { + this._endYearValueState = ValueState.None; + this._endYearValueStateMessage = ""; + this._endMonthValueState = ValueState.None; + this._endMonthValueStateMessage = ""; + this._endDayValueState = ValueState.None; + this._endDayValueStateMessage = ""; + } + } + + // ---- Internal validation ---- + + _doValidate(bEndDate: boolean): boolean { + const yearStr = bEndDate ? this._endYearValue : this._yearValue; + const month = bEndDate ? this._endMonthValue : this._monthValue; + const day = bEndDate ? this._endDayValue : this._dayValue; + + const year = parseInt(yearStr); + + const minY = this.minDate ? this.minDate.getFullYear() : 1; + const maxY = this.maxDate ? this.maxDate.getFullYear() : 9999; + + // 1. Validate year + if (isNaN(year) || year < minY || year > maxY) { + const msg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_YEAR_OUT_OF_RANGE, String(minY), String(maxY)); + if (bEndDate) { + this._endYearValueState = ValueState.Negative; + this._endYearValueStateMessage = msg; + this._endMonthValueState = ValueState.None; + this._endMonthValueStateMessage = ""; + this._endDayValueState = ValueState.None; + this._endDayValueStateMessage = ""; + } else { + this._yearValueState = ValueState.Negative; + this._yearValueStateMessage = msg; + this._monthValueState = ValueState.None; + this._monthValueStateMessage = ""; + this._dayValueState = ValueState.None; + this._dayValueStateMessage = ""; + } + return false; + } + + if (bEndDate) { + this._endYearValueState = ValueState.None; + this._endYearValueStateMessage = ""; + } else { + this._yearValueState = ValueState.None; + this._yearValueStateMessage = ""; + } + + // 2. Validate month bounds when min/max apply to the same year + if (this.minDate && year === minY) { + const minM = this.minDate.getMonth(); + if (month < minM) { + const msg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_MONTH_OUT_OF_RANGE); + if (bEndDate) { + this._endMonthValueState = ValueState.Negative; + this._endMonthValueStateMessage = msg; + this._endDayValueState = ValueState.None; + this._endDayValueStateMessage = ""; + } else { + this._monthValueState = ValueState.Negative; + this._monthValueStateMessage = msg; + this._dayValueState = ValueState.None; + this._dayValueStateMessage = ""; + } + return false; + } + } + if (this.maxDate && year === maxY) { + const maxM = this.maxDate.getMonth(); + if (month > maxM) { + const msg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_MONTH_OUT_OF_RANGE); + if (bEndDate) { + this._endMonthValueState = ValueState.Negative; + this._endMonthValueStateMessage = msg; + this._endDayValueState = ValueState.None; + this._endDayValueStateMessage = ""; + } else { + this._monthValueState = ValueState.Negative; + this._monthValueStateMessage = msg; + this._dayValueState = ValueState.None; + this._dayValueStateMessage = ""; + } + return false; + } + } + + if (bEndDate) { + this._endMonthValueState = ValueState.None; + this._endMonthValueStateMessage = ""; + } else { + this._monthValueState = ValueState.None; + this._monthValueStateMessage = ""; + } + + // 3. Validate day bounds when min/max apply to same year+month + const daysInMonth = this._getDaysInMonth(year, month); + let minD = 1; + let maxD = daysInMonth; + if (this.minDate && year === minY && month === this.minDate.getMonth()) { + minD = this.minDate.getDate(); + } + if (this.maxDate && year === maxY && month === this.maxDate.getMonth()) { + maxD = this.maxDate.getDate(); + } + + if (day < minD || day > maxD) { + const msg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_DAY_OUT_OF_RANGE); + if (bEndDate) { + this._endDayValueState = ValueState.Negative; + this._endDayValueStateMessage = msg; + } else { + this._dayValueState = ValueState.Negative; + this._dayValueStateMessage = msg; + } + return false; + } + + if (bEndDate) { + this._endDayValueState = ValueState.None; + this._endDayValueStateMessage = ""; + } else { + this._dayValueState = ValueState.None; + this._dayValueStateMessage = ""; + } + + return true; + } + + // ---- Event handlers ---- + + _onYearInput(e: CustomEvent, isEnd: boolean) { + const input = e.target as HTMLElement & { value: string }; + const val = input.value; + // Only update the picker timestamp (plain var, no re-render) so the + // dialog navigates to the typed year when opened. Do NOT update + // _yearValue here — that would trigger a re-render which resets the input. + const y = parseInt(val); + if (!isNaN(y) && y > 0 && y < 10000) { + if (isEnd) { + this._endYearPickerTimestamp = Date.UTC(y, 0, 1, 12, 0, 0) / 1000; + } else { + this._yearPickerTimestamp = Date.UTC(y, 0, 1, 12, 0, 0) / 1000; + } + } + } + + _onYearChange(e: CustomEvent, isEnd: boolean) { + // Fires on blur / Enter — commit the value, recompute day count, validate + const input = e.target as HTMLElement & { value: string }; + const val = input.value; + if (isEnd) { + this._endYearValue = val; + } else { + this._yearValue = val; + if (this._dayValue > this._getDaysInMonth(parseInt(val), this._monthValue)) { + this._dayValue = 1; + } + } + this._doValidate(isEnd); + this.fireDecoratorEvent("change", { field: DateHighZoomInputsField.Year, isEndDate: isEnd }); + } + + _onMonthChange(e: CustomEvent, isEnd: boolean) { + const select = e.target as HTMLElement & { value: string }; + const val = parseInt(select.value); + if (isEnd) { + this._endMonthValue = val; + if (this._endDayValue > this._getDaysInMonth(parseInt(this._endYearValue), val)) { + this._endDayValue = 1; + } + } else { + this._monthValue = val; + if (this._dayValue > this._getDaysInMonth(parseInt(this._yearValue), val)) { + this._dayValue = 1; + } + } + this.fireDecoratorEvent("change", { field: DateHighZoomInputsField.Month, isEndDate: isEnd }); + } + + _onDayChange(e: CustomEvent, isEnd: boolean) { + const select = e.target as HTMLElement & { value: string }; + const val = parseInt(select.value); + if (isEnd) { + this._endDayValue = val; + } else { + this._dayValue = val; + } + this.fireDecoratorEvent("change", { field: DateHighZoomInputsField.Day, isEndDate: isEnd }); + } + + // ---- Year picker dialog ---- + + _openYearPicker(isEnd: boolean) { + let ts = isEnd ? this._endYearPickerTimestamp : this._yearPickerTimestamp; + if (!ts) { + const yearStr = isEnd ? this._endYearValue : this._yearValue; + const year = parseInt(yearStr); + const safeYear = isNaN(year) || year <= 0 ? new Date().getFullYear() : year; + ts = Date.UTC(safeYear, 0, 1, 12, 0, 0) / 1000; + } + if (isEnd) { + this._endYearPickerTimestamp = ts; + this._endYearPickerOpen = true; + } else { + this._yearPickerTimestamp = ts; + this._yearPickerOpen = true; + } + } + + _closeYearPicker(isEnd: boolean) { + if (isEnd) { + this._endYearPickerOpen = false; + } else { + this._yearPickerOpen = false; + } + } + + _onYearPickerSelectionChange(e: CustomEvent, isEnd: boolean) { + const ts = e.detail.timestamp; + if (ts === undefined) { return; } + const d = new Date(ts * 1000); + const year = d.getUTCFullYear(); + const newTs = Date.UTC(year, 0, 1, 12, 0, 0) / 1000; + // Store as pending — confirm immediately (no separate OK needed in year picker) + if (isEnd) { + this._endPendingYear = year; + this._endYearPickerTimestamp = newTs; + } else { + this._pendingYear = year; + this._yearPickerTimestamp = newTs; + } + // Auto-confirm on selection + this._confirmYearPicker(isEnd); + } + + _confirmYearPicker(isEnd: boolean) { + const year = isEnd ? this._endPendingYear : this._pendingYear; + if (year === null) { + // No new selection — just close + this._closeYearPicker(isEnd); + return; + } + const newTs = Date.UTC(year, 0, 1, 12, 0, 0) / 1000; + if (isEnd) { + this._endYearValue = String(year); + this._endYearPickerTimestamp = newTs; + this._endPendingYear = null; + this._endYearPickerOpen = false; + if (this._endDayValue > this._getDaysInMonth(year, this._endMonthValue)) { + this._endDayValue = 1; + } + } else { + this._yearValue = String(year); + this._yearPickerTimestamp = newTs; + this._pendingYear = null; + this._yearPickerOpen = false; + if (this._dayValue > this._getDaysInMonth(year, this._monthValue)) { + this._dayValue = 1; + } + } + this.fireDecoratorEvent("change", { field: DateHighZoomInputsField.Year, isEndDate: isEnd }); + } + + _cancelYearPicker(isEnd: boolean) { + if (isEnd) { + this._endPendingYear = null; + } else { + this._pendingYear = null; + } + this._closeYearPicker(isEnd); + } +} + +DateHighZoomInputs.define(); + +export default DateHighZoomInputs; +export type { DateHighZoomInputsChangeEventDetail }; diff --git a/packages/main/src/DateHighZoomInputsTemplate.tsx b/packages/main/src/DateHighZoomInputsTemplate.tsx new file mode 100644 index 0000000000000..b156818953248 --- /dev/null +++ b/packages/main/src/DateHighZoomInputsTemplate.tsx @@ -0,0 +1,155 @@ +import type DateHighZoomInputs from "./DateHighZoomInputs.js"; +import Dialog from "./Dialog.js"; +import YearPicker from "./YearPicker.js"; +import Icon from "./Icon.js"; +import Input from "./Input.js"; +import Select from "./Select.js"; +import Option from "./Option.js"; +import Label from "./Label.js"; +import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js"; +import { isPhone } from "@ui5/webcomponents-base/dist/Device.js"; +import slimArrowDown from "@ui5/webcomponents-icons/dist/slim-arrow-down.js"; +import InputType from "./types/InputType.js"; +import IconMode from "./types/IconMode.js"; + +export default function DateHighZoomInputsTemplate(this: DateHighZoomInputs) { + return ( + <> +
+ { this._isRange + ? <> +
+
{this._fromLabel}
+ { dateFields.call(this, false) } +
+
+
{this._toLabel}
+ { dateFields.call(this, true) } +
+ + : dateFields.call(this, false) + } +
+ + { yearPickerDialog.call(this, false) } + { this._isRange && yearPickerDialog.call(this, true) } + + ); +} + +function dateFields(this: DateHighZoomInputs, isEnd: boolean) { + const yearVal = isEnd ? this._endYearValue : this._yearValue; + const monthVal = isEnd ? this._endMonthValue : this._monthValue; + const dayVal = isEnd ? this._endDayValue : this._dayValue; + const yearVS = isEnd ? this._endYearValueState : this._yearValueState; + const monthVS = isEnd ? this._endMonthValueState : this._monthValueState; + const dayVS = isEnd ? this._endDayValueState : this._dayValueState; + const yearMsg = isEnd ? this._endYearValueStateMessage : this._yearValueStateMessage; + const monthMsg = isEnd ? this._endMonthValueStateMessage : this._monthValueStateMessage; + const dayMsg = isEnd ? this._endDayValueStateMessage : this._dayValueStateMessage; + const dayOpts = isEnd ? this._endDayOptions : this._dayOptions; + const suffix = isEnd ? "-end" : ""; + + return ( +
+ + {/* Year */} +
+ + this._onYearInput(e, isEnd)} + onChange={(e: CustomEvent) => this._onYearChange(e, isEnd)} + > + { yearVS === ValueState.Negative && yearMsg && + {yearMsg} + } + this._openYearPicker(isEnd)} + /> + +
+ + {/* Month */} +
+ + +
+ + {/* Day */} +
+ + +
+ +
+ ); +} + +function yearPickerDialog(this: DateHighZoomInputs, isEnd: boolean) { + const isOpen = isEnd ? this._endYearPickerOpen : this._yearPickerOpen; + const ts = isEnd ? this._endYearPickerTimestamp : this._yearPickerTimestamp; + const yearStr = isEnd ? this._endYearValue : this._yearValue; + const year = parseInt(yearStr); + const safeYear = isNaN(year) || year <= 0 ? new Date().getFullYear() : year; + const selectedTs = ts || Date.UTC(safeYear, 0, 1, 12, 0, 0) / 1000; + const ypId = `${this._id}-yearpicker${isEnd ? "-end" : ""}`; + + return ( + this._closeYearPicker(isEnd)} + > + this._onYearPickerSelectionChange(e, isEnd)} + /> + + ); +} diff --git a/packages/main/src/DatePicker.ts b/packages/main/src/DatePicker.ts index a94c801877365..de046e8cba3a8 100644 --- a/packages/main/src/DatePicker.ts +++ b/packages/main/src/DatePicker.ts @@ -57,6 +57,7 @@ import { DATEPICKER_RANGE_UNDERFLOW, DATEPICKER_RANGE_OVERFLOW, TIMEPICKER_CANCEL_BUTTON, + CALENDAR_FOOTER_OK_BUTTON, } from "./generated/i18n/i18n-defaults.js"; import DateComponentBase from "./DateComponentBase.js"; import type ResponsivePopover from "./ResponsivePopover.js"; @@ -66,6 +67,7 @@ import type CalendarSelectionMode from "./types/CalendarSelectionMode.js"; import type DateTimeInput from "./DateTimeInput.js"; import type { InputAccInfo } from "./Input.js"; import InputType from "./types/InputType.js"; +import type DateHighZoomInputs from "./DateHighZoomInputs.js"; import IconMode from "./types/IconMode.js"; import DatePickerTemplate from "./DatePickerTemplate.js"; @@ -407,6 +409,9 @@ class DatePicker extends DateComponentBase implements IFormInputElement { @query("[ui5-calendar]") _calendar!: Calendar; + @query("[ui5-date-high-zoom-inputs]") + _hzInputs?: DateHighZoomInputs; + @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; @@ -467,10 +472,36 @@ class DatePicker extends DateComponentBase implements IFormInputElement { } onResponsivePopoverBeforeOpen() { + if (this._highZoom) { + // Sync HZ inputs after render (element may not exist yet on first open) + requestAnimationFrame(() => { + if (this._hzInputs) { + const d = this.value ? this.getValueFormat().parse(this.value, true) as Date | null : null; + this._hzInputs.dateValue = d; + this._hzInputs.minDate = this._minDate.toLocalJSDate(); + this._hzInputs.maxDate = this._maxDate.toLocalJSDate(); + this._hzInputs.primaryCalendarType = this._primaryCalendarType; + this._hzInputs.syncStartDate(); + } + }); + return; + } this._calendar.timestamp = this._calendarTimestamp; this._calendarCurrentPicker = this.firstPicker; } + _onHzFocusIn(e: FocusEvent) { + // At high zoom the input should not be editable — immediately blur and open picker + (e.target as HTMLElement).blur(); + if (!this.open) { + this._togglePicker(); + } + } + + _onHzInputsChange() { + // called by DateHighZoomInputs change event — validation is on OK press + } + onBeforeRendering() { ["minDate", "maxDate"].forEach((prop: string) => { const propValue = this[prop as keyof DatePicker] as string; @@ -694,10 +725,10 @@ class DatePicker extends DateComponentBase implements IFormInputElement { } _click(e: MouseEvent) { - if (isPhone()) { + if (isPhone() || this._highZoom) { this.responsivePopover!.opener = this; this.responsivePopover!.open = true; - e.preventDefault(); // prevent immediate selection of any item + e.preventDefault(); } } @@ -865,11 +896,11 @@ class DatePicker extends DateComponentBase implements IFormInputElement { } get showHeader() { - return isPhone(); + return isPhone() || this._highZoom; } get showFooter() { - return isPhone(); + return isPhone() || this._highZoom; } get displayValue(): string { @@ -955,6 +986,28 @@ class DatePicker extends DateComponentBase implements IFormInputElement { return DatePicker.i18nBundle.getText(TIMEPICKER_CANCEL_BUTTON); } + get btnOKLabel() { + return DatePicker.i18nBundle.getText(CALENDAR_FOOTER_OK_BUTTON); + } + + _onHzOk() { + if (!this._hzInputs) { return; } + if (!this._hzInputs.validate()) { return; } + const d = this._hzInputs.getDateObject(); + if (d) { + this.value = this.getValueFormat().format(d); + this.fireDecoratorEvent("change", { value: this.value, valid: true }); + } + this._togglePicker(); + } + + _onHzCancel() { + if (this._hzInputs) { + this._hzInputs.resetValueState(); + } + this._togglePicker(); + } + /** * Defines whether the dialog on mobile should have header * @private @@ -1047,6 +1100,7 @@ class DatePicker extends DateComponentBase implements IFormInputElement { } _togglePicker(): void { + this._highZoom = this._isHighZoom(); this.open = !this.open; } @@ -1098,6 +1152,18 @@ class DatePicker extends DateComponentBase implements IFormInputElement { get type() { return InputType.Text; } + + _onZoomChange(bHighZoom: boolean): void { + if (this.open) { + // picker is open — re-render will pick up the new _highZoom value + // (already set by DateComponentBase before calling this) + this.open = false; + this.open = true; + } else { + // icon visibility is driven by _highZoom property — invalidate + void bHighZoom; + } + } } DatePicker.define(); diff --git a/packages/main/src/DatePickerInputTemplate.tsx b/packages/main/src/DatePickerInputTemplate.tsx index dce19b92138b1..24585353a3e3f 100644 --- a/packages/main/src/DatePickerInputTemplate.tsx +++ b/packages/main/src/DatePickerInputTemplate.tsx @@ -6,9 +6,8 @@ export default function DatePickerInputTemplate(this: DatePicker) { return (
- {!this.open && this.valueStateMessage.length > 0 && } - {!this.readonly && + {!this.readonly && !this._highZoom && + ); + } + return ( + { this._highZoom && + + } + diff --git a/packages/main/src/DateTimePicker.ts b/packages/main/src/DateTimePicker.ts index 80ba6da635d8b..36dc7e53d1f99 100644 --- a/packages/main/src/DateTimePicker.ts +++ b/packages/main/src/DateTimePicker.ts @@ -184,10 +184,12 @@ class DateTimePicker extends DatePicker implements IFormInputElement { */ onEnterDOM() { + super.onEnterDOM(); ResizeHandler.register(document.body, this._handleResizeBound); } onExitDOM() { + super.onExitDOM(); ResizeHandler.deregister(document.body, this._handleResizeBound); } diff --git a/packages/main/src/TimePicker.ts b/packages/main/src/TimePicker.ts index 66fe80902621c..84c5f8ea8f6c8 100644 --- a/packages/main/src/TimePicker.ts +++ b/packages/main/src/TimePicker.ts @@ -373,6 +373,15 @@ class TimePicker extends UI5Element implements IFormInputElement { tempValue?: string; + /** + * True when the effective viewport width is ≤ 320 px (~200% browser zoom on a phone). + * @private + */ + @property({ type: Boolean, noAttribute: true }) + _highZoom = false; + + _fnZoomResizeHandler?: () => void; + /** * Cached instance of DateFormat with a format pattern of "HH:mm:ss". * Used by the getISOFormat method to avoid creating a new DateFormat instance on each call. @@ -413,6 +422,49 @@ class TimePicker extends UI5Element implements IFormInputElement { return this.value || ""; } + onEnterDOM() { + this._highZoom = this._isHighZoom(); + this._startZoomWatch(); + } + + onExitDOM() { + this._stopZoomWatch(); + } + + _isHighZoom(): boolean { + return ((window.visualViewport?.width) ?? window.innerWidth) <= 320; + } + + _startZoomWatch() { + if (this._fnZoomResizeHandler) { + window.removeEventListener("resize", this._fnZoomResizeHandler); + window.visualViewport?.removeEventListener("resize", this._fnZoomResizeHandler); + } + + this._fnZoomResizeHandler = () => { + if (!this.isConnected) { return; } + const bHighZoom = this._isHighZoom(); + if (bHighZoom !== this._highZoom) { + this._highZoom = bHighZoom; + this._onZoomChange(bHighZoom); + } + }; + + window.visualViewport?.addEventListener("resize", this._fnZoomResizeHandler); + window.addEventListener("resize", this._fnZoomResizeHandler); + } + + _stopZoomWatch() { + if (this._fnZoomResizeHandler) { + window.removeEventListener("resize", this._fnZoomResizeHandler); + window.visualViewport?.removeEventListener("resize", this._fnZoomResizeHandler); + this._fnZoomResizeHandler = undefined; + } + } + + // noop — override in later steps + _onZoomChange(_bHighZoom: boolean): void {} + onBeforeRendering() { if (this.value) { this.value = this.normalizeValue(this.value) || this.value; diff --git a/packages/main/src/YearPicker.ts b/packages/main/src/YearPicker.ts index 4780c867b0c4f..45e67c7d61777 100644 --- a/packages/main/src/YearPicker.ts +++ b/packages/main/src/YearPicker.ts @@ -22,13 +22,24 @@ import CalendarDate from "@ui5/webcomponents-localization/dist/dates/CalendarDat import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; import CalendarPart from "./CalendarPart.js"; import type { CalendarYearRangeT, ICalendarPicker } from "./Calendar.js"; -import { YEAR_PICKER_DESCRIPTION } from "./generated/i18n/i18n-defaults.js"; +import { + YEAR_PICKER_DESCRIPTION, + CALENDAR_HEADER_YEAR_RANGE_NEXT_BUTTON_TITLE, + CALENDAR_HEADER_YEAR_RANGE_PREVIOUS_BUTTON_TITLE, + CALENDAR_HEADER_YEAR_RANGE_BUTTON, + CALENDAR_HEADER_YEAR_RANGE_BUTTON_SHORTCUT, + CALENDAR_HEADER_MONTH_NEXT_BUTTON_SHORTCUT, + CALENDAR_HEADER_MONTH_PREVIOUS_BUTTON_SHORTCUT, +} from "./generated/i18n/i18n-defaults.js"; +import type { CalendarHeaderHost } from "./CalendarHeaderTemplate.js"; // Template import YearPickerTemplate from "./YearPickerTemplate.js"; // Styles import yearPickerStyles from "./generated/themes/YearPicker.css.js"; +import calendarHeaderStyles from "./generated/themes/CalendarHeader.css.js"; +import calendarStyles from "./generated/themes/Calendar.css.js"; import CalendarSelectionMode from "./types/CalendarSelectionMode.js"; const isBetween = (x: number, num1: number, num2: number) => x > Math.min(num1, num2) && x < Math.max(num1, num2); @@ -68,7 +79,7 @@ type YearPickerNavigateEventDetail = { */ @customElement({ tag: "ui5-yearpicker", - styles: yearPickerStyles, + styles: [yearPickerStyles, calendarHeaderStyles, calendarStyles], template: YearPickerTemplate, }) /** @@ -84,7 +95,7 @@ type YearPickerNavigateEventDetail = { @event("navigate", { bubbles: true, }) -class YearPicker extends CalendarPart implements ICalendarPicker { +class YearPicker extends CalendarPart implements ICalendarPicker, CalendarHeaderHost { eventDetails!: CalendarPart["eventDetails"] & { "change": YearPickerChangeEventDetail, "navigate": YearPickerNavigateEventDetail, @@ -129,11 +140,115 @@ class YearPicker extends CalendarPart implements ICalendarPicker { @property({ noAttribute: true }) _currentYearRange?: CalendarYearRangeT; + /** + * When true, YearPicker renders its own navigation header (for standalone use outside Calendar). + */ + @property({ type: Boolean, noAttribute: true }) + _showHeader = false; + _firstYear?: number; + /** + * Override the number of years per row. 0 means auto (default: 4, or 2 with secondary calendar). + * @private + */ + @property({ type: Number, noAttribute: true }) + _rowSize = 0; + + /** + * Override the total number of years shown per page. 0 means auto (default: 20, or 8 with secondary calendar). + * @private + */ + @property({ type: Number, noAttribute: true }) + _pageSize = 0; + @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; + // CalendarHeaderHost implementation + + get _previousButtonDisabled() { + return !this._hasPreviousPage(); + } + + get _nextButtonDisabled() { + return !this._hasNextPage(); + } + + get _portraitView() { + return false; + } + + get _isHeaderMonthButtonHidden() { + return true; + } + + get _isHeaderYearButtonHidden() { + return true; + } + + get _isHeaderYearRangeButtonHidden() { + return false; + } + + get _headerYearRangeButtonText() { + if (!this._yearsInterval.length) { + return ""; + } + const firstRow = this._yearsInterval[0]; + const lastRow = this._yearsInterval[this._yearsInterval.length - 1]; + const firstYear = firstRow[0].year; + const lastYear = lastRow[lastRow.length - 1].year; + return `${firstYear} – ${lastYear}`; + } + + get accInfo() { + const rangeText = this._headerYearRangeButtonText; + const [rangeStartText, rangeEndText] = rangeText.split(" – "); + const yearRangeLabel = YearPicker.i18nBundle.getText(CALENDAR_HEADER_YEAR_RANGE_BUTTON, rangeStartText, rangeEndText); + const yearRangeShortcut = YearPicker.i18nBundle.getText(CALENDAR_HEADER_YEAR_RANGE_BUTTON_SHORTCUT); + const nextBtnLabel = YearPicker.i18nBundle.getText(CALENDAR_HEADER_YEAR_RANGE_NEXT_BUTTON_TITLE); + const prevBtnLabel = YearPicker.i18nBundle.getText(CALENDAR_HEADER_YEAR_RANGE_PREVIOUS_BUTTON_TITLE); + const nextBtnShortcut = YearPicker.i18nBundle.getText(CALENDAR_HEADER_MONTH_NEXT_BUTTON_SHORTCUT); + const prevBtnShortcut = YearPicker.i18nBundle.getText(CALENDAR_HEADER_MONTH_PREVIOUS_BUTTON_SHORTCUT); + + return { + ariaLabelYearRangeButton: yearRangeLabel, + ariaLabelNextButton: nextBtnLabel, + ariaLabelPrevButton: prevBtnLabel, + keyShortcutYearRangeButton: yearRangeShortcut, + keyShortcutNextButton: nextBtnShortcut, + keyShortcutPrevButton: prevBtnShortcut, + tooltipYearRangeButton: `${yearRangeLabel} (${yearRangeShortcut})`, + tooltipNextButton: `${nextBtnLabel} (${nextBtnShortcut})`, + tooltipPrevButton: `${prevBtnLabel} (${prevBtnShortcut})`, + }; + } + + onPrevButtonClick(_e: MouseEvent) { + this._showPreviousPage(); + } + + onPrevButtonKeyDown(e: KeyboardEvent) { + if (isEnter(e) || isSpace(e)) { + this._showPreviousPage(); + } + } + + onPrevButtonKeyUp(_e: KeyboardEvent) { /* noop */ } + + onNextButtonClick(_e: MouseEvent) { + this._showNextPage(); + } + + onNextButtonKeyDown(e: KeyboardEvent) { + if (isEnter(e) || isSpace(e)) { + this._showNextPage(); + } + } + + onNextButtonKeyUp(_e: KeyboardEvent) { /* noop */ } + get roleDescription() { return YearPicker.i18nBundle.getText(YEAR_PICKER_DESCRIPTION); } @@ -148,12 +263,12 @@ class YearPicker extends CalendarPart implements ICalendarPicker { } _getPageSize() { - // Total years on a single page depending on using on one or two calendar type + if (this._pageSize > 0) { return this._pageSize; } return this.hasSecondaryCalendarType ? 8 : 20; } _getRowSize() { - // Years per row (5 rows of 4 years each) for one claendar type and (4 row of 2 years each) for two calendar type + if (this._rowSize > 0) { return this._rowSize; } return this.hasSecondaryCalendarType ? 2 : 4; } diff --git a/packages/main/src/YearPickerTemplate.tsx b/packages/main/src/YearPickerTemplate.tsx index 1f9add9491e9c..cb425e25c129e 100644 --- a/packages/main/src/YearPickerTemplate.tsx +++ b/packages/main/src/YearPickerTemplate.tsx @@ -1,6 +1,25 @@ import type YearPicker from "./YearPicker.js"; +import CalendarHeaderTemplate from "./CalendarHeaderTemplate.js"; export default function YearPickerTemplate(this: YearPicker) { + if (this._showHeader) { + return ( +
+
+ { CalendarHeaderTemplate.call(this) } +
+ { grid.call(this) } +
+ ); + } + + return grid.call(this); +} + +function grid(this: YearPicker) { + const rowSize = this._getRowSize(); + const itemWidth = `calc(${(100 / rowSize).toFixed(4)}% - 0.125rem)`; + return (
} onMouseOver={this._onmouseover} onKeyDown={this._onkeydown} onKeyUp={this._onkeyup} @@ -39,5 +59,6 @@ export default function YearPickerTemplate(this: YearPicker) { )}
)} -
); + + ); } diff --git a/packages/main/src/bundle.esm.ts b/packages/main/src/bundle.esm.ts index 6ee70761c593b..82d55a5678eaa 100644 --- a/packages/main/src/bundle.esm.ts +++ b/packages/main/src/bundle.esm.ts @@ -49,6 +49,7 @@ import ColorPicker from "./ColorPicker.js"; import ComboBox from "./ComboBox.js"; import ComboBoxItemCustom from "./ComboBoxItemCustom.js"; import DatePicker from "./DatePicker.js"; +import DateHighZoomInputs from "./DateHighZoomInputs.js"; import DateRangePicker from "./DateRangePicker.js"; import DateTimePicker from "./DateTimePicker.js"; import Dialog from "./Dialog.js"; diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index c43151f060cba..489e62473fb54 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -237,6 +237,22 @@ DATEPICKER_RANGE_OVERFLOW=Fill in a date value that is lower than the set max. v DATEPICKER_RANGE_UNDERFLOW=Fill in a date value that is higher than the set min. value of {0}. +DATEPICKER_HZ_YEAR_LABEL=Year: + +DATEPICKER_HZ_MONTH_LABEL=Month: + +DATEPICKER_HZ_DAY_LABEL=Day: + +DATEPICKER_HZ_FROM_LABEL=From + +DATEPICKER_HZ_TO_LABEL=To + +DATEPICKER_HZ_YEAR_OUT_OF_RANGE=Year must be between {0} and {1} + +DATEPICKER_HZ_MONTH_OUT_OF_RANGE=Month is out of the allowed range + +DATEPICKER_HZ_DAY_OUT_OF_RANGE=Day is out of the allowed range + #XACT: Aria information for the Date Time Picker DATETIME_DESCRIPTION=Date Time Input diff --git a/packages/main/src/i18n/messagebundle_en.properties b/packages/main/src/i18n/messagebundle_en.properties index 68c2a90966f20..e94167f44c36c 100644 --- a/packages/main/src/i18n/messagebundle_en.properties +++ b/packages/main/src/i18n/messagebundle_en.properties @@ -159,6 +159,22 @@ DATEPICKER_RANGE_OVERFLOW=Enter a date lower than the maximum value of {0}. DATEPICKER_RANGE_UNDERFLOW=Enter a date higher than the minimum value of {0}. +DATEPICKER_HZ_YEAR_LABEL=Year: + +DATEPICKER_HZ_MONTH_LABEL=Month: + +DATEPICKER_HZ_DAY_LABEL=Day: + +DATEPICKER_HZ_FROM_LABEL=From + +DATEPICKER_HZ_TO_LABEL=To + +DATEPICKER_HZ_YEAR_OUT_OF_RANGE=Year must be between {0} and {1} + +DATEPICKER_HZ_MONTH_OUT_OF_RANGE=Month is out of the allowed range + +DATEPICKER_HZ_DAY_OUT_OF_RANGE=Day is out of the allowed range + DATETIME_DESCRIPTION=Date Time Input DATETIME_VALUE_MISSING=Enter the date and time in the following format: {0}. diff --git a/packages/main/src/themes/DateHighZoomInputs.css b/packages/main/src/themes/DateHighZoomInputs.css new file mode 100644 index 0000000000000..dd6717764f501 --- /dev/null +++ b/packages/main/src/themes/DateHighZoomInputs.css @@ -0,0 +1,83 @@ +:host { + display: block; + width: 100%; + box-sizing: border-box; +} + +.ui5-dhzi-root { + padding: 0.5rem; + overflow-y: auto; + box-sizing: border-box; +} + +.ui5-dhzi-range { + padding: 0; +} + +.ui5-dhzi-group { + padding: 0.5rem; + border-bottom: 1px solid var(--sapList_BorderColor); +} + +.ui5-dhzi-group:last-child { + border-bottom: none; +} + +.ui5-dhzi-group-label { + font-size: var(--sapFontSmallSize, 0.75rem); + font-weight: bold; + color: var(--sapContent_LabelColor); + margin-bottom: 0.375rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.ui5-dhzi-fields { + width: 100%; +} + +.ui5-dhzi-row { + padding: 0.375rem 0; + width: 100%; +} + +.ui5-dhzi-label { + display: block; + font-family: var(--sapFontFamily); + font-size: var(--sapFontSize); + color: var(--sapContent_LabelColor); + margin-bottom: 0.25rem; +} + +.ui5-dhzi-year-input, +.ui5-dhzi-select { + width: 100%; + box-sizing: border-box; +} + +/* Year picker dialog */ +.ui5-dhzi-year-dialog::part(content) { + padding: 0; + overflow: hidden; +} + +/* YearPicker fills the dialog width — override Calendar's fixed 20rem */ +.ui5-dhzi-yp { + display: block; + width: 100%; + --_ui5_calendar_width: 100%; + --_ui5_calendar_height: auto; +} + +.ui5-dhzi-field-message { + font-size: var(--sapFontSmallSize, 0.75rem); + color: var(--sapNegativeTextColor); + margin-top: 0.125rem; +} + +.ui5-dhzi-yp-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.25rem 0.5rem; +} diff --git a/packages/main/src/themes/DatePickerPopover.css b/packages/main/src/themes/DatePickerPopover.css index 9ac06de4514af..28b2563722ab2 100644 --- a/packages/main/src/themes/DatePickerPopover.css +++ b/packages/main/src/themes/DatePickerPopover.css @@ -23,6 +23,13 @@ padding: 0; } +/* High-zoom mode: DateHighZoomInputs fills the full popover content area */ +[ui5-date-high-zoom-inputs] { + display: block; + width: 100%; + box-sizing: border-box; +} + .ui5-dt-picker-footer { display: flex; justify-content: flex-end; diff --git a/packages/main/src/themes/YearPicker.css b/packages/main/src/themes/YearPicker.css index 7a04f6c41bbcc..daf8ad23f175b 100644 --- a/packages/main/src/themes/YearPicker.css +++ b/packages/main/src/themes/YearPicker.css @@ -7,6 +7,16 @@ height: 100%; } +/* When YearPicker renders its own header (standalone mode), fill parent width */ +.ui5-cal-root { + width: 100%; + height: auto; +} + +.ui5-calheader { + height: var(--_ui5_calendar_header_height); +} + .ui5-yp-root { padding: 2rem 0 1rem 0; display: flex; @@ -27,7 +37,7 @@ .ui5-yp-item { display: flex; margin: var(--_ui5_yearpicker_item_margin); - width: calc(25% - 0.125rem); + width: var(--_ui5_yp_item_width, calc(25% - 0.125rem)); height: var(--_ui5_year_picker_item_height); color: var(--sapButton_Lite_TextColor); background-color: var(--sapButton_Lite_Background); diff --git a/packages/main/src/types/DateHighZoomInputsTypes.ts b/packages/main/src/types/DateHighZoomInputsTypes.ts new file mode 100644 index 0000000000000..c096186236323 --- /dev/null +++ b/packages/main/src/types/DateHighZoomInputsTypes.ts @@ -0,0 +1,12 @@ +enum DateHighZoomInputsMode { + Single = "Single", + Range = "Range", +} + +enum DateHighZoomInputsField { + Year = "Year", + Month = "Month", + Day = "Day", +} + +export { DateHighZoomInputsMode, DateHighZoomInputsField }; diff --git a/packages/main/test/pages/HighZoomDateTimeControls.html b/packages/main/test/pages/HighZoomDateTimeControls.html new file mode 100644 index 0000000000000..5eb5f63fef22f --- /dev/null +++ b/packages/main/test/pages/HighZoomDateTimeControls.html @@ -0,0 +1,182 @@ + + + + + + High-Zoom Date/Time Controls + + + + + + + + + +

High-Zoom Date/Time Controls — Test Page

+ +

Open on mobile at 200% zoom, or use Chrome DevTools → Device Toolbar → 320px width.

+ +
+ + + Viewport: -px — High zoom: - +
+ +
Events will appear here...
+ +
+

DatePicker (Step 04)

+
+ + +
+
+ + + +
+
+ + + +
+
+ +
+

TimePicker (Step 03)

+
+ + +
+
+ +
+

DateTimePicker (Step 05)

+
+ + +
+
+ + +
+
+ +
+

DateRangePicker (Step 06)

+
+ + +
+
+ +
+

Calendar standalone (Step 07)

+
+ +
+
+ + + + + From 20e543588bed5f750eff08d6f9c794ee15af8496 Mon Sep 17 00:00:00 2001 From: Todor Stoyanov Date: Thu, 13 Aug 2026 09:10:13 +0300 Subject: [PATCH 2/6] fix(ui5-date-picker): fix min/max validation at 200% zoom --- packages/main/src/CalendarHeaderTemplate.tsx | 57 ++++- packages/main/src/DateHighZoomInputs.ts | 231 +++++++++--------- .../main/src/DateHighZoomInputsTemplate.tsx | 3 + packages/main/src/DatePicker.ts | 11 - .../main/src/DatePickerPopoverTemplate.tsx | 10 +- .../test/pages/HighZoomDateTimeControls.html | 1 + 6 files changed, 177 insertions(+), 136 deletions(-) diff --git a/packages/main/src/CalendarHeaderTemplate.tsx b/packages/main/src/CalendarHeaderTemplate.tsx index a70e1c085a284..48324ec505004 100644 --- a/packages/main/src/CalendarHeaderTemplate.tsx +++ b/packages/main/src/CalendarHeaderTemplate.tsx @@ -1,9 +1,56 @@ -import type Calendar from "./Calendar.js"; import Icon from "./Icon.js"; import slimArowLeft from "@ui5/webcomponents-icons/dist/slim-arrow-left.js"; import slimArowRight from "@ui5/webcomponents-icons/dist/slim-arrow-right.js"; +export interface CalendarHeaderHost { + _previousButtonDisabled: boolean; + _nextButtonDisabled: boolean; + _portraitView: boolean; + _isHeaderMonthButtonHidden: boolean; + _isHeaderYearButtonHidden: boolean; + _isHeaderYearRangeButtonHidden: boolean; + _headerMonthButtonText?: string; + _headerYearButtonText?: string; + _headerYearButtonTextSecType?: string; + _headerYearRangeButtonText?: string; + _headerYearRangeButtonTextSecType?: string; + secondMonthButtonText?: string; + hasSecondaryCalendarType: boolean; + onPrevButtonClick: (e: MouseEvent) => void; + onPrevButtonKeyDown: (e: KeyboardEvent) => void; + onPrevButtonKeyUp: (e: KeyboardEvent) => void; + onNextButtonClick: (e: MouseEvent) => void; + onNextButtonKeyDown: (e: KeyboardEvent) => void; + onNextButtonKeyUp: (e: KeyboardEvent) => void; + onHeaderMonthButtonPress?: (e: Event) => void; + onMonthButtonKeyDown?: (e: KeyboardEvent) => void; + onMonthButtonKeyUp?: (e: KeyboardEvent) => void; + onHeaderYearButtonPress?: (e: Event) => void; + onYearButtonKeyDown?: (e: KeyboardEvent) => void; + onYearButtonKeyUp?: (e: KeyboardEvent) => void; + onHeaderYearRangeButtonPress?: (e: Event) => void; + onYearRangeButtonKeyDown?: (e: KeyboardEvent) => void; + onYearRangeButtonKeyUp?: (e: KeyboardEvent) => void; + accInfo: { + ariaLabelMonthButton?: string; + ariaLabelYearButton?: string; + ariaLabelYearRangeButton?: string; + ariaLabelNextButton?: string; + ariaLabelPrevButton?: string; + keyShortcutMonthButton?: string; + keyShortcutYearButton?: string; + keyShortcutYearRangeButton?: string; + keyShortcutNextButton?: string; + keyShortcutPrevButton?: string; + tooltipMonthButton?: string; + tooltipYearButton?: string; + tooltipYearRangeButton?: string; + tooltipNextButton?: string; + tooltipPrevButton?: string; + }; +} + interface CalendarHeaderOptions { headerText?: { monthText: string; @@ -16,7 +63,7 @@ interface CalendarHeaderOptions { isMultiple?: boolean; } -export default function CalendarHeaderTemplate(this: Calendar, options?: CalendarHeaderOptions) { +export default function CalendarHeaderTemplate(this: CalendarHeaderHost, options?: CalendarHeaderOptions) { const headerText = options?.headerText; const isFirst = options?.isFirst ?? true; const isLast = options?.isLast ?? true; @@ -41,7 +88,7 @@ export default function CalendarHeaderTemplate(this: Calendar, options?: Calenda ); } -function renderPrevButton(this: Calendar, isFirst: boolean, isMultiple: boolean) { +function renderPrevButton(this: CalendarHeaderHost, isFirst: boolean, isMultiple: boolean) { if (!isFirst && isMultiple) { return
; } @@ -70,7 +117,7 @@ function renderPrevButton(this: Calendar, isFirst: boolean, isMultiple: boolean) } function renderMiddleButtons( - this: Calendar, + this: CalendarHeaderHost, headerText: { monthText: string; yearText: string; @@ -146,7 +193,7 @@ function renderMiddleButtons( ); } -function renderNextButton(this: Calendar, isFirst: boolean, isLast: boolean, isMultiple: boolean) { +function renderNextButton(this: CalendarHeaderHost, isFirst: boolean, isLast: boolean, isMultiple: boolean) { // In landscape mode, show next button only on last calendar const isVertical = this._portraitView; const shouldShowNextButton = !isMultiple || (isVertical ? isFirst : isLast); diff --git a/packages/main/src/DateHighZoomInputs.ts b/packages/main/src/DateHighZoomInputs.ts index cfda256919012..672460200c6e5 100644 --- a/packages/main/src/DateHighZoomInputs.ts +++ b/packages/main/src/DateHighZoomInputs.ts @@ -6,8 +6,8 @@ import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js"; -import "@ui5/webcomponents-localization/dist/features/calendar/Gregorian.js"; import type CalendarType from "@ui5/webcomponents-base/dist/types/CalendarType.js"; +import "@ui5/webcomponents-localization/dist/features/calendar/Gregorian.js"; import { DATEPICKER_HZ_YEAR_LABEL, @@ -36,12 +36,14 @@ type DateHighZoomInputsChangeEventDetail = { * @class * Internal component used by date pickers at high zoom (≤320px viewport). * Renders Year/Month/Day selects instead of a calendar grid. + * State is held here; logic (min/max, formatting) stays in the parent picker. * @constructor * @extends UI5Element * @private */ @customElement({ tag: "ui5-date-high-zoom-inputs", + languageAware: true, renderer: jsxRenderer, styles: DateHighZoomInputsCss, template: DateHighZoomInputsTemplate, @@ -52,42 +54,33 @@ class DateHighZoomInputs extends UI5Element { change: DateHighZoomInputsChangeEventDetail; }; - /** - * Selected start date - * @private - */ + // ---- Props from parent picker ---- + + /** Current selected start date — parent sets this, component syncs display fields */ @property({ type: Object, noAttribute: true }) dateValue: Date | null = null; - /** - * Selected end date (Range mode only) - * @private - */ + /** Current selected end date (Range mode only) */ @property({ type: Object, noAttribute: true }) secondDateValue: Date | null = null; - /** - * Minimum selectable date - * @private - */ - @property({ type: Object, noAttribute: true }) - minDate: Date | null = null; + /** Minimum selectable date as ISO string (yyyy-MM-dd) — already parsed by parent */ + @property({ noAttribute: true }) + minDate = ""; - /** - * Maximum selectable date - * @private - */ - @property({ type: Object, noAttribute: true }) - maxDate: Date | null = null; + /** Maximum selectable date as ISO string (yyyy-MM-dd) — already parsed by parent */ + @property({ noAttribute: true }) + maxDate = ""; - /** - * Single or Range mode - * @private - */ + /** Primary calendar type forwarded from parent */ + @property({ noAttribute: true }) + primaryCalendarType?: `${CalendarType}`; + + /** Single or Range mode */ @property() mode: `${DateHighZoomInputsMode}` = DateHighZoomInputsMode.Single; - // --- Start date field states --- + // ---- Start date display state ---- @property({ noAttribute: true }) _yearValue = ""; @@ -116,7 +109,7 @@ class DateHighZoomInputs extends UI5Element { @property({ noAttribute: true }) _dayValueStateMessage = ""; - // --- End date field states (Range mode) --- + // ---- End date display state (Range mode) ---- @property({ noAttribute: true }) _endYearValue = ""; @@ -145,29 +138,40 @@ class DateHighZoomInputs extends UI5Element { @property({ noAttribute: true }) _endDayValueStateMessage = ""; - // --- Year picker dialog state --- + // ---- Year picker dialog state ---- - /** Whether the year-picker dialog is open for the start date year field */ @property({ type: Boolean, noAttribute: true }) _yearPickerOpen = false; - /** Whether the year-picker dialog is open for the end date year field (Range mode) */ @property({ type: Boolean, noAttribute: true }) _endYearPickerOpen = false; - /** Primary calendar type forwarded from the parent picker */ - @property({ noAttribute: true }) - primaryCalendarType?: `${CalendarType}`; - - // Plain instance vars — no @property to avoid re-render resetting the input + // Plain instance vars — not @property to avoid re-render resetting the input _yearPickerTimestamp = 0; _endYearPickerTimestamp = 0; _pendingYear: number | null = null; _endPendingYear: number | null = null; + // Track last synced dateValue to avoid overwriting user edits on every re-render + _syncedDateValue: Date | null = null; + _syncedSecondDateValue: Date | null = null; + @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; + // ---- Lifecycle ---- + + onBeforeRendering() { + if (this.dateValue !== this._syncedDateValue) { + this._syncedDateValue = this.dateValue; + this.syncStartDate(); + } + if (this._isRange && this.secondDateValue !== this._syncedSecondDateValue) { + this._syncedSecondDateValue = this.secondDateValue; + this.syncEndDate(this.secondDateValue); + } + } + // ---- Labels ---- get _yearLabel() { return DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_YEAR_LABEL); } @@ -182,35 +186,28 @@ class DateHighZoomInputs extends UI5Element { return this.mode === DateHighZoomInputsMode.Range; } - // ---- Month options ---- + // ---- Month / Day options ---- get _monthOptions() { const months = []; for (let i = 0; i < 12; i++) { const d = new Date(2000, i, 1); - months.push({ - value: i, - text: d.toLocaleString("default", { month: "long" }), - }); + months.push({ value: i, text: d.toLocaleString("default", { month: "long" }) }); } return months; } - // ---- Day options (computed from current year + month) ---- - _getDaysInMonth(year: number, month: number) { const y = isNaN(year) ? 2000 : year; return new Date(y, month + 1, 0).getDate(); } get _dayOptions() { - const count = this._getDaysInMonth(parseInt(this._yearValue), this._monthValue); - return Array.from({ length: count }, (_, i) => i + 1); + return Array.from({ length: this._getDaysInMonth(parseInt(this._yearValue), this._monthValue) }, (_, i) => i + 1); } get _endDayOptions() { - const count = this._getDaysInMonth(parseInt(this._endYearValue), this._endMonthValue); - return Array.from({ length: count }, (_, i) => i + 1); + return Array.from({ length: this._getDaysInMonth(parseInt(this._endYearValue), this._endMonthValue) }, (_, i) => i + 1); } // ---- Public API ---- @@ -235,27 +232,19 @@ class DateHighZoomInputs extends UI5Element { } getSelectedDate(): { year: number; month: number; day: number } { - return { - year: parseInt(this._yearValue), - month: this._monthValue, - day: this._dayValue, - }; + return { year: parseInt(this._yearValue), month: this._monthValue, day: this._dayValue }; } getSelectedSecondDate(): { year: number; month: number; day: number } | null { if (!this._isRange) { return null; } - return { - year: parseInt(this._endYearValue), - month: this._endMonthValue, - day: this._endDayValue, - }; + return { year: parseInt(this._endYearValue), month: this._endMonthValue, day: this._endDayValue }; } getDateObject(): Date | null { const { year, month, day } = this.getSelectedDate(); if (isNaN(year) || isNaN(month) || isNaN(day)) { return null; } const d = new Date(year, month, day); - d.setFullYear(year); // guard for years 0-99 + d.setFullYear(year); return d; } @@ -288,17 +277,24 @@ class DateHighZoomInputs extends UI5Element { // ---- Internal validation ---- + _parseISO(iso: string): Date | null { + if (!iso) { return null; } + const d = new Date(iso); + return isNaN(d.getTime()) ? null : d; + } + _doValidate(bEndDate: boolean): boolean { const yearStr = bEndDate ? this._endYearValue : this._yearValue; const month = bEndDate ? this._endMonthValue : this._monthValue; const day = bEndDate ? this._endDayValue : this._dayValue; - const year = parseInt(yearStr); - const minY = this.minDate ? this.minDate.getFullYear() : 1; - const maxY = this.maxDate ? this.maxDate.getFullYear() : 9999; + const minD = this._parseISO(this.minDate); + const maxD = this._parseISO(this.maxDate); + const minY = minD ? minD.getFullYear() : 1; + const maxY = maxD ? maxD.getFullYear() : 9999; - // 1. Validate year + // 1. Year if (isNaN(year) || year < minY || year > maxY) { const msg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_YEAR_OUT_OF_RANGE, String(minY), String(maxY)); if (bEndDate) { @@ -327,42 +323,36 @@ class DateHighZoomInputs extends UI5Element { this._yearValueStateMessage = ""; } - // 2. Validate month bounds when min/max apply to the same year - if (this.minDate && year === minY) { - const minM = this.minDate.getMonth(); - if (month < minM) { - const msg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_MONTH_OUT_OF_RANGE); - if (bEndDate) { - this._endMonthValueState = ValueState.Negative; - this._endMonthValueStateMessage = msg; - this._endDayValueState = ValueState.None; - this._endDayValueStateMessage = ""; - } else { - this._monthValueState = ValueState.Negative; - this._monthValueStateMessage = msg; - this._dayValueState = ValueState.None; - this._dayValueStateMessage = ""; - } - return false; + // 2. Month + if (minD && year === minY && month < minD.getMonth()) { + const msg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_MONTH_OUT_OF_RANGE); + if (bEndDate) { + this._endMonthValueState = ValueState.Negative; + this._endMonthValueStateMessage = msg; + this._endDayValueState = ValueState.None; + this._endDayValueStateMessage = ""; + } else { + this._monthValueState = ValueState.Negative; + this._monthValueStateMessage = msg; + this._dayValueState = ValueState.None; + this._dayValueStateMessage = ""; } + return false; } - if (this.maxDate && year === maxY) { - const maxM = this.maxDate.getMonth(); - if (month > maxM) { - const msg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_MONTH_OUT_OF_RANGE); - if (bEndDate) { - this._endMonthValueState = ValueState.Negative; - this._endMonthValueStateMessage = msg; - this._endDayValueState = ValueState.None; - this._endDayValueStateMessage = ""; - } else { - this._monthValueState = ValueState.Negative; - this._monthValueStateMessage = msg; - this._dayValueState = ValueState.None; - this._dayValueStateMessage = ""; - } - return false; + if (maxD && year === maxY && month > maxD.getMonth()) { + const msg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_MONTH_OUT_OF_RANGE); + if (bEndDate) { + this._endMonthValueState = ValueState.Negative; + this._endMonthValueStateMessage = msg; + this._endDayValueState = ValueState.None; + this._endDayValueStateMessage = ""; + } else { + this._monthValueState = ValueState.Negative; + this._monthValueStateMessage = msg; + this._dayValueState = ValueState.None; + this._dayValueStateMessage = ""; } + return false; } if (bEndDate) { @@ -373,18 +363,14 @@ class DateHighZoomInputs extends UI5Element { this._monthValueStateMessage = ""; } - // 3. Validate day bounds when min/max apply to same year+month + // 3. Day const daysInMonth = this._getDaysInMonth(year, month); - let minD = 1; - let maxD = daysInMonth; - if (this.minDate && year === minY && month === this.minDate.getMonth()) { - minD = this.minDate.getDate(); - } - if (this.maxDate && year === maxY && month === this.maxDate.getMonth()) { - maxD = this.maxDate.getDate(); - } + let minDay = 1; + let maxDay = daysInMonth; + if (minD && year === minY && month === minD.getMonth()) { minDay = minD.getDate(); } + if (maxD && year === maxY && month === maxD.getMonth()) { maxDay = maxD.getDate(); } - if (day < minD || day > maxD) { + if (day < minDay || day > maxDay) { const msg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_DAY_OUT_OF_RANGE); if (bEndDate) { this._endDayValueState = ValueState.Negative; @@ -411,11 +397,7 @@ class DateHighZoomInputs extends UI5Element { _onYearInput(e: CustomEvent, isEnd: boolean) { const input = e.target as HTMLElement & { value: string }; - const val = input.value; - // Only update the picker timestamp (plain var, no re-render) so the - // dialog navigates to the typed year when opened. Do NOT update - // _yearValue here — that would trigger a re-render which resets the input. - const y = parseInt(val); + const y = parseInt(input.value); if (!isNaN(y) && y > 0 && y < 10000) { if (isEnd) { this._endYearPickerTimestamp = Date.UTC(y, 0, 1, 12, 0, 0) / 1000; @@ -426,7 +408,6 @@ class DateHighZoomInputs extends UI5Element { } _onYearChange(e: CustomEvent, isEnd: boolean) { - // Fires on blur / Enter — commit the value, recompute day count, validate const input = e.target as HTMLElement & { value: string }; const val = input.value; if (isEnd) { @@ -499,28 +480,30 @@ class DateHighZoomInputs extends UI5Element { _onYearPickerSelectionChange(e: CustomEvent, isEnd: boolean) { const ts = e.detail.timestamp; if (ts === undefined) { return; } - const d = new Date(ts * 1000); - const year = d.getUTCFullYear(); - const newTs = Date.UTC(year, 0, 1, 12, 0, 0) / 1000; - // Store as pending — confirm immediately (no separate OK needed in year picker) + const year = new Date(ts * 1000).getUTCFullYear(); if (isEnd) { this._endPendingYear = year; - this._endYearPickerTimestamp = newTs; + this._endYearPickerTimestamp = Date.UTC(year, 0, 1, 12, 0, 0) / 1000; } else { this._pendingYear = year; - this._yearPickerTimestamp = newTs; + this._yearPickerTimestamp = Date.UTC(year, 0, 1, 12, 0, 0) / 1000; } - // Auto-confirm on selection this._confirmYearPicker(isEnd); } _confirmYearPicker(isEnd: boolean) { const year = isEnd ? this._endPendingYear : this._pendingYear; if (year === null) { - // No new selection — just close this._closeYearPicker(isEnd); return; } + + const minD = this._parseISO(this.minDate); + const maxD = this._parseISO(this.maxDate); + const minY = minD ? minD.getFullYear() : 1; + const maxY = maxD ? maxD.getFullYear() : 9999; + const isValid = year >= minY && year <= maxY; + const newTs = Date.UTC(year, 0, 1, 12, 0, 0) / 1000; if (isEnd) { this._endYearValue = String(year); @@ -539,6 +522,18 @@ class DateHighZoomInputs extends UI5Element { this._dayValue = 1; } } + + if (!isValid) { + this._doValidate(isEnd); + } else { + if (isEnd) { + this._endYearValueState = ValueState.None; + this._endYearValueStateMessage = ""; + } else { + this._yearValueState = ValueState.None; + this._yearValueStateMessage = ""; + } + } this.fireDecoratorEvent("change", { field: DateHighZoomInputsField.Year, isEndDate: isEnd }); } diff --git a/packages/main/src/DateHighZoomInputsTemplate.tsx b/packages/main/src/DateHighZoomInputsTemplate.tsx index b156818953248..017b4c0c8f067 100644 --- a/packages/main/src/DateHighZoomInputsTemplate.tsx +++ b/packages/main/src/DateHighZoomInputsTemplate.tsx @@ -143,8 +143,11 @@ function yearPickerDialog(this: DateHighZoomInputs, isEnd: boolean) { id={ypId} class="ui5-dhzi-yp" primaryCalendarType={this.primaryCalendarType} + valueFormat="yyyy-MM-dd" timestamp={selectedTs} selectedDates={[selectedTs]} + minDate={this.minDate} + maxDate={this.maxDate} _showHeader={true} _rowSize={2} _pageSize={8} diff --git a/packages/main/src/DatePicker.ts b/packages/main/src/DatePicker.ts index de046e8cba3a8..72f5d2558ae59 100644 --- a/packages/main/src/DatePicker.ts +++ b/packages/main/src/DatePicker.ts @@ -473,17 +473,6 @@ class DatePicker extends DateComponentBase implements IFormInputElement { onResponsivePopoverBeforeOpen() { if (this._highZoom) { - // Sync HZ inputs after render (element may not exist yet on first open) - requestAnimationFrame(() => { - if (this._hzInputs) { - const d = this.value ? this.getValueFormat().parse(this.value, true) as Date | null : null; - this._hzInputs.dateValue = d; - this._hzInputs.minDate = this._minDate.toLocalJSDate(); - this._hzInputs.maxDate = this._maxDate.toLocalJSDate(); - this._hzInputs.primaryCalendarType = this._primaryCalendarType; - this._hzInputs.syncStartDate(); - } - }); return; } this._calendar.timestamp = this._calendarTimestamp; diff --git a/packages/main/src/DatePickerPopoverTemplate.tsx b/packages/main/src/DatePickerPopoverTemplate.tsx index dff8d9665fbf6..c5f6a574d2dcd 100644 --- a/packages/main/src/DatePickerPopoverTemplate.tsx +++ b/packages/main/src/DatePickerPopoverTemplate.tsx @@ -3,6 +3,7 @@ import Button from "./Button.js"; import Calendar from "./Calendar.js"; import Icon from "./Icon.js"; import CalendarDate from "./CalendarDate.js"; +import type CalendarDateLocale from "@ui5/webcomponents-localization/dist/dates/CalendarDate.js"; import ResponsivePopover from "./ResponsivePopover.js"; import DateHighZoomInputs from "./DateHighZoomInputs.js"; import { isPhone } from "@ui5/webcomponents-base/dist/Device.js"; @@ -61,12 +62,17 @@ function defaultHeader(this: DatePicker) { function defaultContent(this: DatePicker) { if (this._highZoom) { + const toISO = (cd: CalendarDateLocale) => + `${String(cd.getYear()).padStart(4, "0")}-${String(cd.getMonth() + 1).padStart(2, "0")}-${String(cd.getDate()).padStart(2, "0")}`; + const minISO = this.minDate ? toISO(this._minDate) : ""; + const maxISO = this.maxDate ? toISO(this._maxDate) : ""; return ( ); diff --git a/packages/main/test/pages/HighZoomDateTimeControls.html b/packages/main/test/pages/HighZoomDateTimeControls.html index 5eb5f63fef22f..6ec5b9b256bd7 100644 --- a/packages/main/test/pages/HighZoomDateTimeControls.html +++ b/packages/main/test/pages/HighZoomDateTimeControls.html @@ -92,6 +92,7 @@

DatePicker (Step 04)

From 722a0f9e7bc036be17617a8bfe02e7cb517ebb78 Mon Sep 17 00:00:00 2001 From: Todor Stoyanov Date: Tue, 18 Aug 2026 09:19:03 +0300 Subject: [PATCH 3/6] feat(zoom): DatePicker HZ validation, calendar type toggle, YearPicker improvements - DatePicker: _hzOkEnabled (disabled until valid), _hzActiveCalType toggle, secondary calendar type icon button in header, _onHzFocusIn to open picker - DateHighZoomInputs: minDate/maxDate as ISO strings, _parseISO without TZ issues, _gregYear/Month/Day source-of-truth for calendar type conversion, _applyCalendarTypeToDisplay using Intl.DateTimeFormat, Intl-based month names - YearPicker: _rowSize, _pageSize, _showHeader properties; CalendarHeader CSS included; CSS item width driven by --_ui5_yp_item_width custom property --- packages/main/src/DateHighZoomInputs.ts | 118 ++++++++++++++++-- packages/main/src/DatePicker.ts | 40 +++++- .../main/src/DatePickerPopoverTemplate.tsx | 13 +- .../main/src/themes/DatePickerPopover.css | 9 ++ .../test/pages/HighZoomDateTimeControls.html | 12 +- 5 files changed, 177 insertions(+), 15 deletions(-) diff --git a/packages/main/src/DateHighZoomInputs.ts b/packages/main/src/DateHighZoomInputs.ts index 672460200c6e5..33708274a491d 100644 --- a/packages/main/src/DateHighZoomInputs.ts +++ b/packages/main/src/DateHighZoomInputs.ts @@ -152,6 +152,11 @@ class DateHighZoomInputs extends UI5Element { _pendingYear: number | null = null; _endPendingYear: number | null = null; + // Gregorian source of truth — used to convert display values on calendar type toggle + _gregYear = 0; + _gregMonth = 0; + _gregDay = 1; + // Track last synced dateValue to avoid overwriting user edits on every re-render _syncedDateValue: Date | null = null; _syncedSecondDateValue: Date | null = null; @@ -189,12 +194,27 @@ class DateHighZoomInputs extends UI5Element { // ---- Month / Day options ---- get _monthOptions() { - const months = []; - for (let i = 0; i < 12; i++) { - const d = new Date(2000, i, 1); - months.push({ value: i, text: d.toLocaleString("default", { month: "long" }) }); - } - return months; + // Map UI5 calendar type names to Intl calendar IDs + const calTypeMap: Record = { + Islamic: "islamic-umalqura", + Buddhist: "buddhist", + Japanese: "japanese", + Persian: "persian", + Gregorian: "gregory", + }; + const calType = this.primaryCalendarType || "Gregorian"; + const intlCal = calTypeMap[calType] || "gregory"; + const locale = navigator.language || "en"; + + return Array.from({ length: 12 }, (_, i) => { + // Use a fixed reference year to get month names + const refDate = new Date(2000, i, 1); + const text = new Intl.DateTimeFormat(locale, { + month: "long", + calendar: intlCal, + } as Intl.DateTimeFormatOptions).format(refDate); + return { value: i, text }; + }); } _getDaysInMonth(year: number, month: number) { @@ -215,10 +235,79 @@ class DateHighZoomInputs extends UI5Element { syncStartDate() { if (!this.dateValue) { return; } const year = this.dateValue.getFullYear(); - this._yearValue = String(year); + const month = this.dateValue.getMonth(); + const day = this.dateValue.getDate(); + // Store Gregorian source of truth + this._gregYear = year; + this._gregMonth = month; + this._gregDay = day; this._yearPickerTimestamp = Date.UTC(year, 0, 1, 12, 0, 0) / 1000; - this._monthValue = this.dateValue.getMonth(); - this._dayValue = this.dateValue.getDate(); + // Show in current calendar type + this._applyCalendarTypeToDisplay(false); + } + + /** + * Converts Gregorian source (this._gregYear/Month/Day) to display values + * in the current primaryCalendarType using Intl.DateTimeFormat. + */ + _applyCalendarTypeToDisplay(isEnd: boolean) { + const calTypeMap: Record = { + Islamic: "islamic-umalqura", + Buddhist: "buddhist", + Japanese: "japanese", + Persian: "persian", + Gregorian: "gregory", + }; + const calType = this.primaryCalendarType || "Gregorian"; + const intlCal = calTypeMap[calType] || "gregory"; + const isGregorian = intlCal === "gregory"; + + const srcYear = isEnd ? 0 : this._gregYear; // end date not yet supported + const srcMonth = isEnd ? 0 : this._gregMonth; + const srcDay = isEnd ? 1 : this._gregDay; + + if (isGregorian) { + this._yearValue = String(srcYear); + this._monthValue = srcMonth; + this._dayValue = srcDay; + return; + } + + const refDate = new Date(srcYear, srcMonth, srcDay); + refDate.setFullYear(srcYear); + const locale = navigator.language || "en"; + const fmt = new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "numeric", + day: "numeric", + calendar: intlCal, + } as Intl.DateTimeFormatOptions); + + const parts = fmt.formatToParts(refDate); + const get = (type: string) => { + const part = parts.find(p => p.type === type); + return part ? parseInt(part.value) : NaN; + }; + + const newYear = get("year"); + const newMonth = get("month") - 1; // Intl months are 1-based + const newDay = get("day"); + + if (!isNaN(newYear)) { + // Force ui5-input to pick up the new value by clearing first + this._yearValue = ""; + requestAnimationFrame(() => { this._yearValue = String(newYear); }); + } + if (!isNaN(newMonth)) { this._monthValue = newMonth; } + if (!isNaN(newDay)) { this._dayValue = newDay; } + } + + /** + * Re-derive display fields from Gregorian source when calendar type changes. + * Does NOT modify the Gregorian source. + */ + convertToCalendarType() { + this._applyCalendarTypeToDisplay(false); } syncEndDate(date: Date | null) { @@ -277,10 +366,15 @@ class DateHighZoomInputs extends UI5Element { // ---- Internal validation ---- - _parseISO(iso: string): Date | null { + _parseISO(iso: string): { getFullYear(): number; getMonth(): number; getDate(): number } | null { if (!iso) { return null; } - const d = new Date(iso); - return isNaN(d.getTime()) ? null : d; + const parts = iso.split("-"); + if (parts.length < 3) { return null; } + const y = parseInt(parts[0]); + const m = parseInt(parts[1]) - 1; // 0-based + const d = parseInt(parts[2]); + if (isNaN(y) || isNaN(m) || isNaN(d)) { return null; } + return { getFullYear: () => y, getMonth: () => m, getDate: () => d }; } _doValidate(bEndDate: boolean): boolean { diff --git a/packages/main/src/DatePicker.ts b/packages/main/src/DatePicker.ts index 72f5d2558ae59..8495646b26b94 100644 --- a/packages/main/src/DatePicker.ts +++ b/packages/main/src/DatePicker.ts @@ -68,6 +68,7 @@ import type DateTimeInput from "./DateTimeInput.js"; import type { InputAccInfo } from "./Input.js"; import InputType from "./types/InputType.js"; import type DateHighZoomInputs from "./DateHighZoomInputs.js"; +import type CalendarType from "@ui5/webcomponents-base/dist/types/CalendarType.js"; import IconMode from "./types/IconMode.js"; import DatePickerTemplate from "./DatePickerTemplate.js"; @@ -412,6 +413,13 @@ class DatePicker extends DateComponentBase implements IFormInputElement { @query("[ui5-date-high-zoom-inputs]") _hzInputs?: DateHighZoomInputs; + @property({ type: Boolean, noAttribute: true }) + _hzOkEnabled = true; + + /** Active calendar type in high-zoom mode — toggled by the header button */ + @property({ noAttribute: true }) + _hzActiveCalType?: `${CalendarType}`; + @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; @@ -473,6 +481,8 @@ class DatePicker extends DateComponentBase implements IFormInputElement { onResponsivePopoverBeforeOpen() { if (this._highZoom) { + this._hzOkEnabled = true; + this._hzActiveCalType = undefined; // reset to primary on each open return; } this._calendar.timestamp = this._calendarTimestamp; @@ -488,7 +498,9 @@ class DatePicker extends DateComponentBase implements IFormInputElement { } _onHzInputsChange() { - // called by DateHighZoomInputs change event — validation is on OK press + if (this._hzInputs) { + this._hzOkEnabled = this._hzInputs.validate(); + } } onBeforeRendering() { @@ -979,6 +991,32 @@ class DatePicker extends DateComponentBase implements IFormInputElement { return DatePicker.i18nBundle.getText(CALENDAR_FOOTER_OK_BUTTON); } + get _hzEffectiveCalType(): `${CalendarType}` { + return this._hzActiveCalType || this._primaryCalendarType; + } + + get _hzShowCalToggle(): boolean { + return this._highZoom && this.hasSecondaryCalendarType; + } + + get _hzCalToggleLabel(): string { + const current = this._hzEffectiveCalType; + const other = current === this._primaryCalendarType ? this._secondaryCalendarType : this._primaryCalendarType; + return other ?? ""; + } + + _onHzCalToggle() { + const current = this._hzEffectiveCalType; + const next = current === this._primaryCalendarType + ? this._secondaryCalendarType + : this._primaryCalendarType; + this._hzActiveCalType = next; + if (this._hzInputs) { + this._hzInputs.primaryCalendarType = this._hzActiveCalType; + this._hzInputs.convertToCalendarType(); + } + } + _onHzOk() { if (!this._hzInputs) { return; } if (!this._hzInputs.validate()) { return; } diff --git a/packages/main/src/DatePickerPopoverTemplate.tsx b/packages/main/src/DatePickerPopoverTemplate.tsx index c5f6a574d2dcd..c176b0870188f 100644 --- a/packages/main/src/DatePickerPopoverTemplate.tsx +++ b/packages/main/src/DatePickerPopoverTemplate.tsx @@ -12,6 +12,7 @@ import error from "@ui5/webcomponents-icons/dist/error.js"; import alert from "@ui5/webcomponents-icons/dist/alert.js"; import sysEnter2 from "@ui5/webcomponents-icons/dist/sys-enter-2.js"; import information from "@ui5/webcomponents-icons/dist/information.js"; +import appointmentIcon from "@ui5/webcomponents-icons/dist/appointment-2.js"; type TemplateHook = () => void; @@ -56,6 +57,15 @@ function defaultHeader(this: DatePicker) {
{this._headerTitleText}
+ { this._hzShowCalToggle && + + } ); } @@ -69,7 +79,7 @@ function defaultContent(this: DatePicker) { return ( {this.btnOKLabel} diff --git a/packages/main/src/themes/DatePickerPopover.css b/packages/main/src/themes/DatePickerPopover.css index 28b2563722ab2..ec2c8dd3b5f80 100644 --- a/packages/main/src/themes/DatePickerPopover.css +++ b/packages/main/src/themes/DatePickerPopover.css @@ -50,3 +50,12 @@ font-family: var(--_ui5_button_fontFamily); text-align: left; } + +/* Calendar type toggle icon in high-zoom header */ +.ui5-dhzi-cal-toggle-btn { + color: var(--sapButton_Lite_TextColor); + cursor: pointer; + flex-shrink: 0; + margin-inline-start: auto; + padding: 0.25rem; +} diff --git a/packages/main/test/pages/HighZoomDateTimeControls.html b/packages/main/test/pages/HighZoomDateTimeControls.html index 6ec5b9b256bd7..d1a3576a0fd4b 100644 --- a/packages/main/test/pages/HighZoomDateTimeControls.html +++ b/packages/main/test/pages/HighZoomDateTimeControls.html @@ -97,10 +97,20 @@

DatePicker (Step 04)

max-date="31.12.2026"> +
+ + + +
@@ -152,7 +162,7 @@

Calendar standalone (Step 07)

output.textContent = new Date().toLocaleTimeString() + " — " + msg + "\n" + output.textContent; }; - ["tp-std", "dp-std", "dp-min-max", "dp-secondary-cal", + ["tp-std", "dp-std", "dp-min-max", "dp-min-max-narrow", "dp-secondary-cal", "dtp-std", "dtp-datevalue", "drs-std", "cal-std"].forEach(id => { document.getElementById(id)?.addEventListener("change", e => { log(`[${id}] change: value="${e.target.value || ''}"`); From 5a155fb49fbf56724e8bc1e575a7bbc79a282867 Mon Sep 17 00:00:00 2001 From: Todor Stoyanov Date: Tue, 18 Aug 2026 11:46:30 +0300 Subject: [PATCH 4/6] =?UTF-8?q?feat(zoom):=20Step=2003=20=E2=80=94=20TimeP?= =?UTF-8?q?icker=20high-zoom=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TimePicker: showHeader includes _highZoom, _togglePicker syncs _highZoom, _handleInputClick opens picker at zoom, _onHzFocusIn handler, _onZoomChange reopens picker on resize - TimePickerTemplate: icon hidden at zoom, onFocusIn uses _onHzFocusIn - TimePickerPopoverTemplate: TimeSelectionInputs with _showLabels at zoom - TimeSelectionInputs: _showLabels property, Hz label getters, Label import - TimeSelectionInputsTemplate: labeled layout with Hours/Minutes/Seconds labels, separators between inputs - TimeSelectionInputs.css: --labeled flex-wrap layout, separator centering - i18n: TIMEPICKER_HZ_HOURS/MINUTES/SECONDS keys --- packages/main/src/TimePicker.ts | 25 ++++++- .../main/src/TimePickerPopoverTemplate.tsx | 24 ++++-- packages/main/src/TimePickerTemplate.tsx | 6 +- packages/main/src/TimeSelectionInputs.ts | 20 +++++ .../main/src/TimeSelectionInputsTemplate.tsx | 75 ++++++++++++------- .../main/src/i18n/messagebundle.properties | 9 +++ .../main/src/i18n/messagebundle_en.properties | 6 ++ .../main/src/themes/TimeSelectionInputs.css | 26 +++++++ 8 files changed, 151 insertions(+), 40 deletions(-) diff --git a/packages/main/src/TimePicker.ts b/packages/main/src/TimePicker.ts index 84c5f8ea8f6c8..feca9dd0b14d0 100644 --- a/packages/main/src/TimePicker.ts +++ b/packages/main/src/TimePicker.ts @@ -463,7 +463,22 @@ class TimePicker extends UI5Element implements IFormInputElement { } // noop — override in later steps - _onZoomChange(_bHighZoom: boolean): void {} + _onZoomChange(bHighZoom: boolean): void { + if (this.open) { + this.open = false; + if (bHighZoom !== this._highZoom) { + this._highZoom = bHighZoom; + } + this.open = true; + } + } + + _onHzFocusIn(e: FocusEvent) { + (e.target as HTMLElement).blur(); + if (!this.open) { + this._togglePicker(); + } + } onBeforeRendering() { if (this.value) { @@ -596,6 +611,7 @@ class TimePicker extends UI5Element implements IFormInputElement { } _togglePicker() { + this._highZoom = this._isHighZoom(); this.open = !this.open; if (this._isMobileDevice) { this._inputsPopover.open = false; @@ -683,6 +699,11 @@ class TimePicker extends UI5Element implements IFormInputElement { return; } + if (this._highZoom) { + this._togglePicker(); + return; + } + if (this._isMobileDevice && target && !target.hasAttribute("ui5-icon")) { this.toggleInputsPopover(); } @@ -1079,7 +1100,7 @@ class TimePicker extends UI5Element implements IFormInputElement { } get showHeader() { - return isPhone(); + return isPhone() || this._highZoom; } /** diff --git a/packages/main/src/TimePickerPopoverTemplate.tsx b/packages/main/src/TimePickerPopoverTemplate.tsx index 78b0ab6662d0e..22cd13eea08bf 100644 --- a/packages/main/src/TimePickerPopoverTemplate.tsx +++ b/packages/main/src/TimePickerPopoverTemplate.tsx @@ -35,13 +35,23 @@ export default function TimePickerPopoverTemplate(this: TimePicker) { { this.shouldDisplayValueStateMessageInResponsivePopover && valueStateTextHeader.call(this) } - + { this._highZoom + ? + : + }