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 ebfdb615049..a63c83d8ab9 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..2f54e1dad52 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,24 @@ 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.length > 1 ? parsedDatetimes[1] : parsedDatetimes[0], + 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 +279,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..d22919068c0 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,87 @@ 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 +// ----------------------------------- + +/** + * The ::before pseudo-element on the calendar-day-wrapper + * draws the colored background track that connects the + * range start to the range end. The track is vertically + * centered and sized to match the day button height. + * + * @prop --range-background: The background color of the range selection track. + */ +:host .calendar-day-wrapper-range-start::before, +:host .calendar-day-wrapper-in-range::before, +:host .calendar-day-wrapper-range-end::before { + position: absolute; + + /** + * Vertically center the track and size it to + * match the calendar day button height. + */ + top: 50%; + + height: var(--range-track-height); + + transform: translateY(-50%); + + background: var(--range-background); + + content: ""; + + z-index: -1; +} + +:host .calendar-day-wrapper-range-start::before { + inset-inline-start: 50%; + inset-inline-end: 0; +} + +:host .calendar-day-wrapper-in-range::before { + inset-inline-start: 0; + inset-inline-end: 0; +} + +:host .calendar-day-wrapper-range-end::before { + inset-inline-start: 0; + inset-inline-end: 50%; +} + +/** + * 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, move the opacity to the wrapper + * so both the track and day button fade equally. Without this, + * the day button becomes semi-transparent (via native disabled opacity) + * while the track stays opaque, causing a visible overlap. + */ +:host(.datetime-disabled) .calendar-day-wrapper-range-start, +:host(.datetime-disabled) .calendar-day-wrapper-in-range, +:host(.datetime-disabled) .calendar-day-wrapper-range-end { + opacity: 0.4; +} + +:host(.datetime-disabled) .calendar-day-wrapper-range-start .calendar-day[disabled], +:host(.datetime-disabled) .calendar-day-wrapper-in-range .calendar-day[disabled], +:host(.datetime-disabled) .calendar-day-wrapper-range-end .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..e894f3a6609 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,95 @@ 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; +} + +// 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..5d4ac50ab33 100644 --- a/core/src/components/datetime/datetime.ios.scss +++ b/core/src/components/datetime/datetime.ios.scss @@ -328,6 +328,65 @@ 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)}; +} + +// 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..e7ac65a9e4e 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 7bc145d116d..146906ceb53 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. @@ -145,7 +151,7 @@ export class Datetime implements ComponentInterface { @State() showMonthAndYear = false; - @State() activeParts: DatetimeParts | DatetimeParts[] = []; + @State() activeParts: DatetimeParts | DatetimeParts[] | DatetimeRangeParts = []; @State() workingParts: DatetimeParts = { month: 5, @@ -286,6 +292,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. */ @@ -395,9 +426,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. * @@ -546,6 +603,19 @@ 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) { + 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. */ @@ -563,7 +633,7 @@ export class Datetime implements ComponentInterface { this.setValue(undefined); } } else { - this.setValue(convertDataToISO(activeParts)); + this.setValue(convertDataToISO(activeParts as DatetimeParts | DatetimeParts[])); } } @@ -581,6 +651,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); } @@ -594,6 +681,15 @@ export class Datetime implements ComponentInterface { */ @Method() async cancel(closeOverlay = false) { + /** + * In range mode, clear the visible selection (including partial ranges) + * so the component returns to an empty state. The value property is + * left untouched, matching single/multiple mode behavior. + */ + if (this.isRangeMode) { + this.activeParts = []; + } + this.ionCancel.emit(); if (closeOverlay) { @@ -614,8 +710,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 @@ -625,7 +721,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(', ')}] `, @@ -656,6 +762,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; }; @@ -675,6 +784,33 @@ 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) || isSameDay(cursor, end)) { + if (isSameDay(cursor, end)) break; + 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 @@ -684,7 +820,7 @@ export class Datetime implements ComponentInterface { return; } - const { multiple, minParts, maxParts, activeParts } = this; + const { minParts, maxParts, activeParts } = this; /** * When setting the active parts, it is possible @@ -699,8 +835,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 { @@ -720,6 +858,66 @@ 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) { + // No range yet — start a new one + this.activeParts = { start: parts }; + return; + } + + const { start, end } = current; + + if (end) { + // A complete range exists + if (isBefore(parts, start)) { + // Before range → new start + this.activeParts = { start: parts }; + } else if (isSameDay(parts, start) || (isAfter(parts, start) && isBefore(parts, end))) { + // Within range (including start) → set new end + if (this.rangeContainsDisabledDate(start, parts)) { + this.activeParts = { start: parts }; + } else { + this.activeParts = { start, end: parts }; + } + } else { + // After or equal to end → new end + if (this.rangeContainsDisabledDate(start, parts)) { + this.activeParts = { start: parts }; + } else { + this.activeParts = { start, end: parts }; + } + } + } else { + // Only start date, no end yet + if (isSameDay(parts, start)) { + // Same day → single-day range + this.activeParts = { start, end: parts }; + } else if (isBefore(parts, start)) { + // Before start → new start + this.activeParts = { start: parts }; + } else { + // After start → set end if no disabled dates in between + if (this.rangeContainsDisabledDate(start, parts)) { + this.activeParts = { start: parts }; + } else { + this.activeParts = { start, end: parts }; + } + } + } + }; + private get isCalendarPicker() { const { presentation } = this; return presentation === 'date' || presentation === 'date-time' || presentation === 'time-date'; @@ -1394,6 +1592,49 @@ export class Datetime implements ComponentInterface { warnIfValueOutOfBounds(valueToProcess, minParts, maxParts); } + /** + * Range mode: convert the value array into DatetimeRangeParts. + */ + if (this.isRangeMode) { + 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 }; + + // Navigate calendar to the start date + const targetValue = clampDate(startParts, 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' }); + } + } else { + this.activeParts = []; + } + return; + } + /** * If there are multiple values, clamp to the last one. * This is because the last value is the one that the user @@ -1492,9 +1733,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); } @@ -1504,6 +1752,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( @@ -1630,6 +1895,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) { @@ -1685,7 +1957,7 @@ export class Datetime implements ComponentInterface { fill={confirmFill} color={this.color} onClick={() => this.confirm(true)} - disabled={isButtonDisabled} + disabled={isConfirmDisabled} > {this.doneText} @@ -2377,7 +2649,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) { @@ -2410,15 +2682,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); @@ -2451,13 +2727,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); } @@ -2466,15 +2748,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 = `${ariaLabel}, ${this.startDateLabel}`; + } + if (isRangeEnd) { + rangeAriaLabel = `${ariaLabel}, ${this.endDateLabel}`; + } + } + return ( -
+