diff --git a/core/api.txt b/core/api.txt
index 5d12fba32c3..3664cb9ed7a 100644
--- a/core/api.txt
+++ b/core/api.txt
@@ -694,6 +694,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
@@ -711,12 +712,14 @@ 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" | 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,startDateLabel,string,'Start date',false,false
ion-datetime,prop,theme,"ios" | "md" | "ionic",undefined,false,false
ion-datetime,prop,titleSelectedDatesFormatter,((selectedDates: string[]) => string) | undefined,undefined,false,false
ion-datetime,prop,value,null | string | string[] | undefined,undefined,false,false
@@ -735,6 +738,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
@@ -756,6 +762,10 @@ ion-datetime,part,month-year-button
ion-datetime,part,navigation-button
ion-datetime,part,next-button
ion-datetime,part,previous-button
+ion-datetime,part,range-end
+ion-datetime,part,range-selection
+ion-datetime,part,range-start
+ion-datetime,part,range-track
ion-datetime,part,time-button
ion-datetime,part,time-button active
ion-datetime,part,wheel
diff --git a/core/src/components.d.ts b/core/src/components.d.ts
index 4e57e18bf61..3389866322f 100644
--- a/core/src/components.d.ts
+++ b/core/src/components.d.ts
@@ -1162,6 +1162,11 @@ export namespace Components {
* @default 'Done'
*/
"doneText": string;
+ /**
+ * The text shown in the datetime header when the end of the range is empty, also used by screen readers to announce the end date. 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
@@ -1218,6 +1223,7 @@ export namespace Components {
"monthValues"?: number[] | number | string;
/**
* 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;
@@ -1246,6 +1252,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 the date selection behavior. `'multiple'` enables selecting multiple individual dates. `'range'` enables selecting a start and end date range. When unset, single-date selection is used. Range selection only works with `presentation="date"` and `preferWheel="false"`.
+ */
+ "selectionMode"?: '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,6 +1286,11 @@ export namespace Components {
* @default 'fixed'
*/
"size": 'cover' | 'fixed';
+ /**
+ * The text shown in the datetime header when the start of the range is empty, also used by screen readers to announce the start date. Only applies when `selectionMode="range"`.
+ * @default 'Start date'
+ */
+ "startDateLabel": string;
/**
* The theme determines the visual appearance of the component.
*/
@@ -7070,6 +7085,11 @@ declare namespace LocalJSX {
* @default 'Done'
*/
"doneText"?: string;
+ /**
+ * The text shown in the datetime header when the end of the range is empty, also used by screen readers to announce the end date. 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
@@ -7122,6 +7142,7 @@ declare namespace LocalJSX {
"monthValues"?: number[] | number | string;
/**
* 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;
@@ -7173,6 +7194,10 @@ declare namespace LocalJSX {
* @default false
*/
"readonly"?: boolean;
+ /**
+ * Controls the date selection behavior. `'multiple'` enables selecting multiple individual dates. `'range'` enables selecting a start and end date range. When unset, single-date selection is used. Range selection only works with `presentation="date"` and `preferWheel="false"`.
+ */
+ "selectionMode"?: '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
@@ -7203,6 +7228,11 @@ declare namespace LocalJSX {
* @default 'fixed'
*/
"size"?: 'cover' | 'fixed';
+ /**
+ * The text shown in the datetime header when the start of the range is empty, also used by screen readers to announce the start date. Only applies when `selectionMode="range"`.
+ * @default 'Start date'
+ */
+ "startDateLabel"?: string;
/**
* The theme determines the visual appearance of the component.
*/
@@ -10721,6 +10751,9 @@ declare namespace LocalJSX {
"locale": string;
"firstDayOfWeek": number;
"multiple": boolean;
+ "selectionMode": 'multiple' | 'range';
+ "startDateLabel": string;
+ "endDateLabel": string;
"value": string | string[] | null;
"showDefaultTitle": boolean;
"showDefaultButtons": boolean;
diff --git a/core/src/components/datetime-button/datetime-button.tsx b/core/src/components/datetime-button/datetime-button.tsx
index 77853a721c5..6b7370bc44b 100644
--- a/core/src/components/datetime-button/datetime-button.tsx
+++ b/core/src/components/datetime-button/datetime-button.tsx
@@ -197,7 +197,16 @@ export class DatetimeButton implements ComponentInterface {
return;
}
- const { value, locale, formatOptions, hourCycle, preferWheel, multiple, titleSelectedDatesFormatter } = datetimeEl;
+ const {
+ value,
+ locale,
+ formatOptions,
+ hourCycle,
+ preferWheel,
+ multiple,
+ selectionMode,
+ titleSelectedDatesFormatter,
+ } = datetimeEl;
const parsedValues = this.getParsedDateValues(value);
@@ -213,6 +222,9 @@ export class DatetimeButton implements ComponentInterface {
return;
}
+ const isRangeMode = selectionMode === 'range';
+ const isMultipleMode = selectionMode === 'multiple' || (selectionMode === undefined && multiple);
+
/**
* If developers incorrectly use multiple="true"
* with non "date" datetimes, then just select
@@ -222,17 +234,14 @@ export class DatetimeButton implements ComponentInterface {
*/
const firstParsedDatetime = parsedDatetimes[0];
const computedHourCycle = getHourCycle(locale, hourCycle);
+ const dateFormatOptions = formatOptions?.date ?? { month: 'short', day: 'numeric', year: 'numeric' };
this.dateText = this.timeText = undefined;
switch (datetimePresentation) {
case 'date-time':
case 'time-date':
- const dateText = getLocalizedDateTime(
- locale,
- firstParsedDatetime,
- formatOptions?.date ?? { month: 'short', day: 'numeric', year: 'numeric' }
- );
+ const dateText = getLocalizedDateTime(locale, firstParsedDatetime, dateFormatOptions);
const timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle, formatOptions?.time);
if (preferWheel) {
this.dateText = `${dateText} ${timeText}`;
@@ -242,7 +251,20 @@ export class DatetimeButton implements ComponentInterface {
}
break;
case 'date':
- if (multiple && parsedValues.length !== 1) {
+ if (isRangeMode && parsedValues.length === 2) {
+ // Range mode: display "startDate - endDate"
+ const startDate = getLocalizedDateTime(locale, parsedDatetimes[0], dateFormatOptions);
+ const endDate = getLocalizedDateTime(locale, parsedDatetimes[1], dateFormatOptions);
+ let rangeText = `${startDate} - ${endDate}`;
+ if (titleSelectedDatesFormatter !== undefined) {
+ try {
+ rangeText = titleSelectedDatesFormatter(parsedValues);
+ } catch (e) {
+ printIonError('[ion-datetime-button] - Exception in provided `titleSelectedDatesFormatter`:', e);
+ }
+ }
+ this.dateText = rangeText;
+ } else if (isMultipleMode && parsedValues.length !== 1) {
let headerText = `${parsedValues.length} days`; // default/fallback for multiple selection
if (titleSelectedDatesFormatter !== undefined) {
try {
@@ -253,11 +275,7 @@ export class DatetimeButton implements ComponentInterface {
}
this.dateText = headerText;
} else {
- this.dateText = getLocalizedDateTime(
- locale,
- firstParsedDatetime,
- formatOptions?.date ?? { month: 'short', day: 'numeric', year: 'numeric' }
- );
+ this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, dateFormatOptions);
}
break;
case 'time':
diff --git a/core/src/components/datetime/datetime-interface.ts b/core/src/components/datetime/datetime-interface.ts
index c126b8d7282..ae7bebf867f 100644
--- a/core/src/components/datetime/datetime-interface.ts
+++ b/core/src/components/datetime/datetime-interface.ts
@@ -36,6 +36,11 @@ export type DatetimeHighlight = { date: string } & DatetimeHighlightStyle;
export type DatetimeHighlightCallback = (dateIsoString: string) => DatetimeHighlightStyle | undefined;
+export interface DatetimeRangeParts {
+ start: DatetimeParts;
+ end?: DatetimeParts;
+}
+
export type DatetimeHourCycle = 'h11' | 'h12' | 'h23' | 'h24';
/**
diff --git a/core/src/components/datetime/datetime.common.scss b/core/src/components/datetime/datetime.common.scss
index 77f86cb9da6..f2078d4d1ad 100644
--- a/core/src/components/datetime/datetime.common.scss
+++ b/core/src/components/datetime/datetime.common.scss
@@ -273,6 +273,8 @@
:host .calendar-day-wrapper {
display: flex;
+ position: relative;
+
align-items: center;
justify-content: center;
@@ -285,6 +287,100 @@
min-height: 0;
overflow: visible;
+
+ /**
+ * Create a stacking context so the range track
+ * (::before with z-index: -1) stays behind the
+ * day button but above the host background.
+ */
+ z-index: 0;
+}
+
+// Range Selection Track
+// -----------------------------------
+
+/**
+ * Each day in the range draws its own piece of the track with a
+ * ::before. In-range days fill the cell, while the start and end
+ * caps cover the half between the pill center and the next day.
+ *
+ * Grid columns are `1fr`, so cell edges land on fractional pixels
+ * and a cell's track can fall a hairline short of its neighbor's.
+ * Each day therefore extends 1px toward the next day in the range,
+ * which the next day paints over except inside such a gap.
+ *
+ * @prop --range-background: The background color of the range selection track.
+ */
+:host .calendar-day-wrapper-in-range::before,
+:host .calendar-day-wrapper-range-start::before,
+:host .calendar-day-wrapper-range-end::before {
+ position: absolute;
+
+ top: 50%;
+
+ inset-inline-start: 0;
+ inset-inline-end: 0;
+
+ height: var(--range-track-height);
+
+ transform: translateY(-50%);
+
+ /**
+ * `--range-background` is translucent by default, so it is layered
+ * over the datetime background to paint an opaque track. Where two
+ * days overlap, the color stays the same instead of doubling up
+ * into a darker line.
+ */
+ background: linear-gradient(var(--range-background), var(--range-background)),
+ linear-gradient(var(--background), var(--background));
+
+ content: "";
+
+ z-index: -1;
+}
+
+:host .calendar-day-wrapper-range-start::before {
+ inset-inline-start: 50%;
+}
+
+:host .calendar-day-wrapper-range-end::before {
+ inset-inline-end: 50%;
+}
+
+/**
+ * Only extend toward a day that draws the track as well. The last
+ * day of a week is followed by the next row, and the next day is
+ * unpainted when it ends the selection or is disabled inside the
+ * range, so extending there would leave a 1px nub.
+ */
+:host .calendar-day-wrapper-in-range:not(:nth-child(7n)):has(+ .calendar-day-wrapper-in-range)::before,
+:host .calendar-day-wrapper-in-range:not(:nth-child(7n)):has(+ .calendar-day-wrapper-range-end)::before,
+:host .calendar-day-wrapper-range-start:not(:nth-child(7n)):has(+ .calendar-day-wrapper-in-range)::before,
+:host .calendar-day-wrapper-range-start:not(:nth-child(7n)):has(+ .calendar-day-wrapper-range-end)::before {
+ inset-inline-end: -1px;
+}
+
+/**
+ * When a single day is both the start and end of the range,
+ * do not show a track since there is nothing to connect.
+ */
+:host .calendar-day-wrapper-range-start.calendar-day-wrapper-range-end::before {
+ display: none;
+}
+
+/**
+ * When the component is disabled, fade the whole grid once instead of
+ * fading each day. Fading a day makes it its own compositing group, so
+ * the 1px that each day extends toward the next would be painted twice
+ * and read as a darker seam. The day buttons drop their own disabled
+ * opacity so the grid is the only thing applying it.
+ */
+:host(.datetime-disabled.datetime-range) .calendar-month-grid {
+ opacity: 0.4;
+}
+
+:host(.datetime-disabled.datetime-range) .calendar-month-grid .calendar-day[disabled] {
+ opacity: 1;
}
/**
diff --git a/core/src/components/datetime/datetime.ionic.scss b/core/src/components/datetime/datetime.ionic.scss
index 42490906491..5d11068fcab 100644
--- a/core/src/components/datetime/datetime.ionic.scss
+++ b/core/src/components/datetime/datetime.ionic.scss
@@ -11,7 +11,7 @@
*/
--background: #{globals.$ion-bg-surface-default};
--focus-ring-color: #{globals.$ion-border-focus-default};
- --focus-ring-width: #{globals.$ion-border-radius-025};
+ --focus-ring-width: #{globals.$ion-border-size-050};
/**
* TODO(FW-6931): Remove these variables upon solving the root cause
*/
@@ -211,6 +211,101 @@
color: globals.$ion-text-default;
}
+// Range Selection
+// -----------------------------------
+
+:host {
+ --range-background: #{globals.$ion-semantics-primary-100};
+ --range-track-height: #{globals.$ion-scale-1200};
+}
+
+// Range start: left-pill shape (rounded left, square right)
+.calendar-day.calendar-day-range-start {
+ @include globals.border-radius(globals.$ion-border-radius-full, 0, 0, globals.$ion-border-radius-full);
+
+ background: globals.$ion-semantics-primary-100;
+ color: globals.$ion-text-default;
+}
+
+// Range end: right-pill shape (square left, rounded right)
+.calendar-day.calendar-day-range-end {
+ @include globals.border-radius(0, globals.$ion-border-radius-full, globals.$ion-border-radius-full, 0);
+
+ background: globals.$ion-semantics-primary-100;
+ color: globals.$ion-text-default;
+}
+
+// Single day range: both start and end, restore full circle
+.calendar-day.calendar-day-range-start.calendar-day-range-end {
+ @include globals.border-radius(globals.$ion-border-radius-full);
+}
+
+// In-range days: rectangular, no rounding
+.calendar-day.calendar-day-in-range {
+ @include globals.border-radius(0);
+
+ background: none;
+ color: globals.$ion-text-default;
+}
+
+// Current day keeps its blue text even when part of a range
+.calendar-day.calendar-day-today.calendar-day-in-range {
+ color: globals.$ion-semantics-primary-base;
+}
+
+.calendar-day.calendar-day-today.calendar-day-range-start,
+.calendar-day.calendar-day-today.calendar-day-range-end {
+ color: globals.$ion-semantics-primary-base;
+}
+
+// Hovered range start/end/in-range days
+@media (any-hover: hover) {
+ .calendar-day.calendar-day-range-start:not([disabled]):not(:active):hover,
+ .calendar-day.calendar-day-range-end:not([disabled]):not(:active):hover,
+ .calendar-day.calendar-day-in-range:not([disabled]):not(:active):hover {
+ background: globals.$ion-semantics-primary-200;
+ }
+}
+
+// Pressed range start/end/in-range days
+.calendar-day.calendar-day-range-start:active,
+.calendar-day.calendar-day-range-end:active,
+.calendar-day.calendar-day-in-range:active {
+ background: globals.$ion-bg-primary-subtle-press;
+}
+
+// The ionic theme uses color (not opacity) for disabled days, so undo
+// the grid-level opacity from common.scss to avoid double-fading.
+:host(.datetime-disabled.datetime-range) .calendar-month-grid {
+ opacity: 1;
+}
+
+// Disabled range days show faded text over the range track.
+:host(:not(.datetime-readonly)) .calendar-day.calendar-day-range-start[disabled],
+:host(:not(.datetime-readonly)) .calendar-day.calendar-day-range-end[disabled],
+:host(:not(.datetime-readonly)) .calendar-day.calendar-day-in-range[disabled] {
+ color: globals.$ion-primitives-neutral-500;
+
+ opacity: 1;
+}
+
+// Readonly range days should look like a normal range (not faded).
+// Needs higher specificity to override the general disabled day color.
+:host(.datetime-readonly) .calendar-day.calendar-day-range-start[disabled],
+:host(.datetime-readonly) .calendar-day.calendar-day-range-end[disabled] {
+ color: globals.$ion-text-default;
+}
+
+:host(.datetime-readonly) .calendar-day.calendar-day-in-range[disabled] {
+ color: globals.$ion-text-default;
+}
+
+:host(.datetime-readonly) .calendar-day.calendar-day-today.calendar-day-in-range[disabled],
+:host(.datetime-readonly) .calendar-day.calendar-day-today.calendar-day-range-start[disabled],
+:host(.datetime-readonly) .calendar-day.calendar-day-today.calendar-day-range-end[disabled] {
+ color: globals.$ion-semantics-primary-base;
+}
+
// Time / Header
// -----------------------------------
:host .datetime-time {
diff --git a/core/src/components/datetime/datetime.ios.scss b/core/src/components/datetime/datetime.ios.scss
index 5f5855964db..a6af5333350 100644
--- a/core/src/components/datetime/datetime.ios.scss
+++ b/core/src/components/datetime/datetime.ios.scss
@@ -328,6 +328,76 @@
color: $text-color-step-700;
}
+// Range Selection
+// -----------------------------------
+
+:host {
+ --range-background: #{ion-color(primary, base, 0.2)};
+ --range-track-height: #{$datetime-ios-day-height};
+}
+
+:host(.ion-color) {
+ --range-background: #{current-color(base, 0.2)};
+}
+
+/**
+ * The wrapper has `height: 0`, which makes `top: 50%` on the range
+ * track resolve to 0 and pins the track to the top of the cell.
+ * Override to `auto` so the track stays vertically centered. Apply
+ * it to every wrapper in range mode so range rows are not taller
+ * than the other rows.
+ */
+:host(.datetime-range) .calendar-day-wrapper {
+ height: auto;
+}
+
+// Range start: left-pill shape (rounded left, square right)
+:host .calendar-day.calendar-day-range-start {
+ @include border-radius(50%, 0, 0, 50%);
+
+ background: ion-color(primary, base);
+ color: ion-color(primary, contrast);
+
+ font-weight: 600;
+}
+
+// Range end: right-pill shape (square left, rounded right)
+:host .calendar-day.calendar-day-range-end {
+ @include border-radius(0, 50%, 50%, 0);
+
+ background: ion-color(primary, base);
+ color: ion-color(primary, contrast);
+
+ font-weight: 600;
+}
+
+// Single day range: both start and end, restore full circle
+:host .calendar-day.calendar-day-range-start.calendar-day-range-end {
+ @include border-radius(50%);
+}
+
+// In-range days: rectangular, no rounding
+:host .calendar-day.calendar-day-in-range {
+ @include border-radius(0);
+
+ background: none;
+}
+
+:host(.ion-color) .calendar-day.calendar-day-range-start,
+:host(.ion-color) .calendar-day.calendar-day-range-end {
+ background: current-color(base);
+ color: current-color(contrast);
+}
+
+// Current day keeps blue text when in range
+:host .calendar-day.calendar-day-today.calendar-day-in-range {
+ color: ion-color(primary, base);
+}
+
+:host(.ion-color) .calendar-day.calendar-day-today.calendar-day-in-range {
+ color: current-color(base);
+}
+
// Time / Header
// -----------------------------------
:host .datetime-time {
diff --git a/core/src/components/datetime/datetime.md.scss b/core/src/components/datetime/datetime.md.scss
index c9eb839c224..15a87f4f166 100644
--- a/core/src/components/datetime/datetime.md.scss
+++ b/core/src/components/datetime/datetime.md.scss
@@ -186,6 +186,63 @@
color: $text-color-step-500;
}
+// Range Selection
+// -----------------------------------
+
+:host {
+ --range-background: #{ion-color(primary, base, 0.2)};
+ --range-track-height: #{$datetime-md-day-height};
+}
+
+:host(.ion-color) {
+ --range-background: #{current-color(base, 0.2)};
+}
+
+// Range start: left-pill shape (rounded left, square right)
+:host .calendar-day.calendar-day-range-start {
+ @include border-radius(50%, 0, 0, 50%);
+
+ background: ion-color(primary, base);
+ color: ion-color(primary, contrast);
+}
+
+// Range end: right-pill shape (square left, rounded right)
+:host .calendar-day.calendar-day-range-end {
+ @include border-radius(0, 50%, 50%, 0);
+
+ background: ion-color(primary, base);
+ color: ion-color(primary, contrast);
+}
+
+// Single day range: both start and end, restore full circle
+:host .calendar-day.calendar-day-range-start.calendar-day-range-end {
+ @include border-radius(50%);
+}
+
+// In-range days: rectangular, no rounding
+:host .calendar-day.calendar-day-in-range {
+ @include border-radius(0);
+
+ background: none;
+}
+
+:host(.ion-color) .calendar-day.calendar-day-range-start,
+:host(.ion-color) .calendar-day.calendar-day-range-end {
+ background: current-color(base);
+ color: current-color(contrast);
+}
+
+// Current day keeps blue text when in range
+:host .calendar-day.calendar-day-today.calendar-day-in-range {
+ border: none;
+
+ color: ion-color(primary, base);
+}
+
+:host(.ion-color) .calendar-day.calendar-day-today.calendar-day-in-range {
+ color: current-color(base);
+}
+
// Time / Header
// -----------------------------------
:host .datetime-time {
diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx
index 4e086f0b294..8f76cdc1841 100644
--- a/core/src/components/datetime/datetime.tsx
+++ b/core/src/components/datetime/datetime.tsx
@@ -16,6 +16,7 @@ import type {
DatetimePresentation,
DatetimeChangeEventDetail,
DatetimeParts,
+ DatetimeRangeParts,
TitleSelectedDatesFormatter,
DatetimeHighlight,
DatetimeHighlightStyle,
@@ -104,6 +105,11 @@ import { checkForPresentationFormatMismatch, warnIfTimeZoneProvided } from './ut
* @part calendar-day today - The calendar day that contains the current day.
* @part calendar-day disabled - The calendar day that is disabled.
*
+ * @part range-start - The calendar day button that is the start of a range selection.
+ * @part range-end - The calendar day button that is the end of a range selection.
+ * @part range-selection - The calendar day button that is part of a range selection (includes start and end).
+ * @part range-track - The calendar day wrapper element whose ::before pseudo-element draws the range highlight track.
+ *
* @part datetime-header - The datetime header contains the content for the `title` slot and the selected date.
* @part datetime-title - The element that contains the `title` slot content.
* @part datetime-selected-date - The element that contains the selected date.
@@ -153,7 +159,7 @@ export class Datetime implements ComponentInterface {
@State() showMonthAndYear = false;
- @State() activeParts: DatetimeParts | DatetimeParts[] = [];
+ @State() activeParts: DatetimeParts | DatetimeParts[] | DatetimeRangeParts = [];
@State() workingParts: DatetimeParts = {
month: 5,
@@ -294,6 +300,31 @@ export class Datetime implements ComponentInterface {
return hasDatePresentation && !preferWheel;
}
+ private get isMultipleMode() {
+ return this.selectionMode === 'multiple' || (this.selectionMode === undefined && this.multiple);
+ }
+
+ private get isRangeMode() {
+ return this.selectionMode === 'range';
+ }
+
+ /**
+ * Type guard: returns true if the given activeParts is a DatetimeRangeParts object.
+ */
+ private isRangeParts(parts: DatetimeParts | DatetimeParts[] | DatetimeRangeParts): parts is DatetimeRangeParts {
+ return this.isRangeMode && parts !== null && typeof parts === 'object' && !Array.isArray(parts) && 'start' in parts;
+ }
+
+ /**
+ * Returns the current range parts if in range mode, or undefined otherwise.
+ */
+ private get rangeParts(): DatetimeRangeParts | undefined {
+ if (this.isRangeParts(this.activeParts)) {
+ return this.activeParts;
+ }
+ return undefined;
+ }
+
/**
* The text to display on the picker's cancel button.
*/
@@ -403,9 +434,35 @@ 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 the date selection behavior.
+ * `'multiple'` enables selecting multiple individual dates.
+ * `'range'` enables selecting a start and end date range.
+ * When unset, single-date selection is used.
+ *
+ * Range selection only works with `presentation="date"` and `preferWheel="false"`.
+ */
+ @Prop() selectionMode?: 'multiple' | 'range';
+
+ /**
+ * The text shown in the datetime header when the start of the range
+ * is empty, also used by screen readers to announce the start date.
+ * Only applies when `selectionMode="range"`.
+ */
+ @Prop() startDateLabel = 'Start date';
+
+ /**
+ * The text shown in the datetime header when the end of the range
+ * is empty, also used by screen readers to announce the end date.
+ * Only applies when `selectionMode="range"`.
+ */
+ @Prop() endDateLabel = 'End date';
+
/**
* Used to apply custom text and background colors to specific dates.
*
@@ -554,6 +611,20 @@ export class Datetime implements ComponentInterface {
async confirm(closeOverlay = false) {
const { isCalendarPicker, activeParts, preferWheel, workingParts } = this;
+ /**
+ * In range mode, only confirm when both start and end are selected.
+ */
+ if (this.isRangeMode) {
+ if (!this.isRangeParts(activeParts) || !activeParts.end) {
+ return;
+ }
+ this.setValue([convertDataToISO(activeParts.start), convertDataToISO(activeParts.end)]);
+ if (closeOverlay) {
+ this.closeParentOverlay(CONFIRM_ROLE);
+ }
+ return;
+ }
+
/**
* We only update the value if the presentation is not a calendar picker.
*/
@@ -571,7 +642,7 @@ export class Datetime implements ComponentInterface {
this.setValue(undefined);
}
} else {
- this.setValue(convertDataToISO(activeParts));
+ this.setValue(convertDataToISO(activeParts as DatetimeParts | DatetimeParts[]));
}
}
@@ -591,6 +662,23 @@ export class Datetime implements ComponentInterface {
*/
@Method()
async reset(startDate?: string) {
+ if (this.isRangeMode) {
+ /**
+ * In range mode, reset clears the full range. If a startDate
+ * is provided, the calendar navigates to that month without
+ * selecting anything so the user begins a fresh selection.
+ */
+ this.activeParts = [];
+ if (startDate) {
+ const parsed = parseDate(startDate);
+ if (parsed) {
+ const target = Array.isArray(parsed) ? parsed[0] : parsed;
+ this.setWorkingParts(target);
+ }
+ }
+ return;
+ }
+
this.processValue(startDate);
}
@@ -629,8 +717,8 @@ export class Datetime implements ComponentInterface {
}
private warnIfIncorrectValueUsage = () => {
- const { multiple, value } = this;
- if (!multiple && Array.isArray(value)) {
+ const { value } = this;
+ if (!this.isMultipleMode && !this.isRangeMode && Array.isArray(value)) {
/**
* We do some processing on the `value` array so
* that it looks more like an array when logged to
@@ -640,7 +728,17 @@ 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 neither selectionMode="multiple" nor selectionMode="range" is set. This is incorrect usage and may result in unexpected behaviors.
+
+ Value Passed: [${value.map((v) => `'${v}'`).join(', ')}]
+`,
+ this.el
+ );
+ }
+
+ if (this.isRangeMode && Array.isArray(value) && value.length > 2) {
+ printIonWarning(
+ `[ion-datetime] - More than 2 values were passed with selectionMode="range". Only the first and last values will be used.
Value Passed: [${value.map((v) => `'${v}'`).join(', ')}]
`,
@@ -671,6 +769,9 @@ export class Datetime implements ComponentInterface {
private getActivePart = () => {
const { activeParts } = this;
+ if (this.isRangeParts(activeParts)) {
+ return activeParts.start;
+ }
return Array.isArray(activeParts) ? activeParts[0] : activeParts;
};
@@ -692,6 +793,32 @@ export class Datetime implements ComponentInterface {
};
};
+ /**
+ * Checks whether the span from start to end contains any disabled day.
+ * Used by range mode to prevent selecting across disabled dates.
+ */
+ private rangeContainsDisabledDate = (start: DatetimeParts, end: DatetimeParts): boolean => {
+ const { minParts, maxParts, parsedDayValues, isDateEnabled } = this;
+
+ let cursor = getNextDay(start);
+ while (isBefore(cursor, end)) {
+ if (isDayDisabled(cursor, minParts, maxParts, parsedDayValues)) {
+ return true;
+ }
+ if (isDateEnabled) {
+ try {
+ if (!isDateEnabled(convertDataToISO(cursor))) {
+ return true;
+ }
+ } catch {
+ // Swallow user callback exceptions during validation
+ }
+ }
+ cursor = getNextDay(cursor);
+ }
+ return false;
+ };
+
private setActiveParts = (parts: DatetimeParts, removeDate = false) => {
/** if the datetime component is in readonly mode,
* allow browsing of the calendar without changing
@@ -703,7 +830,7 @@ export class Datetime implements ComponentInterface {
this.hasUnconfirmedSelection = true;
- const { multiple, minParts, maxParts, activeParts } = this;
+ const { minParts, maxParts, activeParts } = this;
/**
* When setting the active parts, it is possible
@@ -718,8 +845,10 @@ export class Datetime implements ComponentInterface {
const validatedParts = validateParts(parts, minParts, maxParts);
this.setWorkingParts(validatedParts);
- if (multiple) {
- const activePartsArray = Array.isArray(activeParts) ? activeParts : [activeParts];
+ if (this.isRangeMode) {
+ this.setActivePartsRange(validatedParts);
+ } else if (this.isMultipleMode) {
+ const activePartsArray = Array.isArray(activeParts) ? activeParts : [activeParts as DatetimeParts];
if (removeDate) {
this.activeParts = activePartsArray.filter((p) => !isSameDay(p, validatedParts));
} else {
@@ -739,6 +868,26 @@ export class Datetime implements ComponentInterface {
this.confirm();
};
+ /**
+ * Handles range selection logic when a day is clicked.
+ *
+ * Creating a new range: first click sets start, second sets end.
+ * Modifying an existing range:
+ * - Before range → new start
+ * - Within range → new end
+ * - After range → new end
+ * If the resulting span contains a disabled date, start a new range instead.
+ */
+ private setActivePartsRange = (parts: DatetimeParts) => {
+ const current = this.rangeParts;
+
+ if (!current || isBefore(parts, current.start) || this.rangeContainsDisabledDate(current.start, parts)) {
+ this.activeParts = { start: parts };
+ } else {
+ this.activeParts = { start: current.start, end: parts };
+ }
+ };
+
private get isCalendarPicker() {
const { presentation } = this;
return presentation === 'date' || presentation === 'date-time' || presentation === 'time-date';
@@ -1449,6 +1598,57 @@ export class Datetime implements ComponentInterface {
warnIfValueOutOfBounds(valueToProcess, minParts, maxParts);
}
+ /**
+ * Range mode: convert the value array into DatetimeRangeParts.
+ */
+ if (this.isRangeMode) {
+ /**
+ * The calendar navigates to the start of the range, or to the default
+ * date when there is nothing selected, so that an empty range mode
+ * opens on the same month as the other selection modes.
+ */
+ let navigateTo: DatetimeParts;
+
+ if (hasValue && Array.isArray(valueToProcess)) {
+ let rangeValues = valueToProcess;
+
+ // If more than 2 entries, use first and last
+ if (rangeValues.length > 2) {
+ rangeValues = [rangeValues[0], rangeValues[rangeValues.length - 1]];
+ }
+
+ let startParts = rangeValues[0];
+ let endParts = rangeValues.length > 1 ? rangeValues[1] : undefined;
+
+ // Normalize reversed pairs so earlier date is start
+ if (endParts && isAfter(startParts, endParts)) {
+ const temp = startParts;
+ startParts = endParts;
+ endParts = temp;
+ }
+
+ this.activeParts = endParts ? { start: startParts, end: endParts } : { start: startParts };
+ navigateTo = startParts;
+ } else {
+ this.activeParts = [];
+ navigateTo = Array.isArray(valueToProcess) ? valueToProcess[0] : valueToProcess;
+ }
+
+ const targetValue = clampDate(navigateTo, minParts, maxParts);
+ const { month, day, year } = targetValue;
+ const didChangeMonth =
+ (month !== undefined && month !== workingParts.month) || (year !== undefined && year !== workingParts.year);
+ const bodyIsVisible = el.classList.contains('datetime-ready');
+ const { isGridStyle, showMonthAndYear } = this;
+
+ if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear) {
+ this.animateToDate(targetValue);
+ } else {
+ this.setWorkingParts({ month, day, year, hour: 0, minute: 0, ampm: 'am' });
+ }
+ return;
+ }
+
/**
* If there are multiple values, clamp to the last one.
* This is because the last value is the one that the user
@@ -1548,9 +1748,16 @@ export class Datetime implements ComponentInterface {
};
componentWillLoad() {
- const { el, formatOptions, highlightedDates, multiple, presentation, preferWheel } = this;
+ const { el, formatOptions, highlightedDates, multiple, selectionMode, presentation, preferWheel } = this;
- if (multiple) {
+ if (selectionMode === undefined && multiple) {
+ printIonWarning(
+ '[ion-datetime] - The "multiple" property is deprecated. Use selectionMode="multiple" instead.',
+ el
+ );
+ }
+
+ if (this.isMultipleMode) {
if (presentation !== 'date') {
printIonWarning('[ion-datetime] - Multiple date selection is only supported for presentation="date".', el);
}
@@ -1560,6 +1767,23 @@ export class Datetime implements ComponentInterface {
}
}
+ if (this.isRangeMode) {
+ if (presentation !== 'date') {
+ printIonWarning('[ion-datetime] - Range selection is only supported for presentation="date".', el);
+ }
+
+ if (preferWheel) {
+ printIonWarning('[ion-datetime] - Range selection is not supported with preferWheel="true".', el);
+ }
+ }
+
+ if (selectionMode !== undefined && multiple) {
+ printIonWarning(
+ '[ion-datetime] - Both "selectionMode" and "multiple" are set. "selectionMode" takes precedence.',
+ el
+ );
+ }
+
if (highlightedDates !== undefined) {
if (presentation !== 'date' && presentation !== 'date-time' && presentation !== 'time-date') {
printIonWarning(
@@ -1686,6 +1910,13 @@ export class Datetime implements ComponentInterface {
* is disabled or readonly.
*/
const isButtonDisabled = disabled || readonly;
+
+ /**
+ * In range mode, the confirm button should be disabled until
+ * both a start and end date have been selected.
+ */
+ const isRangeIncomplete = this.isRangeMode && (!this.isRangeParts(this.activeParts) || !this.activeParts.end);
+ const isConfirmDisabled = isButtonDisabled || isRangeIncomplete;
const confirmFill = theme === 'ionic' ? 'solid' : undefined;
const hasSlottedButtons = this.el.querySelector('[slot="buttons"]') !== null;
if (!hasSlottedButtons && !showDefaultButtons && !showClearButton) {
@@ -1741,7 +1972,7 @@ export class Datetime implements ComponentInterface {
fill={confirmFill}
color={this.color}
onClick={() => this.confirm(true)}
- disabled={isButtonDisabled}
+ disabled={isConfirmDisabled}
>
{this.doneText}
@@ -2043,8 +2274,8 @@ export class Datetime implements ComponentInterface {
month: ev.detail.value,
});
- // Month wheel is navigation-only in multi-select mode as a fix for https://github.com/ionic-team/ionic-framework/issues/29673
- if (!this.multiple) {
+ // Month wheel is navigation-only in multi-select and range modes as a fix for https://github.com/ionic-team/ionic-framework/issues/29673
+ if (!this.isMultipleMode && !this.isRangeMode) {
this.setActiveParts({
...activePart,
month: ev.detail.value,
@@ -2092,8 +2323,8 @@ export class Datetime implements ComponentInterface {
year: ev.detail.value,
});
- // Year wheel is navigation-only in multi-select mode as a fix for https://github.com/ionic-team/ionic-framework/issues/29673
- if (!this.multiple) {
+ // Year wheel is navigation-only in multi-select and range modes as a fix for https://github.com/ionic-team/ionic-framework/issues/29673
+ if (!this.isMultipleMode && !this.isRangeMode) {
this.setActiveParts({
...activePart,
year: ev.detail.value,
@@ -2433,7 +2664,7 @@ export class Datetime implements ComponentInterface {
{getDaysOfMonth(month, year, this.firstDayOfWeek % 7, this.showAdjacentDays).map((dateObject, index) => {
const { day, dayOfWeek, isAdjacentDay } = dateObject;
- const { el, highlightedDates, isDateEnabled, multiple, showAdjacentDays } = this;
+ const { el, highlightedDates, isDateEnabled, showAdjacentDays } = this;
let _month = month;
let _year = year;
if (showAdjacentDays && isAdjacentDay && day !== null) {
@@ -2466,15 +2697,19 @@ export class Datetime implements ComponentInterface {
ariaLabel,
ariaSelected,
disabled: isDayDisabled,
+ isRangeStart,
+ isRangeEnd,
+ isInRange,
text,
} = getCalendarDayState(
this.locale,
referenceParts,
- this.activeParts,
+ this.isRangeParts(this.activeParts) ? [] : this.activeParts,
this.todayParts,
this.minParts,
this.maxParts,
- this.parsedDayValues
+ this.parsedDayValues,
+ this.rangeParts
);
const dateIsoString = convertDataToISO(referenceParts);
@@ -2507,13 +2742,19 @@ export class Datetime implements ComponentInterface {
const isButtonDisabled = isCalDayDisabled || isDatetimeDisabled;
+ /**
+ * Determine if this day is part of the range selection.
+ * Range styling takes priority over highlightedDates.
+ */
+ const isPartOfRange = isRangeStart || isRangeEnd || isInRange;
+
let dateStyle: DatetimeHighlightStyle | undefined = undefined;
/**
- * Custom highlight styles should not override the style for selected dates,
- * nor apply to "filler days" at the start of the grid.
+ * Custom highlight styles should not override the style for selected dates
+ * or range selection, nor apply to "filler days" at the start of the grid.
*/
- if (highlightedDates !== undefined && !isActive && day !== null && !isAdjacentDay) {
+ if (highlightedDates !== undefined && !isActive && !isPartOfRange && day !== null && !isAdjacentDay) {
dateStyle = getHighlightStyles(highlightedDates, dateIsoString, el);
}
@@ -2522,15 +2763,43 @@ export class Datetime implements ComponentInterface {
// "Filler days" at the beginning of the grid should not get the calendar day
// CSS parts added to them
if (!isCalendarPadding && !isAdjacentDay) {
- dateParts = `calendar-day${isActive ? ' active' : ''}${isToday ? ' today' : ''}${
- isCalDayDisabled ? ' disabled' : ''
- }`;
+ dateParts = `calendar-day${isActive || isRangeStart || isRangeEnd ? ' active' : ''}${
+ isToday ? ' today' : ''
+ }${isCalDayDisabled ? ' disabled' : ''}${isRangeStart ? ' range-start range-selection' : ''}${
+ isRangeEnd ? ' range-end range-selection' : ''
+ }${isInRange ? ' range-selection' : ''}`;
} else if (isAdjacentDay) {
dateParts = `calendar-day${isCalDayDisabled ? ' disabled' : ''}`;
}
+ /**
+ * Build the wrapper's shadow part attribute for range track styling.
+ */
+ const wrapperParts = isPartOfRange && !isCalendarPadding && !isAdjacentDay ? 'range-track' : undefined;
+
+ /**
+ * Build the aria-label for range mode accessibility.
+ */
+ let rangeAriaLabel = ariaLabel;
+ if (this.isRangeMode && ariaLabel) {
+ if (isRangeStart) {
+ rangeAriaLabel += `, ${this.startDateLabel}`;
+ }
+ if (isRangeEnd) {
+ rangeAriaLabel += `, ${this.endDateLabel}`;
+ }
+ }
+
return (
-