Skip to content

Commit b7e66e8

Browse files
feat(datetime): add selectionMode="range", monthNavigation="scroll", and monthYearPickerView="grid"
Introduces three new opt-in props to ion-datetime: selectionMode="range" - First tap sets the range start; second tap commits the range and emits ionChange with a two-element ISO array [start, end] - Second tap before first auto-swaps if needed so start is always earlier - Third tap resets and starts a new range - Deprecates the `multiple` boolean in favour of selectionMode="multiple" - Range state (isRangeStart, isInRange, isRangeEnd) flows through getCalendarDayState() and is exposed as CSS classes on day buttons and ::before pseudo-element track on their wrapper cells monthNavigation="scroll" - Replaces the horizontal 3-month swipe window with a free-scrolling vertical list of ~13 months (+-6 from the working month, clamped to min/max) - Each month card renders its own heading and days-of-week row inline - A debounced scroll listener updates workingParts and the visually- hidden aria-live region as the user scrolls - Arrow buttons are hidden in this mode (display: none) monthYearPickerView="grid" - Replaces the wheel picker overlay with a month name grid (3 col x 4 row) and a paginated year grid (4 col x 6 row, 24 years per page) - Year pages are navigated with prev/next arrow buttons; the page resets to the one containing the working year each time the picker opens - Also applied to month/month-year/year presentations which previously hardcoded the wheel picker regardless of the prop Adds unit tests for the new range state logic and E2E test files with index.html pages for all three features.
1 parent 30765e2 commit b7e66e8

15 files changed

Lines changed: 2084 additions & 104 deletions

File tree

core/src/components.d.ts

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo
1515
import { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface";
1616
import { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface";
1717
import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface";
18-
import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
18+
import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimeParts, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
1919
import { GalleryColumns, GalleryGap } from "./components/gallery/gallery-interface";
2020
import { SpinnerTypes } from "./components/spinner/spinner-configs";
2121
import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
@@ -54,7 +54,7 @@ export { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo
5454
export { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface";
5555
export { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface";
5656
export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface";
57-
export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
57+
export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimeParts, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
5858
export { GalleryColumns, GalleryGap } from "./components/gallery/gallery-interface";
5959
export { SpinnerTypes } from "./components/spinner/spinner-configs";
6060
export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
@@ -1204,15 +1204,22 @@ export namespace Components {
12041204
*/
12051205
"minuteValues"?: number[] | number | string;
12061206
/**
1207-
* The mode determines the platform behaviors of the component.
1207+
* Controls the month navigation mode when using a grid-style layout. - `"arrows"` (default) preserves the existing prev/next button behaviour. - `"scroll"` swaps the horizontal scroll axis to vertical. The `previous-button` and `next-button` shadow parts remain in the DOM and keyboard-focusable in both modes.
1208+
* @default 'arrows'
12081209
*/
1209-
"mode"?: "ios" | "md";
1210+
"monthNavigation": 'arrows' | 'scroll';
12101211
/**
12111212
* Values used to create the list of selectable months. By default the month values range from `1` to `12`. However, to control exactly which months to display, the `monthValues` input can take a number, an array of numbers, or a string of comma separated numbers. For example, if only summer months should be shown, then this input value would be `monthValues="6,7,8"`. Note that month numbers do *not* have a zero-based index, meaning January's value is `1`, and December's is `12`.
12121213
*/
12131214
"monthValues"?: number[] | number | string;
1215+
/**
1216+
* Controls the month/year picker overlay style when using a grid-style layout. - `"wheel"` (default) preserves the existing `ion-picker-column` behaviour. - `"grid"` replaces the wheel columns with a month name grid and a year grid shown simultaneously inside the existing toggle overlay.
1217+
* @default 'wheel'
1218+
*/
1219+
"monthYearPickerView": 'wheel' | 'grid';
12141220
/**
12151221
* If `true`, multiple dates can be selected at once. Only applies to `presentation="date"` and `preferWheel="false"`.
1222+
* @deprecated Use `selectionMode="multiple"` instead.
12161223
* @default false
12171224
*/
12181225
"multiple": boolean;
@@ -1241,6 +1248,10 @@ export namespace Components {
12411248
* @param startDate A valid [ISO-8601 string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format) to reset the datetime state to.
12421249
*/
12431250
"reset": (startDate?: string) => Promise<void>;
1251+
/**
1252+
* Controls date selection behaviour when using a grid-style layout. - `"multiple"` enables toggling of individual dates (replaces the deprecated `multiple` boolean). - `"range"` enables start/end date range selection. `value` will emit a two-element ISO 8601 string array `[startDate, endDate]` once both dates are selected. Only applies to `presentation="date"` and `preferWheel="false"`. Logs a warning if used with any other `presentation` or with `preferWheel="true"`.
1253+
*/
1254+
"selectionMode"?: 'multiple' | 'range';
12441255
/**
12451256
* If `true`, the datetime calendar displays a six-week (42-day) layout, including days from the previous and next months to fill the grid. These adjacent days are selectable unless disabled.
12461257
* @default false
@@ -1271,10 +1282,6 @@ export namespace Components {
12711282
* @default 'fixed'
12721283
*/
12731284
"size": 'cover' | 'fixed';
1274-
/**
1275-
* The theme determines the visual appearance of the component.
1276-
*/
1277-
"theme"?: "ios" | "md" | "ionic";
12781285
/**
12791286
* A callback used to format the header text that shows how many dates are selected. Only used if there are 0 or more than 1 selected (i.e. unused for exactly 1). By default, the header text is set to "numberOfDates days". See https://ionicframework.com/docs/troubleshooting/runtime#accessing-this if you need to access `this` from within the callback.
12801287
*/
@@ -7258,15 +7265,22 @@ declare namespace LocalJSX {
72587265
*/
72597266
"minuteValues"?: number[] | number | string;
72607267
/**
7261-
* The mode determines the platform behaviors of the component.
7268+
* Controls the month navigation mode when using a grid-style layout. - `"arrows"` (default) preserves the existing prev/next button behaviour. - `"scroll"` swaps the horizontal scroll axis to vertical. The `previous-button` and `next-button` shadow parts remain in the DOM and keyboard-focusable in both modes.
7269+
* @default 'arrows'
72627270
*/
7263-
"mode"?: "ios" | "md";
7271+
"monthNavigation"?: 'arrows' | 'scroll';
72647272
/**
72657273
* Values used to create the list of selectable months. By default the month values range from `1` to `12`. However, to control exactly which months to display, the `monthValues` input can take a number, an array of numbers, or a string of comma separated numbers. For example, if only summer months should be shown, then this input value would be `monthValues="6,7,8"`. Note that month numbers do *not* have a zero-based index, meaning January's value is `1`, and December's is `12`.
72667274
*/
72677275
"monthValues"?: number[] | number | string;
7276+
/**
7277+
* Controls the month/year picker overlay style when using a grid-style layout. - `"wheel"` (default) preserves the existing `ion-picker-column` behaviour. - `"grid"` replaces the wheel columns with a month name grid and a year grid shown simultaneously inside the existing toggle overlay.
7278+
* @default 'wheel'
7279+
*/
7280+
"monthYearPickerView"?: 'wheel' | 'grid';
72687281
/**
72697282
* If `true`, multiple dates can be selected at once. Only applies to `presentation="date"` and `preferWheel="false"`.
7283+
* @deprecated Use `selectionMode="multiple"` instead.
72707284
* @default false
72717285
*/
72727286
"multiple"?: boolean;
@@ -7318,6 +7332,10 @@ declare namespace LocalJSX {
73187332
* @default false
73197333
*/
73207334
"readonly"?: boolean;
7335+
/**
7336+
* Controls date selection behaviour when using a grid-style layout. - `"multiple"` enables toggling of individual dates (replaces the deprecated `multiple` boolean). - `"range"` enables start/end date range selection. `value` will emit a two-element ISO 8601 string array `[startDate, endDate]` once both dates are selected. Only applies to `presentation="date"` and `preferWheel="false"`. Logs a warning if used with any other `presentation` or with `preferWheel="true"`.
7337+
*/
7338+
"selectionMode"?: 'multiple' | 'range';
73217339
/**
73227340
* If `true`, the datetime calendar displays a six-week (42-day) layout, including days from the previous and next months to fill the grid. These adjacent days are selectable unless disabled.
73237341
* @default false
@@ -7348,10 +7366,6 @@ declare namespace LocalJSX {
73487366
* @default 'fixed'
73497367
*/
73507368
"size"?: 'cover' | 'fixed';
7351-
/**
7352-
* The theme determines the visual appearance of the component.
7353-
*/
7354-
"theme"?: "ios" | "md" | "ionic";
73557369
/**
73567370
* A callback used to format the header text that shows how many dates are selected. Only used if there are 0 or more than 1 selected (i.e. unused for exactly 1). By default, the header text is set to "numberOfDates days". See https://ionicframework.com/docs/troubleshooting/runtime#accessing-this if you need to access `this` from within the callback.
73577371
*/
@@ -10967,6 +10981,9 @@ declare namespace LocalJSX {
1096710981
"locale": string;
1096810982
"firstDayOfWeek": number;
1096910983
"multiple": boolean;
10984+
"selectionMode": 'multiple' | 'range';
10985+
"monthNavigation": 'arrows' | 'scroll';
10986+
"monthYearPickerView": 'wheel' | 'grid';
1097010987
"value": string | string[] | null;
1097110988
"showDefaultTitle": boolean;
1097210989
"showDefaultButtons": boolean;

core/src/components/datetime/datetime-interface.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,23 @@ export type DatetimeHighlightCallback = (dateIsoString: string) => DatetimeHighl
3838

3939
export type DatetimeHourCycle = 'h11' | 'h12' | 'h23' | 'h24';
4040

41+
export type DatetimeSelectionMode = 'multiple' | 'range';
42+
43+
export type DatetimeMonthNavigation = 'arrows' | 'scroll';
44+
45+
export type DatetimeMonthYearPickerView = 'wheel' | 'grid';
46+
47+
/**
48+
* Represents the active parts when selectionMode="range".
49+
* `start` is always set once the user picks a first date.
50+
* `end` is set once the user picks the second date (or `start` is set again
51+
* when a complete range is reset).
52+
*/
53+
export interface DatetimeRangeParts {
54+
start: DatetimeParts;
55+
end?: DatetimeParts;
56+
}
57+
4158
/**
4259
* FormatOptions must include date and/or time; it cannot be an empty object
4360
*/

core/src/components/datetime/datetime.common.scss

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,13 @@
271271
}
272272

273273
:host .calendar-day-wrapper {
274+
/**
275+
* position: relative is required so that the ::before pseudo-element used for
276+
* the range-selection highlight track is positioned relative to the wrapper,
277+
* not the scroll container.
278+
*/
279+
position: relative;
280+
274281
display: flex;
275282

276283
align-items: center;
@@ -384,3 +391,220 @@
384391

385392
align-items: center;
386393
}
394+
395+
// Accessibility: visually-hidden aria-live region
396+
// -----------------------------------
397+
398+
/**
399+
* The `.calendar-month-year-announce` element is a visually-hidden live region
400+
* that announces the current month/year to screen readers whenever `workingParts`
401+
* changes. This is especially important in `monthNavigation="scroll"` mode where
402+
* the scroll gesture alone is insufficient for AT users to detect the change.
403+
*
404+
* The clip/clip-path technique is preferred over display:none or visibility:hidden
405+
* because those hide the element from the accessibility tree as well.
406+
*/
407+
:host .calendar-month-year-announce {
408+
position: absolute;
409+
410+
width: 1px;
411+
height: 1px;
412+
413+
@include padding(0px);
414+
@include margin(0px);
415+
416+
overflow: hidden;
417+
418+
clip: rect(0 0 0 0);
419+
clip-path: inset(50%);
420+
421+
white-space: nowrap;
422+
}
423+
424+
// Feature: monthNavigation="scroll" (vertical month scroll)
425+
// -----------------------------------
426+
427+
/**
428+
* When `monthNavigation="scroll"`, all months in the valid range are rendered
429+
* in a single continuous vertical list. There is no virtual window or
430+
* scroll-snap — the user scrolls freely through months.
431+
*
432+
* The shared calendar header (month/year toggle + prev/next arrows) is hidden;
433+
* each month card renders its own heading instead.
434+
*/
435+
:host(.datetime-month-navigation-scroll) .calendar-header {
436+
/**
437+
* Keep the element in the DOM for the aria-live region (screen reader
438+
* announcements) but remove all visual space.
439+
*/
440+
position: absolute;
441+
442+
pointer-events: none;
443+
}
444+
445+
:host(.datetime-month-navigation-scroll) .calendar-body {
446+
flex-direction: column;
447+
448+
overflow-x: hidden;
449+
overflow-y: scroll;
450+
451+
/**
452+
* Cap the scroll container so roughly 1.5 months are visible at a time.
453+
* When the datetime is inside a modal/popover the parent constrains the
454+
* height anyway and this max-height has no effect.
455+
*/
456+
max-height: 460px;
457+
}
458+
459+
/**
460+
* Per-month heading shown at the top of each month card in scroll mode.
461+
* Styled to match the image reference: prominent left-aligned text in the
462+
* primary colour.
463+
*/
464+
:host(.datetime-month-navigation-scroll) .calendar-month-scroll-heading {
465+
@include padding(16px, 16px, 4px);
466+
467+
font-size: 18px;
468+
font-weight: 600;
469+
470+
color: current-color(base);
471+
}
472+
473+
// Feature: monthYearPickerView="grid"
474+
// -----------------------------------
475+
476+
/**
477+
* Grid-based month/year picker overlay.
478+
* Shown inside the existing .datetime-year toggle overlay when
479+
* `monthYearPickerView="grid"`. The toggle mechanism and `month-year-button`
480+
* shadow part are fully preserved.
481+
*/
482+
:host .month-year-grid-container {
483+
display: flex;
484+
485+
flex-direction: column;
486+
487+
overflow: auto;
488+
489+
@include padding(8px);
490+
}
491+
492+
:host .month-year-grid {
493+
display: grid;
494+
495+
/* Month grid: 3 columns × 4 rows */
496+
grid-template-columns: repeat(3, 1fr);
497+
498+
gap: 8px;
499+
500+
margin-bottom: 16px;
501+
}
502+
503+
/**
504+
* Year section: wraps the prev/next nav row and the year grid.
505+
*/
506+
:host .month-year-grid-year-section {
507+
display: flex;
508+
509+
flex-direction: column;
510+
}
511+
512+
/**
513+
* Nav row that holds the prev/next year-page arrow buttons.
514+
* Buttons are pushed to the inline-end so they sit at the right (LTR) / left (RTL).
515+
*/
516+
:host .month-year-grid-year-nav {
517+
display: flex;
518+
519+
justify-content: flex-end;
520+
521+
@include margin-horizontal(0px);
522+
}
523+
524+
/**
525+
* Year grid: 4 columns (no scroll — pagination is handled by the nav arrows).
526+
*/
527+
:host .month-year-grid-years {
528+
/* 4 columns to match the reference design */
529+
grid-template-columns: repeat(4, 1fr);
530+
}
531+
532+
:host .month-year-grid-cell {
533+
display: flex;
534+
535+
align-items: center;
536+
justify-content: center;
537+
538+
@include padding(8px, 4px);
539+
540+
border: none;
541+
542+
border-radius: 8px;
543+
544+
background: none;
545+
546+
color: currentColor;
547+
548+
font-family: inherit;
549+
font-size: inherit;
550+
551+
cursor: pointer;
552+
553+
appearance: none;
554+
}
555+
556+
:host .month-year-grid-cell[disabled] {
557+
opacity: 0.3;
558+
559+
pointer-events: none;
560+
}
561+
562+
// Feature: selectionMode="range" — range highlight track
563+
// -----------------------------------
564+
565+
/**
566+
* The range-selection track is rendered as a ::before pseudo-element on the
567+
* `.calendar-day-wrapper` rather than on the `.calendar-day` button itself.
568+
* This is necessary because the button element has a fixed circular size that
569+
* does not span the full grid cell, so a background on the button alone would
570+
* produce gaps in the highlight track between adjacent days.
571+
*
572+
* The track is split into three parts:
573+
* - `range-start`: right half of the cell only (connecting to the next day)
574+
* - `in-range`: full cell width
575+
* - `range-end`: left half of the cell only (connecting from the previous day)
576+
*
577+
* Logical properties (`inset-inline-start/end`) are used so that RTL layouts
578+
* automatically reverse the direction of the connecting fill.
579+
*/
580+
:host .calendar-day-wrapper-range-start::before,
581+
:host .calendar-day-wrapper-in-range::before,
582+
:host .calendar-day-wrapper-range-end::before {
583+
content: '';
584+
585+
position: absolute;
586+
587+
inset-block: 0;
588+
589+
background: current-color(base, 0.15);
590+
591+
z-index: 0;
592+
}
593+
594+
:host .calendar-day-wrapper-range-start::before {
595+
/* Connect only to the inline-end (right in LTR, left in RTL) */
596+
inset-inline-start: 50%;
597+
inset-inline-end: 0;
598+
}
599+
600+
:host .calendar-day-wrapper-in-range::before {
601+
/* Full cell width */
602+
inset-inline-start: 0;
603+
inset-inline-end: 0;
604+
}
605+
606+
:host .calendar-day-wrapper-range-end::before {
607+
/* Connect only from the inline-start (left in LTR, right in RTL) */
608+
inset-inline-start: 0;
609+
inset-inline-end: 50%;
610+
}

0 commit comments

Comments
 (0)