Repository navigation
feat(datetime): enable range selection - #31581
OS-susmitabhowmik wants to merge 139 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.
Lift the focused calendar day wrapper above subsequent days so the focus ring is not covered by range selection track elements.
Only --range-background needs 0.12 opacity. Changing .calendar-day-active to 0.12 was a regression that lightened the selected day in single and multiple selection modes on iOS.
The flag stayed set when the user completed a range with buttons then cancelled, causing the next programmatic value set to skip navigation. Comparing the end date's month with the visible month covers the same case without stale state.
The live region must be in the DOM before its content changes for screen readers to announce it. Render it for the whole of range mode and use getHeaderSelectedDateText() instead of hardcoded English. Move inline styles to .datetime-range-announcement class in datetime.common.scss.
Appending ", in range" put hard-coded English into apps in other languages. MD2 doesn't announce days inside a range — the live region covers that. Start/end labels remain as they use translatable props.
…ay click Calling setActiveParts right after animateToDate moves workingParts while the scroll is still running, causing the calendar to flash one month ahead. Wait for the animation to finish first.
RTL only changes the track direction, which the committed screenshot already covers. Partial, disabled, readonly, and focus states render the same in both directions, so running them in RTL adds snapshots without adding coverage.
Tests were using the current month instead of a fixed month, causing failures when run outside of June. Navigate to June with RANGE_VALUE then reset to clear the selection while keeping the calendar on June.
ShaneK
left a comment
There was a problem hiding this comment.
Sorry for coming back to this after it was already approved! I noticed the reset() issue below and figured I'd do another quick pass while I was in here. It's just a few things, and most of them are small.
| * is provided, the calendar navigates to that month without | ||
| * selecting anything so the user begins a fresh selection. | ||
| */ | ||
| this.activeParts = []; |
There was a problem hiding this comment.
| this.activeParts = []; | |
| this.activeParts = []; | |
| this.hasUnconfirmedSelection = false; |
This returns before anything clears hasUnconfirmedSelection, which processValue does for single mode. We moved the discard to the next present in the cancel fix for single selection so that app code calling reset() or confirm() on dismiss still sticks, and this branch skips that. So with Jun 10 - 20 confirmed, going to July, tapping the 15th and then cancelling with reset() in ionModalWillDismiss reopens on Jun 10 - 20 again, where single mode stays cleared. Could we add a range version of "should keep the selection set by reset() before dismissing without confirming" to the cancel tests too?
There was a problem hiding this comment.
Thanks for catching this! Updated and added a test in 9feb6c6
| const parsed = parseDate(startDate); | ||
| if (parsed) { | ||
| const target = Array.isArray(parsed) ? parsed[0] : parsed; | ||
| this.setWorkingParts(target); |
There was a problem hiding this comment.
| this.setWorkingParts(target); | |
| this.setWorkingParts(clampDate(target, this.minParts, this.maxParts)); |
Single mode clamps this through processValue, so I think we should here too. With max="2025-12-31", reset('2030-01-01') goes to January 2030, and I think both month buttons end up disabled since December 2029 is past max as well.
|
|
||
| // In-range days: rectangular, no rounding | ||
| .calendar-day.calendar-day-in-range { | ||
| @include globals.border-radius(0); |
There was a problem hiding this comment.
In the ionic theme this resets the radius the :focus-visible border uses, so a focused day inside the range gets a square ring while every other day gets a circle. You can see it on the 15th in the ionic in-range focus screenshot. Is the square from the Figma? If not, I think keeping the radius for :focus-visible would fix it.
There was a problem hiding this comment.
This was intentional, the focus state is a square for the ionic theme in Figma
| 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.
I know this one came from a suggestion, but after reset('2022-12-01') the June days aren't rendered anymore, so this count is 0 even if the range stays. Could we check the header reads Start date - End date instead?
| const jul2Wrapper = jul2.locator('..'); | ||
| await expect(jul2).toHaveAttribute('part', /range-end/); | ||
| await expect(jul2).toHaveAttribute('part', /range-selection/); | ||
| await expect(jul2Wrapper).toHaveAttribute('part', /range-track/); |
There was a problem hiding this comment.
The part attributes get set right away, so this passes whether or not the range waits for the scroll. Could we also wait for the scroll to finish and check the toggle reads July 2022? That way the overshoot fix from dc18439 has a test.
| * | ||
| * Range selection only works with `presentation="date"` and `preferWheel="false"`. | ||
| */ | ||
| @Prop() selectionMode?: 'multiple' | 'range'; |
There was a problem hiding this comment.
I think the docs for value and titleSelectedDatesFormatter need a range update too. The value doc still says arrays are only for multiple="true", and the formatter doc says it's never called for one date, but range mode calls it with [start].
The range branch in reset() returned early before processValue, which clears the flag for single mode. Without this, calling reset() on an unconfirmed range inside an overlay would have the stale flag cause the reset to be discarded on the next present.
Single mode clamps through processValue, but range mode's early return
skipped it. Without clamping, reset('2030-01-01') with max="2025-12-31"
would navigate past the max with both month buttons disabled.
…t count
The previous assertion checked that no .calendar-day-range-start elements
existed, but after reset('2022-12-01') the June days aren't rendered so
the count is trivially 0. Check the header shows placeholder labels instead.
The part attributes are set immediately, so the previous assertions passed regardless of whether the scroll settled. Add a month-year toggle check to cover the overshoot fix from dc18439.
…e mode The value doc still said arrays are only for multiple="true", and the formatter doc said it's never called for one date. Range mode uses arrays for value and always calls the formatter.
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