Compare commits

...

9 Commits

Author SHA1 Message Date
Sean Perkins
ad350b79e5 chore: revert bug fix attempt for month/year interface 2024-01-22 14:14:51 -05:00
Sean Perkins
2b1141d4c4 wip: workaround for month/year picker changing value 2024-01-19 21:38:19 -05:00
Sean Perkins
9e4be25f8e test: screenshot tests for range selection 2024-01-19 21:28:32 -05:00
Sean Perkins
2813725dab feat: --range-background css variable 2024-01-19 21:07:09 -05:00
Sean Perkins
0ddd5d51a4 chore: prettier formatting 2024-01-19 20:48:58 -05:00
Sean Perkins
a65bce2ce5 feat: support start/end object format for date range 2024-01-19 20:43:41 -05:00
Sean Perkins
804d51f3ef fix: implementation and styling 2024-01-19 20:05:32 -05:00
Sean Perkins
dd5fe792c7 fix: implementation issues 2024-01-07 16:35:59 -05:00
Sean Perkins
6295e12543 wip: date range architecture discovery 2024-01-07 16:22:38 -05:00
16 changed files with 702 additions and 70 deletions

View File

@@ -408,6 +408,7 @@ ion-datetime,prop,multiple,boolean,false,false,false
ion-datetime,prop,name,string,this.inputId,false,false
ion-datetime,prop,preferWheel,boolean,false,false,false
ion-datetime,prop,presentation,"date" | "date-time" | "month" | "month-year" | "time" | "time-date" | "year",'date-time',false,false
ion-datetime,prop,range,boolean,false,false,false
ion-datetime,prop,readonly,boolean,false,false,false
ion-datetime,prop,showClearButton,boolean,false,false,false
ion-datetime,prop,showDefaultButtons,boolean,false,false,false
@@ -415,7 +416,7 @@ ion-datetime,prop,showDefaultTimeLabel,boolean,true,false,false
ion-datetime,prop,showDefaultTitle,boolean,false,false,false
ion-datetime,prop,size,"cover" | "fixed",'fixed',false,false
ion-datetime,prop,titleSelectedDatesFormatter,((selectedDates: string[]) => string) | undefined,undefined,false,false
ion-datetime,prop,value,null | string | string[] | undefined,undefined,false,false
ion-datetime,prop,value,null | string | string[] | undefined | { start: string; end: string; },undefined,false,false
ion-datetime,prop,yearValues,number | number[] | string | undefined,undefined,false,false
ion-datetime,method,cancel,cancel(closeOverlay?: boolean) => Promise<void>
ion-datetime,method,confirm,confirm(closeOverlay?: boolean) => Promise<void>

View File

@@ -15,7 +15,7 @@ import { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo
import { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface";
import { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface";
import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface";
import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, DatetimeValue, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
import { SpinnerTypes } from "./components/spinner/spinner-configs";
import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
import { CounterFormatter } from "./components/item/item-interface";
@@ -51,7 +51,7 @@ export { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo
export { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface";
export { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface";
export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface";
export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, DatetimeValue, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
export { SpinnerTypes } from "./components/spinner/spinner-configs";
export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
export { CounterFormatter } from "./components/item/item-interface";
@@ -914,6 +914,10 @@ export namespace Components {
* Which values you want to select. `"date"` will show a calendar picker to select the month, day, and year. `"time"` will show a time picker to select the hour, minute, and (optionally) AM/PM. `"date-time"` will show the date picker first and time picker second. `"time-date"` will show the time picker first and date picker second.
*/
"presentation": DatetimePresentation;
/**
* If `true`, a single range of dates can be selected at once. Only applies to `presentation="date"` and `preferWheel="false"`.
*/
"range": boolean;
/**
* If `true`, the datetime appears normal but the selected date cannot be changed.
*/
@@ -949,7 +953,7 @@ export namespace Components {
/**
* The value of the datetime as a valid ISO 8601 datetime string. This should be an array of strings only when `multiple="true"`.
*/
"value"?: string | string[] | null;
"value"?: DatetimeValue;
/**
* Values used to create the list of selectable years. By default the year values range between the `min` and `max` datetime inputs. However, to control exactly which years to display, the `yearValues` input can take a number, an array of numbers, or string of comma separated numbers. For example, to show upcoming and recent leap years, then this input's value would be `yearValues="2008,2012,2016,2020,2024"`.
*/
@@ -5642,6 +5646,10 @@ declare namespace LocalJSX {
* Which values you want to select. `"date"` will show a calendar picker to select the month, day, and year. `"time"` will show a time picker to select the hour, minute, and (optionally) AM/PM. `"date-time"` will show the date picker first and time picker second. `"time-date"` will show the time picker first and date picker second.
*/
"presentation"?: DatetimePresentation;
/**
* If `true`, a single range of dates can be selected at once. Only applies to `presentation="date"` and `preferWheel="false"`.
*/
"range"?: boolean;
/**
* If `true`, the datetime appears normal but the selected date cannot be changed.
*/
@@ -5673,7 +5681,7 @@ declare namespace LocalJSX {
/**
* The value of the datetime as a valid ISO 8601 datetime string. This should be an array of strings only when `multiple="true"`.
*/
"value"?: string | string[] | null;
"value"?: DatetimeValue;
/**
* Values used to create the list of selectable years. By default the year values range between the `min` and `max` datetime inputs. However, to control exactly which years to display, the `yearValues` input can take a number, an array of numbers, or string of comma separated numbers. For example, to show upcoming and recent leap years, then this input's value would be `yearValues="2008,2012,2016,2020,2024"`.
*/

View File

@@ -6,7 +6,7 @@ import { createColorClasses } from '@utils/theme';
import { getIonMode } from '../../global/ionic-global';
import type { Color } from '../../interface';
import type { DatetimePresentation } from '../datetime/datetime-interface';
import type { DatetimePresentation, DatetimeValue } from '../datetime/datetime-interface';
import { getToday } from '../datetime/utils/data';
import { getMonthAndYear, getMonthDayAndYear, getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format';
import { getHourCycle } from '../datetime/utils/helpers';
@@ -172,7 +172,7 @@ export class DatetimeButton implements ComponentInterface {
* can work with an array internally and not need
* to keep checking if the datetime value is `string` or `string[]`.
*/
private getParsedDateValues = (value?: string[] | string | null): string[] => {
private getParsedDateValues = (value?: DatetimeValue): string[] => {
if (value === undefined || value === null) {
return [];
}
@@ -181,7 +181,11 @@ export class DatetimeButton implements ComponentInterface {
return value;
}
return [value];
if (typeof value === 'string') {
return [value];
}
return [value.start, value.end];
};
/**

View File

@@ -1,5 +1,5 @@
export interface DatetimeChangeEventDetail {
value?: string | string[] | null;
value?: DatetimeValue;
}
export interface DatetimeCustomEvent extends CustomEvent {
@@ -17,6 +17,11 @@ export interface DatetimeParts {
ampm?: 'am' | 'pm';
}
export interface DatetimeRangeParts {
start: DatetimeParts;
end: DatetimeParts;
}
export type DatetimePresentation = 'date-time' | 'time-date' | 'date' | 'time' | 'month' | 'year' | 'month-year';
export type TitleSelectedDatesFormatter = (selectedDates: string[]) => string;
@@ -36,3 +41,9 @@ export type DatetimeHighlight = { date: string } & DatetimeHighlightStyle;
export type DatetimeHighlightCallback = (dateIsoString: string) => DatetimeHighlightStyle | undefined;
export type DatetimeHourCycle = 'h11' | 'h12' | 'h23' | 'h24';
export type DatetimeRangeValue = { start: string; end: string };
export type DatetimeMultipleValue = string[];
export type DatetimeValue = string | null | DatetimeMultipleValue | DatetimeRangeValue;

View File

@@ -5,6 +5,7 @@
:host {
--background: var(--ion-color-light, #ffffff);
--background-rgb: var(--ion-color-light-rgb);
--range-background: #{current-color(base, 0.1)};
--title-color: #{$text-color-step-400};
}
@@ -182,7 +183,11 @@
* The second set of @support checks account for all other browsers that
* do not support mod() yet.
*/
@supports ((border-radius: mod(1px, 1px)) and (background: -webkit-named-image(apple-pay-logo-black)) and (not (contain-intrinsic-size: none))) or (not (border-radius: mod(1px, 1px))) {
@supports (
(border-radius: mod(1px, 1px)) and (background: -webkit-named-image(apple-pay-logo-black)) and
(not (contain-intrinsic-size: none))
)
or (not (border-radius: mod(1px, 1px))) {
.calendar-days-of-week .day-of-week {
width: auto;
height: auto;
@@ -198,7 +203,6 @@
// Calendar / Body
// -----------------------------------
:host .calendar-body .calendar-month .calendar-month-grid {
/**
* We need to apply the padding to
* each month grid item otherwise
@@ -206,7 +210,12 @@
* this padding a snapping point if applied
* on .calendar-month
*/
@include padding($datetime-ios-padding * 0.5, $datetime-ios-padding * 0.5, $datetime-ios-padding * 0.5, $datetime-ios-padding * 0.5);
@include padding(
$datetime-ios-padding * 0.5,
$datetime-ios-padding * 0.5,
$datetime-ios-padding * 0.5,
$datetime-ios-padding * 0.5
);
align-items: center;
@@ -214,13 +223,13 @@
}
:host .calendar-day-wrapper {
@include padding(4px);
@include padding(2px);
// This is required so that the calendar day wrapper
// will collapse instead of expanding to fill the button
height: 0;
min-height: dynamic-font(16px);
min-height: dynamic-font(35px);
}
:host .calendar-day {
@@ -278,7 +287,12 @@
// Footer
// -----------------------------------
:host .datetime-buttons {
@include padding($datetime-ios-padding * 0.5, $datetime-ios-padding * 0.5, $datetime-ios-padding * 0.5, $datetime-ios-padding * 0.5);
@include padding(
$datetime-ios-padding * 0.5,
$datetime-ios-padding * 0.5,
$datetime-ios-padding * 0.5,
$datetime-ios-padding * 0.5
);
border-top: $datetime-ios-border-color;
}
@@ -294,3 +308,51 @@
:host .datetime-action-buttons {
width: 100%;
}
// Range Selection
// -----------------------------------
.calendar-day-in-range.calendar-day-wrapper:not(.calendar-day-wrapper-padding) {
position: relative;
&:not(.calendar-day-range-start):not(.calendar-day-range-end) {
background: var(--range-background);
}
&.calendar-day-range-start .calendar-day,
&.calendar-day-range-end .calendar-day {
background: var(--ion-color-base);
color: var(--ion-color-contrast);
z-index: 1;
}
&.calendar-day-range-start:not(.calendar-day-range-end)::after,
&.calendar-day-range-end:not(.calendar-day-range-start)::before {
position: absolute;
width: 50%;
height: 100%;
content: " ";
.calendar-day {
background: var(--ion-color-base);
color: var(--ion-color-contrast);
}
}
&.calendar-day-range-start::after {
@include position(null, 0, null, null);
background: var(--range-background);
}
&.calendar-day-range-end::before {
@include position(null, null, null, 0);
background: var(--range-background);
}
}

View File

@@ -4,13 +4,19 @@
:host {
--background: var(--ion-color-step-100, #ffffff);
--range-background: #{current-color(base, 0.1)};
--title-color: #{current-color(contrast)};
}
// Header
// -----------------------------------
:host .datetime-header {
@include padding($datetime-md-header-padding, $datetime-md-header-padding, $datetime-md-header-padding, $datetime-md-header-padding);
@include padding(
$datetime-md-header-padding,
$datetime-md-header-padding,
$datetime-md-header-padding,
$datetime-md-header-padding
);
background: current-color(base);
color: var(--title-color);
@@ -161,3 +167,39 @@
justify-content: flex-end;
}
// Range Selection
// -----------------------------------
.calendar-day-in-range.calendar-day-wrapper:not(.calendar-day-wrapper-padding) {
position: relative;
&:not(.calendar-day-range-start):not(.calendar-day-range-end) {
background: var(--range-background);
}
&.calendar-day-range-start .calendar-day,
&.calendar-day-range-end .calendar-day {
z-index: 1;
}
&.calendar-day-range-start:not(.calendar-day-range-end)::after,
&.calendar-day-range-end:not(.calendar-day-range-start)::before {
position: absolute;
width: 50%;
height: 100%;
background: var(--range-background);
content: " ";
}
&.calendar-day-range-start::after {
@include position(null, 0, null, null);
}
&.calendar-day-range-end::before {
@include position(null, null, null, 0);
}
}

View File

@@ -16,6 +16,8 @@
* @prop --wheel-fade-background-rgb: The color of the gradient covering non-selected items
* when using a wheel style layout, or in the month/year picker for grid style layouts. Must
* be in RGB format, e.g. `255, 255, 255`.
*
* @prop --range-background: The background of the range track between a start and end date.
*/
display: flex;
@@ -79,9 +81,9 @@
* the text alignment too.
*/
:host .wheel-order-year-first .day-column {
order: 3;
order: 3;
text-align: end;
text-align: end;
}
:host .wheel-order-year-first .month-column {
@@ -161,7 +163,8 @@
* sufficient to resolve the issue mentioned above, which
* is why we do another set of @supports checks.
*/
@supports (not (background: -webkit-named-image(apple-pay-logo-black))) or ((background: -webkit-named-image(apple-pay-logo-black)) and (aspect-ratio: 1/1)) {
@supports (not (background: -webkit-named-image(apple-pay-logo-black))) or
((background: -webkit-named-image(apple-pay-logo-black)) and (aspect-ratio: 1/1)) {
:host(.show-month-and-year) .calendar-next-prev,
:host(.show-month-and-year) .calendar-days-of-week,
:host(.show-month-and-year) .calendar-body,
@@ -281,7 +284,6 @@
// Calendar / Body
// -----------------------------------
:host .calendar-body {
/**
* Show all calendar months inline
* and allow them to take up 100% of

View File

@@ -19,6 +19,8 @@ import type {
DatetimeHighlightStyle,
DatetimeHighlightCallback,
DatetimeHourCycle,
DatetimeValue,
DatetimeRangeParts,
} from './datetime-interface';
import { isSameDay, warnIfValueOutOfBounds, isBefore, isAfter } from './utils/comparison';
import type { WheelColumnOption } from './utils/data';
@@ -63,6 +65,9 @@ import {
import {
getCalendarDayState,
getHighlightStyles,
isDateInRange,
isDateRangeEnd,
isDateRangeStart,
isDayDisabled,
isMonthDisabled,
isNextMonthDisabled,
@@ -127,7 +132,7 @@ export class Datetime implements ComponentInterface {
@State() showMonthAndYear = false;
@State() activeParts: DatetimeParts | DatetimeParts[] = [];
@State() activeParts: DatetimeParts | DatetimeParts[] | DatetimeRangeParts = [];
@State() workingParts: DatetimeParts = {
month: 5,
@@ -349,6 +354,12 @@ export class Datetime implements ComponentInterface {
*/
@Prop() multiple = false;
/**
* If `true`, a single range of dates can be selected at once.
* Only applies to `presentation="date"` and `preferWheel="false"`.
*/
@Prop() range = false;
/**
* Used to apply custom text and background colors to specific dates.
*
@@ -364,7 +375,7 @@ export class Datetime implements ComponentInterface {
* The value of the datetime as a valid ISO 8601 datetime string.
* This should be an array of strings only when `multiple="true"`.
*/
@Prop({ mutable: true }) value?: string | string[] | null;
@Prop({ mutable: true }) value?: DatetimeValue;
/**
* Update the datetime value when the value changes
@@ -545,8 +556,8 @@ export class Datetime implements ComponentInterface {
}
private warnIfIncorrectValueUsage = () => {
const { multiple, value } = this;
if (!multiple && Array.isArray(value)) {
const { multiple, value, range } = this;
if (!multiple && !range && Array.isArray(value)) {
/**
* We do some processing on the `value` array so
* that it looks more like an array when logged to
@@ -606,16 +617,21 @@ export class Datetime implements ComponentInterface {
};
};
// TODO - I'm not happy with this change/API.
// But we need to avoid calling confirm()
// Otherwise changing the month/year in the wheel picker
// will update the value of the range selection.
private setActiveParts = (parts: DatetimeParts, removeDate = false) => {
/** if the datetime component is in readonly mode,
/**
* If the datetime component is in readonly mode,
* allow browsing of the calendar without changing
* the set value
* the set value.
*/
if (this.readonly) {
return;
}
const { multiple, minParts, maxParts, activeParts } = this;
const { multiple, minParts, maxParts, activeParts, range } = this;
/**
* When setting the active parts, it is possible
@@ -631,12 +647,47 @@ export class Datetime implements ComponentInterface {
this.setWorkingParts(validatedParts);
if (multiple) {
const activePartsArray = Array.isArray(activeParts) ? activeParts : [activeParts];
const activePartsArray = Array.isArray(activeParts) ? activeParts : ([activeParts] as DatetimeParts[]);
if (removeDate) {
this.activeParts = activePartsArray.filter((p) => !isSameDay(p, validatedParts));
} else {
this.activeParts = [...activePartsArray, validatedParts];
}
} else if (range) {
if (Array.isArray(this.activeParts)) {
/**
* If the active parts is an array, then we need to determine which part of the range
* we are setting. We can do this by comparing if the validatedParts is before or after
* the first active part. Users can select the same day as the start or end of the range.
*/
const [start, end] = this.activeParts;
if (start !== undefined && isBefore(validatedParts, start)) {
this.activeParts = {
start: validatedParts,
end,
};
} else if (end !== undefined && isAfter(validatedParts, end)) {
this.activeParts = {
start,
end: validatedParts,
};
} else {
this.activeParts = {
start: validatedParts,
end: validatedParts,
};
}
} else {
/**
* If the activeParts is not an array, that means they have not made a selection yet
* and that the current activeParts is just the default value.
* We can set the start and end range to the same value.
*/
this.activeParts = {
start: validatedParts,
end: validatedParts,
};
}
} else {
this.activeParts = {
...validatedParts,
@@ -1202,7 +1253,7 @@ export class Datetime implements ComponentInterface {
});
}
private processValue = (value?: string | string[] | null) => {
private processValue = (value?: DatetimeValue) => {
const hasValue = value !== null && value !== undefined && (!Array.isArray(value) || value.length > 0);
const valueToProcess = hasValue ? parseDate(value) : this.defaultParts;
@@ -1332,7 +1383,7 @@ export class Datetime implements ComponentInterface {
};
componentWillLoad() {
const { el, highlightedDates, multiple, presentation, preferWheel } = this;
const { el, highlightedDates, multiple, presentation, preferWheel, range } = this;
if (multiple) {
if (presentation !== 'date') {
@@ -1357,6 +1408,18 @@ export class Datetime implements ComponentInterface {
}
}
if (range) {
if (presentation !== 'date') {
printIonWarning('The range property is only supported with presentation="date".', el);
}
if (multiple) {
printIonWarning('The range property cannot be used with multiple="true".', el);
}
if (preferWheel) {
printIonWarning('The range property is not supported with preferWheel="true".', el);
}
}
const hourValues = (this.parsedHourValues = convertToArrayOfNumbers(this.hourValues));
const minuteValues = (this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues));
const monthValues = (this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues));
@@ -1558,7 +1621,7 @@ export class Datetime implements ComponentInterface {
private renderCombinedDatePickerColumn() {
const { defaultParts, disabled, workingParts, locale, minParts, maxParts, todayParts, isDateEnabled } = this;
const activePart = this.getActivePartsWithFallback();
const activePart = this.getActivePartsWithFallback() as DatetimeParts;
/**
* By default, generate a range of 3 months:
@@ -1759,7 +1822,7 @@ export class Datetime implements ComponentInterface {
const { disabled, workingParts } = this;
const activePart = this.getActivePartsWithFallback();
const activePart = this.getActivePartsWithFallback() as DatetimeParts;
const pickerColumnValue = (workingParts.day !== null ? workingParts.day : this.defaultParts.day) ?? undefined;
return (
@@ -1815,7 +1878,7 @@ export class Datetime implements ComponentInterface {
const { disabled, workingParts } = this;
const activePart = this.getActivePartsWithFallback();
const activePart = this.getActivePartsWithFallback() as DatetimeParts;
return (
<ion-picker-column
@@ -1869,7 +1932,7 @@ export class Datetime implements ComponentInterface {
const { disabled, workingParts } = this;
const activePart = this.getActivePartsWithFallback();
const activePart = this.getActivePartsWithFallback() as DatetimeParts;
return (
<ion-picker-column
@@ -1954,7 +2017,7 @@ export class Datetime implements ComponentInterface {
const { disabled, workingParts } = this;
if (hoursData.length === 0) return [];
const activePart = this.getActivePartsWithFallback();
const activePart = this.getActivePartsWithFallback() as DatetimeParts;
return (
<ion-picker-column
@@ -1993,7 +2056,7 @@ export class Datetime implements ComponentInterface {
const { disabled, workingParts } = this;
if (minutesData.length === 0) return [];
const activePart = this.getActivePartsWithFallback();
const activePart = this.getActivePartsWithFallback() as DatetimeParts;
return (
<ion-picker-column
@@ -2034,7 +2097,7 @@ export class Datetime implements ComponentInterface {
return [];
}
const activePart = this.getActivePartsWithFallback();
const activePart = this.getActivePartsWithFallback() as DatetimeParts;
const isDayPeriodRTL = isLocaleDayPeriodRTL(this.locale);
return (
@@ -2211,7 +2274,7 @@ export class Datetime implements ComponentInterface {
<div class="calendar-month-grid">
{getDaysOfMonth(month, year, this.firstDayOfWeek % 7).map((dateObject, index) => {
const { day, dayOfWeek } = dateObject;
const { el, highlightedDates, isDateEnabled, multiple } = this;
const { el, highlightedDates, isDateEnabled, multiple, range, activeParts } = this;
const referenceParts = { month, day, year };
const isCalendarPadding = day === null;
const {
@@ -2273,16 +2336,30 @@ export class Datetime implements ComponentInterface {
let dateParts = undefined;
const inRange = range && isDateInRange(referenceParts, activeParts as DatetimeRangeParts);
const isRangeStart = range && isDateRangeStart(referenceParts, activeParts as DatetimeRangeParts);
const isRangeEnd = range && isDateRangeEnd(referenceParts, activeParts as DatetimeRangeParts);
// "Filler days" at the beginning of the grid should not get the calendar day
// CSS parts added to them
if (!isCalendarPadding) {
dateParts = `calendar-day${isActive ? ' active' : ''}${isToday ? ' today' : ''}${
isCalDayDisabled ? ' disabled' : ''
}${inRange ? ' range-selection' : ''}${isRangeStart ? ' range-start' : ''}${
isRangeEnd ? ' range-end' : ''
}`;
}
return (
<div class="calendar-day-wrapper">
<div
class={{
'calendar-day-wrapper': true,
'calendar-day-wrapper-padding': isCalendarPadding,
'calendar-day-in-range': inRange,
'calendar-day-range-start': isRangeStart,
'calendar-day-range-end': isRangeEnd,
}}
>
<button
// We need to use !important for the inline styles here because
// otherwise the CSS shadow parts will override these styles.
@@ -2330,8 +2407,8 @@ export class Datetime implements ComponentInterface {
year,
});
// multiple only needs date info, so we can wipe out other fields like time
if (multiple) {
// multiple and range only needs date info, so we can wipe out other fields like time
if (multiple || range) {
this.setActiveParts(
{
month,
@@ -2388,7 +2465,7 @@ export class Datetime implements ComponentInterface {
private renderTimeOverlay() {
const { disabled, hourCycle, isTimePopoverOpen, locale } = this;
const computedHourCycle = getHourCycle(locale, hourCycle);
const activePart = this.getActivePartsWithFallback();
const activePart = this.getActivePartsWithFallback() as DatetimeParts;
return [
<div class="time-header">{this.renderTimeLabel()}</div>,
@@ -2471,7 +2548,7 @@ export class Datetime implements ComponentInterface {
}
} else {
// for exactly 1 day selected (multiple set or not), show a formatted version of that
headerText = getMonthAndDay(this.locale, this.getActivePartsWithFallback());
headerText = getMonthAndDay(this.locale, this.getActivePartsWithFallback() as DatetimeParts); // TODO verify if this can show with range enabled
}
return headerText;

View File

@@ -0,0 +1,68 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs().forEach(({ title, screenshot, config }) => {
test.describe(title('datetime: range'), () => {
test('should not have visual regressions', async ({ page }) => {
await page.setContent(
`
<ion-datetime presentation="date" range="true"></ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.value = {
start: '2023-01-09',
end:'2023-01-16'
};
</script>
`,
config
);
const datetime = page.locator('ion-datetime');
await expect(datetime).toHaveScreenshot(screenshot('datetime-range'));
});
});
});
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('datetime: range: styling api'), () => {
test('should not have visual regressions', async ({ page }) => {
await page.setContent(
`
<style>
ion-datetime {
--range-background: rgb(235, 68, 90, 0.1);
}
ion-datetime::part(range-start),
ion-datetime::part(range-end) {
background: rgb(235, 68, 90);
}
ion-datetime::part(range-start) {
border-radius: 32px 0 0 32px;
}
ion-datetime::part(range-end) {
border-radius: 0 32px 32px 0;
}
ion-datetime::part(range-start range-end) {
border-radius: 50%;
}
</style>
<ion-datetime presentation="date" range="true"></ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.value = {
start: '2023-01-09',
end:'2023-01-16'
};
</script>
`,
config
);
const datetime = page.locator('ion-datetime');
await expect(datetime).toHaveScreenshot(screenshot('datetime-range-styling-api'));
});
});
});

View File

@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Datetime - Range</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(250px, 1fr));
grid-row-gap: 20px;
grid-column-gap: 20px;
}
h2 {
font-size: 12px;
font-weight: normal;
color: #6f7378;
margin-top: 10px;
margin-left: 5px;
}
@media screen and (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
padding: 0;
}
}
.options-popover {
--width: 300px;
}
ion-modal.ios,
ion-popover.datetime-popover.ios {
--width: 350px;
--height: 420px;
}
ion-modal.md,
ion-popover.datetime-popover.md {
--width: 350px;
}
ion-datetime {
width: 350px;
}
body.dark {
--ion-color-primary: #428cff;
--ion-color-primary-rgb: 66, 140, 255;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #3a7be0;
--ion-color-primary-tint: #5598ff;
--ion-color-secondary: #50c8ff;
--ion-color-secondary-rgb: 80, 200, 255;
--ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #46b0e0;
--ion-color-secondary-tint: #62ceff;
--ion-color-tertiary: #6a64ff;
--ion-color-tertiary-rgb: 106, 100, 255;
--ion-color-tertiary-contrast: #ffffff;
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
--ion-color-tertiary-shade: #5d58e0;
--ion-color-tertiary-tint: #7974ff;
--ion-color-success: #2fdf75;
--ion-color-success-rgb: 47, 223, 117;
--ion-color-success-contrast: #000000;
--ion-color-success-contrast-rgb: 0, 0, 0;
--ion-color-success-shade: #29c467;
--ion-color-success-tint: #44e283;
--ion-color-warning: #ffd534;
--ion-color-warning-rgb: 255, 213, 52;
--ion-color-warning-contrast: #000000;
--ion-color-warning-contrast-rgb: 0, 0, 0;
--ion-color-warning-shade: #e0bb2e;
--ion-color-warning-tint: #ffd948;
--ion-color-danger: #ff4961;
--ion-color-danger-rgb: 255, 73, 97;
--ion-color-danger-contrast: #ffffff;
--ion-color-danger-contrast-rgb: 255, 255, 255;
--ion-color-danger-shade: #e04055;
--ion-color-danger-tint: #ff5b71;
--ion-color-dark: #f4f5f8;
--ion-color-dark-rgb: 244, 245, 248;
--ion-color-dark-contrast: #000000;
--ion-color-dark-contrast-rgb: 0, 0, 0;
--ion-color-dark-shade: #d7d8da;
--ion-color-dark-tint: #f5f6f9;
--ion-color-medium: #989aa2;
--ion-color-medium-rgb: 152, 154, 162;
--ion-color-medium-contrast: #000000;
--ion-color-medium-contrast-rgb: 0, 0, 0;
--ion-color-medium-shade: #86888f;
--ion-color-medium-tint: #a2a4ab;
--ion-color-light: #222428;
--ion-color-light-rgb: 34, 36, 40;
--ion-color-light-contrast: #ffffff;
--ion-color-light-contrast-rgb: 255, 255, 255;
--ion-color-light-shade: #1e2023;
--ion-color-light-tint: #383a3e;
}
/*
* iOS Dark Theme
* -------------------------------------------
*/
.ios body.dark {
--ion-background-color: #000000;
--ion-background-color-rgb: 0, 0, 0;
--ion-text-color: #ffffff;
--ion-text-color-rgb: 255, 255, 255;
--ion-color-step-50: #0d0d0d;
--ion-color-step-100: #1a1a1a;
--ion-color-step-150: #262626;
--ion-color-step-200: #333333;
--ion-color-step-250: #404040;
--ion-color-step-300: #4d4d4d;
--ion-color-step-350: #595959;
--ion-color-step-400: #666666;
--ion-color-step-450: #737373;
--ion-color-step-500: #808080;
--ion-color-step-550: #8c8c8c;
--ion-color-step-600: #999999;
--ion-color-step-650: #a6a6a6;
--ion-color-step-700: #b3b3b3;
--ion-color-step-750: #bfbfbf;
--ion-color-step-800: #cccccc;
--ion-color-step-850: #d9d9d9;
--ion-color-step-900: #e6e6e6;
--ion-color-step-950: #f2f2f2;
--ion-item-background: #000000;
--ion-card-background: #1c1c1d;
}
.ios body.dark ion-modal {
--ion-background-color: var(--ion-color-step-100);
--ion-toolbar-background: var(--ion-color-step-150);
--ion-toolbar-border-color: var(--ion-color-step-250);
--ion-item-background: var(--ion-color-step-150);
}
/*
* Material Design Dark Theme
* -------------------------------------------
*/
.md body.dark {
--ion-background-color: #121212;
--ion-background-color-rgb: 18, 18, 18;
--ion-text-color: #ffffff;
--ion-text-color-rgb: 255, 255, 255;
--ion-border-color: #222222;
--ion-color-step-50: #1e1e1e;
--ion-color-step-100: #2a2a2a;
--ion-color-step-150: #363636;
--ion-color-step-200: #414141;
--ion-color-step-250: #4d4d4d;
--ion-color-step-300: #595959;
--ion-color-step-350: #656565;
--ion-color-step-400: #717171;
--ion-color-step-450: #7d7d7d;
--ion-color-step-500: #898989;
--ion-color-step-550: #949494;
--ion-color-step-600: #a0a0a0;
--ion-color-step-650: #acacac;
--ion-color-step-700: #b8b8b8;
--ion-color-step-750: #c4c4c4;
--ion-color-step-800: #d0d0d0;
--ion-color-step-850: #dbdbdb;
--ion-color-step-900: #e7e7e7;
--ion-color-step-950: #f3f3f3;
--ion-item-background: #1e1e1e;
--ion-toolbar-background: #1f1f1f;
--ion-tab-bar-background: #1f1f1f;
--ion-card-background: #1e1e1e;
}
</style>
</head>
<body>
<ion-app>
<ion-header translucent="true">
<ion-toolbar>
<ion-title>Datetime - Basic</ion-title>
<ion-buttons slot="end">
<ion-button id="popover-trigger">Options</ion-button>
</ion-buttons>
<ion-popover class="options-popover" trigger="popover-trigger">
<ion-list lines="none">
<ion-item>
<ion-label>Dark Mode</ion-label>
<ion-checkbox slot="end"></ion-checkbox>
</ion-item>
<ion-item detail="true" href="?ionic:mode=ios">
<ion-label>iOS Mode</ion-label>
</ion-item>
<ion-item detail="true" href="?ionic:mode=md">
<ion-label>MD Mode</ion-label>
</ion-item>
<ion-item>
<ion-label>Show Default Title</ion-label>
<ion-toggle id="titleToggle"></ion-toggle>
</ion-item>
<ion-item>
<ion-label>Show Default Buttons</ion-label>
<ion-toggle id="buttonsToggle"></ion-toggle>
</ion-item>
<ion-item>
<ion-label>Locale</ion-label>
<ion-input placeholder="default" id="locale"></ion-input>
</ion-item>
<ion-item>
<ion-label>Color</ion-label>
<ion-select id="color" value="primary">
<ion-select-option value="primary">Primary</ion-select-option>
<ion-select-option value="secondary">Secondary</ion-select-option>
<ion-select-option value="tertiary">Tertiary</ion-select-option>
<ion-select-option value="success">Success</ion-select-option>
<ion-select-option value="warning">Warning</ion-select-option>
<ion-select-option value="danger">Danger</ion-select-option>
</ion-select>
</ion-item>
</ion-list>
</ion-popover>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>Date Range</h2>
<ion-datetime presentation="date" id="date-range" range="true"></ion-datetime>
</div>
</div>
</ion-content>
<script>
const datetime = document.querySelector('#date-range');
datetime.addEventListener('ionChange', (event) => {
console.log('Listen ionChange', event.detail);
});
datetime.value = {
start: '2024-01-05',
end: '2024-02-10',
};
</script>
</ion-app>
</body>
</html>

View File

@@ -1,4 +1,4 @@
import type { DatetimeParts, DatetimeHourCycle } from '../datetime-interface';
import type { DatetimeParts, DatetimeHourCycle, DatetimeValue, DatetimeRangeParts } from '../datetime-interface';
import { is24Hour } from './helpers';
import { convertDataToISO } from './manipulation';
@@ -344,6 +344,28 @@ export const getLocalizedDayPeriod = (locale: string, dayPeriod: 'am' | 'pm' | u
*
* @param value The value to format, either an ISO string or an array thereof.
*/
export const formatValue = (value: string | string[] | null | undefined) => {
return Array.isArray(value) ? value.join(',') : value;
export const formatValue = (value?: DatetimeValue) => {
if (Array.isArray(value)) {
return value.join(',');
}
if (typeof value === 'object' && value !== null) {
return [value.start, value.end].join(',');
}
return value;
};
/**
* Takes an incoming active parts object and formats it into an array
* of active parts.
* @param activeParts The active parts object to format.
* @returns Active parts as an array.
*/
export const activePartsToArray = (activeParts: DatetimeParts | DatetimeRangeParts | DatetimeParts[]) => {
if (Array.isArray(activeParts)) {
return activeParts;
}
if ('start' in activeParts) {
return [activeParts.start, activeParts.end];
}
return [activeParts];
};

View File

@@ -1,4 +1,9 @@
import type { DatetimeParts } from '../datetime-interface';
import type {
DatetimeMultipleValue,
DatetimeParts,
DatetimeRangeParts,
DatetimeRangeValue,
} from '../datetime-interface';
import { isAfter, isBefore, isSameDay } from './comparison';
import { getNumDaysInMonth } from './helpers';
@@ -13,13 +18,26 @@ const fourDigit = (val: number | undefined): string => {
};
export function convertDataToISO(data: DatetimeParts): string;
export function convertDataToISO(data: DatetimeParts[]): string[];
export function convertDataToISO(data: DatetimeParts | DatetimeParts[]): string | string[];
export function convertDataToISO(data: DatetimeParts | DatetimeParts[]): string | string[] {
export function convertDataToISO(data: DatetimeRangeParts): DatetimeRangeValue;
export function convertDataToISO(data: DatetimeParts[]): DatetimeMultipleValue;
export function convertDataToISO(
data: DatetimeParts | DatetimeRangeParts | DatetimeParts[]
): string | DatetimeMultipleValue;
export function convertDataToISO(
data: DatetimeParts | DatetimeRangeParts | DatetimeParts[]
): string | DatetimeMultipleValue | DatetimeRangeValue {
// Multiple selection
if (Array.isArray(data)) {
return data.map((parts) => convertDataToISO(parts));
}
// Range selection
if ('start' in data) {
return {
start: convertDataToISO(data.start),
end: convertDataToISO(data.end),
};
}
// Single selection
// https://www.w3.org/TR/NOTE-datetime
let rtn = '';
if (data.year !== undefined) {

View File

@@ -1,6 +1,6 @@
import { printIonWarning } from '@utils/logging';
import type { DatetimeParts } from '../datetime-interface';
import type { DatetimeMultipleValue, DatetimeParts, DatetimeRangeValue } from '../datetime-interface';
import { isAfter, isBefore } from './comparison';
import { getNumDaysInMonth } from './helpers';
@@ -59,11 +59,16 @@ export const getPartsFromCalendarDay = (el: HTMLElement): DatetimeParts => {
* it adjusts the date for the current timezone.
*/
export function parseDate(val: string): DatetimeParts | undefined;
export function parseDate(val: string[]): DatetimeParts[] | undefined;
export function parseDate(val: DatetimeMultipleValue): DatetimeParts[] | undefined;
export function parseDate(val: DatetimeRangeValue): DatetimeParts[] | undefined;
export function parseDate(val: undefined | null): undefined;
export function parseDate(val: string | string[]): DatetimeParts | DatetimeParts[] | undefined;
export function parseDate(val: string | string[] | undefined | null): DatetimeParts | DatetimeParts[] | undefined;
export function parseDate(val: string | string[] | undefined | null): DatetimeParts | DatetimeParts[] | undefined {
export function parseDate(val: string | DatetimeMultipleValue): DatetimeParts | DatetimeParts[] | undefined;
export function parseDate(
val: string | DatetimeMultipleValue | DatetimeRangeValue | undefined | null
): DatetimeParts | DatetimeParts[] | undefined;
export function parseDate(
val: string | DatetimeMultipleValue | DatetimeRangeValue | undefined | null
): DatetimeParts | DatetimeParts[] | undefined {
if (Array.isArray(val)) {
const parsedArray: DatetimeParts[] = [];
for (const valStr of val) {
@@ -86,6 +91,18 @@ export function parseDate(val: string | string[] | undefined | null): DatetimePa
return parsedArray;
}
if (typeof val === 'object') {
if (val?.start && val?.end) {
const parsedStart = parseDate(val.start);
const parsedEnd = parseDate(val.end);
if (!parsedStart || !parsedEnd) {
return undefined;
}
return [parsedStart, parsedEnd];
}
return undefined;
}
// manually parse IS0 cuz Date.parse cannot be trusted
// ISO 8601 format: 1994-12-15T13:47:20Z
let parse: any[] | null = null;

View File

@@ -5,10 +5,11 @@ import type {
DatetimeHighlightCallback,
DatetimeHighlightStyle,
DatetimeParts,
DatetimeRangeParts,
} from '../datetime-interface';
import { isAfter, isBefore, isSameDay } from './comparison';
import { generateDayAriaLabel, getDay } from './format';
import { activePartsToArray, generateDayAriaLabel, getDay } from './format';
import { getNextMonth, getPreviousMonth } from './manipulation';
export const isYearDisabled = (refYear: number, minParts?: DatetimeParts, maxParts?: DatetimeParts) => {
@@ -96,26 +97,17 @@ export const isDayDisabled = (
export const getCalendarDayState = (
locale: string,
refParts: DatetimeParts,
activeParts: DatetimeParts | DatetimeParts[],
activeParts: DatetimeParts | DatetimeRangeParts | DatetimeParts[],
todayParts: DatetimeParts,
minParts?: DatetimeParts,
maxParts?: DatetimeParts,
dayValues?: number[]
) => {
/**
* activeParts signals what day(s) are currently selected in the datetime.
* If multiple="true", this will be an array, but the logic in this util
* is the same whether we have one selected day or many because we're only
* calculating the state for one button. So, we treat a single activeParts value
* the same as an array of length one.
*/
const activePartsArray = Array.isArray(activeParts) ? activeParts : [activeParts];
/**
* The day button is active if it is selected, or in other words, if refParts
* matches at least one selected date.
*/
const isActive = activePartsArray.find((parts) => isSameDay(refParts, parts)) !== undefined;
const isActive = activePartsToArray(activeParts).find((parts) => isSameDay(refParts, parts)) !== undefined;
const isToday = isSameDay(refParts, todayParts);
const disabled = isDayDisabled(refParts, minParts, maxParts, dayValues);
@@ -227,3 +219,32 @@ export const getHighlightStyles = (
return undefined;
};
export const isDateRangeStart = (referenceParts: DatetimeParts, activeParts: DatetimeRangeParts) => {
if (activeParts !== undefined && Array.isArray(activeParts)) {
const startDate = activeParts[0];
return startDate !== undefined && isSameDay(referenceParts, startDate);
}
return false;
};
export const isDateRangeEnd = (referenceParts: DatetimeParts, activeParts: DatetimeRangeParts) => {
if (activeParts !== undefined && Array.isArray(activeParts)) {
const endDate = activeParts[1];
return endDate !== undefined && isSameDay(referenceParts, endDate);
}
return false;
};
export const isDateInRange = (referenceParts: DatetimeParts, activeParts: DatetimeRangeParts) => {
if (activeParts !== undefined && Array.isArray(activeParts)) {
const startDate = activeParts[0];
const endDate = activeParts[1];
const isAfterStart =
startDate !== undefined && (isAfter(referenceParts, startDate) || isSameDay(referenceParts, startDate));
const isBeforeEnd =
endDate !== undefined && (isBefore(referenceParts, endDate) || isSameDay(referenceParts, endDate));
return isAfterStart && isBeforeEnd;
}
return false;
};

View File

@@ -635,7 +635,7 @@ Set `scrollEvents` to `true` to enable.
@ProxyCmp({
inputs: ['cancelText', 'clearText', 'color', 'dayValues', 'disabled', 'doneText', 'firstDayOfWeek', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', 'readonly', 'showClearButton', 'showDefaultButtons', 'showDefaultTimeLabel', 'showDefaultTitle', 'size', 'titleSelectedDatesFormatter', 'value', 'yearValues'],
inputs: ['cancelText', 'clearText', 'color', 'dayValues', 'disabled', 'doneText', 'firstDayOfWeek', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', 'range', 'readonly', 'showClearButton', 'showDefaultButtons', 'showDefaultTimeLabel', 'showDefaultTitle', 'size', 'titleSelectedDatesFormatter', 'value', 'yearValues'],
methods: ['confirm', 'reset', 'cancel']
})
@Component({
@@ -643,7 +643,7 @@ Set `scrollEvents` to `true` to enable.
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['cancelText', 'clearText', 'color', 'dayValues', 'disabled', 'doneText', 'firstDayOfWeek', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', 'readonly', 'showClearButton', 'showDefaultButtons', 'showDefaultTimeLabel', 'showDefaultTitle', 'size', 'titleSelectedDatesFormatter', 'value', 'yearValues'],
inputs: ['cancelText', 'clearText', 'color', 'dayValues', 'disabled', 'doneText', 'firstDayOfWeek', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', 'range', 'readonly', 'showClearButton', 'showDefaultButtons', 'showDefaultTimeLabel', 'showDefaultTitle', 'size', 'titleSelectedDatesFormatter', 'value', 'yearValues'],
})
export class IonDatetime {
protected el: HTMLElement;

View File

@@ -294,6 +294,7 @@ export const IonDatetime = /*@__PURE__*/ defineContainer<JSX.IonDatetime, JSX.Io
'firstDayOfWeek',
'titleSelectedDatesFormatter',
'multiple',
'range',
'highlightedDates',
'value',
'showDefaultTitle',