Skip to content

feat(datetime): enable range selection - #31581

Open
OS-susmitabhowmik wants to merge 39 commits into
nextfrom
ROU-12704-datepicker-range
Open

OS-susmitabhowmik wants to merge 39 commits into
nextfrom
ROU-12704-datepicker-range

Conversation

@OS-susmitabhowmik

@OS-susmitabhowmik OS-susmitabhowmik commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Issue number: resolves internal


What is the current behavior?

ion-datetime supports selecting a single date or multiple individual dates (via the multiple boolean prop). There is no way to select a contiguous range of dates with a start and end date.

What is the new behavior?

  • Adds a new selectionMode prop with "range" and "multiple" options. Single-date selection remains the default when unset.
  • Range mode (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.
  • Range selection is visualized with pill-shaped start/end days and a colored track connecting them, consistent across ionic, iOS, and MD themes.
  • Today's date retains its blue text color when it falls within a range.
  • Adds startDateLabel and endDateLabel props for customizing header text and screen reader announcements.
  • Adds --range-background CSS custom property for theming the range track color.
  • Adds range-start, range-end, range-selection, and range-track CSS shadow parts.
  • The confirm button is disabled until both start and end dates are selected.
  • ion-datetime-button displays the range as "startDate - endDate".
  • Readonly ranges display normally (not faded); disabled ranges fade the track and pills equally.
  • Deprecates the multiple boolean prop in favor of selectionMode="multiple".
  • Range selection only works with presentation="date" and preferWheel="false".

Does this introduce a breaking change?

  • Yes
  • No

The multiple prop continues to work but is deprecated. A console warning is logged when used.

Other information

OS-susmitabhowmik and others added 13 commits October 13, 2026 18:18
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.
@vercel

vercel Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ionic-framework Ready Ready Preview Oct 27, 2026 6:58pm UTC

Request Review

@github-actions github-actions Bot added package: core @ionic/core package package: angular @ionic/angular package package: vue @ionic/vue package labels Oct 16, 2026
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.
Reorder properties to satisfy stylelint order/properties-order rule
and replace bare border-radius with the border-radius mixin in the
disabled range overlay.
Ionitron and others added 2 commits October 16, 2026 17:16
Move z-index after min-width/min-height/overflow in calendar-day-wrapper.
Move @include border-radius before declarations in disabled range overlay.

@ShaneK ShaneK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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%);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Current:
ios-rtl

This fix:
image


@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'],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +2771 to +2774
rangeAriaLabel = `${ariaLabel}, ${this.startDateLabel}`;
}
if (isRangeEnd) {
rangeAriaLabel = `${ariaLabel}, ${this.endDateLabel}`;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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".

Comment on lines +610 to +612
if (this.isRangeParts(activeParts) && activeParts.end) {
this.setValue([convertDataToISO(activeParts.start), convertDataToISO(activeParts.end)]);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +872 to +918
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 };
}
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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!

…ltiple selection modes

Use isMultipleMode and isRangeMode getters instead of the deprecated
multiple prop so the wheel columns don't incorrectly write to the
selection in the new selectionMode modes.
…L support

Pass comma-separated arguments to the border-radius mixin instead of a
single space-separated list so it outputs logical properties that flip
correctly in RTL layouts.
…S list

Add selectionMode, startDateLabel, and endDateLabel to the standalone
directive inputs so Angular property binding works in standalone apps.
Use += instead of = when building rangeAriaLabel so a single-day range
announces both "Start date" and "End date" labels instead of only the
end label.
…ange

Return early from confirm() when the range is missing an end date,
preventing a slotted Done button from dismissing the modal without
committing a value.
Add comprehensive e2e tests covering range selection, single-day
ranges, CSS classes, ionChange events, header text, confirm/reset/cancel
behavior, disabled dates, disabled/readonly states, month/year wheel
navigation-only behavior, and RTL visual regressions.

Add unit tests for getCalendarDayState range flags: isRangeStart,
isRangeEnd, isInRange, single-day ranges, disabled days in range,
and partial ranges without end.
…abledDate

Collapse redundant branches in setActivePartsRange into a single
condition since the within-range and after-range cases are identical,
and same-day clicks work implicitly. Simplify the loop condition in
rangeContainsDisabledDate to just while(isBefore(cursor, end)).
Tests that create a datetime with no initial value now use the visible
month's day buttons via .calendar-month:nth-child(2) selectors and
dynamically build ISO strings from data attributes, instead of
hardcoding data-month="6" which only works when the calendar shows
June 2022. Also set an initial value on the disabled-dates test to
navigate to the correct month.
Use gradient background on in-range wrappers to eliminate subpixel
gaps between adjacent grid cells. Override height: 0 on iOS range
wrappers so gradient percentage calculations resolve correctly.
Remove -1px extensions on start/end caps to prevent alpha doubling.

This branch was successfully deployed

1 active deployment
Preview — 0f67fc51 Deployed Oct 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package: angular @ionic/angular package package: core @ionic/core package package: vue @ionic/vue package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants