Repository navigation
feat(datetime): enable range selection - #31581
OS-susmitabhowmik wants to merge 63 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.
ShaneK
left a comment
There was a problem hiding this comment.
Great work on the fixes, everything from the first round works! Just a few more things, mainly cancel erasing a confirmed range that hasn't changed and the disabled fade picking up non-range datetimes in the ionic theme. A handful of the new tests also pass without their fix, so I left suggestions for those.
Removing the isRangeMode block in cancel() that eagerly cleared activeParts. Since processValue already restores from value, the block caused a confirmed range to visually disappear on reopen. Adds a regression test for the scenario.
The ionic theme handles disabled days via color, so the grid-level opacity from common.scss double-faded the range. Reset it to 1 in the ionic theme and add a disabled range example to the test page.
- Confirm overlay test now uses ion-modal + confirm(true) to verify the modal stays open with an incomplete range - Month/year wheel tests pick values after the range so they fail on old code - Disabled day assertion checks for range-start instead of active - State spec uses a disabled day inside the range, not outside it - Multiple mode month wheel test picks August instead of nth(0)
Adds a test that verifies the month wheel does not change the selection in multiple mode, as requested in PR review.
ShaneK
left a comment
There was a problem hiding this comment.
Great work! Looks good to me 🚀
…olor for ionic Make range start/end dates fully circular (border-radius: 50%) for ios and md themes instead of half-pill shapes. For the ionic theme, keep the pill shape but use the pressed background color so range endpoints are visually distinct from in-range days.
- Use circular range endpoints (border-radius: 50%) for ios/md - Ionic: use primary blue circles for range start/end with inverse text, correct semantic tokens (text-select, text-disabled, bg-primary-subtle-default), and pressed primary for hover/press - Update range track color to bg-primary-subtle-default (#e9ecfc) - Suppress active/focus highlight on in-range days for all themes
Add :host prefix to range start/end rules so they override the general disabled day rule. Ensure disabled and readonly range endpoints keep inverse (white) text on the primary blue circle.
| 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; |
There was a problem hiding this comment.
- Reading
parsedDatetimes[0]and[1]in the order given makes a programmatic['2023-10-31', '2023-10-23']show "Oct 31, 2023 - Oct 23, 2023" on the button, while the datetime normalizes it to Oct 23 to Oct 31. - The
length === 2check sends a three-entry value to the single-date branch, so the button shows only the first date while the datetime uses the first and last. titleSelectedDatesFormattergets the raw array here but the normalized pair in the datetime header.
| 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; | |
| if (isRangeMode && parsedValues.length >= 2) { | |
| /** | |
| * Use the first and last entries in date order, matching | |
| * how ion-datetime reads a range value. | |
| */ | |
| let startIndex = 0; | |
| let endIndex = parsedValues.length - 1; | |
| if (isAfter(parsedDatetimes[startIndex], parsedDatetimes[endIndex])) { | |
| [startIndex, endIndex] = [endIndex, startIndex]; | |
| } | |
| const rangeValues = [parsedValues[startIndex], parsedValues[endIndex]]; | |
| const startDate = getLocalizedDateTime(locale, parsedDatetimes[startIndex], dateFormatOptions); | |
| const endDate = getLocalizedDateTime(locale, parsedDatetimes[endIndex], dateFormatOptions); | |
| let rangeText = `${startDate} - ${endDate}`; | |
| if (titleSelectedDatesFormatter !== undefined) { | |
| try { | |
| rangeText = titleSelectedDatesFormatter(rangeValues); | |
| } catch (e) { | |
| printIonError('[ion-datetime-button] - Exception in provided `titleSelectedDatesFormatter`:', e); | |
| } | |
| } | |
| this.dateText = rangeText; |
This also needs import { isAfter } from '../datetime/utils/comparison'; at the top of the file. Please add an e2e case for a reversed value.
| const { minParts, maxParts, parsedDayValues, isDateEnabled } = this; | ||
|
|
||
| let cursor = getNextDay(start); | ||
| while (isBefore(cursor, end)) { | ||
| if (isDayDisabled(cursor, minParts, maxParts, parsedDayValues)) { | ||
| return true; | ||
| } |
There was a problem hiding this comment.
- Passing only
parsedDayValuesto the span check lets a user pick Jan 30 to Mar 2 withmonthValues="1,3", so the range covers all of February. - The design doc treats days excluded by
dayValues,monthValuesandyearValuesas disabled for range purposes. - Please add a test that a range can't span a month excluded by
monthValues.
| const { minParts, maxParts, parsedDayValues, isDateEnabled } = this; | |
| let cursor = getNextDay(start); | |
| while (isBefore(cursor, end)) { | |
| if (isDayDisabled(cursor, minParts, maxParts, parsedDayValues)) { | |
| return true; | |
| } | |
| const { minParts, maxParts, parsedDayValues, parsedMonthValues, parsedYearValues, isDateEnabled } = this; | |
| let cursor = getNextDay(start); | |
| while (isBefore(cursor, end)) { | |
| if ( | |
| isDayDisabled(cursor, minParts, maxParts, parsedDayValues) || | |
| (parsedMonthValues !== undefined && !parsedMonthValues.includes(cursor.month)) || | |
| (parsedYearValues !== undefined && !parsedYearValues.includes(cursor.year)) | |
| ) { | |
| return true; | |
| } |
| if (!range) return []; | ||
| if (!range.end) return [convertDataToISO(range.start)]; |
There was a problem hiding this comment.
- Leaving the early
return []andreturn [convertDataToISO(range.start)]unbraced makes them easy to extend into a bug later, and the rest of the range code braces itsifbodies.
| if (!range) return []; | |
| if (!range.end) return [convertDataToISO(range.start)]; | |
| if (!range) { | |
| return []; | |
| } | |
| if (!range.end) { | |
| return [convertDataToISO(range.start)]; | |
| } |
| // Range start: full circle | ||
| // Use :host prefix for specificity over the general disabled day rule. | ||
| :host .calendar-day.calendar-day-range-start { | ||
| @include globals.border-radius(globals.$ion-border-radius-full); | ||
|
|
||
| background: globals.$ion-bg-primary-base-default; | ||
| color: globals.$ion-text-inverse; | ||
| } | ||
|
|
||
| // Range end: full circle | ||
| :host .calendar-day.calendar-day-range-end { | ||
| @include globals.border-radius(globals.$ion-border-radius-full); | ||
|
|
||
| background: globals.$ion-bg-primary-base-default; | ||
| color: globals.$ion-text-inverse; | ||
| } | ||
|
|
||
| // 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); | ||
| } |
There was a problem hiding this comment.
- Both endpoints are already full circles, so the
.calendar-day-range-start.calendar-day-range-endrule sets the same radius again and can go. - The start and end blocks are identical, so one selector list keeps them from drifting apart.
| // Range start: full circle | |
| // Use :host prefix for specificity over the general disabled day rule. | |
| :host .calendar-day.calendar-day-range-start { | |
| @include globals.border-radius(globals.$ion-border-radius-full); | |
| background: globals.$ion-bg-primary-base-default; | |
| color: globals.$ion-text-inverse; | |
| } | |
| // Range end: full circle | |
| :host .calendar-day.calendar-day-range-end { | |
| @include globals.border-radius(globals.$ion-border-radius-full); | |
| background: globals.$ion-bg-primary-base-default; | |
| color: globals.$ion-text-inverse; | |
| } | |
| // 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); | |
| } | |
| // Range start and end: full circle | |
| // Use :host prefix for specificity over the general disabled day rule. | |
| :host .calendar-day.calendar-day-range-start, | |
| :host .calendar-day.calendar-day-range-end { | |
| @include globals.border-radius(globals.$ion-border-radius-full); | |
| background: globals.$ion-bg-primary-base-default; | |
| color: globals.$ion-text-inverse; | |
| } |
| // Range start: full circle | ||
| :host .calendar-day.calendar-day-range-start { | ||
| @include border-radius(50%); | ||
|
|
||
| background: ion-color(primary, base); | ||
| color: ion-color(primary, contrast); | ||
|
|
||
| font-weight: 600; | ||
| } | ||
|
|
||
| // Range end: full circle | ||
| :host .calendar-day.calendar-day-range-end { | ||
| @include border-radius(50%); | ||
|
|
||
| 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%); | ||
| } |
There was a problem hiding this comment.
Same as the ionic theme: the single-day rule repeats the endpoints' radius, and the start and end blocks are identical.
| // Range start: full circle | |
| :host .calendar-day.calendar-day-range-start { | |
| @include border-radius(50%); | |
| background: ion-color(primary, base); | |
| color: ion-color(primary, contrast); | |
| font-weight: 600; | |
| } | |
| // Range end: full circle | |
| :host .calendar-day.calendar-day-range-end { | |
| @include border-radius(50%); | |
| 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%); | |
| } | |
| // Range start and end: full circle | |
| :host .calendar-day.calendar-day-range-start, | |
| :host .calendar-day.calendar-day-range-end { | |
| @include border-radius(50%); | |
| background: ion-color(primary, base); | |
| color: ion-color(primary, contrast); | |
| font-weight: 600; | |
| } |
| /** | ||
| * 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; |
There was a problem hiding this comment.
- The design doc revisited this decision on purpose, so a user can't commit half a range, but no test covers it. Losing
isRangeIncompletewould let a half range through without any test failing. - Please add a test to
test/range/datetime.e2e.ts:
test('confirm button should be disabled until the range is complete', async () => {
const datetime = await fixture.goto(config, [RANGE_START], { showDefaultButtons: true });
const confirmButton = datetime.locator('#confirm-button button');
await expect(confirmButton).toBeDisabled();
await datetime.locator('[data-month="6"][data-day="18"]').click();
await expect(confirmButton).toBeEnabled();
});| 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; | ||
| } |
There was a problem hiding this comment.
- The
startDatebranch is range-only behavior from the design doc, but "reset should clear the range selection" only callsreset()with no argument. - Please add a test to
test/range/datetime.e2e.ts:
test('reset with a date should clear the range and show that month', async ({ page }) => {
const datetime = await fixture.goto(config, RANGE_VALUE);
await datetime.evaluate((el: HTMLIonDatetimeElement) => el.reset('2022-12-01'));
await page.waitForChanges();
await expect(datetime.locator('.calendar-month-year-toggle')).toContainText('December 2022');
await expect(datetime.locator('.calendar-day-range-start')).toHaveCount(0);
});There was a problem hiding this comment.
- Picking a start date gives screen reader users no cue that an end date comes next. The design doc calls for an
aria-liveregion that announces it. - Days inside the range only announce their date, so a screen reader user can't tell which days fall between the start and end. The design doc conflicts on this: the Architecture section asks for an "in range" announcement, while the Requirements section only lists start and end. Please add it, or flag it and we'll update the design doc to match the PR.
There was a problem hiding this comment.
Please restore toolbar-basic-text-icon-buttons-ionic-md-rtl-light-Mobile-Chrome-linux.png from next. It's unrelated to this PR and came back in after the revert. If that test then fails because the snapshot really does need updating, go ahead and update it.
| * @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. |
There was a problem hiding this comment.
- The
::beforeis an implementation detail, so naming it in the part description turns it into documented API. The docs should describe what the part is, like the otherrange-*parts do.
| * @part range-track - The calendar day wrapper element whose ::before pseudo-element draws the range highlight track. | |
| * @part range-track - The track that connects the start and end days of a range selection. |
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