Compare commits

...

3 Commits

Author SHA1 Message Date
Sean Perkins
f3e8b4d00b chore: type rework 2024-02-09 13:06:40 -05:00
Shawn Taylor
ce6352c46a partially improve types 2024-02-09 12:18:33 -05:00
Shawn Taylor
3d06d06deb feat(datetime): formatOptions for time button and header
revert example
2024-02-09 10:40:24 -05:00
9 changed files with 273 additions and 27 deletions

View File

@@ -394,6 +394,7 @@ ion-datetime,prop,dayValues,number | number[] | string | undefined,undefined,fal
ion-datetime,prop,disabled,boolean,false,false,false
ion-datetime,prop,doneText,string,'Done',false,false
ion-datetime,prop,firstDayOfWeek,number,0,false,false
ion-datetime,prop,formatOptions,undefined | { date?: DateTimeFormatOptions | undefined; time?: DateTimeFormatOptions | undefined; },undefined,false,false
ion-datetime,prop,highlightedDates,((dateIsoString: string) => DatetimeHighlightStyle | undefined) | DatetimeHighlight[] | undefined,undefined,false,false
ion-datetime,prop,hourCycle,"h11" | "h12" | "h23" | "h24" | undefined,undefined,false,false
ion-datetime,prop,hourValues,number | number[] | string | undefined,undefined,false,false

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, DatetimeFormatOptions, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, 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, DatetimeFormatOptions, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, 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";
@@ -858,6 +858,10 @@ export namespace Components {
* The first day of the week to use for `ion-datetime`. The default value is `0` and represents Sunday.
*/
"firstDayOfWeek": number;
/**
* Formatting options, separated by date and time.
*/
"formatOptions"?: DatetimeFormatOptions;
/**
* Used to apply custom text and background colors to specific dates. Can be either an array of objects containing ISO strings and colors, or a callback that receives an ISO string and returns the colors. Only applies to the `date`, `date-time`, and `time-date` presentations, with `preferWheel="false"`.
*/
@@ -5541,6 +5545,10 @@ declare namespace LocalJSX {
* The first day of the week to use for `ion-datetime`. The default value is `0` and represents Sunday.
*/
"firstDayOfWeek"?: number;
/**
* Formatting options, separated by date and time.
*/
"formatOptions"?: DatetimeFormatOptions;
/**
* Used to apply custom text and background colors to specific dates. Can be either an array of objects containing ISO strings and colors, or a callback that receives an ISO string and returns the colors. Only applies to the `date`, `date-time`, and `time-date` presentations, with `preferWheel="false"`.
*/

View File

@@ -23,16 +23,20 @@ export type TitleSelectedDatesFormatter = (selectedDates: string[]) => string;
export type DatetimeHighlightStyle =
| {
textColor: string;
backgroundColor?: string;
}
textColor: string;
backgroundColor?: string;
}
| {
textColor?: string;
backgroundColor: string;
};
textColor?: string;
backgroundColor: string;
};
export type DatetimeHighlight = { date: string } & DatetimeHighlightStyle;
export type DatetimeHighlightCallback = (dateIsoString: string) => DatetimeHighlightStyle | undefined;
export type DatetimeHourCycle = 'h11' | 'h12' | 'h23' | 'h24';
export type TimeFormatOptions = { time: Intl.DateTimeFormatOptions };
export type DateFormatOptions = { date: Intl.DateTimeFormatOptions };
export type DatetimeFormatOptions = TimeFormatOptions | DateFormatOptions;

View File

@@ -20,6 +20,9 @@ import type {
DatetimeHighlightStyle,
DatetimeHighlightCallback,
DatetimeHourCycle,
DatetimeFormatOptions,
TimeFormatOptions,
DateFormatOptions,
} from './datetime-interface';
import { isSameDay, warnIfValueOutOfBounds, isBefore, isAfter } from './utils/comparison';
import {
@@ -171,6 +174,16 @@ export class Datetime implements ComponentInterface {
*/
@Prop() disabled = false;
/**
* Formatting options, separated by date and time.
*/
@Prop() formatOptions?: DatetimeFormatOptions;
@Watch('formatOptions')
protected formatOptionsChanged() {
this.errorIfTimeZoneProvided();
}
/**
* If `true`, the datetime appears normal but the selected date cannot be changed.
*/
@@ -1357,7 +1370,7 @@ export class Datetime implements ComponentInterface {
};
componentWillLoad() {
const { el, highlightedDates, multiple, presentation, preferWheel } = this;
const { el, formatOptions, highlightedDates, multiple, presentation, preferWheel } = this;
if (multiple) {
if (presentation !== 'date') {
@@ -1382,6 +1395,10 @@ export class Datetime implements ComponentInterface {
}
}
if (formatOptions) {
this.errorIfTimeZoneProvided();
}
const hourValues = (this.parsedHourValues = convertToArrayOfNumbers(this.hourValues));
const minuteValues = (this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues));
const monthValues = (this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues));
@@ -1409,6 +1426,28 @@ export class Datetime implements ComponentInterface {
this.emitStyle();
}
get timeFormatOptions(): Intl.DateTimeFormatOptions | undefined {
const timeOptions = (this.formatOptions as TimeFormatOptions)?.time;
return timeOptions;
}
get dateFormatOptions(): Intl.DateTimeFormatOptions | undefined {
const dateOptions = (this.formatOptions as DateFormatOptions)?.date;
return dateOptions;
}
private errorIfTimeZoneProvided() {
const { timeFormatOptions, dateFormatOptions } = this;
if (
dateFormatOptions?.timeZone ||
timeFormatOptions?.timeZone ||
dateFormatOptions?.timeZoneName ||
timeFormatOptions?.timeZoneName
) {
printIonWarning('Datetime: "timeZone" and "timeZoneName" are not supported in "formatOptions".');
}
}
private emitStyle() {
this.ionStyle.emit({
interactive: true,
@@ -2354,10 +2393,16 @@ export class Datetime implements ComponentInterface {
}
private renderTimeOverlay() {
const { disabled, hourCycle, isTimePopoverOpen, locale } = this;
const { disabled, timeFormatOptions, hourCycle, isTimePopoverOpen, locale } = this;
const computedHourCycle = getHourCycle(locale, hourCycle);
const activePart = this.getActivePartsWithFallback();
const timeButtonFormatOptions = timeFormatOptions || {
hour: 'numeric',
minute: 'numeric',
computedHourCycle,
};
return [
<div class="time-header">{this.renderTimeLabel()}</div>,
<button
@@ -2389,7 +2434,7 @@ export class Datetime implements ComponentInterface {
}
}}
>
{getLocalizedTime(locale, activePart, computedHourCycle)}
{getLocalizedTime(locale, activePart, computedHourCycle, timeButtonFormatOptions)}
</button>,
<ion-popover
alignment="center"
@@ -2424,7 +2469,7 @@ export class Datetime implements ComponentInterface {
}
private getHeaderSelectedDateText() {
const { activeParts, multiple, titleSelectedDatesFormatter } = this;
const { activeParts, dateFormatOptions, multiple, titleSelectedDatesFormatter } = this;
const isArray = Array.isArray(activeParts);
let headerText: string;
@@ -2438,8 +2483,15 @@ export class Datetime implements ComponentInterface {
}
}
} else {
const headerFormatOptions: Intl.DateTimeFormatOptions = dateFormatOptions ?? {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
};
// 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(), headerFormatOptions);
}
return headerText;

View File

@@ -565,3 +565,89 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
});
/**
* This behavior does not differ across
* directions.
*/
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('datetime: formatOptions'), () => {
test('should format header and time button', async ({ page }) => {
await page.setContent(
`
<ion-datetime value="2022-02-01T16:30:00">
<span slot="title">Select Date</span>
</ion-datetime>
`,
config
);
await page.locator('.datetime-ready').waitFor();
const datetime = page.locator('ion-datetime');
await datetime.evaluate(
(el: HTMLIonDatetimeElement) =>
(el.formatOptions = {
time: { hour: '2-digit', minute: '2-digit' },
date: { day: '2-digit', month: 'long', era: 'short' },
})
);
await page.waitForChanges();
const headerDate = page.locator('ion-datetime .datetime-selected-date');
await expect(headerDate).toHaveText('February 01 AD');
const timeBody = page.locator('ion-datetime .time-body');
await expect(timeBody).toHaveText('04:30 PM');
await expect(datetime).toHaveScreenshot(screenshot('datetime-format-options'));
});
});
});
/**
* This behavior does not differ across
* modes/directions.
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('datetime: formatOptions timeZone error'), () => {
test('should throw a warning if time zone is provided', async ({ page }) => {
const logs: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'warning') {
logs.push(msg.text());
}
});
await page.setContent(
`
<ion-datetime value="2022-02-01T16:30:00">
<span slot="title">Select Date</span>
</ion-datetime>
`,
config
);
const datetime = page.locator('ion-datetime');
await datetime.evaluate(
(el: HTMLIonDatetimeElement) =>
(el.formatOptions = {
time: { timeZone: 'UTC' },
})
);
await page.locator('.datetime-ready').waitFor();
await page.waitForChanges();
expect(logs.length).toBe(1);
expect(logs[0]).toContain(
'[Ionic Warning]: Datetime: "timeZone" and "timeZoneName" are not supported in "formatOptions".'
);
});
});
});

View File

@@ -53,6 +53,46 @@ describe('getMonthAndDay()', () => {
it('should return sáb, 1 abr', () => {
expect(getMonthAndDay('es-ES', { month: 4, day: 1, year: 2006 })).toEqual('sáb, 1 abr');
});
it('should use formatOptions', () => {
const datetimeParts: DatetimeParts = {
day: 1,
month: 1,
year: 2022,
hour: 9,
minute: 40,
};
const formatOptions: Intl.DateTimeFormatOptions = {
day: '2-digit',
weekday: 'long',
month: 'narrow',
hour: '2-digit',
minute: '2-digit',
};
// Even though this method is intended to be used for date, the time may be displayed as well when passing formatOptions
expect(getMonthAndDay('en-US', datetimeParts, formatOptions)).toEqual('Saturday, J 01, 09:40 AM');
});
it('should override provided time zone with UTC', () => {
const datetimeParts: DatetimeParts = {
day: 1,
month: 1,
year: 2022,
hour: 23,
minute: 40,
};
const formatOptions: Intl.DateTimeFormatOptions = {
timeZone: 'Australia/Sydney',
weekday: 'short',
month: 'short',
day: 'numeric',
};
expect(getMonthAndDay('en-US', datetimeParts, formatOptions)).toEqual('Sat, Jan 1');
});
});
describe('getFormattedHour()', () => {
@@ -144,6 +184,7 @@ describe('getLocalizedTime', () => {
expect(getLocalizedTime('en-GB', datetimeParts, 'h12')).toEqual('12:00 am');
});
it('should parse time-only values correctly', () => {
const datetimeParts: Partial<DatetimeParts> = {
hour: 22,
@@ -153,4 +194,42 @@ describe('getLocalizedTime', () => {
expect(getLocalizedTime('en-US', datetimeParts as DatetimeParts, 'h12')).toEqual('10:40 PM');
expect(getLocalizedTime('en-US', datetimeParts as DatetimeParts, 'h23')).toEqual('22:40');
});
it('should use formatOptions', () => {
const datetimeParts: DatetimeParts = {
day: 1,
month: 1,
year: 2022,
hour: 9,
minute: 40,
};
const formatOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
dayPeriod: 'short',
day: '2-digit',
};
// Even though this method is intended to be used for time, the date may be displayed as well when passing formatOptions
expect(getLocalizedTime('en-US', datetimeParts, 'h12', formatOptions)).toEqual('01, 09:40 in the morning');
});
it('should override provided time zone with UTC', () => {
const datetimeParts: DatetimeParts = {
day: 1,
month: 1,
year: 2022,
hour: 9,
minute: 40,
};
const formatOptions: Intl.DateTimeFormatOptions = {
timeZone: 'Australia/Sydney',
hour: 'numeric',
minute: 'numeric',
};
expect(getLocalizedTime('en-US', datetimeParts, 'h12', formatOptions)).toEqual('9:40 AM');
});
});

View File

@@ -11,7 +11,12 @@ const getFormattedDayPeriod = (dayPeriod?: string) => {
return dayPeriod.toUpperCase();
};
export const getLocalizedTime = (locale: string, refParts: DatetimeParts, hourCycle: DatetimeHourCycle): string => {
export const getLocalizedTime = (
locale: string,
refParts: DatetimeParts,
hourCycle: DatetimeHourCycle,
formatOptions?: Intl.DateTimeFormatOptions
): string => {
const timeParts: Pick<DatetimeParts, 'hour' | 'minute'> = {
hour: refParts.hour,
minute: refParts.minute,
@@ -21,9 +26,18 @@ export const getLocalizedTime = (locale: string, refParts: DatetimeParts, hourCy
return 'Invalid Time';
}
const defaultFormatOptions: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: 'numeric' };
// If any options are provided, don't use any of the defaults.
const options: Intl.DateTimeFormatOptions = formatOptions ?? defaultFormatOptions;
return new Intl.DateTimeFormat(locale, {
hour: 'numeric',
minute: 'numeric',
...options,
/**
* We use hourCycle here instead of hour12 due to:
* https://bugs.chromium.org/p/chromium/issues/detail?id=1347316&q=hour12&can=2
*/
hourCycle,
/**
* Setting the timeZone to UTC prevents
* new Intl.DatetimeFormat from subtracting
@@ -31,11 +45,6 @@ export const getLocalizedTime = (locale: string, refParts: DatetimeParts, hourCy
* when formatting the time.
*/
timeZone: 'UTC',
/**
* We use hourCycle here instead of hour12 due to:
* https://bugs.chromium.org/p/chromium/issues/detail?id=1347316&q=hour12&can=2
*/
hourCycle,
/**
* Setting Z at the end indicates that this
* date string is in the UTC time zone. This
@@ -150,11 +159,17 @@ export const generateDayAriaLabel = (locale: string, today: boolean, refParts: D
* Gets the day of the week, month, and day
* Used for the header in MD mode.
*/
export const getMonthAndDay = (locale: string, refParts: DatetimeParts) => {
export const getMonthAndDay = (locale: string, refParts: DatetimeParts, formatOptions?: Intl.DateTimeFormatOptions) => {
const defaultFormatOptions: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric' };
// If any options are provided, don't use any of the defaults. This way the developer can (for example) choose to not have the weekday displayed at all.
const options: Intl.DateTimeFormatOptions = formatOptions ?? defaultFormatOptions;
const date = getNormalizedDate(refParts);
return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(
date
);
return new Intl.DateTimeFormat(locale, {
...options,
timeZone: 'UTC',
}).format(date);
};
/**

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', 'formatOptions', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', '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', 'formatOptions', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', 'readonly', 'showClearButton', 'showDefaultButtons', 'showDefaultTimeLabel', 'showDefaultTitle', 'size', 'titleSelectedDatesFormatter', 'value', 'yearValues'],
})
export class IonDatetime {
protected el: HTMLElement;

View File

@@ -274,6 +274,7 @@ export const IonDatetime = /*@__PURE__*/ defineContainer<JSX.IonDatetime, JSX.Io
'color',
'name',
'disabled',
'formatOptions',
'readonly',
'isDateEnabled',
'min',