diff --git a/packages/main/src/Calendar.ts b/packages/main/src/Calendar.ts index 06cd361ae13e0..be7f6ec7bc636 100644 --- a/packages/main/src/Calendar.ts +++ b/packages/main/src/Calendar.ts @@ -387,7 +387,12 @@ class Calendar extends CalendarPart { this._handleResizeBound = this._handleResize.bind(this); } + override get _shouldWatchZoom(): boolean { + return true; + } + onEnterDOM() { + super.onEnterDOM(); ResizeHandler.register(document.body, this._handleResizeBound); this._handleResize(); } @@ -421,6 +426,7 @@ class Calendar extends CalendarPart { } onExitDOM() { + super.onExitDOM(); ResizeHandler.deregister(document.body, this._handleResizeBound); } @@ -948,6 +954,21 @@ class Calendar extends CalendarPart { this._selectedItemType = "None"; } + get _hzDatePickerValue(): string { + const ts = this._selectedDatesTimestamps[0]; + if (!ts) { return ""; } + return this.getFormat().format(UI5Date.getInstance(ts * 1000), true); + } + + _onHzDatePickerChange(e: CustomEvent<{ value: string, valid: boolean }>) { + if (!e.detail.valid) { return; } + const parsed = this.getFormat().parse(e.detail.value, true) as Date | null; + if (!parsed) { return; } + const timestamp = CalendarDateComponent.fromLocalJSDate(parsed).valueOf() / 1000; + this.timestamp = timestamp; + this._fireEventAndUpdateSelectedDates([timestamp]); + } + get _specialDates() { return this.getSlottedNodes("specialDates"); } 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/CalendarTemplate.tsx b/packages/main/src/CalendarTemplate.tsx index 8ec303aecc7c4..c20b11b02c2e8 100644 --- a/packages/main/src/CalendarTemplate.tsx +++ b/packages/main/src/CalendarTemplate.tsx @@ -5,8 +5,23 @@ import YearPicker from "./YearPicker.js"; import YearRangePicker from "./YearRangePicker.js"; import CalendarHeaderTemplate from "./CalendarHeaderTemplate.js"; import CalendarSelectionMode from "./types/CalendarSelectionMode.js"; +import DatePicker from "./DatePicker.js"; export default function CalendarTemplate(this: Calendar) { + if (this._highZoom && this.selectionMode === "Single") { + return ( + + ); + } + const showMultipleMonths = this._monthsToShow > 1 && !this._isDayPickerHidden; const shouldRenderSeparateHeaders = this._isDefaultHeaderModeInMultipleMonths && !this._portraitView; const shouldRenderInlineHeaders = this._isDefaultHeaderModeInMultipleMonths && this._portraitView; diff --git a/packages/main/src/DateComponentBase.ts b/packages/main/src/DateComponentBase.ts index 91be57d5c0a45..9473eacda8c41 100644 --- a/packages/main/src/DateComponentBase.ts +++ b/packages/main/src/DateComponentBase.ts @@ -13,6 +13,8 @@ import CalendarDate from "@ui5/webcomponents-localization/dist/dates/CalendarDat import { getMaxCalendarDate, getMinCalendarDate } from "@ui5/webcomponents-localization/dist/dates/ExtremeDates.js"; import UI5Date from "@ui5/webcomponents-localization/dist/dates/UI5Date.js"; import type CalendarWeekNumbering from "./types/CalendarWeekNumbering.js"; +import { isHighZoom, startHighZoomWatch } from "./util/HighZoomWatch.js"; +import type { HighZoomWatcher } from "./util/HighZoomWatch.js"; /** * @class @@ -124,10 +126,63 @@ 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; + + _zoomWatcher?: HighZoomWatcher; + constructor() { super(); } + /** + * Whether this component reacts to high-zoom (switches its UI at ≤320px). Only the + * top-level pickers and the standalone Calendar do; the internal sub-pickers + * (day/month/year) inherit this base but never consume _highZoom, so they opt out + * to avoid attaching redundant resize listeners. + */ + get _shouldWatchZoom(): boolean { + return false; + } + + onEnterDOM() { + if (!this._shouldWatchZoom) { return; } + this._highZoom = isHighZoom(); + this._startZoomWatch(); + } + + onExitDOM() { + this._stopZoomWatch(); + } + + _isHighZoom(): boolean { + return isHighZoom(); + } + + _startZoomWatch() { + this._stopZoomWatch(); + this._zoomWatcher = startHighZoomWatch( + () => this._highZoom, + bHighZoom => { + // _highZoom is a reactive @property — changing it re-renders the + // component and swaps the picker content / input icon accordingly. + this._highZoom = bHighZoom; + }, + () => this.isConnected, + ); + } + + _stopZoomWatch() { + if (this._zoomWatcher) { + this._zoomWatcher.stop(); + this._zoomWatcher = undefined; + } + } + 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..a29d16a7c9623 --- /dev/null +++ b/packages/main/src/DateHighZoomInputs.ts @@ -0,0 +1,647 @@ +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 type CalendarType from "@ui5/webcomponents-base/dist/types/CalendarType.js"; +import getLocale from "@ui5/webcomponents-base/dist/locale/getLocale.js"; +import getCachedLocaleDataInstance from "@ui5/webcomponents-localization/dist/getCachedLocaleDataInstance.js"; +import CalendarDate from "@ui5/webcomponents-localization/dist/dates/CalendarDate.js"; +import "@ui5/webcomponents-localization/dist/features/calendar/Gregorian.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. + * 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, +}) +@event("change") +class DateHighZoomInputs extends UI5Element { + eventDetails!: { + change: DateHighZoomInputsChangeEventDetail; + }; + + // ---- Props from parent picker ---- + + /** Current selected start date — parent sets this, component syncs display fields */ + @property({ type: Object, noAttribute: true }) + dateValue: Date | null = null; + + /** Current selected end date (Range mode only) */ + @property({ type: Object, noAttribute: true }) + secondDateValue: Date | null = null; + + /** Minimum selectable date as ISO string (yyyy-MM-dd) — already parsed by parent */ + @property({ noAttribute: true }) + minDate = ""; + + /** Maximum selectable date as ISO string (yyyy-MM-dd) — already parsed by parent */ + @property({ noAttribute: true }) + maxDate = ""; + + /** Primary calendar type forwarded from parent */ + @property({ noAttribute: true }) + primaryCalendarType?: `${CalendarType}`; + + /** Single or Range mode */ + @property() + mode: `${DateHighZoomInputsMode}` = DateHighZoomInputsMode.Single; + + // ---- Start date display state ---- + + @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 display state (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 ---- + + @property({ type: Boolean, noAttribute: true }) + _yearPickerOpen = false; + + @property({ type: Boolean, noAttribute: true }) + _endYearPickerOpen = false; + + // Plain instance vars — not @property to avoid re-render resetting the input + _yearPickerTimestamp = 0; + _endYearPickerTimestamp = 0; + _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; + _gregEndYear = 0; + _gregEndMonth = 0; + _gregEndDay = 1; + _hasEndValue = false; + + // Track last synced dateValue to avoid overwriting user edits on every re-render + _syncedDateValue: Date | null = null; + _syncedSecondDateValue: Date | null = null; + _syncedCalType?: `${CalendarType}`; + + @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); + } + // When the calendar type changes (e.g. parent toggled primary/secondary), re-derive + // the display values from the unchanged Gregorian source of truth. + if (this.primaryCalendarType !== this._syncedCalType) { + this._syncedCalType = this.primaryCalendarType; + this.convertToCalendarType(); + } + } + + // ---- 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 / Day options ---- + + get _calType(): `${CalendarType}` { + return this.primaryCalendarType || "Gregorian"; + } + + get _monthOptions() { + const localeData = getCachedLocaleDataInstance(getLocale()); + const monthsNames = localeData.getMonthsStandAlone("wide", this._calType); + return monthsNames.map((text, i) => ({ value: i, text })); + } + + /** + * Number of days in the given month of the given year, in the current calendar type. + * Uses CalendarDate so non-Gregorian calendars (e.g. Islamic 29/30-day months) are correct. + */ + _getDaysInMonth(year: number, month: number) { + if (Number.isNaN(year)) { + // Fallback to the current year expressed in the active calendar type, so the + // day count matches the (possibly non-Gregorian) year the caller is working in. + year = CalendarDate.fromLocalJSDate(new Date(), this._calType).getYear(); + } + // Day 0 of the next month is the last day of this month, in the target calendar. + const lastDay = new CalendarDate(year, month, 1, this._calType); + lastDay.setMonth(month + 1, 0); + return lastDay.getDate(); + } + + get _dayOptions() { + return Array.from({ length: this._getDaysInMonth(parseInt(this._yearValue), this._monthValue) }, (_, i) => i + 1); + } + + get _endDayOptions() { + return Array.from({ length: this._getDaysInMonth(parseInt(this._endYearValue), this._endMonthValue) }, (_, i) => i + 1); + } + + // ---- Public API ---- + + syncStartDate() { + if (!this.dateValue) { return; } + // Store Gregorian source of truth + this._gregYear = this.dateValue.getFullYear(); + this._gregMonth = this.dateValue.getMonth(); + this._gregDay = this.dateValue.getDate(); + // Show in current calendar type + this._applyCalendarTypeToDisplay(false); + } + + /** + * Converts the Gregorian source (this._gregYear/Month/Day, or the end equivalents) + * to display values in the current primaryCalendarType, using UI5 CalendarDate so + * non-Gregorian calendars (Islamic, Buddhist, …) are handled correctly. + */ + _applyCalendarTypeToDisplay(isEnd: boolean) { + const srcYear = isEnd ? this._gregEndYear : this._gregYear; + const srcMonth = isEnd ? this._gregEndMonth : this._gregMonth; + const srcDay = isEnd ? this._gregEndDay : this._gregDay; + + // Build the Gregorian source date with setFullYear so years 1-99 are not + // remapped to 1901-1999 by the Date constructor. + const srcDate = new Date(2000, 0, 1); + srcDate.setFullYear(srcYear, srcMonth, srcDay); + + // Gregorian source → target calendar type + const calDate = CalendarDate.fromLocalJSDate(srcDate, this._calType); + const dispYear = calDate.getYear(); + const dispMonth = calDate.getMonth(); + const dispDay = calDate.getDate(); + const pickerTs = this._yearToTimestamp(srcYear); + + if (isEnd) { + this._endYearValue = String(dispYear); + this._endMonthValue = dispMonth; + this._endDayValue = dispDay; + this._endYearPickerTimestamp = pickerTs; + } else { + this._yearValue = String(dispYear); + this._monthValue = dispMonth; + this._dayValue = dispDay; + this._yearPickerTimestamp = pickerTs; + } + } + + /** + * Re-derive display fields from the Gregorian source when the calendar type changes. + * Does NOT modify the Gregorian source. + */ + convertToCalendarType() { + this._applyCalendarTypeToDisplay(false); + if (this._isRange && this._hasEndValue) { + this._applyCalendarTypeToDisplay(true); + } + } + + syncEndDate(date: Date | null) { + if (!date) { this._hasEndValue = false; return; } + this._hasEndValue = true; + // Store Gregorian source of truth for the end date + this._gregEndYear = date.getFullYear(); + this._gregEndMonth = date.getMonth(); + this._gregEndDay = date.getDate(); + this._applyCalendarTypeToDisplay(true); + } + + /** + * Converts the current display values (in this._calType) back to a Gregorian + * {year, month, day}. Returns null if the display values are not a valid date. + */ + _displayToGregorian(isEnd: boolean): { year: number; month: number; day: number } | null { + const yearStr = isEnd ? this._endYearValue : this._yearValue; + const month = isEnd ? this._endMonthValue : this._monthValue; + const day = isEnd ? this._endDayValue : this._dayValue; + const year = parseInt(yearStr); + if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) { return null; } + const jsDate = new CalendarDate(year, month, day, this._calType).toLocalJSDate(); + return { year: jsDate.getFullYear(), month: jsDate.getMonth(), day: jsDate.getDate() }; + } + + getSelectedDate(): { year: number; month: number; day: number } { + const greg = this._displayToGregorian(false); + return greg || { year: NaN, month: NaN, day: NaN }; + } + + getSelectedSecondDate(): { year: number; month: number; day: number } | null { + if (!this._isRange) { return null; } + return this._displayToGregorian(true); + } + + getDateObject(): Date | null { + const { year, month, day } = this.getSelectedDate(); + if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) { return null; } + const d = new Date(year, month, day); + d.setFullYear(year); + 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 ---- + + _parseISO(iso: string): { getFullYear(): number; getMonth(): number; getDate(): number } | null { + if (!iso) { return null; } + 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 (Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) { return null; } + return { getFullYear: () => y, getMonth: () => m, getDate: () => d }; + } + + _doValidate(bEndDate: boolean): boolean { + // Flag one field as invalid (or clear all when field is null). + const applyState = (field: "year" | "month" | "day" | null, msg: string) => { + if (bEndDate) { + this._endYearValueState = field === "year" ? ValueState.Negative : ValueState.None; + this._endYearValueStateMessage = field === "year" ? msg : ""; + this._endMonthValueState = field === "month" ? ValueState.Negative : ValueState.None; + this._endMonthValueStateMessage = field === "month" ? msg : ""; + this._endDayValueState = field === "day" ? ValueState.Negative : ValueState.None; + this._endDayValueStateMessage = field === "day" ? msg : ""; + } else { + this._yearValueState = field === "year" ? ValueState.Negative : ValueState.None; + this._yearValueStateMessage = field === "year" ? msg : ""; + this._monthValueState = field === "month" ? ValueState.Negative : ValueState.None; + this._monthValueStateMessage = field === "month" ? msg : ""; + this._dayValueState = field === "day" ? ValueState.Negative : ValueState.None; + this._dayValueStateMessage = field === "day" ? msg : ""; + } + }; + + const yearStr = bEndDate ? this._endYearValue : this._yearValue; + const displayYear = parseInt(yearStr); + + 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 yearMsg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_YEAR_OUT_OF_RANGE, String(minY), String(maxY)); + const monthMsg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_MONTH_OUT_OF_RANGE); + const dayMsg = DateHighZoomInputs.i18nBundle.getText(DATEPICKER_HZ_DAY_OUT_OF_RANGE); + + // Convert current display values to a Gregorian date for range comparison. + const greg = this._displayToGregorian(bEndDate); + if (Number.isNaN(displayYear) || !greg) { + applyState("year", yearMsg); + return false; + } + + // 1. Year bound (compare in Gregorian) + if (greg.year < minY || greg.year > maxY) { + applyState("year", yearMsg); + return false; + } + + const selected = new CalendarDate(greg.year, greg.month, greg.day); + + // 2. Full-date lower bound + if (minD) { + const minDate = new CalendarDate(minD.getFullYear(), minD.getMonth(), minD.getDate()); + if (selected.isBefore(minDate)) { + applyState(greg.month < minD.getMonth() ? "month" : "day", greg.month < minD.getMonth() ? monthMsg : dayMsg); + return false; + } + } + + // 3. Full-date upper bound + if (maxD) { + const maxDate = new CalendarDate(maxD.getFullYear(), maxD.getMonth(), maxD.getDate()); + if (selected.isAfter(maxDate)) { + applyState(greg.month > maxD.getMonth() ? "month" : "day", greg.month > maxD.getMonth() ? monthMsg : dayMsg); + return false; + } + } + + // All good — clear all states. + applyState(null, ""); + return true; + } + + // ---- Event handlers ---- + + /** + * After a user edits year/month/day, re-derive the Gregorian source of truth from + * the current display values so calendar-type toggles don't revert the edit. + */ + _syncGregorianFromDisplay(isEnd: boolean) { + const greg = this._displayToGregorian(isEnd); + if (!greg) { return; } + if (isEnd) { + this._gregEndYear = greg.year; + this._gregEndMonth = greg.month; + this._gregEndDay = greg.day; + this._hasEndValue = true; + } else { + this._gregYear = greg.year; + this._gregMonth = greg.month; + this._gregDay = greg.day; + } + } + + /** Builds a picker timestamp (seconds) for Jan 1 of the given year, safe for years < 100. */ + _yearToTimestamp(year: number): number { + const d = new Date(2000, 0, 1, 12, 0, 0); + d.setFullYear(year, 0, 1); + return d.getTime() / 1000; + } + + _onYearInput(e: CustomEvent, isEnd: boolean) { + const input = e.target as HTMLElement & { value: string }; + const y = parseInt(input.value); + if (!Number.isNaN(y) && y > 0 && y < 10000) { + if (isEnd) { + this._endYearPickerTimestamp = this._yearToTimestamp(y); + } else { + this._yearPickerTimestamp = this._yearToTimestamp(y); + } + } + } + + _onYearChange(e: CustomEvent, isEnd: boolean) { + const input = e.target as HTMLElement & { value: string }; + const val = input.value; + if (isEnd) { + this._endYearValue = val; + if (this._endDayValue > this._getDaysInMonth(parseInt(val), this._endMonthValue)) { + this._endDayValue = 1; + } + } else { + this._yearValue = val; + if (this._dayValue > this._getDaysInMonth(parseInt(val), this._monthValue)) { + this._dayValue = 1; + } + } + this._syncGregorianFromDisplay(isEnd); + 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._syncGregorianFromDisplay(isEnd); + this._doValidate(isEnd); + 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._syncGregorianFromDisplay(isEnd); + this._doValidate(isEnd); + this.fireDecoratorEvent("change", { field: DateHighZoomInputsField.Day, isEndDate: isEnd }); + } + + // ---- Year picker dialog ---- + + /** + * The timestamp (seconds) the year picker should open on: the stored picker timestamp, + * or one derived from the current year field (falling back to the current year). + */ + _yearPickerSelectedTimestamp(isEnd: boolean): number { + const ts = isEnd ? this._endYearPickerTimestamp : this._yearPickerTimestamp; + if (ts) { return ts; } + const yearStr = isEnd ? this._endYearValue : this._yearValue; + const year = parseInt(yearStr); + const safeYear = Number.isNaN(year) || year <= 0 ? new Date().getFullYear() : year; + return this._yearToTimestamp(safeYear); + } + + _openYearPicker(isEnd: boolean) { + const ts = this._yearPickerSelectedTimestamp(isEnd); + 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; } + // The YearPicker runs in this._calType, so derive the display year from the timestamp + // in that calendar type (not Gregorian). + const displayYear = CalendarDate.fromTimestamp(ts * 1000, this._calType).getYear(); + if (isEnd) { + this._endPendingYear = displayYear; + this._endYearPickerTimestamp = ts; + } else { + this._pendingYear = displayYear; + this._yearPickerTimestamp = ts; + } + this._confirmYearPicker(isEnd); + } + + _confirmYearPicker(isEnd: boolean) { + const displayYear = isEnd ? this._endPendingYear : this._pendingYear; + if (displayYear === null) { + this._closeYearPicker(isEnd); + return; + } + + if (isEnd) { + this._endYearValue = String(displayYear); + this._endPendingYear = null; + this._endYearPickerOpen = false; + if (this._endDayValue > this._getDaysInMonth(displayYear, this._endMonthValue)) { + this._endDayValue = 1; + } + } else { + this._yearValue = String(displayYear); + this._pendingYear = null; + this._yearPickerOpen = false; + if (this._dayValue > this._getDaysInMonth(displayYear, this._monthValue)) { + this._dayValue = 1; + } + } + + // Update Gregorian source of truth and the picker timestamp from the display values. + this._syncGregorianFromDisplay(isEnd); + const greg = isEnd + ? { year: this._gregEndYear } + : { year: this._gregYear }; + const pickerTs = this._yearToTimestamp(greg.year); + if (isEnd) { + this._endYearPickerTimestamp = pickerTs; + } else { + this._yearPickerTimestamp = pickerTs; + } + + // Re-validate so out-of-range years are flagged (and in-range ones cleared). + this._doValidate(isEnd); + 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..c5522768bb93f --- /dev/null +++ b/packages/main/src/DateHighZoomInputsTemplate.tsx @@ -0,0 +1,155 @@ +import type DateHighZoomInputs from "./DateHighZoomInputs.js"; +import type { YearPickerChangeEventDetail } from "./YearPicker.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 selectedTs = this._yearPickerSelectedTimestamp(isEnd); + 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..aada3cf211f97 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,8 @@ 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 type CalendarType from "@ui5/webcomponents-base/dist/types/CalendarType.js"; import IconMode from "./types/IconMode.js"; import DatePickerTemplate from "./DatePickerTemplate.js"; @@ -407,6 +410,20 @@ class DatePicker extends DateComponentBase implements IFormInputElement { @query("[ui5-calendar]") _calendar!: Calendar; + @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}`; + + override get _shouldWatchZoom(): boolean { + return true; + } + @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; @@ -467,10 +484,29 @@ 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; 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() { + if (this._hzInputs) { + this._hzOkEnabled = this._hzInputs.validate(); + } + } + onBeforeRendering() { ["minDate", "maxDate"].forEach((prop: string) => { const propValue = this[prop as keyof DatePicker] as string; @@ -694,10 +730,11 @@ class DatePicker extends DateComponentBase implements IFormInputElement { } _click(e: MouseEvent) { - if (isPhone()) { - this.responsivePopover!.opener = this; - this.responsivePopover!.open = true; - e.preventDefault(); // prevent immediate selection of any item + if (isPhone() || this._highZoom) { + if (!this.open) { + this.open = true; + } + e.preventDefault(); } } @@ -865,11 +902,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 +992,55 @@ class DatePicker extends DateComponentBase implements IFormInputElement { return DatePicker.i18nBundle.getText(TIMEPICKER_CANCEL_BUTTON); } + get btnOKLabel() { + 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; + // Setting _hzActiveCalType re-renders and passes the new type to DateHighZoomInputs + // via primaryCalendarType={this._hzEffectiveCalType}; the child re-derives its + // display values from its Gregorian source of truth in onBeforeRendering. + this._hzActiveCalType = next; + } + + _onHzOk() { + if (!this._hzInputs) { return; } + if (!this._hzInputs.validate()) { return; } + const d = this._hzInputs.getDateObject(); + if (d) { + const newValue = this.getValueFormat().format(d); + // Route through _updateValueAndFireEvents (like the calendar-selection path) so + // value-state, liveValue sync and preventable change handling stay consistent. + this._updateValueAndFireEvents(newValue, true, ["change", "value-changed"]); + } + this._togglePicker(); + } + + _onHzCancel() { + if (this._hzInputs) { + this._hzInputs.resetValueState(); + } + this._togglePicker(); + } + /** * Defines whether the dialog on mobile should have header * @private @@ -1047,6 +1133,7 @@ class DatePicker extends DateComponentBase implements IFormInputElement { } _togglePicker(): void { + this._highZoom = this._isHighZoom(); this.open = !this.open; } 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 && void; @@ -54,11 +57,37 @@ function defaultHeader(this: DatePicker) {
{this._headerTitleText}
+ { this._hzShowCalToggle && + + }
); } 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 ( + + ); + } + return ( + { this._highZoom && + + } + diff --git a/packages/main/src/DateRangePicker.ts b/packages/main/src/DateRangePicker.ts index 6e7d2fa97b75e..725ab6fdb997f 100644 --- a/packages/main/src/DateRangePicker.ts +++ b/packages/main/src/DateRangePicker.ts @@ -324,6 +324,36 @@ class DateRangePicker extends DatePicker implements IFormInputElement { this._togglePicker(); } + /** + * @override — range mode: validate both dates + */ + override _onHzInputsChange() { + if (this._hzInputs) { + this._hzOkEnabled = this._hzInputs.validate() && this._hzInputs.validateEndDate(); + } + } + + /** + * @override — range mode: build "startDate - endDate" value string + */ + override _onHzOk() { + if (!this._hzInputs) { return; } + if (!this._hzInputs.validate() || !this._hzInputs.validateEndDate()) { return; } + const startD = this._hzInputs.getDateObject(); + const endSel = this._hzInputs.getSelectedSecondDate(); + if (!startD || !endSel) { return; } + const endD = new Date(endSel.year, endSel.month, endSel.day); + endD.setFullYear(endSel.year); + // Format the local Date objects directly (like the base single-date _onHzOk), + // ordering start/end chronologically. Do NOT route through _buildValue, which + // interprets its timestamps as UTC and would shift local dates by a day. + const [firstD, lastD] = startD.getTime() <= endD.getTime() ? [startD, endD] : [endD, startD]; + const format = this.getValueFormat(); + const newValue = `${format.format(firstD)} ${this._effectiveDelimiter} ${format.format(lastD)}`; + this._updateValueAndFireEvents(newValue, true, ["change", "value-changed"]); + this._togglePicker(); + } + /** * @override */ diff --git a/packages/main/src/DateRangePickerTemplate.tsx b/packages/main/src/DateRangePickerTemplate.tsx index 8e7d715cfcdf6..178ae6648b83b 100644 --- a/packages/main/src/DateRangePickerTemplate.tsx +++ b/packages/main/src/DateRangePickerTemplate.tsx @@ -1,19 +1,42 @@ import Calendar from "./Calendar.js"; import CalendarDateRange from "./CalendarDateRange.js"; import type DateRangePicker from "./DateRangePicker.js"; +import type CalendarDateLocale from "@ui5/webcomponents-localization/dist/dates/CalendarDate.js"; import DatePickerInputTemplate from "./DatePickerInputTemplate.js"; import DatePickerPopoverTemplate from "./DatePickerPopoverTemplate.js"; +import DateHighZoomInputs from "./DateHighZoomInputs.js"; import Button from "./Button.js"; export default function DateRangePickerTemplate(this: DateRangePicker) { return [ DatePickerInputTemplate.call(this), - DatePickerPopoverTemplate.call(this, { content, initialFocus: this.initialFocusId, footer: this._isPhone ? footer : undefined }), + DatePickerPopoverTemplate.call(this, { content, initialFocus: this.initialFocusId, footer: this._isPhone || this._highZoom ? footer : undefined }), ]; } function content(this: DateRangePicker) { + 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) : ""; + const startDate = this._startDateTimestamp ? this.startDateValue : null; + const endDate = this._endDateTimestamp ? this.endDateValue : null; + return ( + + ); + } + return ( + + + + ); + } + return (
- - {this._calendarSelectedDates.map(date => - - )} - - - { !this._phoneView && } - - { this.showTimeView && - + : + selectionMode={this._calendarSelectionMode} + minDate={this.minDate} + maxDate={this.maxDate} + calendarWeekNumbering={this.calendarWeekNumbering} + onSelectionChange={this.onSelectedDatesChange} + onShowMonthView={this.onHeaderShowMonthPress} + onShowYearView={this.onHeaderShowYearPress} + hideWeekNumbers={this.hideWeekNumbers} + _currentPicker={this._calendarCurrentPicker} + > + {this._calendarSelectedDates.map(date => + + )} + + } + + { !this._phoneView && } + + { this.showTimeView && + (this._highZoom + ? + : + ) }
@@ -79,6 +110,30 @@ function content(this: DateTimePicker) { } function footer(this: DateTimePicker) { + if (this._highZoom) { + return ( + + ); + } + return (
, string>; @@ -373,6 +375,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; + + _zoomWatcher?: HighZoomWatcher; + /** * 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 +424,46 @@ class TimePicker extends UI5Element implements IFormInputElement { return this.value || ""; } + onEnterDOM() { + this._highZoom = isHighZoom(); + this._startZoomWatch(); + } + + onExitDOM() { + this._stopZoomWatch(); + } + + _isHighZoom(): boolean { + return isHighZoom(); + } + + _startZoomWatch() { + this._stopZoomWatch(); + this._zoomWatcher = startHighZoomWatch( + () => this._highZoom, + bHighZoom => { + // _highZoom is a reactive @property — changing it re-renders the + // component and swaps the picker content / input icon accordingly. + this._highZoom = bHighZoom; + }, + () => this.isConnected, + ); + } + + _stopZoomWatch() { + if (this._zoomWatcher) { + this._zoomWatcher.stop(); + this._zoomWatcher = undefined; + } + } + + _onHzFocusIn(e: FocusEvent) { + (e.target as HTMLElement).blur(); + if (!this.open) { + this._togglePicker(); + } + } + onBeforeRendering() { if (this.value) { this.value = this.normalizeValue(this.value) || this.value; @@ -544,6 +595,7 @@ class TimePicker extends UI5Element implements IFormInputElement { } _togglePicker() { + this._highZoom = this._isHighZoom(); this.open = !this.open; if (this._isMobileDevice) { this._inputsPopover.open = false; @@ -631,6 +683,11 @@ class TimePicker extends UI5Element implements IFormInputElement { return; } + if (this._highZoom) { + this._togglePicker(); + return; + } + if (this._isMobileDevice && target && !target.hasAttribute("ui5-icon")) { this.toggleInputsPopover(); } @@ -1027,7 +1084,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 + ? + : + } + ); } 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..75d1a2cb583f6 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 @@ -612,6 +628,15 @@ TIMEPICKER_INPUTS_ENTER_MINUTES=Please enter minutes #XACT: Time Picker Inputs tooltip/aria-label for Seconds input TIMEPICKER_INPUTS_ENTER_SECONDS=Please enter seconds +#XLBL: Short label for Hours field in high-zoom time picker +TIMEPICKER_HZ_HOURS=Hours: + +#XLBL: Short label for Minutes field in high-zoom time picker +TIMEPICKER_HZ_MINUTES=Minutes: + +#XLBL: Short label for Seconds field in high-zoom time picker +TIMEPICKER_HZ_SECONDS=Seconds: + #XACT: Time Picker 'Open Picker' icon title TIMEPICKER_OPEN_ICON_TITLE=Open Picker diff --git a/packages/main/src/i18n/messagebundle_en.properties b/packages/main/src/i18n/messagebundle_en.properties index 68c2a90966f20..9f6f0992aaf30 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}. @@ -412,6 +428,12 @@ TIMEPICKER_INPUTS_ENTER_MINUTES=Please enter minutes TIMEPICKER_INPUTS_ENTER_SECONDS=Please enter seconds +TIMEPICKER_HZ_HOURS=Hours: + +TIMEPICKER_HZ_MINUTES=Minutes: + +TIMEPICKER_HZ_SECONDS=Seconds: + TIMEPICKER_OPEN_ICON_TITLE=Open Picker TIMEPICKER_OPEN_ICON_TITLE_OPENED=Close Picker diff --git a/packages/main/src/themes/DateHighZoomInputs.css b/packages/main/src/themes/DateHighZoomInputs.css new file mode 100644 index 0000000000000..0daff6568eb73 --- /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.5rem; + 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..637bf2ac94343 100644 --- a/packages/main/src/themes/DatePickerPopover.css +++ b/packages/main/src/themes/DatePickerPopover.css @@ -23,11 +23,19 @@ 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; + flex-wrap: wrap; justify-content: flex-end; align-items: center; - height: 2.75rem; + min-height: 2.75rem; width: 100%; } @@ -43,3 +51,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/src/themes/DateTimePickerPopover.css b/packages/main/src/themes/DateTimePickerPopover.css index 71ec4f021aba4..2e94e1c9fb143 100644 --- a/packages/main/src/themes/DateTimePickerPopover.css +++ b/packages/main/src/themes/DateTimePickerPopover.css @@ -57,9 +57,10 @@ .ui5-dt-picker-footer { display: flex; + flex-wrap: wrap; justify-content: flex-end; align-items: center; - height: 2.75rem; + min-height: 2.75rem; width: 100%; } @@ -99,3 +100,20 @@ .ui5-dt-picker-content--phone .ui5-dt-time { min-width: var(--_ui5_datetime_timeview_phonemode_width); } + +/* High-zoom time inputs — same sizing as .ui5-dt-time but without clocks */ +.ui5-dt-hz-time-inputs { + width: 100%; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; +} + +/* High-zoom content — override fixed height/min-width so content fits the narrow screen */ +.ui5-dt-picker-content--hz.ui5-dt-picker-content { + height: auto; + min-width: 0; + width: 100%; + flex-direction: column; +} diff --git a/packages/main/src/themes/TimeSelectionInputs.css b/packages/main/src/themes/TimeSelectionInputs.css index 66f155ef85924..78db00d514543 100644 --- a/packages/main/src/themes/TimeSelectionInputs.css +++ b/packages/main/src/themes/TimeSelectionInputs.css @@ -10,6 +10,33 @@ align-items: center; } +/* High-zoom labeled layout — only adds labels and wrapping, keeps input sizes */ +.ui5-time-selection-inputs--labeled { + min-width: 0; + width: 100%; + flex-wrap: wrap; + justify-content: center; + align-items: flex-end; + box-sizing: border-box; +} + +.ui5-time-selection-input-cell { + display: flex; + flex-direction: column; + align-items: stretch; +} + +/* In labeled mode all cells same width so separators are equidistant */ +.ui5-time-selection-inputs--labeled .ui5-time-selection-input-cell { + width: 2.875rem; + align-items: center; +} + +.ui5-time-selection-input-label { + display: block; + margin-bottom: 0.25rem; +} + .ui5-time-selection-separator { display: inline-block; min-width: 0.5rem; @@ -20,6 +47,20 @@ color: var(--sapTextColor); } +/* In labeled mode align separator to center of input (input height ≈ 2.25rem, label ≈ 1.25rem) */ +.ui5-time-selection-inputs--labeled .ui5-time-selection-separator { + margin-bottom: calc((2.25rem / 2) - 0.5em); +} + +/* AM/PM always on its own row in labeled mode */ +.ui5-time-selection-inputs--labeled .ui5-time-selection-ampm-cell { + flex-basis: 100%; + display: flex; + flex-direction: column; + align-items: center; + margin-top: 0.5rem; +} + .ui5-hidden-text { display: none; -} \ No newline at end of file +} 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/src/util/HighZoomWatch.ts b/packages/main/src/util/HighZoomWatch.ts new file mode 100644 index 0000000000000..341ba7f7c9508 --- /dev/null +++ b/packages/main/src/util/HighZoomWatch.ts @@ -0,0 +1,69 @@ +/** + * Shared high-zoom (≤320px effective viewport) detection used by the date/time pickers. + * + * At ~200% browser zoom on a narrow viewport the calendar/clock grids no longer fit, so the + * pickers switch to a select-based UI. Detection is based on the effective viewport width + * rather than a true zoom API (which browsers do not expose). + * + * TimePicker cannot extend DateComponentBase, so this logic lives here and is shared by both. + */ + +/** Effective viewport width (CSS px) at or below which pickers switch to their high-zoom UI. */ +const HIGH_ZOOM_MAX_VIEWPORT_WIDTH = 320; + +/** + * Returns true when the effective viewport width is ≤ the high-zoom threshold. + * Note: this measures viewport width, which correlates with (but is not exactly) zoom level. + */ +const isHighZoom = (): boolean => { + return ((window.visualViewport?.width) ?? window.innerWidth) <= HIGH_ZOOM_MAX_VIEWPORT_WIDTH; +}; + +type HighZoomWatcher = { + /** Removes the resize listeners and cancels any pending animation frame. */ + stop: () => void; +}; + +/** + * Starts watching for viewport changes that cross the high-zoom threshold. + * `onChange` is called (with the new value) only when the high-zoom state actually flips, + * deferred to the next animation frame so `visualViewport.width` reflects the settled layout. + */ +const startHighZoomWatch = ( + getCurrent: () => boolean, + onChange: (highZoom: boolean) => void, + isConnected: () => boolean, +): HighZoomWatcher => { + let rafId = 0; + + const handler = () => { + if (rafId) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + rafId = 0; + if (!isConnected()) { return; } + const bHighZoom = isHighZoom(); + if (bHighZoom !== getCurrent()) { + onChange(bHighZoom); + } + }); + }; + + window.visualViewport?.addEventListener("resize", handler); + window.addEventListener("resize", handler); + + return { + stop() { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = 0; + } + window.visualViewport?.removeEventListener("resize", handler); + window.removeEventListener("resize", handler); + }, + }; +}; + +export { HIGH_ZOOM_MAX_VIEWPORT_WIDTH, isHighZoom, startHighZoomWatch }; +export type { HighZoomWatcher }; diff --git a/packages/main/test/pages/HighZoomDateTimeControls.html b/packages/main/test/pages/HighZoomDateTimeControls.html new file mode 100644 index 0000000000000..2498322725bf0 --- /dev/null +++ b/packages/main/test/pages/HighZoomDateTimeControls.html @@ -0,0 +1,224 @@ + + + + + + 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)

+
+ +
+
+ + + + +