Repository navigation
feat(datetime): enable range selection - #31581
OS-susmitabhowmik wants to merge 57 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.
…nused boolean-attribute utils Simplify endDate access since parsedDatetimes[1] is guaranteed to exist when parsedValues.length === 2. Remove nullableBooleanAttribute utility files that are no longer used.
Move disabled opacity from individual range wrappers to the calendar grid to prevent compositing seams at 1px track overlaps. Navigate to default date when range has no value so empty range mode opens on the correct month. Add disabled/readonly visual regression tests and fix highlighted dates to use current month.
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.
| /** | ||
| * 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 = []; | ||
| } | ||
|
|
There was a problem hiding this comment.
| /** | |
| * 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 seems like a big issue. Since #31558 came in from next, cancel() already restores the selection from value, but only once something was selected, so with nothing changed this clear sticks. Pressing Cancel on a confirmed range without touching anything erases it, and on reopen the header reads "Start date - End date", nothing is highlighted and Done is disabled, though value still holds the range. A partial range with no value still clears without this block.
This test fails with the block and passes without it:
test('should keep the confirmed range when reopened after cancelling without changes', async ({ page }) => {
await page.setContent(
`
<ion-modal>
<ion-datetime locale="en-US" presentation="date" selection-mode="range" show-default-buttons="true"></ion-datetime>
</ion-modal>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.value = ['2022-06-08', '2022-06-18'];
</script>
`,
config
);
const modal = page.locator('ion-modal');
const datetime = page.locator('ion-datetime');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
await modal.evaluate((el: HTMLIonModalElement) => el.present());
await ionModalDidPresent.next();
await datetime.locator('#cancel-button').click();
await ionModalDidDismiss.next();
await modal.evaluate((el: HTMLIonModalElement) => el.present());
await ionModalDidPresent.next();
await expect(datetime.locator('[data-month="6"][data-day="8"]')).toHaveClass(/calendar-day-range-start/);
await expect(datetime.locator('[data-month="6"][data-day="18"]')).toHaveClass(/calendar-day-range-end/);
});There was a problem hiding this comment.
Thanks for catching this! I removed the code block and added this test in 1cb5bb9
| :host(.datetime-disabled) .calendar-month-grid { | ||
| opacity: 0.4; | ||
| } | ||
|
|
||
| :host(.datetime-disabled) .calendar-month-grid .calendar-day[disabled] { |
There was a problem hiding this comment.
| :host(.datetime-disabled) .calendar-month-grid { | |
| opacity: 0.4; | |
| } | |
| :host(.datetime-disabled) .calendar-month-grid .calendar-day[disabled] { | |
| :host(.datetime-disabled.datetime-range) .calendar-month-grid { | |
| opacity: 0.4; | |
| } | |
| :host(.datetime-disabled.datetime-range) .calendar-month-grid .calendar-day[disabled] { |
This fades every disabled datetime, not just ranges. The ionic theme already grays out disabled days with a color, so a plain disabled datetime in the ionic theme comes out a lot lighter than on next. Scoped to range mode, a disabled single date renders exactly like next on all three themes and the disabled range doesn't change.
Next:
This PR:
There was a problem hiding this comment.
Great catch! I fixed this issue within 7aef96c and updated the Disabled test page to include the disabled range https://ionic-framework-git-rou-12704-datepicker-range-ionic1.vercel.app/src/components/datetime/test/disabled?ionic:theme=ionic
| test('confirm should not close overlay with incomplete range', async ({ page }) => { | ||
| const datetime = await fixture.goto(config, undefined, { showDefaultButtons: true }); | ||
|
|
||
| const day8 = fixture.getVisibleDay(8); | ||
| await day8.click(); | ||
| await page.waitForChanges(); | ||
|
|
||
| // Calling confirm with only start selected should not set value | ||
| await datetime.evaluate((el: HTMLIonDatetimeElement) => el.confirm()); | ||
| await expect(datetime).toHaveJSProperty('value', undefined); | ||
| }); |
There was a problem hiding this comment.
| test('confirm should not close overlay with incomplete range', async ({ page }) => { | |
| const datetime = await fixture.goto(config, undefined, { showDefaultButtons: true }); | |
| const day8 = fixture.getVisibleDay(8); | |
| await day8.click(); | |
| await page.waitForChanges(); | |
| // Calling confirm with only start selected should not set value | |
| await datetime.evaluate((el: HTMLIonDatetimeElement) => el.confirm()); | |
| await expect(datetime).toHaveJSProperty('value', undefined); | |
| }); | |
| test('confirm should not close overlay with incomplete range', async ({ page }) => { | |
| await page.setContent( | |
| ` | |
| <ion-modal> | |
| <ion-datetime locale="en-US" presentation="date" selection-mode="range"></ion-datetime> | |
| </ion-modal> | |
| <script> | |
| const datetime = document.querySelector('ion-datetime'); | |
| datetime.value = ['2022-06-08']; | |
| </script> | |
| `, | |
| config | |
| ); | |
| const modal = page.locator('ion-modal'); | |
| const datetime = page.locator('ion-datetime'); | |
| const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); | |
| await modal.evaluate((el: HTMLIonModalElement) => el.present()); | |
| await page.locator('.datetime-ready').waitFor(); | |
| await datetime.evaluate((el: HTMLIonDatetimeElement) => el.confirm(true)); | |
| await page.waitForChanges(); | |
| // The modal is still open, so the range can be completed and confirmed. | |
| await datetime.locator('[data-month="6"][data-day="18"]').click(); | |
| await datetime.evaluate((el: HTMLIonDatetimeElement) => el.confirm(true)); | |
| await ionModalDidDismiss.next(); | |
| expect(ionModalDidDismiss).toHaveReceivedEventTimes(1); | |
| await expect(datetime).toHaveJSProperty('value', RANGE_VALUE); | |
| }); |
This passes with or without the fix, since it never opens an overlay or passes closeOverlay. This version only passes if the first confirm(true) leaves the modal open.
| await dropdownButton.click(); | ||
| await page.waitForChanges(); | ||
|
|
||
| const monthOption = page.locator('.month-column ion-picker-column-option').nth(0); |
There was a problem hiding this comment.
| const monthOption = page.locator('.month-column ion-picker-column-option').nth(0); | |
| const monthOption = page.locator('.month-column ion-picker-column-option').filter({ hasText: 'August' }); |
January is before the range, so picking it never changed value even with the old !this.multiple check, and this passes either way. August comes after the end, so it fails on the old code. Could you also add this test with selection-mode="multiple"? That fix doesn't have one yet.
| await dropdownButton.click(); | ||
| await page.waitForChanges(); | ||
|
|
||
| const yearOption = page.locator('.year-column ion-picker-column-option').nth(0); |
There was a problem hiding this comment.
| const yearOption = page.locator('.year-column ion-picker-column-option').nth(0); | |
| const yearOption = page.locator('.year-column ion-picker-column-option').filter({ hasText: '2023' }); |
Same here, the first year is before the range, so this passes on the old code too.
|
|
||
| // Range should not span the disabled 15th — should start a new range instead | ||
| await expect(day10).not.toHaveClass(/calendar-day-range-start/); | ||
| await expect(day20).toHaveClass(/calendar-day-active/); |
There was a problem hiding this comment.
| await expect(day20).toHaveClass(/calendar-day-active/); | |
| await expect(day20).toHaveClass(/calendar-day-range-start/); |
The end day gets calendar-day-active too, so this passes even if the range runs through the disabled 15th. Checking for the range start only passes when a new range starts.
| const disabledDay = { month: 6, day: 7, year: 2022 }; | ||
| const minParts = { month: 6, day: 9, year: 2022 }; |
There was a problem hiding this comment.
| const disabledDay = { month: 6, day: 7, year: 2022 }; | |
| const minParts = { month: 6, day: 9, year: 2022 }; | |
| const disabledDay = { month: 6, day: 12, year: 2022 }; | |
| const minParts = { month: 6, day: 13, year: 2022 }; |
Day 7 is outside the range, so this passes even without the !disabled check. The day 12 and min 13 setup from the comment puts the disabled day inside the range.
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.
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