Repository navigation
feat(datetime): enable range selection - #31581
OS-susmitabhowmik wants to merge 22 commits into
Conversation
Introduces the internal type that represents a date range as a start/end pair, used by activeParts when selectionMode="range" is enabled.
Adds the selectionMode property with 'multiple' and 'range' values, along with startDateLabel and endDateLabel props for range header placeholders. Deprecates the multiple boolean in favor of selectionMode="multiple" with backward compatibility. Includes validation warnings for misconfigured range/multiple usage.
Extends getCalendarDayState with an optional rangeParts parameter that returns isRangeStart, isInRange, and isRangeEnd flags. These drive the CSS class names and shadow parts applied to each calendar day wrapper.
Adds the core range selection behavior: setActivePartsRange handles start/end date picking with disabled-date validation, confirm suppresses emission until both dates are set, cancel clears partial ranges, reset clears and optionally navigates, and processValue normalizes programmatic range values (reversed pairs, >2 entries). Updates type narrowing across the component to accommodate the DatetimeRangeParts state shape.
Adds range CSS classes (calendar-day-wrapper-range-start, -in-range, -range-end) and shadow parts (range-start, range-end, range-selection, range-track) to the calendar day wrappers and buttons. Range styling takes priority over highlightedDates. Adds screen reader announcements for start/end dates using startDateLabel and endDateLabel props.
Adds ::before pseudo-element on .calendar-day-wrapper to draw the colored background track between range start and end. Introduces the --range-background CSS variable for customization. Adds range day styling across all three themes (ios, md, ionic) with appropriate colors for start/end buttons and in-range days.
Updates the datetime header to show range text (e.g. "Oct 1 - Oct 5") using startDateLabel/endDateLabel as placeholders for empty slots. Supports titleSelectedDatesFormatter in range mode. Updates ion-datetime-button to display "startDate - endDate" format when selectionMode="range" is active.
The confirm button is now disabled while the range selection is incomplete (missing an end date), preventing users from committing a half-finished range.
Adds two interactive test pages for manual visual testing of the date range feature across all themes and modes: - range/index.html: covers basic range, programmatic values, single-day range, header, custom labels, buttons, disabled dates, min/max, highlighted dates, custom formatter, adjacent days, and readonly. - range-styling/index.html: covers --range-background CSS variable, range-start/range-end/range-track shadow parts, custom track height, color prop, and a range spanning today.
Updates the range highlight track to match the Figma reference: - Track height matches the day button height (vertically centered) instead of filling the full wrapper - In-range days no longer show a circle background, only the track behind them is visible - Adds --range-track-height CSS variable per theme (ios: 40px, md: 42px, ionic: scale-1200) - Range start/end keep their selected day circle appearance
- Fix range track not visible by adding z-index: 0 stacking context on calendar-day-wrapper so the ::before track renders above the host background - Fix press/hover states on range days being overridden by the in-range background: none rule due to CSS specificity - Fix --focus-ring-width using wrong token ($ion-border-radius-025 → $ion-border-size-050) to match Figma border-size/050 spec - Add disabled overlay (rgba white 60%) for range days to match Figma - Remove invalid ::part(range-track)::before test case from range-styling - Align range test pages to match existing test page conventions
Add selectionMode, startDateLabel, and endDateLabel props to the public API manifest, TypeScript definitions, and Angular/Vue proxies.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Remove redundant `activeParts.start` check in confirm() since the type guard already guarantees start exists (strict-boolean-expressions). Add isRangeStart, isRangeEnd, isInRange to state.spec.ts expectations to match the new return shape from getCalendarDayState().
The toolbar-basic-text-icon-buttons ionic/md/rtl/light Chrome snapshot was inadvertently changed and no longer matches what CI renders.
This reverts commit 405bac7.
Reorder properties to satisfy stylelint order/properties-order rule and replace bare border-radius with the border-radius mixin in the disabled range overlay.
Move z-index after min-width/min-height/overflow in calendar-day-wrapper. Move @include border-radius before declarations in disabled range overlay.
…ed overlap - Apply pill shapes (left-pill start, right-pill end, rectangular in-range) consistently across ionic, iOS, and MD themes to prevent the range track from peeking out around circular day buttons. - Fix disabled overlap by moving opacity to the wrapper so the track and day button fade equally when the component is disabled. - Keep today's blue text visible when in range for iOS and MD. - Add readonly range overrides for ionic theme so range days don't look faded in readonly mode. - Add "Range Spanning Today" and "Disabled" test cases to range test page.
ShaneK
left a comment
There was a problem hiding this comment.
Looks really good so far, the core selection behaves the way the design doc describes! Just a few things to fix, mainly the month/year wheels still changing the selection, the RTL pill shape and the missing standalone Angular inputs, and there aren't any tests for range yet.
| return hasDatePresentation && !preferWheel; | ||
| } | ||
|
|
||
| private get isMultipleMode() { |
There was a problem hiding this comment.
This seems like a big issue. The month and year wheels still check !this.multiple before writing to the selection (month, year), so neither new mode is covered. With a Jun 8 - Jun 18 range, picking August on the month wheel changes the value to ['2022-06-08', '2022-08-08'] and fires ionChange, and the year wheel does the same. Picking January keeps value but drops the range, so the header shows "Sat, Jan 8 - End date".
Using selection-mode="multiple" hits this too since the wheel adds a date, so the fix from #29673 doesn't carry over to the new prop. Could both checks use these getters instead?
if (!this.isMultipleMode && !this.isRangeMode) {|
|
||
| // Range start: left-pill shape (rounded left, square right) | ||
| :host .calendar-day.calendar-day-range-start { | ||
| @include border-radius(50% 0 0 50%); |
There was a problem hiding this comment.
In RTL the start and end pills are rounded on the side touching the track and flat on the outside, on all three themes. The mixin gets one space separated list here, so it outputs a physical border-radius instead of the logical properties. Could you pass the values as separate arguments?
// range start
@include border-radius(50%, 0, 0, 50%);
// range end
@include border-radius(0, 50%, 50%, 0);The same goes for md and the ionic theme. LTR looks identical with the change.
|
|
||
| @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'], |
There was a problem hiding this comment.
The lazy proxies got the three new props, but the standalone DATETIME_INPUTS list didn't, so in a standalone app range mode only works through the kebab case selection-mode attribute. Binding [selectionMode] fails to compile with NG8002: Can't bind to 'selectionMode' since it isn't a known property of 'ion-datetime', and a plain selectionMode="range" compiles but quietly stays in single date mode. Adding selectionMode, startDateLabel and endDateLabel to that list fixes both.
| rangeAriaLabel = `${ariaLabel}, ${this.startDateLabel}`; | ||
| } | ||
| if (isRangeEnd) { | ||
| rangeAriaLabel = `${ariaLabel}, ${this.endDateLabel}`; |
There was a problem hiding this comment.
| rangeAriaLabel = `${ariaLabel}, ${this.startDateLabel}`; | |
| } | |
| if (isRangeEnd) { | |
| rangeAriaLabel = `${ariaLabel}, ${this.endDateLabel}`; | |
| rangeAriaLabel += `, ${this.startDateLabel}`; | |
| } | |
| if (isRangeEnd) { | |
| rangeAriaLabel += `, ${this.endDateLabel}`; |
With a single day range the second assignment overwrites the first, so the day is announced as "Friday, June 10, End date" with no start label. With this it reads "Friday, June 10, Start date, End date".
| if (this.isRangeParts(activeParts) && activeParts.end) { | ||
| this.setValue([convertDataToISO(activeParts.start), convertDataToISO(activeParts.end)]); | ||
| } |
There was a problem hiding this comment.
| if (this.isRangeParts(activeParts) && activeParts.end) { | |
| this.setValue([convertDataToISO(activeParts.start), convertDataToISO(activeParts.end)]); | |
| } | |
| if (!this.isRangeParts(activeParts) || !activeParts.end) { | |
| return; | |
| } | |
| this.setValue([convertDataToISO(activeParts.start), convertDataToISO(activeParts.end)]); |
With only a start date picked, confirm(true) still closes the overlay, so a slotted Done button dismisses the modal without committing anything. The default buttons avoid this because confirm is disabled there, so I think returning early here would match that?
| @@ -0,0 +1,250 @@ | |||
| <!DOCTYPE html> | |||
There was a problem hiding this comment.
I couldn't find any automated tests for range. The state.spec.ts changes only add false for the new fields, so nothing checks that the range flags are ever true, and there are no screenshots either. An RTL one would have caught the pill radius issue. Could you add an e2e spec?
| 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 }; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
| 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 }; | |
| } | |
| } | |
| } | |
| 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 }; | |
| } |
Nit: the "within" and "after" branches do the same thing, and a same day click works without its own case because rangeContainsDisabledDate(start, start) is false, so I think this can be one condition. The loop in that function can also just be while (isBefore(cursor, end)). Up to you!


Issue number: resolves internal
What is the current behavior?
ion-datetimesupports selecting a single date or multiple individual dates (via themultipleboolean prop). There is no way to select a contiguous range of dates with a start and end date.What is the new behavior?
selectionModeprop with"range"and"multiple"options. Single-date selection remains the default when unset.selectionMode="range") allows users to select a start and end date. The first tap sets the start, the second tap sets the end. Tapping again resets the range.startDateLabelandendDateLabelprops for customizing header text and screen reader announcements.--range-backgroundCSS custom property for theming the range track color.range-start,range-end,range-selection, andrange-trackCSS shadow parts.ion-datetime-buttondisplays the range as "startDate - endDate".multipleboolean prop in favor ofselectionMode="multiple".presentation="date"andpreferWheel="false".Does this introduce a breaking change?
The
multipleprop continues to work but is deprecated. A console warning is logged when used.Other information