diff --git a/core/api.txt b/core/api.txt
index 8147e65814d..dc574aafa63 100644
--- a/core/api.txt
+++ b/core/api.txt
@@ -693,6 +693,7 @@ ion-datetime,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "se
ion-datetime,prop,dayValues,number | number[] | string | undefined,undefined,false,false
ion-datetime,prop,disabled,boolean,false,false,false
ion-datetime,prop,doneText,string,'Done',false,false
+ion-datetime,prop,endDateLabel,string,'End date',false,false
ion-datetime,prop,firstDayOfWeek,number,0,false,false
ion-datetime,prop,formatOptions,undefined | { date: DateTimeFormatOptions; time?: DateTimeFormatOptions | undefined; } | { date?: DateTimeFormatOptions | undefined; time: DateTimeFormatOptions; },undefined,false,false
ion-datetime,prop,highlightedDates,((dateIsoString: string) => DatetimeHighlightStyle | undefined) | DatetimeHighlight[] | undefined,undefined,false,false
@@ -703,20 +704,22 @@ ion-datetime,prop,locale,string,'default',false,false
ion-datetime,prop,max,string | undefined,undefined,false,false
ion-datetime,prop,min,string | undefined,undefined,false,false
ion-datetime,prop,minuteValues,number | number[] | string | undefined,undefined,false,false
-ion-datetime,prop,mode,"ios" | "md",undefined,false,false
+ion-datetime,prop,monthNavigation,"arrows" | "scroll",'arrows',false,false
ion-datetime,prop,monthValues,number | number[] | string | undefined,undefined,false,false
+ion-datetime,prop,monthYearPickerView,"grid" | "wheel",'wheel',false,false
ion-datetime,prop,multiple,boolean,false,false,false
ion-datetime,prop,name,string,this.inputId,false,false
ion-datetime,prop,preferWheel,boolean,false,false,false
ion-datetime,prop,presentation,"date" | "date-time" | "month" | "month-year" | "time" | "time-date" | "year",'date-time',false,false
ion-datetime,prop,readonly,boolean,false,false,false
+ion-datetime,prop,selectionMode,"multiple" | "range" | "single" | undefined,undefined,false,false
ion-datetime,prop,showAdjacentDays,boolean,false,false,false
ion-datetime,prop,showClearButton,boolean,false,false,false
ion-datetime,prop,showDefaultButtons,boolean,false,false,false
ion-datetime,prop,showDefaultTimeLabel,boolean,true,false,false
ion-datetime,prop,showDefaultTitle,boolean,false,false,false
ion-datetime,prop,size,"cover" | "fixed",'fixed',false,false
-ion-datetime,prop,theme,"ios" | "md" | "ionic",undefined,false,false
+ion-datetime,prop,startDateLabel,string,'Start date',false,false
ion-datetime,prop,titleSelectedDatesFormatter,((selectedDates: string[]) => string) | undefined,undefined,false,false
ion-datetime,prop,value,null | string | string[] | undefined,undefined,false,false
ion-datetime,prop,yearValues,number | number[] | string | undefined,undefined,false,false
@@ -734,6 +737,9 @@ ion-datetime,css-prop,--background-rgb,ios
ion-datetime,css-prop,--background-rgb,md
ion-datetime,css-prop,--focus-ring-color,ionic
ion-datetime,css-prop,--focus-ring-width,ionic
+ion-datetime,css-prop,--range-background,ionic
+ion-datetime,css-prop,--range-background,ios
+ion-datetime,css-prop,--range-background,md
ion-datetime,css-prop,--title-color,ios
ion-datetime,css-prop,--title-color,md
ion-datetime,css-prop,--wheel-fade-background-rgb,ios
@@ -742,10 +748,6 @@ ion-datetime,css-prop,--wheel-highlight-background,ios
ion-datetime,css-prop,--wheel-highlight-background,md
ion-datetime,css-prop,--wheel-highlight-border-radius,ios
ion-datetime,css-prop,--wheel-highlight-border-radius,md
-ion-datetime,part,calendar-day
-ion-datetime,part,calendar-day active
-ion-datetime,part,calendar-day disabled
-ion-datetime,part,calendar-day today
ion-datetime,part,calendar-days-of-week
ion-datetime,part,calendar-header
ion-datetime,part,datetime-header
@@ -755,11 +757,6 @@ ion-datetime,part,month-year-button
ion-datetime,part,navigation-button
ion-datetime,part,next-button
ion-datetime,part,previous-button
-ion-datetime,part,time-button
-ion-datetime,part,time-button active
-ion-datetime,part,wheel
-ion-datetime,part,wheel-item
-ion-datetime,part,wheel-item active
ion-datetime-button,shadow
ion-datetime-button,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record | undefined,'primary',false,true
diff --git a/core/src/components.d.ts b/core/src/components.d.ts
index e6746fc853d..d3830651cd4 100644
--- a/core/src/components.d.ts
+++ b/core/src/components.d.ts
@@ -1161,6 +1161,11 @@ export namespace Components {
* @default 'Done'
*/
"doneText": string;
+ /**
+ * The label for the range end date shown in the header when `showDefaultTitle` is enabled and no end date has been selected yet. Useful for translating or customizing the default placeholder text. Only applies when `selectionMode="range"`.
+ * @default 'End date'
+ */
+ "endDateLabel": string;
/**
* The first day of the week to use for `ion-datetime`. The default value is `0` and represents Sunday.
* @default 0
@@ -1208,15 +1213,22 @@ export namespace Components {
*/
"minuteValues"?: number[] | number | string;
/**
- * The mode determines the platform behaviors of the component.
+ * Controls the month navigation mode when using a grid-style layout. - `"arrows"` (default) preserves the existing prev/next button behaviour. - `"scroll"` swaps the horizontal scroll axis to vertical. The `previous-button` and `next-button` shadow parts remain in the DOM and keyboard-focusable in both modes.
+ * @default 'arrows'
*/
- "mode"?: "ios" | "md";
+ "monthNavigation": 'arrows' | 'scroll';
/**
* Values used to create the list of selectable months. By default the month values range from `1` to `12`. However, to control exactly which months to display, the `monthValues` input can take a number, an array of numbers, or a string of comma separated numbers. For example, if only summer months should be shown, then this input value would be `monthValues="6,7,8"`. Note that month numbers do *not* have a zero-based index, meaning January's value is `1`, and December's is `12`.
*/
"monthValues"?: number[] | number | string;
+ /**
+ * Controls the month/year picker overlay style when using a grid-style layout. - `"wheel"` (default) preserves the existing `ion-picker-column` behaviour. - `"grid"` replaces the wheel columns with a month name grid and a year grid shown simultaneously inside the existing toggle overlay.
+ * @default 'wheel'
+ */
+ "monthYearPickerView": 'wheel' | 'grid';
/**
* If `true`, multiple dates can be selected at once. Only applies to `presentation="date"` and `preferWheel="false"`.
+ * @deprecated Use `selectionMode="multiple"` instead.
* @default false
*/
"multiple": boolean;
@@ -1245,6 +1257,10 @@ export namespace Components {
* @param startDate A valid [ISO-8601 string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format) to reset the datetime state to.
*/
"reset": (startDate?: string) => Promise;
+ /**
+ * Controls date selection behaviour when using a grid-style layout. - `"multiple"` enables toggling of individual dates (replaces the deprecated `multiple` boolean). - `"range"` enables start/end date range selection. `value` will emit a two-element ISO 8601 string array `[startDate, endDate]` once both dates are selected. Only applies to `presentation="date"` and `preferWheel="false"`. Logs a warning if used with any other `presentation` or with `preferWheel="true"`.
+ */
+ "selectionMode"?: 'single' | 'multiple' | 'range';
/**
* If `true`, the datetime calendar displays a six-week (42-day) layout, including days from the previous and next months to fill the grid. These adjacent days are selectable unless disabled.
* @default false
@@ -1276,9 +1292,10 @@ export namespace Components {
*/
"size": 'cover' | 'fixed';
/**
- * The theme determines the visual appearance of the component.
+ * The label for the range start date shown in the header when `showDefaultTitle` is enabled and no start date has been selected yet. Useful for translating or customizing the default placeholder text. Only applies when `selectionMode="range"`.
+ * @default 'Start date'
*/
- "theme"?: "ios" | "md" | "ionic";
+ "startDateLabel": string;
/**
* A callback used to format the header text that shows how many dates are selected. Only used if there are 0 or more than 1 selected (i.e. unused for exactly 1). By default, the header text is set to "numberOfDates days". See https://ionicframework.com/docs/troubleshooting/runtime#accessing-this if you need to access `this` from within the callback.
*/
@@ -7219,6 +7236,11 @@ declare namespace LocalJSX {
* @default 'Done'
*/
"doneText"?: string;
+ /**
+ * The label for the range end date shown in the header when `showDefaultTitle` is enabled and no end date has been selected yet. Useful for translating or customizing the default placeholder text. Only applies when `selectionMode="range"`.
+ * @default 'End date'
+ */
+ "endDateLabel"?: string;
/**
* The first day of the week to use for `ion-datetime`. The default value is `0` and represents Sunday.
* @default 0
@@ -7262,15 +7284,22 @@ declare namespace LocalJSX {
*/
"minuteValues"?: number[] | number | string;
/**
- * The mode determines the platform behaviors of the component.
+ * Controls the month navigation mode when using a grid-style layout. - `"arrows"` (default) preserves the existing prev/next button behaviour. - `"scroll"` swaps the horizontal scroll axis to vertical. The `previous-button` and `next-button` shadow parts remain in the DOM and keyboard-focusable in both modes.
+ * @default 'arrows'
*/
- "mode"?: "ios" | "md";
+ "monthNavigation"?: 'arrows' | 'scroll';
/**
* Values used to create the list of selectable months. By default the month values range from `1` to `12`. However, to control exactly which months to display, the `monthValues` input can take a number, an array of numbers, or a string of comma separated numbers. For example, if only summer months should be shown, then this input value would be `monthValues="6,7,8"`. Note that month numbers do *not* have a zero-based index, meaning January's value is `1`, and December's is `12`.
*/
"monthValues"?: number[] | number | string;
+ /**
+ * Controls the month/year picker overlay style when using a grid-style layout. - `"wheel"` (default) preserves the existing `ion-picker-column` behaviour. - `"grid"` replaces the wheel columns with a month name grid and a year grid shown simultaneously inside the existing toggle overlay.
+ * @default 'wheel'
+ */
+ "monthYearPickerView"?: 'wheel' | 'grid';
/**
* If `true`, multiple dates can be selected at once. Only applies to `presentation="date"` and `preferWheel="false"`.
+ * @deprecated Use `selectionMode="multiple"` instead.
* @default false
*/
"multiple"?: boolean;
@@ -7322,6 +7351,10 @@ declare namespace LocalJSX {
* @default false
*/
"readonly"?: boolean;
+ /**
+ * Controls date selection behaviour when using a grid-style layout. - `"multiple"` enables toggling of individual dates (replaces the deprecated `multiple` boolean). - `"range"` enables start/end date range selection. `value` will emit a two-element ISO 8601 string array `[startDate, endDate]` once both dates are selected. Only applies to `presentation="date"` and `preferWheel="false"`. Logs a warning if used with any other `presentation` or with `preferWheel="true"`.
+ */
+ "selectionMode"?: 'single' | 'multiple' | 'range';
/**
* If `true`, the datetime calendar displays a six-week (42-day) layout, including days from the previous and next months to fill the grid. These adjacent days are selectable unless disabled.
* @default false
@@ -7353,9 +7386,10 @@ declare namespace LocalJSX {
*/
"size"?: 'cover' | 'fixed';
/**
- * The theme determines the visual appearance of the component.
+ * The label for the range start date shown in the header when `showDefaultTitle` is enabled and no start date has been selected yet. Useful for translating or customizing the default placeholder text. Only applies when `selectionMode="range"`.
+ * @default 'Start date'
*/
- "theme"?: "ios" | "md" | "ionic";
+ "startDateLabel"?: string;
/**
* A callback used to format the header text that shows how many dates are selected. Only used if there are 0 or more than 1 selected (i.e. unused for exactly 1). By default, the header text is set to "numberOfDates days". See https://ionicframework.com/docs/troubleshooting/runtime#accessing-this if you need to access `this` from within the callback.
*/
@@ -10971,8 +11005,13 @@ declare namespace LocalJSX {
"locale": string;
"firstDayOfWeek": number;
"multiple": boolean;
+ "selectionMode": 'single' | 'multiple' | 'range';
+ "monthNavigation": 'arrows' | 'scroll';
+ "monthYearPickerView": 'wheel' | 'grid';
"value": string | string[] | null;
"showDefaultTitle": boolean;
+ "startDateLabel": string;
+ "endDateLabel": string;
"showDefaultButtons": boolean;
"showClearButton": boolean;
"showDefaultTimeLabel": boolean;
diff --git a/core/src/components/datetime/datetime-interface.ts b/core/src/components/datetime/datetime-interface.ts
index c126b8d7282..b749c9cfc9e 100644
--- a/core/src/components/datetime/datetime-interface.ts
+++ b/core/src/components/datetime/datetime-interface.ts
@@ -38,6 +38,23 @@ export type DatetimeHighlightCallback = (dateIsoString: string) => DatetimeHighl
export type DatetimeHourCycle = 'h11' | 'h12' | 'h23' | 'h24';
+export type DatetimeSelectionMode = 'multiple' | 'range';
+
+export type DatetimeMonthNavigation = 'arrows' | 'scroll';
+
+export type DatetimeMonthYearPickerView = 'wheel' | 'grid';
+
+/**
+ * Represents the active parts when selectionMode="range".
+ * `start` is always set once the user picks a first date.
+ * `end` is set once the user picks the second date (or `start` is set again
+ * when a complete range is reset).
+ */
+export interface DatetimeRangeParts {
+ start: DatetimeParts;
+ end?: DatetimeParts;
+}
+
/**
* FormatOptions must include date and/or time; it cannot be an empty object
*/
diff --git a/core/src/components/datetime/datetime.common.scss b/core/src/components/datetime/datetime.common.scss
index 77f86cb9da6..f2faf508627 100644
--- a/core/src/components/datetime/datetime.common.scss
+++ b/core/src/components/datetime/datetime.common.scss
@@ -271,7 +271,18 @@
}
:host .calendar-day-wrapper {
+ /**
+ * position: relative is required so that the ::before pseudo-element used for
+ * the range-selection highlight track is positioned relative to the wrapper,
+ * not the scroll container.
+ */
+ /**
+ * position: relative is required so that the ::before pseudo-element used for
+ * the range-selection highlight track is positioned relative to the wrapper,
+ * not the scroll container.
+ */
display: flex;
+ position: relative;
align-items: center;
justify-content: center;
@@ -384,3 +395,306 @@
align-items: center;
}
+
+// Accessibility: visually-hidden aria-live region
+// -----------------------------------
+
+/**
+ * The `.calendar-month-year-announce` element is a visually-hidden live region
+ * that announces the current month/year to screen readers whenever `workingParts`
+ * changes. This is especially important in `monthNavigation="scroll"` mode where
+ * the scroll gesture alone is insufficient for AT users to detect the change.
+ *
+ * The clip/clip-path technique is preferred over display:none or visibility:hidden
+ * because those hide the element from the accessibility tree as well.
+ */
+:host .calendar-month-year-announce {
+ @include padding(0px);
+ @include margin(0px);
+
+ position: absolute;
+
+ width: 1px;
+ height: 1px;
+
+ white-space: nowrap;
+
+ overflow: hidden;
+
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+}
+
+/**
+ * The `.calendar-range-announce` element is a visually-hidden live region used in
+ * `selectionMode="range"` mode. After the user selects a start date it announces
+ * "Select an end date" to screen readers so AT users know the next expected action.
+ */
+:host .calendar-range-announce {
+ @include padding(0px);
+ @include margin(0px);
+
+ position: absolute;
+
+ width: 1px;
+ height: 1px;
+
+ white-space: nowrap;
+
+ overflow: hidden;
+
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+}
+
+// Feature: monthNavigation="scroll" (vertical month scroll)
+// -----------------------------------
+
+/**
+ * When `monthNavigation="scroll"`, all months in the valid range are rendered
+ * in a single continuous vertical list. There is no virtual window or
+ * scroll-snap — the user scrolls freely through months.
+ *
+ * The shared calendar header (month/year toggle + prev/next arrows) is hidden;
+ * each month card renders its own heading instead.
+ */
+:host(.datetime-month-navigation-scroll) .calendar-header {
+ /**
+ * Keep the element in the DOM for the aria-live region (screen reader
+ * announcements) but remove all visual space.
+ */
+ position: absolute;
+
+ pointer-events: none;
+}
+
+:host(.datetime-month-navigation-scroll) .calendar-body {
+ flex-direction: column;
+
+ overflow-x: hidden;
+ overflow-y: scroll;
+
+ /**
+ * Cap the scroll container so roughly 1.5 months are visible at a time.
+ * When the datetime is inside a modal/popover the parent constrains the
+ * height anyway and this max-height has no effect.
+ */
+ max-height: 460px;
+}
+
+/**
+ * Per-month heading shown at the top of each month card in scroll mode.
+ * Styled to match the image reference: prominent left-aligned text in the
+ * primary colour.
+ */
+:host(.datetime-month-navigation-scroll) .calendar-month-scroll-heading {
+ @include padding(16px, 16px, 4px);
+
+ color: current-color(base);
+
+ font-size: 18px;
+ font-weight: 600;
+}
+
+// Feature: monthYearPickerView="grid"
+// -----------------------------------
+
+/**
+ * Grid-based month/year picker overlay.
+ * Shown inside the existing .datetime-year toggle overlay when
+ * `monthYearPickerView="grid"`. The toggle mechanism and `month-year-button`
+ * shadow part are fully preserved.
+ */
+:host .month-year-grid-container {
+ @include padding(8px);
+
+ display: flex;
+
+ flex-direction: column;
+
+ overflow: auto;
+}
+
+/**
+ * When `monthYearPickerView="grid"`, the single month-year toggle button is
+ * replaced by two side-by-side buttons (month name + year). This wrapper
+ * lays them out in a row with a small gap.
+ */
+:host .calendar-month-year-grid-buttons {
+ display: flex;
+
+ gap: 4px;
+}
+
+/**
+ * Segmented control used inside the inline grid for `presentation="month-year"`.
+ * Two tab-style buttons ("Month" / "Year") switch the panel between the month
+ * grid and the year grid.
+ */
+:host .month-year-segment {
+ @include margin(0px, 0px, 12px, 0px);
+ @include border-radius(8px);
+ @include padding(2px);
+
+ display: flex;
+
+ gap: 2px;
+
+ background: var(--ion-color-step-100, rgba(0, 0, 0, 0.06));
+}
+
+:host .month-year-segment-btn {
+ @include padding(6px, 12px);
+ @include border-radius(6px);
+
+ flex: 1;
+
+ transition: background 150ms ease, box-shadow 150ms ease, opacity 150ms ease;
+
+ border: none;
+
+ background: none;
+
+ color: currentColor;
+
+ font-family: inherit;
+ font-size: inherit;
+
+ cursor: pointer;
+
+ opacity: 0.6;
+
+ appearance: none;
+}
+
+:host .month-year-segment-btn-active {
+ background: var(--ion-background-color, #fff);
+
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+
+ opacity: 1;
+}
+
+:host .month-year-grid {
+ display: grid;
+
+ /* Month grid: 3 columns × 4 rows */
+ grid-template-columns: repeat(3, 1fr);
+
+ gap: 8px;
+
+ margin-bottom: 16px;
+}
+
+/**
+ * Year section: wraps the prev/next nav row and the year grid.
+ */
+:host .month-year-grid-year-section {
+ display: flex;
+
+ flex-direction: column;
+}
+
+/**
+ * Nav row that holds the prev/next year-page arrow buttons.
+ * Buttons are pushed to the inline-end so they sit at the right (LTR) / left (RTL).
+ */
+:host .month-year-grid-year-nav {
+ @include margin-horizontal(0px);
+
+ display: flex;
+
+ justify-content: flex-end;
+}
+
+/**
+ * Year grid: 4 columns (no scroll — pagination is handled by the nav arrows).
+ */
+:host .month-year-grid-years {
+ /* 4 columns to match the reference design */
+ grid-template-columns: repeat(4, 1fr);
+}
+
+:host .month-year-grid-cell {
+ @include padding(8px, 4px);
+ @include border-radius(8px);
+
+ display: flex;
+
+ align-items: center;
+ justify-content: center;
+
+ border: none;
+
+ background: none;
+
+ color: currentColor;
+
+ font-family: inherit;
+ font-size: inherit;
+
+ cursor: pointer;
+
+ appearance: none;
+}
+
+:host .month-year-grid-cell[disabled] {
+ opacity: 0.3;
+
+ pointer-events: none;
+}
+
+// Feature: selectionMode="range" — range highlight track
+// -----------------------------------
+
+/**
+ * The range-selection track is rendered as a ::before pseudo-element on the
+ * `.calendar-day-wrapper` rather than on the `.calendar-day` button itself.
+ * This is necessary because the button element has a fixed circular size that
+ * does not span the full grid cell, so a background on the button alone would
+ * produce gaps in the highlight track between adjacent days.
+ *
+ * The track is split into three parts:
+ * - `range-start`: right half of the cell only (connecting to the next day)
+ * - `in-range`: full cell width
+ * - `range-end`: left half of the cell only (connecting from the previous day)
+ *
+ * Logical properties (`inset-inline-start/end`) are used so that RTL layouts
+ * automatically reverse the direction of the connecting fill.
+ */
+:host .calendar-day-wrapper-range-start::before,
+:host .calendar-day-wrapper-in-range::before,
+:host .calendar-day-wrapper-range-end::before {
+ /**
+ * @prop --range-background: The background color of the range selection track
+ * (the area between the start and end dates, inclusive). Defaults to 15% opacity
+ * of the component color.
+ */
+ position: absolute;
+
+ inset-block: 0;
+
+ background: var(--range-background, #{current-color(base, 0.15)});
+
+ content: "";
+
+ z-index: 0;
+}
+
+:host .calendar-day-wrapper-range-start::before {
+ /* Connect only to the inline-end (right in LTR, left in RTL) */
+ inset-inline-start: 50%;
+ inset-inline-end: 0;
+}
+
+:host .calendar-day-wrapper-in-range::before {
+ /* Full cell width */
+ inset-inline-start: 0;
+ inset-inline-end: 0;
+}
+
+:host .calendar-day-wrapper-range-end::before {
+ /* Connect only from the inline-start (left in LTR, right in RTL) */
+ inset-inline-start: 0;
+ inset-inline-end: 50%;
+}
diff --git a/core/src/components/datetime/datetime.ios.scss b/core/src/components/datetime/datetime.ios.scss
index 8c5959c899f..adb2e2bdbc4 100644
--- a/core/src/components/datetime/datetime.ios.scss
+++ b/core/src/components/datetime/datetime.ios.scss
@@ -343,3 +343,10 @@
::slotted([slot="buttons"]) {
width: 100%;
}
+
+// Grid picker — active cell
+// -----------------------------------
+
+:host .month-year-grid-cell.month-year-grid-cell-active {
+ color: current-color(base);
+}
diff --git a/core/src/components/datetime/datetime.md.scss b/core/src/components/datetime/datetime.md.scss
index 84d52c13e60..f759b491489 100644
--- a/core/src/components/datetime/datetime.md.scss
+++ b/core/src/components/datetime/datetime.md.scss
@@ -195,3 +195,10 @@
justify-content: flex-end;
}
+
+// Grid picker — active cell
+// -----------------------------------
+
+:host .month-year-grid-cell.month-year-grid-cell-active {
+ color: current-color(base);
+}
diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx
index e3fde4c1937..44f33a82c34 100644
--- a/core/src/components/datetime/datetime.tsx
+++ b/core/src/components/datetime/datetime.tsx
@@ -18,6 +18,7 @@ import type {
DatetimePresentation,
DatetimeChangeEventDetail,
DatetimeParts,
+ DatetimeRangeParts,
TitleSelectedDatesFormatter,
DatetimeHighlight,
DatetimeHighlightStyle,
@@ -38,7 +39,14 @@ import {
getTimeColumnsData,
getCombinedDateColumnData,
} from './utils/data';
-import { formatValue, getLocalizedDateTime, getLocalizedTime, getMonthAndYear } from './utils/format';
+import {
+ formatValue,
+ getLocalizedDateTime,
+ getLocalizedTime,
+ getMonthAndYear,
+ getMonthName,
+ getYear,
+} from './utils/format';
import { isLocaleDayPeriodRTL, isMonthFirstLocale, getNumDaysInMonth, getHourCycle } from './utils/helpers';
import {
calculateHourFromAMPM,
@@ -110,6 +118,27 @@ import { checkForPresentationFormatMismatch, warnIfTimeZoneProvided } from './ut
* @part datetime-title - The element that contains the `title` slot content.
* @part datetime-selected-date - The element that contains the selected date.
*/
+/** Number of years shown per page in the `monthYearPickerView="grid"` year grid (4 cols × 6 rows). */
+const YEAR_GRID_PAGE_SIZE = 24;
+
+/**
+ * Returns the aria-label for a calendar day button, appending a range-state
+ * suffix when the day is a range start, range end, or falls inside a range.
+ * Returns the base label unchanged for non-range days.
+ */
+const getRangeAriaLabel = (
+ baseLabel: string | null,
+ isRangeStart: boolean,
+ isRangeEnd: boolean,
+ isInRange: boolean
+): string | null => {
+ if (!baseLabel) return baseLabel;
+ if (isRangeStart) return `${baseLabel}, range start`;
+ if (isRangeEnd) return `${baseLabel}, range end`;
+ if (isInRange) return `${baseLabel}, in range`;
+ return baseLabel;
+};
+
@Component({
tag: 'ion-datetime',
styleUrls: {
@@ -122,6 +151,8 @@ import { checkForPresentationFormatMismatch, warnIfTimeZoneProvided } from './ut
export class Datetime implements ComponentInterface {
private inputId = `ion-dt-${datetimeIds++}`;
private calendarBodyRef?: HTMLElement;
+ private monthButtonRef?: HTMLElement;
+ private yearButtonRef?: HTMLElement;
private popoverRef?: HTMLIonPopoverElement;
private intersectionTrackerRef?: HTMLElement;
private clearFocusVisible?: () => void;
@@ -151,9 +182,46 @@ export class Datetime implements ComponentInterface {
private resolveForceDateScrolling?: () => void;
+ /**
+ * Set to `true` by the scroll-mode scroll listener when the render window
+ * re-centres. `componentDidRender` reads this flag and restores `scrollTop`
+ * synchronously (before the browser paints) so the visible month stays in place.
+ */
+ private scrollModeNeedsPositionRestore = false;
+
+ /**
+ * How far (in px) the user was scrolled past the top of the target month
+ * when the window re-centre was triggered. Captured before the re-render so
+ * it can be re-applied afterward, preserving the exact visual position even
+ * when months have different heights (4, 5, or 6 week rows).
+ */
+ private scrollModeRestoreOffset = 0;
+
@State() showMonthAndYear = false;
- @State() activeParts: DatetimeParts | DatetimeParts[] = [];
+ /** Starting year of the currently displayed year page in the grid picker. */
+ @State() yearGridPageStart = 0;
+
+ /** Which grid panel is active in the month/year grid overlay ('month' or 'year'). */
+ @State() gridPickerActiveTab: 'month' | 'year' = 'month';
+
+ @State() activeParts: DatetimeParts | DatetimeParts[] | DatetimeRangeParts = [];
+
+ /**
+ * The month used as the centre of the ±6 scroll window in
+ * `monthNavigation="scroll"` mode. Decoupled from `workingParts` so that
+ * `workingParts` can update freely for aria-live announcements without
+ * triggering a window re-render on every month boundary. The window only
+ * re-centres when the user scrolls within 1 month of either edge.
+ */
+ @State() scrollWindowCenter: DatetimeParts = {
+ month: 5,
+ day: 28,
+ year: 2021,
+ hour: 13,
+ minute: 52,
+ ampm: 'pm',
+ };
@State() workingParts: DatetimeParts = {
month: 5,
@@ -403,9 +471,41 @@ export class Datetime implements ComponentInterface {
/**
* If `true`, multiple dates can be selected at once. Only
* applies to `presentation="date"` and `preferWheel="false"`.
+ * @deprecated Use `selectionMode="multiple"` instead.
*/
@Prop() multiple = false;
+ /**
+ * Controls date selection behaviour when using a grid-style layout.
+ *
+ * - `"multiple"` enables toggling of individual dates (replaces the deprecated `multiple` boolean).
+ * - `"range"` enables start/end date range selection. `value` will emit a two-element ISO 8601
+ * string array `[startDate, endDate]` once both dates are selected.
+ *
+ * Only applies to `presentation="date"` and `preferWheel="false"`.
+ * Logs a warning if used with any other `presentation` or with `preferWheel="true"`.
+ */
+ @Prop() selectionMode?: 'single' | 'multiple' | 'range';
+
+ /**
+ * Controls the month navigation mode when using a grid-style layout.
+ *
+ * - `"arrows"` (default) preserves the existing prev/next button behaviour.
+ * - `"scroll"` swaps the horizontal scroll axis to vertical. The
+ * `previous-button` and `next-button` shadow parts remain in the DOM
+ * and keyboard-focusable in both modes.
+ */
+ @Prop() monthNavigation: 'arrows' | 'scroll' = 'arrows';
+
+ /**
+ * Controls the month/year picker overlay style when using a grid-style layout.
+ *
+ * - `"wheel"` (default) preserves the existing `ion-picker-column` behaviour.
+ * - `"grid"` replaces the wheel columns with a month name grid and a year grid
+ * shown simultaneously inside the existing toggle overlay.
+ */
+ @Prop() monthYearPickerView: 'wheel' | 'grid' = 'wheel';
+
/**
* Used to apply custom text and background colors to specific dates.
*
@@ -438,6 +538,49 @@ export class Datetime implements ComponentInterface {
this.ionValueChange.emit({ value });
}
+ /**
+ * Manages focus when the month/year grid overlay opens or closes.
+ *
+ * On open: moves focus into the first enabled cell of the month grid so
+ * keyboard and screen-reader users can navigate immediately.
+ * On close: returns focus to the `month-year-button` so the user's
+ * position in the page is not lost.
+ *
+ * Only active when `monthYearPickerView="grid"` and the picker is used as
+ * a toggle overlay (date/date-time/time-date presentations). The always-
+ * visible grid used by month/month-year/year presentations is excluded.
+ */
+ @Watch('showMonthAndYear')
+ protected showMonthAndYearChanged(isOpen: boolean) {
+ if (this.monthYearPickerView !== 'grid') {
+ return;
+ }
+
+ // Focus management only applies to the toggle overlay (date/date-time/time-date).
+ // The always-visible inline grid used by month/month-year/year presentations
+ // has no overlay to open or close.
+ const { presentation } = this;
+ const isOverlayPresentation =
+ presentation === 'date' || presentation === 'date-time' || presentation === 'time-date';
+ if (!isOverlayPresentation) {
+ return;
+ }
+
+ raf(() => {
+ if (isOpen) {
+ const selector =
+ this.gridPickerActiveTab === 'year'
+ ? '.month-year-grid-years .month-year-grid-cell:not([disabled])'
+ : '.month-year-grid:not(.month-year-grid-years) .month-year-grid-cell:not([disabled])';
+ const firstCell = this.el.shadowRoot?.querySelector(selector);
+ firstCell?.focus();
+ } else {
+ const returnRef = this.gridPickerActiveTab === 'year' ? this.yearButtonRef : this.monthButtonRef;
+ returnRef?.focus();
+ }
+ });
+ }
+
/**
* If `true`, a header will be shown above the calendar
* picker. This will include both the slotted title, and
@@ -445,6 +588,22 @@ export class Datetime implements ComponentInterface {
*/
@Prop() showDefaultTitle = false;
+ /**
+ * The label for the range start date shown in the header when
+ * `showDefaultTitle` is enabled and no start date has been selected yet.
+ * Useful for translating or customizing the default placeholder text.
+ * Only applies when `selectionMode="range"`.
+ */
+ @Prop() startDateLabel = 'Start date';
+
+ /**
+ * The label for the range end date shown in the header when
+ * `showDefaultTitle` is enabled and no end date has been selected yet.
+ * Useful for translating or customizing the default placeholder text.
+ * Only applies when `selectionMode="range"`.
+ */
+ @Prop() endDateLabel = 'End date';
+
/**
* If `true`, the default "Cancel" and "OK" buttons
* will be rendered at the bottom of the `ion-datetime`
@@ -558,20 +717,33 @@ export class Datetime implements ComponentInterface {
* We only update the value if the presentation is not a calendar picker.
*/
if (activeParts !== undefined || !isCalendarPicker) {
- const activePartsIsArray = Array.isArray(activeParts);
- if (activePartsIsArray && activeParts.length === 0) {
- if (preferWheel) {
- /**
- * If the datetime is using a wheel picker, but the
- * active parts are empty, then the user has confirmed the
- * initial value (working parts) presented to them.
- */
- this.setValue(convertDataToISO(workingParts));
- } else {
- this.setValue(undefined);
+ if (this.isRangeMode) {
+ /**
+ * For range mode, only emit a value once both start and end are selected.
+ * Suppress ionChange for partial ranges (start set, end not yet set) to
+ * avoid sending incomplete data to consumers.
+ */
+ const rangeParts = this.rangeActiveParts;
+ if (rangeParts?.start && rangeParts.end) {
+ this.setValue([convertDataToISO(rangeParts.start), convertDataToISO(rangeParts.end)]);
}
+ // If range is incomplete, do not emit — wait for the user to pick an end date.
} else {
- this.setValue(convertDataToISO(activeParts));
+ const activePartsIsArray = Array.isArray(activeParts);
+ if (activePartsIsArray && (activeParts as DatetimeParts[]).length === 0) {
+ if (preferWheel) {
+ /**
+ * If the datetime is using a wheel picker, but the
+ * active parts are empty, then the user has confirmed the
+ * initial value (working parts) presented to them.
+ */
+ this.setValue(convertDataToISO(workingParts));
+ } else {
+ this.setValue(undefined);
+ }
+ } else {
+ this.setValue(convertDataToISO(activeParts as DatetimeParts | DatetimeParts[]));
+ }
}
}
@@ -590,6 +762,24 @@ export class Datetime implements ComponentInterface {
@Method()
async reset(startDate?: string) {
this.processValue(startDate);
+
+ /**
+ * In range mode, a single ISO date string is not a valid range value.
+ * Clear activeParts so the user can begin a fresh range selection from
+ * the reset date rather than leaving the component in an inconsistent state.
+ */
+ if (this.isRangeMode) {
+ this.activeParts = [];
+ }
+
+ /**
+ * If the grid picker is active, re-align the year page to the working
+ * year after the reset so the correct year is visible without the user
+ * having to manually page to it.
+ */
+ if (this.monthYearPickerView === 'grid') {
+ this.yearGridPageStart = Math.floor(this.workingParts.year / YEAR_GRID_PAGE_SIZE) * YEAR_GRID_PAGE_SIZE;
+ }
}
/**
@@ -602,6 +792,15 @@ export class Datetime implements ComponentInterface {
*/
@Method()
async cancel(closeOverlay = false) {
+ /**
+ * In range mode, cancelling should fully reset any partial range selection
+ * (i.e. a start date with no end date) so the component returns to an
+ * empty state rather than leaving a dangling start date.
+ */
+ if (this.isRangeMode) {
+ this.activeParts = [];
+ }
+
this.ionCancel.emit();
if (closeOverlay) {
@@ -622,8 +821,11 @@ export class Datetime implements ComponentInterface {
}
private warnIfIncorrectValueUsage = () => {
- const { multiple, value } = this;
- if (!multiple && Array.isArray(value)) {
+ const { value } = this;
+ const isMultipleMode = this.isMultipleMode;
+ const isRangeMode = this.isRangeMode;
+
+ if (!isMultipleMode && !isRangeMode && Array.isArray(value)) {
/**
* We do some processing on the `value` array so
* that it looks more like an array when logged to
@@ -633,13 +835,20 @@ export class Datetime implements ComponentInterface {
* Custom behavior: ['a', 'b']
*/
printIonWarning(
- `[ion-datetime] - An array of values was passed, but multiple is "false". This is incorrect usage and may result in unexpected behaviors. To dismiss this warning, pass a string to the "value" property when multiple="false".
+ `[ion-datetime] - An array of values was passed, but multiple/range selection is not enabled. This is incorrect usage and may result in unexpected behaviors. To dismiss this warning, pass a string to the "value" property, or set selectionMode="multiple" or selectionMode="range".
Value Passed: [${value.map((v) => `'${v}'`).join(', ')}]
`,
this.el
);
}
+
+ if (isRangeMode && Array.isArray(value) && value.length > 2) {
+ printIonWarning(
+ `[ion-datetime] - For selectionMode="range", the "value" property should be an array of 1 or 2 ISO 8601 date strings [startDate] or [startDate, endDate]. Received ${value.length} value(s).`,
+ this.el
+ );
+ }
};
private setValue = (value?: string | string[] | null) => {
@@ -664,7 +873,15 @@ export class Datetime implements ComponentInterface {
private getActivePart = () => {
const { activeParts } = this;
- return Array.isArray(activeParts) ? activeParts[0] : activeParts;
+ if (Array.isArray(activeParts)) {
+ return activeParts[0] as DatetimeParts | undefined;
+ }
+ // Range mode: return the start date as the representative active part
+ const rangeParts = this.rangeActiveParts;
+ if (rangeParts) {
+ return rangeParts.start;
+ }
+ return activeParts as DatetimeParts;
};
private closeParentOverlay = (role: string) => {
@@ -683,6 +900,37 @@ export class Datetime implements ComponentInterface {
};
};
+ /**
+ * Returns `true` when the datetime is in multiple-date selection mode.
+ * Accounts for both the new `selectionMode="multiple"` prop and the
+ * deprecated `multiple` boolean prop.
+ */
+ private get isMultipleMode() {
+ return this.selectionMode === 'multiple' || (this.selectionMode === undefined && this.multiple);
+ }
+
+ /** Returns `true` when the datetime is in date-range selection mode. */
+ private get isRangeMode() {
+ return this.selectionMode === 'range';
+ }
+
+ /**
+ * Type-guard that checks whether `activeParts` currently holds a
+ * `DatetimeRangeParts` object (`{ start, end? }`).
+ */
+ private get rangeActiveParts(): DatetimeRangeParts | undefined {
+ const { activeParts } = this;
+ if (
+ !Array.isArray(activeParts) &&
+ activeParts !== null &&
+ typeof activeParts === 'object' &&
+ 'start' in activeParts
+ ) {
+ return activeParts as DatetimeRangeParts;
+ }
+ return undefined;
+ }
+
private setActiveParts = (parts: DatetimeParts, removeDate = false) => {
/** if the datetime component is in readonly mode,
* allow browsing of the calendar without changing
@@ -692,7 +940,9 @@ export class Datetime implements ComponentInterface {
return;
}
- const { multiple, minParts, maxParts, activeParts } = this;
+ const { minParts, maxParts, activeParts } = this;
+ const isMultipleMode = this.isMultipleMode;
+ const isRangeMode = this.isRangeMode;
/**
* When setting the active parts, it is possible
@@ -707,13 +957,38 @@ export class Datetime implements ComponentInterface {
const validatedParts = validateParts(parts, minParts, maxParts);
this.setWorkingParts(validatedParts);
- if (multiple) {
- const activePartsArray = Array.isArray(activeParts) ? activeParts : [activeParts];
+ if (isMultipleMode) {
+ const activePartsArray = Array.isArray(activeParts)
+ ? (activeParts as DatetimeParts[])
+ : [activeParts as DatetimeParts];
if (removeDate) {
this.activeParts = activePartsArray.filter((p) => !isSameDay(p, validatedParts));
} else {
this.activeParts = [...activePartsArray, validatedParts];
}
+ } else if (isRangeMode) {
+ const current = this.rangeActiveParts;
+
+ /**
+ * If there is no existing start, or if a complete range already exists,
+ * start a fresh range with the tapped day as the start.
+ * Return early — ionChange must not fire until the range is committed.
+ */
+ if (!current || current.end !== undefined) {
+ this.activeParts = { start: validatedParts };
+ return;
+ }
+
+ /**
+ * A start exists but no end yet. Commit the end date.
+ * Swap start/end if the tapped date is before the start.
+ * Fall through so confirm() fires and ionChange is emitted.
+ */
+ if (isBefore(validatedParts, current.start)) {
+ this.activeParts = { start: validatedParts, end: current.start };
+ } else {
+ this.activeParts = { start: current.start, end: validatedParts };
+ }
} else {
this.activeParts = {
...validatedParts,
@@ -910,18 +1185,105 @@ export class Datetime implements ComponentInterface {
}
/**
- * For performance reasons, we only render 3
- * months at a time: The current month, the previous
- * month, and the next month. We have a scroll listener
+ * In scroll mode, all months are rendered in a continuous vertical list.
+ * We just scroll to the working month on init and update workingParts
+ * as the user scrolls (for the aria-live region).
+ */
+ if (this.monthNavigation === 'scroll') {
+ writeTask(() => {
+ const workingMonthEl = calendarBodyRef.querySelector(
+ `.calendar-month[data-month="${this.workingParts.month}"][data-year="${this.workingParts.year}"]`
+ );
+ if (workingMonthEl) {
+ calendarBodyRef.scrollTop = workingMonthEl.offsetTop;
+ }
+
+ let scrollTimeout: ReturnType | undefined;
+ const scrollCallback = () => {
+ if (scrollTimeout) clearTimeout(scrollTimeout);
+ scrollTimeout = setTimeout(() => {
+ /**
+ * Find the month whose top edge is closest to the top of the scroll
+ * container and update workingParts so the aria-live region announces
+ * the correct month to screen readers.
+ */
+ const containerTop = calendarBodyRef.getBoundingClientRect().top;
+ let nearest: HTMLElement | null = null;
+ let nearestDist = Infinity;
+ for (const el of calendarBodyRef.querySelectorAll('.calendar-month[data-month]')) {
+ const dist = Math.abs(el.getBoundingClientRect().top - containerTop);
+ if (dist < nearestDist) {
+ nearestDist = dist;
+ nearest = el;
+ }
+ }
+ if (nearest) {
+ const month = Number(nearest.dataset.month);
+ const year = Number(nearest.dataset.year);
+ if (month !== this.workingParts.month || year !== this.workingParts.year) {
+ /**
+ * Always update workingParts so the aria-live region announces
+ * the correct month as the user scrolls.
+ */
+ writeTask(() => this.setWorkingParts({ ...this.workingParts, month, year }));
+
+ /**
+ * Only re-centre the scroll window when the user is within 2
+ * months of either edge. This avoids a DOM rebuild (and the
+ * associated scrollTop restore) on every single month boundary.
+ */
+ const months = this.generateScrollModeMonths();
+ const first = months[0];
+ const last = months[months.length - 1];
+ // Compute the month 1 step inside each edge
+ const secondMonth = getNextMonth({ year: first.year, month: first.month, day: null });
+ const secondLastMonth = getPreviousMonth({ year: last.year, month: last.month, day: null });
+ const nearStart =
+ (year === first.year && month === first.month) ||
+ (year === secondMonth.year && month === secondMonth.month);
+ const nearEnd =
+ (year === last.year && month === last.month) ||
+ (year === secondLastMonth.year && month === secondLastMonth.month);
+ if (nearStart || nearEnd) {
+ /**
+ * Capture how far past the top of the target month the user is
+ * scrolled RIGHT NOW (before the DOM re-render shifts things).
+ * Re-applying this offset after the re-render keeps the exact
+ * visual position stable even when months have different row counts.
+ */
+ const targetEl = calendarBodyRef.querySelector(
+ `.calendar-month[data-month="${month}"][data-year="${year}"]`
+ );
+ this.scrollModeRestoreOffset = targetEl ? calendarBodyRef.scrollTop - targetEl.offsetTop : 0;
+ this.scrollModeNeedsPositionRestore = true;
+ this.scrollWindowCenter = { ...this.workingParts, month, year };
+ }
+ }
+ }
+ }, 50);
+ };
+
+ calendarBodyRef.addEventListener('scroll', scrollCallback);
+ this.destroyCalendarListener = () => {
+ calendarBodyRef.removeEventListener('scroll', scrollCallback);
+ };
+ });
+ return;
+ }
+
+ /**
+ * Horizontal arrows mode — 3-month virtual window with scroll-snap.
+ *
+ * For performance reasons, we only render 3 months at a time: The current
+ * month, the previous month, and the next month. We have a scroll listener
* on the calendar body to append/prepend new months.
*
- * We can do this because Stencil is smart enough to not
- * re-create the .calendar-month containers, but rather
- * update the content within those containers.
+ * We can do this because Stencil is smart enough to not re-create the
+ * .calendar-month containers, but rather update the content within those
+ * containers.
*
- * As an added bonus, WebKit has some troubles with
- * scroll-snap-stop: always, so not rendering all of
- * the months in a row allows us to mostly sidestep
+ * As an added bonus, WebKit has some troubles with scroll-snap-stop: always,
+ * so not rendering all of the months in a row allows us to mostly sidestep
* that issue.
*/
const months = calendarBodyRef.querySelectorAll('.calendar-month');
@@ -945,11 +1307,8 @@ export class Datetime implements ComponentInterface {
const box = calendarBodyRef.getBoundingClientRect();
/**
- * If the current scroll position is all the way to the left
- * then we have scrolled to the previous month.
- * Otherwise, assume that we have scrolled to the next
- * month. We have a tolerance of 2px to account for
- * sub pixel rendering.
+ * Horizontal scroll: scrollLeft ≈ 0 (LTR) or ≈ 0 (RTL) means we scrolled
+ * to the previous (leftmost) month.
*
* Check below the next line ensures that we did not
* swipe and abort (i.e. we swiped but we are still on the current month).
@@ -1324,11 +1683,32 @@ export class Datetime implements ComponentInterface {
* the scroll callback in this file does not fire,
* and the resolveForceDateScrolling promise never resolves.
*/
- if (workingMonth && forceRenderDate === undefined) {
+ if (workingMonth && forceRenderDate === undefined && this.monthNavigation !== 'scroll') {
calendarBodyRef.scrollLeft = workingMonth.clientWidth * (isRTL(this.el) ? -1 : 1);
}
}
+ /**
+ * Scroll mode: after the ±6 window re-centres, the DOM months have shifted.
+ * Restore scrollTop synchronously (before the browser paints) so the currently
+ * visible month stays in place.
+ *
+ * We use `scrollWindowCenter` (not `workingParts`) because `setWorkingParts`
+ * is deferred in a writeTask and is therefore stale at this point.
+ * `scrollWindowCenter` was set synchronously in the scroll listener and always
+ * holds the month/year that triggered the re-centre.
+ */
+ if (this.scrollModeNeedsPositionRestore && this.monthNavigation === 'scroll' && calendarBodyRef) {
+ this.scrollModeNeedsPositionRestore = false;
+ const { scrollWindowCenter, scrollModeRestoreOffset } = this;
+ const targetEl = calendarBodyRef.querySelector(
+ `.calendar-month[data-month="${scrollWindowCenter.month}"][data-year="${scrollWindowCenter.year}"]`
+ );
+ if (targetEl) {
+ calendarBodyRef.scrollTop = targetEl.offsetTop + scrollModeRestoreOffset;
+ }
+ }
+
if (prevPresentation === null) {
this.prevPresentation = presentation;
return;
@@ -1401,7 +1781,25 @@ export class Datetime implements ComponentInterface {
* `value` property is set.
*/
if (hasValue) {
- if (Array.isArray(valueToProcess)) {
+ if (this.isRangeMode && Array.isArray(valueToProcess) && valueToProcess.length >= 2) {
+ /**
+ * For range mode with a two-element array value, map the first element to
+ * `start` and the last element to `end`, clamping both to min/max bounds.
+ */
+ this.activeParts = {
+ start: clampDate(valueToProcess[0], minParts, maxParts),
+ end: clampDate(valueToProcess[valueToProcess.length - 1], minParts, maxParts),
+ };
+ } else if (this.isRangeMode && Array.isArray(valueToProcess) && valueToProcess.length === 1) {
+ /**
+ * A single-element array in range mode is a valid partial range (start date
+ * only, no end date yet). `end` is intentionally omitted so the component
+ * enters the "awaiting end date" state rather than treating this as an error.
+ */
+ this.activeParts = {
+ start: clampDate(valueToProcess[0], minParts, maxParts),
+ };
+ } else if (Array.isArray(valueToProcess)) {
this.activeParts = [...valueToProcess];
} else {
this.activeParts = {
@@ -1427,24 +1825,36 @@ export class Datetime implements ComponentInterface {
const bodyIsVisible = el.classList.contains('datetime-ready');
const { isGridStyle, showMonthAndYear } = this;
- if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear) {
+ if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear && this.monthNavigation !== 'scroll') {
/**
* Only animate if:
* 1. We're using grid style (wheel style pickers should just jump to new value)
* 2. The month and/or year actually changed, and both are defined (otherwise there's nothing to animate to)
* 3. The calendar body is visible (prevents animation when in collapsed datetime-button, for example)
* 4. The month/year picker is not open (since you wouldn't see the animation anyway)
+ * 5. Not in scroll mode — scroll mode does not use the snap-based animation; the
+ * month is already visible in the continuous list so no programmatic scroll is needed.
*/
this.animateToDate(targetValue);
} else {
- this.setWorkingParts({
+ const newParts: DatetimeParts = {
month,
day,
year,
hour,
minute,
- ampm,
- });
+ ampm: ampm as 'am' | 'pm',
+ };
+ this.setWorkingParts(newParts);
+ /**
+ * Re-centre the scroll window when the month/year actually changed
+ * (e.g. programmatic value changes, initial load, reset). Skip when
+ * only the day/time changed so that clicking or deselecting a date
+ * within the already-visible month does not trigger a scroll jump.
+ */
+ if (didChangeMonth) {
+ this.scrollWindowCenter = newParts;
+ }
}
};
@@ -1482,9 +1892,40 @@ export class Datetime implements ComponentInterface {
};
componentWillLoad() {
- const { el, formatOptions, highlightedDates, multiple, presentation, preferWheel } = this;
+ const { el, formatOptions, highlightedDates, multiple, selectionMode, monthNavigation, presentation, preferWheel } =
+ this;
+
+ /**
+ * When monthYearPickerView="grid" and the presentation is month/month-year/year,
+ * the grid is always visible (no toggle), so we must pre-initialize the year page.
+ */
+ if (this.monthYearPickerView === 'grid') {
+ this.yearGridPageStart = Math.floor(this.workingParts.year / YEAR_GRID_PAGE_SIZE) * YEAR_GRID_PAGE_SIZE;
+ }
+
+ /**
+ * Warn when the deprecated `multiple` boolean is used.
+ * Developers should migrate to `selectionMode="multiple"`.
+ */
+ if (multiple && selectionMode === undefined) {
+ printIonWarning(
+ '[ion-datetime] - The `multiple` prop is deprecated. Use `selectionMode="multiple"` instead.',
+ el
+ );
+ }
- if (multiple) {
+ /**
+ * Warn when both `multiple` and `selectionMode` are set — `selectionMode`
+ * takes precedence and `multiple` is ignored.
+ */
+ if (multiple && selectionMode !== undefined) {
+ printIonWarning(
+ `[ion-datetime] - Both \`multiple\` and \`selectionMode\` are set. \`selectionMode="${selectionMode}"\` takes precedence; \`multiple\` will be ignored. Remove the \`multiple\` prop to dismiss this warning.`,
+ el
+ );
+ }
+
+ if (multiple || selectionMode === 'multiple') {
if (presentation !== 'date') {
printIonWarning('[ion-datetime] - Multiple date selection is only supported for presentation="date".', el);
}
@@ -1494,6 +1935,20 @@ export class Datetime implements ComponentInterface {
}
}
+ if (selectionMode === 'range') {
+ if (presentation !== 'date') {
+ printIonWarning('[ion-datetime] - Range date selection is only supported for presentation="date".', el);
+ }
+
+ if (preferWheel) {
+ printIonWarning('[ion-datetime] - Range date selection is not supported with preferWheel="true".', el);
+ }
+ }
+
+ if (monthNavigation === 'scroll' && preferWheel) {
+ printIonWarning('[ion-datetime] - monthNavigation="scroll" has no effect when preferWheel="true".', el);
+ }
+
if (highlightedDates !== undefined) {
if (presentation !== 'date' && presentation !== 'date-time' && presentation !== 'time-date') {
printIonWarning(
@@ -1570,14 +2025,27 @@ export class Datetime implements ComponentInterface {
return;
}
- const left = (nextMonth as HTMLElement).offsetWidth * 2;
-
const scrollMode = config.getBoolean('animated', true) ? 'smooth' : 'instant';
- calendarBodyRef.scrollTo({
- top: 0,
- left: left * (isRTL(this.el) ? -1 : 1),
- behavior: scrollMode,
- });
+
+ if (this.monthNavigation === 'scroll') {
+ /**
+ * Vertical scroll mode: navigate to the next month card in the continuous list.
+ */
+ const nextParts = getNextMonth(this.workingParts);
+ const nextEl = calendarBodyRef.querySelector(
+ `.calendar-month[data-month="${nextParts.month}"][data-year="${nextParts.year}"]`
+ );
+ if (nextEl) {
+ calendarBodyRef.scrollTo({ top: nextEl.offsetTop, behavior: scrollMode });
+ }
+ } else {
+ const left = (nextMonth as HTMLElement).offsetWidth * 2;
+ calendarBodyRef.scrollTo({
+ top: 0,
+ left: left * (isRTL(this.el) ? -1 : 1),
+ behavior: scrollMode,
+ });
+ }
};
private prevMonth = () => {
@@ -1591,20 +2059,87 @@ export class Datetime implements ComponentInterface {
return;
}
- const left = (prevMonth as HTMLElement).offsetWidth * 2;
-
const scrollMode = config.getBoolean('animated', true) ? 'smooth' : 'instant';
- calendarBodyRef.scrollTo({
- top: 0,
- left: left * (isRTL(this.el) ? 1 : -1),
- behavior: scrollMode,
- });
+
+ if (this.monthNavigation === 'scroll') {
+ /**
+ * Vertical scroll mode: navigate to the previous month card in the continuous list.
+ */
+ const prevParts = getPreviousMonth(this.workingParts);
+ const prevEl = calendarBodyRef.querySelector(
+ `.calendar-month[data-month="${prevParts.month}"][data-year="${prevParts.year}"]`
+ );
+ if (prevEl) {
+ calendarBodyRef.scrollTo({ top: prevEl.offsetTop, behavior: scrollMode });
+ }
+ } else {
+ const left = (prevMonth as HTMLElement).offsetWidth * 2;
+ calendarBodyRef.scrollTo({
+ top: 0,
+ left: left * (isRTL(this.el) ? 1 : -1),
+ behavior: scrollMode,
+ });
+ }
};
private toggleMonthAndYearView = () => {
this.showMonthAndYear = !this.showMonthAndYear;
};
+ /**
+ * Opens the grid picker overlay to the specified tab (month or year).
+ * Resets the year page to the one containing the current working year
+ * each time the picker opens.
+ */
+ private openGridPicker = (tab: 'month' | 'year') => {
+ this.gridPickerActiveTab = tab;
+ this.yearGridPageStart = Math.floor(this.workingParts.year / YEAR_GRID_PAGE_SIZE) * YEAR_GRID_PAGE_SIZE;
+ this.showMonthAndYear = true;
+ };
+
+ /**
+ * Handles arrow-key navigation within a `role="grid"` container in the
+ * month/year grid picker. Left/Right move one cell, Up/Down move one row
+ * (`cols` cells). Disabled cells are skipped; navigation stops at the edges.
+ */
+ private handleGridKeyDown = (ev: KeyboardEvent, cols: number) => {
+ const grid = ev.currentTarget as HTMLElement;
+ const cells = Array.from(grid.querySelectorAll('[role="gridcell"]'));
+ const focused = cells.find((c) => c === (this.el.shadowRoot?.activeElement ?? document.activeElement));
+ if (!focused) return;
+
+ const idx = cells.indexOf(focused);
+ let nextIdx: number | undefined;
+
+ switch (ev.key) {
+ case 'ArrowRight':
+ ev.preventDefault();
+ nextIdx = idx + 1;
+ break;
+ case 'ArrowLeft':
+ ev.preventDefault();
+ nextIdx = idx - 1;
+ break;
+ case 'ArrowDown':
+ ev.preventDefault();
+ nextIdx = idx + cols;
+ break;
+ case 'ArrowUp':
+ ev.preventDefault();
+ nextIdx = idx - cols;
+ break;
+ default:
+ return;
+ }
+
+ if (nextIdx !== undefined && nextIdx >= 0 && nextIdx < cells.length) {
+ const target = cells[nextIdx];
+ if (!target.hasAttribute('disabled')) {
+ target.focus();
+ }
+ }
+ };
+
/**
* Universal render methods
* These are pieces of datetime that
@@ -1620,6 +2155,13 @@ export class Datetime implements ComponentInterface {
* is disabled or readonly.
*/
const isButtonDisabled = disabled || readonly;
+ /**
+ * In range mode, the confirm button should remain disabled until the
+ * user has selected both a start and end date. This prevents committing
+ * an incomplete range value.
+ */
+ const isConfirmDisabled =
+ isButtonDisabled || (this.isRangeMode && !(this.rangeActiveParts?.start && this.rangeActiveParts?.end));
const confirmFill = theme === 'ionic' ? 'solid' : undefined;
const hasSlottedButtons = this.el.querySelector('[slot="buttons"]') !== null;
if (!hasSlottedButtons && !showDefaultButtons && !showClearButton) {
@@ -1675,7 +2217,7 @@ export class Datetime implements ComponentInterface {
fill={confirmFill}
color={this.color}
onClick={() => this.confirm(true)}
- disabled={isButtonDisabled}
+ disabled={isConfirmDisabled}
>
{this.doneText}
@@ -2228,6 +2770,62 @@ export class Datetime implements ComponentInterface {
* Grid Render Methods
*/
+ /**
+ * In `monthNavigation="scroll"` mode, a sliding window of months is rendered
+ * in a vertical list. Only a portion of the list is visible at a time — the
+ * rest scrolls into view. The window is 6 months before and 6 months after the
+ * current working month (13 total), clamped to min/max bounds.
+ */
+ private generateScrollModeMonths(): DatetimeParts[] {
+ const { minParts, maxParts, scrollWindowCenter } = this;
+ const WINDOW = 6; // months before and after the window centre
+
+ // Compute window start (centre - WINDOW)
+ let startMonth = scrollWindowCenter.month - WINDOW;
+ let startYear = scrollWindowCenter.year;
+ while (startMonth <= 0) {
+ startMonth += 12;
+ startYear--;
+ }
+
+ // Compute window end (centre + WINDOW)
+ let endMonth = scrollWindowCenter.month + WINDOW;
+ let endYear = scrollWindowCenter.year;
+ while (endMonth > 12) {
+ endMonth -= 12;
+ endYear++;
+ }
+
+ // Clamp to min/max bounds
+ if (minParts) {
+ const minM = minParts.month ?? 1;
+ if (startYear < minParts.year || (startYear === minParts.year && startMonth < minM)) {
+ startYear = minParts.year;
+ startMonth = minM;
+ }
+ }
+ if (maxParts) {
+ const maxM = maxParts.month ?? 12;
+ if (endYear > maxParts.year || (endYear === maxParts.year && endMonth > maxM)) {
+ endYear = maxParts.year;
+ endMonth = maxM;
+ }
+ }
+
+ const result: DatetimeParts[] = [];
+ let y = startYear;
+ let m = startMonth;
+ while (y < endYear || (y === endYear && m <= endMonth)) {
+ result.push({ year: y, month: m, day: null });
+ m++;
+ if (m > 12) {
+ m = 1;
+ y++;
+ }
+ }
+ return result;
+ }
+
private renderCalendarHeader(theme: Theme) {
const { disabled, datetimeNextIcon, datetimePreviousIcon, datetimeCollapsedIcon, datetimeExpandedIcon } = this;
@@ -2239,32 +2837,80 @@ export class Datetime implements ComponentInterface {
return (
+
+
+
+ Datetime - Month Navigation
+
+
+
+
+
+
Arrows (Default)
+
+
+
+
+
Scroll
+
+
+
+
+
Scroll - With Min/Max
+
+
+
+
+
Scroll - With Buttons
+
+
+
+
+
+
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts
new file mode 100644
index 00000000000..e1fa4918543
--- /dev/null
+++ b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts
@@ -0,0 +1,124 @@
+import { expect } from '@playwright/test';
+import { configs, test } from '@utils/test/playwright';
+
+/**
+ * Visual regressions are mode/direction-dependent.
+ */
+configs().forEach(({ title, screenshot, config }) => {
+ test.describe(title('datetime: month navigation (visual regressions)'), () => {
+ test('arrows mode should not have visual regressions', async ({ page }) => {
+ await page.setContent(
+ ``,
+ config
+ );
+ await page.locator('.datetime-ready').waitFor();
+ await expect(page.locator('ion-datetime')).toHaveScreenshot(screenshot('datetime-month-navigation-arrows'));
+ });
+
+ test('scroll mode should not have visual regressions', async ({ page }) => {
+ await page.setContent(
+ ``,
+ config
+ );
+ await page.locator('.datetime-ready').waitFor();
+ await expect(page.locator('ion-datetime')).toHaveScreenshot(screenshot('datetime-month-navigation-scroll'));
+ });
+ });
+});
+
+/**
+ * Functionality is the same across modes/directions.
+ */
+configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
+ test.describe(title('datetime: month navigation (functionality)'), () => {
+ test('arrows mode: next button should advance the month', async ({ page }) => {
+ await page.setContent(
+ ``,
+ config
+ );
+ await page.locator('.datetime-ready').waitFor();
+
+ const datetime = page.locator('ion-datetime');
+ const monthYear = datetime.locator('.calendar-month-year');
+ const nextButton = datetime.locator('.calendar-next-prev ion-button:nth-child(2)');
+
+ await expect(monthYear).toContainText('June 2022');
+ await nextButton.click();
+ await page.waitForChanges();
+ await expect(monthYear).toContainText('July 2022');
+ });
+
+ test('arrows mode: prev button should go back a month', async ({ page }) => {
+ await page.setContent(
+ ``,
+ config
+ );
+ await page.locator('.datetime-ready').waitFor();
+
+ const datetime = page.locator('ion-datetime');
+ const monthYear = datetime.locator('.calendar-month-year');
+ const prevButton = datetime.locator('.calendar-next-prev ion-button:nth-child(1)');
+
+ await expect(monthYear).toContainText('June 2022');
+ await prevButton.click();
+ await page.waitForChanges();
+ await expect(monthYear).toContainText('May 2022');
+ });
+
+ test('scroll mode: per-month headings should be rendered', async ({ page }) => {
+ await page.setContent(
+ ``,
+ config
+ );
+ await page.locator('.datetime-ready').waitFor();
+
+ const headings = page.locator('ion-datetime .calendar-month-scroll-heading');
+ // The ±6 window centres on the working month — verify June 2022 is somewhere in the DOM
+ await expect(headings.filter({ hasText: 'June 2022' }).first()).toBeVisible();
+ });
+
+ test('scroll mode: aria-live region should be present for screen readers', async ({ page }) => {
+ await page.setContent(
+ ``,
+ config
+ );
+ await page.locator('.datetime-ready').waitFor();
+
+ const datetime = page.locator('ion-datetime');
+ // Scroll mode renders a minimal header with only an aria-live region (no arrow buttons).
+ const announceRegion = datetime.locator('.calendar-month-year-announce');
+ await expect(announceRegion).toBeAttached();
+ });
+
+ test('scroll mode: clicking a day should select it and emit ionChange', async ({ page }) => {
+ await page.setContent(
+ ``,
+ config
+ );
+ await page.locator('.datetime-ready').waitFor();
+
+ const datetime = page.locator('ion-datetime');
+ const ionChangeSpy = await page.spyOnEvent('ionChange');
+
+ await datetime.locator('[data-month="6"][data-day="20"]').first().click();
+ await ionChangeSpy.next();
+
+ await expect(datetime).toHaveJSProperty('value', '2022-06-20');
+ });
+
+ test('scroll mode: min/max should clamp the rendered month window', async ({ page }) => {
+ await page.setContent(
+ ``,
+ config
+ );
+ await page.locator('.datetime-ready').waitFor();
+
+ const headings = page.locator('ion-datetime .calendar-month-scroll-heading');
+ const texts = await headings.allTextContents();
+
+ // First heading should be April (min), last should be September (max)
+ expect(texts[0]).toContain('April 2022');
+ expect(texts[texts.length - 1]).toContain('September 2022');
+ });
+ });
+});
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..de9da55dead
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..636c18caf8f
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..7bd2bd516f1
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-rtl-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..86c8079826b
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-rtl-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-rtl-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..8441449752c
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-rtl-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-rtl-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..024ef4cbb02
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-ios-rtl-Mobile-Safari-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..da57f71764d
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..f7f719fa520
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..eaed67291f1
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-rtl-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..177f2aa8a17
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-rtl-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-rtl-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..439c8a7edfe
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-rtl-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-rtl-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..43d8b42da64
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-arrows-md-rtl-Mobile-Safari-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..671a5ce4227
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..153498b71e8
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..cff015478a1
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-rtl-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..9e18b68fb5c
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-rtl-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-rtl-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..44b855f6d84
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-rtl-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-rtl-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..e5642ca32d0
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-ios-rtl-Mobile-Safari-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..725ac9a3872
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..aaa87a18ce2
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..8cbe811baea
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-rtl-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..03bd36cefd2
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-rtl-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-rtl-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..c14f4fcfaa1
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-rtl-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-rtl-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..6365ec9c991
Binary files /dev/null and b/core/src/components/datetime/test/month-navigation/datetime.e2e.ts-snapshots/datetime-month-navigation-scroll-md-rtl-Mobile-Safari-linux.png differ
diff --git a/core/src/components/datetime/test/month-navigation/index.html b/core/src/components/datetime/test/month-navigation/index.html
new file mode 100644
index 00000000000..124e4351a80
--- /dev/null
+++ b/core/src/components/datetime/test/month-navigation/index.html
@@ -0,0 +1,98 @@
+
+
+