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 ( -
+
+
+

Inline - Disabled Range

+ +
+

Inline - Prefer Wheel

@@ -76,6 +81,9 @@

Inline - Prefer Wheel

diff --git a/core/src/components/datetime/test/multiple/datetime.e2e.ts b/core/src/components/datetime/test/multiple/datetime.e2e.ts index 22fada77bda..501d09b3ffc 100644 --- a/core/src/components/datetime/test/multiple/datetime.e2e.ts +++ b/core/src/components/datetime/test/multiple/datetime.e2e.ts @@ -388,7 +388,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await dropdownButton.click(); await page.waitForChanges(); - const monthOption = page.locator('.month-column ion-picker-column-option').nth(0); + const monthOption = page.locator('.month-column ion-picker-column-option').filter({ hasText: 'August' }); await monthOption.click(); await page.waitForChanges(); diff --git a/core/src/components/datetime/test/range-styling/index.html b/core/src/components/datetime/test/range-styling/index.html new file mode 100644 index 00000000000..116838c1226 --- /dev/null +++ b/core/src/components/datetime/test/range-styling/index.html @@ -0,0 +1,144 @@ + + + + + Datetime - Range Styling + + + + + + + + + + + + Datetime - Range Styling + + + +
+ +
+

Default Range Styling

+ +
+ + +
+

Custom --range-background

+ +
+ + +
+

Custom Shadow Parts (range-start, range-end)

+ +
+ + +
+

Color Prop (secondary)

+ +
+ + +
+

Range Spanning Today

+ +
+
+
+
+ + + + diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts b/core/src/components/datetime/test/range/datetime.e2e.ts new file mode 100644 index 00000000000..b3d207fc41b --- /dev/null +++ b/core/src/components/datetime/test/range/datetime.e2e.ts @@ -0,0 +1,603 @@ +import { expect } from '@playwright/test'; +import type { Locator } from '@playwright/test'; +import type { E2EPage, E2EPageOptions, ScreenshotFn } from '@utils/test/playwright'; +import { configs, test } from '@utils/test/playwright'; + +const RANGE_START = '2022-06-08'; +const RANGE_END = '2022-06-18'; +const RANGE_VALUE = [RANGE_START, RANGE_END]; + +interface DatetimeRangeConfig { + showDefaultTitle?: boolean; + showDefaultButtons?: boolean; + showClearButton?: boolean; + startDateLabel?: string; + endDateLabel?: string; + disabled?: boolean; + readonly?: boolean; +} + +class DatetimeRangeFixture { + readonly page: E2EPage; + datetime!: Locator; + + constructor(page: E2EPage) { + this.page = page; + } + + async goto(config: E2EPageOptions, value?: string[], datetimeConfig?: DatetimeRangeConfig) { + const { showDefaultTitle, showDefaultButtons, showClearButton, startDateLabel, endDateLabel, disabled, readonly } = + datetimeConfig ?? {}; + + const valueScript = value ? `datetime.value = [${value.map((v) => `'${v}'`).join(',')}];` : ''; + + const attrs = [ + 'locale="en-US"', + 'presentation="date"', + 'selection-mode="range"', + showDefaultTitle ? 'show-default-title="true"' : '', + showDefaultButtons ? 'show-default-buttons="true"' : '', + showClearButton ? 'show-clear-button="true"' : '', + startDateLabel ? `start-date-label="${startDateLabel}"` : '', + endDateLabel ? `end-date-label="${endDateLabel}"` : '', + disabled ? 'disabled="true"' : '', + readonly ? 'readonly="true"' : '', + ] + .filter(Boolean) + .join(' '); + + await this.page.setContent( + ` + + + `, + config + ); + + this.datetime = this.page.locator('ion-datetime'); + await this.page.locator('.datetime-ready').waitFor(); + + return this.datetime; + } + + /** + * Helper to get a day button in the visible month by day number. + * Uses .calendar-month:nth-child(2) to target the visible (center) month. + */ + getVisibleDay(day: number) { + return this.datetime.locator(`.calendar-month:nth-child(2) [data-day="${day}"]`); + } + + /** + * Helper to build an ISO date string from a visible day button's data attributes. + */ + async getISOFromDay(day: Locator) { + const year = await day.getAttribute('data-year'); + let month = await day.getAttribute('data-month'); + if (month && month.length < 2) month = '0' + month; + const dayNum = await day.getAttribute('data-day'); + const paddedDay = dayNum && dayNum.length < 2 ? '0' + dayNum : dayNum; + return `${year}-${month}-${paddedDay}`; + } + + async expectRangeDatePicker(id: string, screenshot: ScreenshotFn) { + await expect(this.datetime).toHaveScreenshot(screenshot(`datetime-range-${id}`)); + } +} + +/** + * Visual regression tests across modes and directions. + */ +configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('datetime: range selection (visual regressions)'), () => { + let fixture: DatetimeRangeFixture; + test.beforeEach(async ({ page }) => { + fixture = new DatetimeRangeFixture(page); + }); + + test('committed range should not have visual regressions', async () => { + await fixture.goto(config, RANGE_VALUE); + await fixture.expectRangeDatePicker('committed', screenshot); + }); + + test('partial range (start only) should not have visual regressions', async () => { + await fixture.goto(config, [RANGE_START]); + await fixture.expectRangeDatePicker('partial', screenshot); + }); + + test('disabled range should not have visual regressions', async () => { + await fixture.goto(config, RANGE_VALUE, { disabled: true }); + await fixture.expectRangeDatePicker('disabled', screenshot); + }); + + test('readonly range should not have visual regressions', async () => { + await fixture.goto(config, RANGE_VALUE, { readonly: true }); + await fixture.expectRangeDatePicker('readonly', screenshot); + }); + }); +}); + +/** + * Functional tests — behavior is the same across modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('datetime: range selection (functionality)'), () => { + let fixture: DatetimeRangeFixture; + test.beforeEach(async ({ page }) => { + fixture = new DatetimeRangeFixture(page); + }); + + // --------------------------------------------------------------- + // Basic range selection + // --------------------------------------------------------------- + + test('clicking two days should select a range', async ({ page }) => { + const datetime = await fixture.goto(config); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + const day8 = fixture.getVisibleDay(8); + const day18 = fixture.getVisibleDay(18); + + await day8.click(); + await page.waitForChanges(); + + // After first click, no ionChange yet (range incomplete) + expect(ionChangeSpy).not.toHaveReceivedEvent(); + + await day18.click(); + await ionChangeSpy.next(); + + const startISO = await fixture.getISOFromDay(day8); + const endISO = await fixture.getISOFromDay(day18); + await expect(datetime).toHaveJSProperty('value', [startISO, endISO]); + }); + + test('clicking a day before the start should set a new start', async ({ page }) => { + const datetime = await fixture.goto(config, RANGE_VALUE); + + const day3 = datetime.locator('[data-month="6"][data-day="3"]'); + await day3.click(); + await page.waitForChanges(); + + // Clicking before the start resets to a new start — range is now partial + await expect(day3).toHaveClass(/calendar-day-active/); + }); + + test('clicking a day within an existing range should set a new end', async ({ page }) => { + const datetime = await fixture.goto(config, RANGE_VALUE); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + const day12 = datetime.locator('[data-month="6"][data-day="12"]'); + await day12.click(); + await ionChangeSpy.next(); + + await expect(datetime).toHaveJSProperty('value', ['2022-06-08', '2022-06-12']); + }); + + test('clicking a day after the end should set a new end', async ({ page }) => { + const datetime = await fixture.goto(config, RANGE_VALUE); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + const day22 = datetime.locator('[data-month="6"][data-day="22"]'); + await day22.click(); + await ionChangeSpy.next(); + + await expect(datetime).toHaveJSProperty('value', ['2022-06-08', '2022-06-22']); + }); + + // --------------------------------------------------------------- + // Single-day range + // --------------------------------------------------------------- + + test('clicking the same day twice should create a single-day range', async ({ page }) => { + const datetime = await fixture.goto(config); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + const day10 = fixture.getVisibleDay(10); + await day10.click(); + await page.waitForChanges(); + await day10.click(); + await ionChangeSpy.next(); + + const dayISO = await fixture.getISOFromDay(day10); + await expect(datetime).toHaveJSProperty('value', [dayISO, dayISO]); + }); + + // --------------------------------------------------------------- + // CSS classes + // --------------------------------------------------------------- + + test('range start, end, and in-range days should have correct classes', async () => { + const datetime = await fixture.goto(config, RANGE_VALUE); + + const day8 = datetime.locator('[data-month="6"][data-day="8"]'); + const day12 = datetime.locator('[data-month="6"][data-day="12"]'); + const day18 = datetime.locator('[data-month="6"][data-day="18"]'); + const day5 = datetime.locator('[data-month="6"][data-day="5"]'); + + await expect(day8).toHaveClass(/calendar-day-range-start/); + await expect(day18).toHaveClass(/calendar-day-range-end/); + await expect(day12).toHaveClass(/calendar-day-in-range/); + await expect(day5).not.toHaveClass(/calendar-day-in-range/); + await expect(day5).not.toHaveClass(/calendar-day-range-start/); + }); + + // --------------------------------------------------------------- + // Screen reader labels + // --------------------------------------------------------------- + + test('start and end days should be announced with their labels', async () => { + const datetime = await fixture.goto(config, RANGE_VALUE); + + const day8 = datetime.locator('[data-month="6"][data-day="8"]'); + const day12 = datetime.locator('[data-month="6"][data-day="12"]'); + const day18 = datetime.locator('[data-month="6"][data-day="18"]'); + + await expect(day8).toHaveAttribute('aria-label', 'Wednesday, June 8, Start date'); + await expect(day18).toHaveAttribute('aria-label', 'Saturday, June 18, End date'); + // Days inside the range are not endpoints, so they get no label + await expect(day12).toHaveAttribute('aria-label', 'Sunday, June 12'); + }); + + test('single-day range should be announced as both start and end', async () => { + const datetime = await fixture.goto(config, [RANGE_START, RANGE_START]); + + const day8 = datetime.locator('[data-month="6"][data-day="8"]'); + + await expect(day8).toHaveAttribute('aria-label', 'Wednesday, June 8, Start date, End date'); + }); + + test('days should be announced with custom labels', async () => { + const datetime = await fixture.goto(config, RANGE_VALUE, { + startDateLabel: 'Check in', + endDateLabel: 'Check out', + }); + + const day8 = datetime.locator('[data-month="6"][data-day="8"]'); + const day18 = datetime.locator('[data-month="6"][data-day="18"]'); + + await expect(day8).toHaveAttribute('aria-label', 'Wednesday, June 8, Check in'); + await expect(day18).toHaveAttribute('aria-label', 'Saturday, June 18, Check out'); + }); + + // --------------------------------------------------------------- + // ionChange event detail + // --------------------------------------------------------------- + + test('ionChange should emit with array value detail', async ({ page }) => { + await fixture.goto(config); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + const day8 = fixture.getVisibleDay(8); + const day18 = fixture.getVisibleDay(18); + + await day8.click(); + await day18.click(); + await ionChangeSpy.next(); + + const startISO = await fixture.getISOFromDay(day8); + const endISO = await fixture.getISOFromDay(day18); + expect(ionChangeSpy).toHaveReceivedEventDetail({ + value: [startISO, endISO], + }); + }); + + // --------------------------------------------------------------- + // Header text + // --------------------------------------------------------------- + + test('header should show placeholder labels when no range is selected', async () => { + const datetime = await fixture.goto(config, undefined, { showDefaultTitle: true }); + const header = datetime.locator('.datetime-selected-date'); + + await expect(header).toHaveText(/Start date/); + await expect(header).toHaveText(/End date/); + }); + + test('header should show start date and End date placeholder for partial range', async () => { + const datetime = await fixture.goto(config, [RANGE_START], { showDefaultTitle: true }); + const header = datetime.locator('.datetime-selected-date'); + + await expect(header).toHaveText(/Jun 8/); + await expect(header).toHaveText(/End date/); + }); + + test('header should show both dates for a complete range', async () => { + const datetime = await fixture.goto(config, RANGE_VALUE, { showDefaultTitle: true }); + const header = datetime.locator('.datetime-selected-date'); + + await expect(header).toHaveText(/Jun 8/); + await expect(header).toHaveText(/Jun 18/); + }); + + test('header should use custom labels', async () => { + const datetime = await fixture.goto(config, undefined, { + showDefaultTitle: true, + startDateLabel: 'Check in', + endDateLabel: 'Check out', + }); + const header = datetime.locator('.datetime-selected-date'); + + await expect(header).toHaveText(/Check in/); + await expect(header).toHaveText(/Check out/); + }); + + // --------------------------------------------------------------- + // Confirm / Reset / Cancel with buttons + // --------------------------------------------------------------- + + test('with buttons, value should not update until confirm is called', async ({ page }) => { + const datetime = await fixture.goto(config, undefined, { showDefaultButtons: true }); + + const day8 = fixture.getVisibleDay(8); + const day18 = fixture.getVisibleDay(18); + + await day8.click(); + await day18.click(); + await page.waitForChanges(); + + // Value should not be set until confirmed + await expect(datetime).toHaveJSProperty('value', undefined); + + const startISO = await fixture.getISOFromDay(day8); + const endISO = await fixture.getISOFromDay(day18); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => el.confirm()); + await expect(datetime).toHaveJSProperty('value', [startISO, endISO]); + }); + + test('confirm should not close overlay with incomplete range', async ({ page }) => { + await page.setContent( + ` + + + + + `, + config + ); + const modal = page.locator('ion-modal'); + const datetime = page.locator('ion-datetime'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + await modal.evaluate((el: HTMLIonModalElement) => el.present()); + await page.locator('.datetime-ready').waitFor(); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => el.confirm(true)); + await page.waitForChanges(); + + // The modal is still open, so the range can be completed and confirmed. + await datetime.locator('[data-month="6"][data-day="18"]').click(); + await datetime.evaluate((el: HTMLIonDatetimeElement) => el.confirm(true)); + await ionModalDidDismiss.next(); + + expect(ionModalDidDismiss).toHaveReceivedEventTimes(1); + await expect(datetime).toHaveJSProperty('value', RANGE_VALUE); + }); + + test('reset should clear the range selection', async ({ page }) => { + const datetime = await fixture.goto(config, RANGE_VALUE, { showDefaultButtons: true }); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => el.reset()); + await page.waitForChanges(); + + const day8 = datetime.locator('[data-month="6"][data-day="8"]'); + const day18 = datetime.locator('[data-month="6"][data-day="18"]'); + + await expect(day8).not.toHaveClass(/calendar-day-range-start/); + await expect(day18).not.toHaveClass(/calendar-day-range-end/); + }); + + test('clear button should clear the range', async ({ page }) => { + const datetime = await fixture.goto(config, RANGE_VALUE, { + showDefaultButtons: true, + showClearButton: true, + }); + + const doneButton = datetime.locator('#confirm-button'); + const clearButton = datetime.locator('#clear-button'); + + await doneButton.click(); + await clearButton.click(); + await page.waitForChanges(); + + await expect(datetime).toHaveJSProperty('value', undefined); + }); + + // --------------------------------------------------------------- + // Programmatic value setting + // --------------------------------------------------------------- + + test('setting value programmatically should update the calendar', async () => { + const datetime = await fixture.goto(config); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => { + el.value = ['2022-06-05', '2022-06-15']; + }); + + const day5 = datetime.locator('[data-month="6"][data-day="5"]'); + const day10 = datetime.locator('[data-month="6"][data-day="10"]'); + const day15 = datetime.locator('[data-month="6"][data-day="15"]'); + + await expect(day5).toHaveClass(/calendar-day-range-start/); + await expect(day15).toHaveClass(/calendar-day-range-end/); + await expect(day10).toHaveClass(/calendar-day-in-range/); + }); + + // --------------------------------------------------------------- + // Disabled dates within range + // --------------------------------------------------------------- + + test('should not allow range spanning a disabled date', async ({ page }) => { + await page.setContent( + ` + + + `, + config + ); + + await page.locator('.datetime-ready').waitFor(); + const datetime = page.locator('ion-datetime'); + + const day10 = datetime.locator('[data-month="6"][data-day="10"]'); + const day20 = datetime.locator('[data-month="6"][data-day="20"]'); + + await day10.click(); + await page.waitForChanges(); + await day20.click(); + await page.waitForChanges(); + + // Range should not span the disabled 15th — should start a new range instead + await expect(day10).not.toHaveClass(/calendar-day-range-start/); + await expect(day20).toHaveClass(/calendar-day-range-start/); + }); + + // --------------------------------------------------------------- + // Disabled and readonly states + // --------------------------------------------------------------- + + test('disabled datetime should show range but not allow interaction', async () => { + const datetime = await fixture.goto(config, RANGE_VALUE, { disabled: true }); + + const day8 = datetime.locator('[data-month="6"][data-day="8"]'); + const day18 = datetime.locator('[data-month="6"][data-day="18"]'); + + // Range should still be visually displayed + await expect(day8).toHaveClass(/calendar-day-range-start/); + await expect(day18).toHaveClass(/calendar-day-range-end/); + + // Days should be disabled + await expect(day8).toHaveAttribute('disabled', ''); + }); + + test('readonly datetime should show range but not allow interaction', async () => { + const datetime = await fixture.goto(config, RANGE_VALUE, { readonly: true }); + + const day8 = datetime.locator('[data-month="6"][data-day="8"]'); + const day18 = datetime.locator('[data-month="6"][data-day="18"]'); + + await expect(day8).toHaveClass(/calendar-day-range-start/); + await expect(day18).toHaveClass(/calendar-day-range-end/); + }); + + // --------------------------------------------------------------- + // Month/year wheel should not change selection in range mode + // --------------------------------------------------------------- + + test('using month wheel should not change selection in range mode', async ({ page }) => { + await page.setContent( + ` + + + `, + config + ); + + await page.waitForSelector('.datetime-ready'); + const datetime = page.locator('ion-datetime'); + + const dropdownButton = page.locator('.calendar-month-year-toggle'); + await dropdownButton.click(); + await page.waitForChanges(); + + const monthOption = page.locator('.month-column ion-picker-column-option').filter({ hasText: 'August' }); + await monthOption.click(); + await page.waitForChanges(); + + // Value should remain unchanged — wheel is navigation-only in range mode + await expect(datetime).toHaveJSProperty('value', RANGE_VALUE); + }); + + test('using month wheel should not change selection in multiple mode', async ({ page }) => { + await page.setContent( + ` + + + `, + config + ); + + await page.waitForSelector('.datetime-ready'); + const datetime = page.locator('ion-datetime'); + + const dropdownButton = page.locator('.calendar-month-year-toggle'); + await dropdownButton.click(); + await page.waitForChanges(); + + const monthOption = page.locator('.month-column ion-picker-column-option').filter({ hasText: 'August' }); + await monthOption.click(); + await page.waitForChanges(); + + // Value should remain unchanged — wheel is navigation-only in multiple mode + await expect(datetime).toHaveJSProperty('value', ['2022-06-08', '2022-06-18']); + }); + + test('using year wheel should not change selection in range mode', async ({ page }) => { + await page.setContent( + ` + + + `, + config + ); + + await page.waitForSelector('.datetime-ready'); + const datetime = page.locator('ion-datetime'); + + const dropdownButton = page.locator('.calendar-month-year-toggle'); + await dropdownButton.click(); + await page.waitForChanges(); + + const yearOption = page.locator('.year-column ion-picker-column-option').filter({ hasText: '2023' }); + await yearOption.click(); + await page.waitForChanges(); + + // Value should remain unchanged — wheel is navigation-only in range mode + await expect(datetime).toHaveJSProperty('value', RANGE_VALUE); + }); + }); +}); + +/** + * RTL-specific visual regression tests to verify pill border-radius direction. + */ +configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['rtl'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('datetime: range selection RTL (visual regressions)'), () => { + let fixture: DatetimeRangeFixture; + test.beforeEach(async ({ page }) => { + fixture = new DatetimeRangeFixture(page); + }); + + test('range pills should be correctly rounded in RTL', async () => { + await fixture.goto(config, RANGE_VALUE); + await fixture.expectRangeDatePicker('rtl-pills', screenshot); + }); + }); +}); diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..f598d0f24aa Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..b26f5402edf Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..7c3049ed98f Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-rtl-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..d6239639810 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-rtl-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..1fe401c5939 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-rtl-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..99bafb68fbf Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ionic-md-rtl-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..ad1d9364e89 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..09058c7700c Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..42173be1468 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..20fb1decc3f Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c2cef591092 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..39a0bced7ab Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..fff49d9ac25 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..35a545eaf66 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..c2c994cff1d Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..970b149b4e6 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..056299861b4 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..52fc4a3dba3 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-committed-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..0b79aea44b3 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..f7555f549d0 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..addd8706830 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-rtl-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e41316aee78 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-rtl-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..cc8185294e2 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-rtl-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..e2823d6a87c Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ionic-md-rtl-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..01d0c01ce1e Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..fd05af77c7c Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..8f49cbd429c Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..10b9ecb3eeb Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..0ee7f7874d3 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..4a511861dd9 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..69d4ac5b13c Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..8bf59646363 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..497fe22217f Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..6ffddaaaef3 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..32401781eee Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..9b08ef1001d Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-disabled-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..40ba27cde1a Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..2d09f017ca3 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..f123a90cf0b Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-rtl-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e90d90380ce Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-rtl-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..d93edab076d Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-rtl-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..ea4e3c05f9b Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ionic-md-rtl-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..802f167a844 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..8a1bbcfd1f4 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..b6afc79aea4 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..c78172290c1 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..37e6f31c91b Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..1e671e366cd Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..32eab25ed1f Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..d6ab1580491 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..44f54875f51 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..68c281c7ef3 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..8f12d2e0cf9 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..6da89c8884d Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-partial-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..baf528f5610 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..0fb06d709e0 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..51bf56f83bf Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-rtl-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..d9bebde4cd4 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-rtl-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..62b9cd2e39b Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-rtl-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..24dea013f93 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ionic-md-rtl-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..0740cabb1b9 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..09058c7700c Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..42173be1468 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..2d3654d8582 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c2cef591092 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..39a0bced7ab Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..8baf7bb53fc Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..35a545eaf66 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..d5329c1a823 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..4bff4d43a59 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..056299861b4 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..52fc4a3dba3 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-readonly-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ionic-md-rtl-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..d6239639810 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ionic-md-rtl-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..1fe401c5939 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ionic-md-rtl-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..99bafb68fbf Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ionic-md-rtl-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ios-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..20fb1decc3f Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ios-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c2cef591092 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ios-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ios-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..39a0bced7ab Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-md-rtl-Mobile-Chrome-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-md-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..970b149b4e6 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-md-rtl-Mobile-Firefox-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-md-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..056299861b4 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-md-rtl-Mobile-Safari-linux.png b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-md-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..52fc4a3dba3 Binary files /dev/null and b/core/src/components/datetime/test/range/datetime.e2e.ts-snapshots/datetime-range-rtl-pills-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/range/index.html b/core/src/components/datetime/test/range/index.html new file mode 100644 index 00000000000..8631947c4fe --- /dev/null +++ b/core/src/components/datetime/test/range/index.html @@ -0,0 +1,254 @@ + + + + + Datetime - Range + + + + + + + + + + + + Datetime - Range + + + +
+ +
+

Basic Range (No Value)

+ +
+ + +
+

Programmatic Default Value

+ +
+ + +
+

Single Day Range

+ +
+ + +
+

With Header

+ +
+ + +
+

Custom Labels (Check in / Check out)

+ +
+ + +
+

With Buttons

+ +
+ + +
+

With Disabled Dates (15th disabled)

+ +
+ + +
+

Min/Max (Jun 5 - Jun 25)

+ +
+ + +
+

With Highlighted Dates

+ +
+ + +
+

Custom Formatter

+ +
+ + +
+

With Adjacent Days

+ +
+ + +
+

Range Spanning Today

+ +
+ + +
+

Disabled

+ +
+ + +
+

Readonly

+ +
+
+
+
+ + + + diff --git a/core/src/components/datetime/test/state.spec.ts b/core/src/components/datetime/test/state.spec.ts index 55f039a7c07..86ac07517ca 100644 --- a/core/src/components/datetime/test/state.spec.ts +++ b/core/src/components/datetime/test/state.spec.ts @@ -13,6 +13,9 @@ describe('getCalendarDayState()', () => { ariaSelected: null, ariaLabel: 'Tuesday, January 1', text: '1', + isRangeStart: false, + isRangeEnd: false, + isInRange: false, }); expect(getCalendarDayState('en-US', refA, refA, refC)).toEqual({ @@ -22,6 +25,9 @@ describe('getCalendarDayState()', () => { ariaSelected: 'true', ariaLabel: 'Tuesday, January 1', text: '1', + isRangeStart: false, + isRangeEnd: false, + isInRange: false, }); expect(getCalendarDayState('en-US', refA, refB, refA)).toEqual({ @@ -31,6 +37,9 @@ describe('getCalendarDayState()', () => { ariaSelected: null, ariaLabel: 'Today, Tuesday, January 1', text: '1', + isRangeStart: false, + isRangeEnd: false, + isInRange: false, }); expect(getCalendarDayState('en-US', refA, refA, refA)).toEqual({ @@ -40,6 +49,9 @@ describe('getCalendarDayState()', () => { ariaSelected: 'true', ariaLabel: 'Today, Tuesday, January 1', text: '1', + isRangeStart: false, + isRangeEnd: false, + isInRange: false, }); expect(getCalendarDayState('en-US', refA, refA, refA, undefined, undefined, [1])).toEqual({ @@ -49,6 +61,9 @@ describe('getCalendarDayState()', () => { ariaSelected: 'true', ariaLabel: 'Today, Tuesday, January 1', text: '1', + isRangeStart: false, + isRangeEnd: false, + isInRange: false, }); expect(getCalendarDayState('en-US', refA, refA, refA, undefined, undefined, [2])).toEqual({ @@ -58,8 +73,98 @@ describe('getCalendarDayState()', () => { ariaSelected: 'true', ariaLabel: 'Today, Tuesday, January 1', text: '1', + isRangeStart: false, + isRangeEnd: false, + isInRange: false, }); }); + + it('should return isRangeStart true when day matches range start', () => { + const start = { month: 6, day: 8, year: 2022 }; + const end = { month: 6, day: 18, year: 2022 }; + const today = { month: 6, day: 15, year: 2022 }; + const rangeParts = { start, end }; + + const result = getCalendarDayState('en-US', start, [], today, undefined, undefined, undefined, rangeParts); + expect(result.isRangeStart).toEqual(true); + expect(result.isRangeEnd).toEqual(false); + expect(result.isInRange).toEqual(false); + }); + + it('should return isRangeEnd true when day matches range end', () => { + const start = { month: 6, day: 8, year: 2022 }; + const end = { month: 6, day: 18, year: 2022 }; + const today = { month: 6, day: 15, year: 2022 }; + const rangeParts = { start, end }; + + const result = getCalendarDayState('en-US', end, [], today, undefined, undefined, undefined, rangeParts); + expect(result.isRangeEnd).toEqual(true); + expect(result.isRangeStart).toEqual(false); + expect(result.isInRange).toEqual(false); + }); + + it('should return isInRange true for days between range start and end', () => { + const start = { month: 6, day: 8, year: 2022 }; + const end = { month: 6, day: 18, year: 2022 }; + const middle = { month: 6, day: 12, year: 2022 }; + const today = { month: 6, day: 15, year: 2022 }; + const rangeParts = { start, end }; + + const result = getCalendarDayState('en-US', middle, [], today, undefined, undefined, undefined, rangeParts); + expect(result.isInRange).toEqual(true); + expect(result.isRangeStart).toEqual(false); + expect(result.isRangeEnd).toEqual(false); + }); + + it('should return all range flags false for days outside the range', () => { + const start = { month: 6, day: 8, year: 2022 }; + const end = { month: 6, day: 18, year: 2022 }; + const outside = { month: 6, day: 5, year: 2022 }; + const today = { month: 6, day: 15, year: 2022 }; + const rangeParts = { start, end }; + + const result = getCalendarDayState('en-US', outside, [], today, undefined, undefined, undefined, rangeParts); + expect(result.isRangeStart).toEqual(false); + expect(result.isRangeEnd).toEqual(false); + expect(result.isInRange).toEqual(false); + }); + + it('should return both isRangeStart and isRangeEnd true for single-day range', () => { + const day = { month: 6, day: 10, year: 2022 }; + const today = { month: 6, day: 15, year: 2022 }; + const rangeParts = { start: day, end: day }; + + const result = getCalendarDayState('en-US', day, [], today, undefined, undefined, undefined, rangeParts); + expect(result.isRangeStart).toEqual(true); + expect(result.isRangeEnd).toEqual(true); + expect(result.isInRange).toEqual(false); + }); + + it('should not mark disabled days as isInRange', () => { + const start = { month: 6, day: 8, year: 2022 }; + const end = { month: 6, day: 18, year: 2022 }; + const today = { month: 6, day: 15, year: 2022 }; + const rangeParts = { start, end }; + + // Day 12 is disabled via min (setting min to day 13 disables day 12) + const disabledDay = { month: 6, day: 12, year: 2022 }; + const minParts = { month: 6, day: 13, year: 2022 }; + + const result = getCalendarDayState('en-US', disabledDay, [], today, minParts, undefined, undefined, rangeParts); + expect(result.isInRange).toEqual(false); + expect(result.disabled).toEqual(true); + }); + + it('should return isRangeStart true but not isRangeEnd when range has no end', () => { + const start = { month: 6, day: 8, year: 2022 }; + const today = { month: 6, day: 15, year: 2022 }; + const rangeParts = { start }; + + const result = getCalendarDayState('en-US', start, [], today, undefined, undefined, undefined, rangeParts); + expect(result.isRangeStart).toEqual(true); + expect(result.isRangeEnd).toEqual(false); + expect(result.isInRange).toEqual(false); + }); }); describe('isDayDisabled()', () => { diff --git a/core/src/components/datetime/utils/state.ts b/core/src/components/datetime/utils/state.ts index fc05b968b65..d192ef33367 100644 --- a/core/src/components/datetime/utils/state.ts +++ b/core/src/components/datetime/utils/state.ts @@ -5,6 +5,7 @@ import type { DatetimeHighlightCallback, DatetimeHighlightStyle, DatetimeParts, + DatetimeRangeParts, } from '../datetime-interface'; import { isAfter, isBefore, isSameDay } from './comparison'; @@ -100,7 +101,8 @@ export const getCalendarDayState = ( todayParts: DatetimeParts, minParts?: DatetimeParts, maxParts?: DatetimeParts, - dayValues?: number[] + dayValues?: number[], + rangeParts?: DatetimeRangeParts ) => { /** * activeParts signals what day(s) are currently selected in the datetime. @@ -120,6 +122,23 @@ export const getCalendarDayState = ( const isToday = isSameDay(refParts, todayParts); const disabled = isDayDisabled(refParts, minParts, maxParts, dayValues); + /** + * Range state: determine if this day is the range start, end, or in between. + */ + let isRangeStart = false; + let isRangeEnd = false; + let isInRange = false; + + if (rangeParts) { + const { start, end } = rangeParts; + isRangeStart = isSameDay(refParts, start); + + if (end) { + isRangeEnd = isSameDay(refParts, end); + isInRange = !disabled && !isRangeStart && !isRangeEnd && isAfter(refParts, start) && isBefore(refParts, end); + } + } + /** * Note that we always return one object regardless of whether activeParts * was an array, since we pare down to one value for isActive. @@ -128,6 +147,9 @@ export const getCalendarDayState = ( disabled, isActive, isToday, + isRangeStart, + isRangeEnd, + isInRange, ariaSelected: isActive ? 'true' : null, ariaLabel: generateDayAriaLabel(locale, isToday, refParts), text: refParts.day != null ? getDay(locale, refParts) : null, diff --git a/core/src/components/toolbar/test/basic/toolbar.e2e.ts-snapshots/toolbar-basic-text-icon-buttons-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/toolbar/test/basic/toolbar.e2e.ts-snapshots/toolbar-basic-text-icon-buttons-ionic-md-rtl-light-Mobile-Chrome-linux.png index c59e21a59f1..0ed37741902 100644 Binary files a/core/src/components/toolbar/test/basic/toolbar.e2e.ts-snapshots/toolbar-basic-text-icon-buttons-ionic-md-rtl-light-Mobile-Chrome-linux.png and b/core/src/components/toolbar/test/basic/toolbar.e2e.ts-snapshots/toolbar-basic-text-icon-buttons-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/packages/angular/src/lazy/directives/proxies.ts b/packages/angular/src/lazy/directives/proxies.ts index 885b667cfef..b04470da4ff 100644 --- a/packages/angular/src/lazy/directives/proxies.ts +++ b/packages/angular/src/lazy/directives/proxies.ts @@ -700,7 +700,7 @@ Set `scrollEvents` to `true` to enable. @ProxyCmp({ - inputs: ['cancelText', 'clearText', 'color', 'dayValues', 'disabled', 'doneText', 'firstDayOfWeek', 'formatOptions', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', 'readonly', 'showAdjacentDays', 'showClearButton', 'showDefaultButtons', 'showDefaultTimeLabel', 'showDefaultTitle', 'size', 'theme', 'titleSelectedDatesFormatter', 'value', 'yearValues'], + inputs: ['cancelText', 'clearText', 'color', 'dayValues', 'disabled', 'doneText', 'endDateLabel', 'firstDayOfWeek', 'formatOptions', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', 'readonly', 'selectionMode', 'showAdjacentDays', 'showClearButton', 'showDefaultButtons', 'showDefaultTimeLabel', 'showDefaultTitle', 'size', 'startDateLabel', 'theme', 'titleSelectedDatesFormatter', 'value', 'yearValues'], methods: ['confirm', 'reset', 'cancel'] }) @Component({ @@ -708,7 +708,7 @@ Set `scrollEvents` to `true` to enable. changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['cancelText', 'clearText', 'color', 'dayValues', { name: 'disabled', transform: nullableBooleanAttribute }, 'doneText', 'firstDayOfWeek', 'formatOptions', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', { name: 'multiple', transform: nullableBooleanAttribute }, 'name', { name: 'preferWheel', transform: nullableBooleanAttribute }, 'presentation', { name: 'readonly', transform: nullableBooleanAttribute }, { name: 'showAdjacentDays', transform: nullableBooleanAttribute }, { name: 'showClearButton', transform: nullableBooleanAttribute }, { name: 'showDefaultButtons', transform: nullableBooleanAttribute }, { name: 'showDefaultTimeLabel', transform: nullableBooleanAttribute }, { name: 'showDefaultTitle', transform: nullableBooleanAttribute }, 'size', 'theme', 'titleSelectedDatesFormatter', 'value', 'yearValues'], + inputs: ['cancelText', 'clearText', 'color', 'dayValues', { name: 'disabled', transform: nullableBooleanAttribute }, 'doneText', 'endDateLabel', 'firstDayOfWeek', 'formatOptions', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', { name: 'multiple', transform: nullableBooleanAttribute }, 'name', { name: 'preferWheel', transform: nullableBooleanAttribute }, 'presentation', { name: 'readonly', transform: nullableBooleanAttribute }, 'selectionMode', { name: 'showAdjacentDays', transform: nullableBooleanAttribute }, { name: 'showClearButton', transform: nullableBooleanAttribute }, { name: 'showDefaultButtons', transform: nullableBooleanAttribute }, { name: 'showDefaultTimeLabel', transform: nullableBooleanAttribute }, { name: 'showDefaultTitle', transform: nullableBooleanAttribute }, 'size', 'startDateLabel', 'theme', 'titleSelectedDatesFormatter', 'value', 'yearValues'], outputs: ['ionCancel', 'ionChange', 'ionFocus', 'ionBlur'], standalone: false }) diff --git a/packages/angular/src/standalone/directives/datetime.ts b/packages/angular/src/standalone/directives/datetime.ts index f8ccd7ec918..fe46fa199f9 100644 --- a/packages/angular/src/standalone/directives/datetime.ts +++ b/packages/angular/src/standalone/directives/datetime.ts @@ -24,6 +24,7 @@ const DATETIME_INPUTS = [ 'dayValues', { name: 'disabled', transform: nullableBooleanAttribute }, 'doneText', + 'endDateLabel', 'firstDayOfWeek', 'formatOptions', 'highlightedDates', @@ -41,12 +42,14 @@ const DATETIME_INPUTS = [ { name: 'preferWheel', transform: nullableBooleanAttribute }, 'presentation', { name: 'readonly', transform: nullableBooleanAttribute }, + 'selectionMode', { name: 'showAdjacentDays', transform: nullableBooleanAttribute }, { name: 'showClearButton', transform: nullableBooleanAttribute }, { name: 'showDefaultButtons', transform: nullableBooleanAttribute }, { name: 'showDefaultTimeLabel', transform: nullableBooleanAttribute }, { name: 'showDefaultTitle', transform: nullableBooleanAttribute }, 'size', + 'startDateLabel', 'titleSelectedDatesFormatter', 'value', 'yearValues', diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index a6ef271d432..ed2909fed4d 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -351,6 +351,9 @@ export const IonDatetime: StencilVueComponent