Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(datetime): formatOptions for time button and header #29009

Merged
merged 22 commits into from
Feb 15, 2024
Merged
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
@@ -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; } | { time: DateTimeFormatOptions; },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
12 changes: 10 additions & 2 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
@@ -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"`.
*/
4 changes: 4 additions & 0 deletions core/src/components/datetime/datetime-interface.ts
Original file line number Diff line number Diff line change
@@ -36,3 +36,7 @@ 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;
60 changes: 55 additions & 5 deletions core/src/components/datetime/datetime.tsx
Original file line number Diff line number Diff line change
@@ -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,24 @@ export class Datetime implements ComponentInterface {
*/
@Prop() disabled = false;

/**
* Formatting options, separated by date and time.
*/
@Prop() formatOptions?: DatetimeFormatOptions;
Copy link
Contributor

@averyjohnston averyjohnston Feb 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: It would be cool to have a test HTML page to mess around with this ready to go. Maybe something with a text box that lets you enter a value for formatOptions dynamically, and a button to update the datetime with what you've entered? Definitely only do this if you have spare time though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to also improve the JSDoc comment here. This is the information that will show in the docs and intellisense.

Would be good to include what format it requires and maybe a link out to MDN for those rules.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i improved the comment


@Watch('formatOptions')
protected formatOptionsChanged() {
this.errorIfTimeZoneProvided();
}

get dateFormatOptions(): Intl.DateTimeFormatOptions | undefined {
return (this.formatOptions as DateFormatOptions)?.date;
}

get timeFormatOptions(): Intl.DateTimeFormatOptions | undefined {
return (this.formatOptions as TimeFormatOptions)?.time;
}

/**
* If `true`, the datetime appears normal but the selected date cannot be changed.
*/
@@ -1357,7 +1378,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 +1403,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 +1434,18 @@ export class Datetime implements ComponentInterface {
this.emitStyle();
}

private errorIfTimeZoneProvided() {
const { dateFormatOptions, timeFormatOptions } = this;
if (
dateFormatOptions?.timeZone ||
dateFormatOptions?.timeZoneName ||
timeFormatOptions?.timeZone ||
timeFormatOptions?.timeZoneName
) {
printIonWarning('Datetime: "timeZone" and "timeZoneName" are not supported in "formatOptions".');
}
}

private emitStyle() {
this.ionStyle.emit({
interactive: true,
@@ -2354,10 +2391,16 @@ export class Datetime implements ComponentInterface {
}

private renderTimeOverlay() {
const { disabled, hourCycle, isTimePopoverOpen, locale } = this;
const { disabled, hourCycle, isTimePopoverOpen, locale, timeFormatOptions } = 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 +2432,7 @@ export class Datetime implements ComponentInterface {
}
}}
>
{getLocalizedTime(locale, activePart, computedHourCycle)}
{getLocalizedTime(locale, activePart, computedHourCycle, timeButtonFormatOptions)}
</button>,
<ion-popover
alignment="center"
@@ -2424,7 +2467,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 +2481,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;
74 changes: 74 additions & 0 deletions core/src/components/datetime/test/basic/datetime.e2e.ts
Original file line number Diff line number Diff line change
@@ -565,3 +565,77 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
});

/**
* This behavior does not differ across
* directions.
*/
configs({ directions: ['ltr'] }).forEach(({ title, 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>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {
time: { hour: '2-digit', minute: '2-digit' },
date: { day: '2-digit', month: 'long', era: 'short' },
}
</script>
`,
config
);

await page.locator('.datetime-ready').waitFor();

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');
});
});
});

/**
* 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>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {
time: { timeZone: 'UTC' },
}
</script>
`,
config
);

await page.locator('.datetime-ready').waitFor();

expect(logs.length).toBe(1);
expect(logs[0]).toContain(
'[Ionic Warning]: Datetime: "timeZone" and "timeZoneName" are not supported in "formatOptions".'
);
});
});
});
79 changes: 79 additions & 0 deletions core/src/components/datetime/test/format.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
39 changes: 27 additions & 12 deletions core/src/components/datetime/utils/format.ts
Original file line number Diff line number Diff line change
@@ -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,21 +26,25 @@ 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 = 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
* the user's current timezone offset
* 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 = 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);
};

/**
4 changes: 2 additions & 2 deletions packages/angular/src/directives/proxies.ts
Original file line number Diff line number Diff line change
@@ -635,15 +635,15 @@ 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({
selector: 'ion-datetime',
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;
1 change: 1 addition & 0 deletions packages/vue/src/proxies.ts
Original file line number Diff line number Diff line change
@@ -274,6 +274,7 @@ export const IonDatetime = /*@__PURE__*/ defineContainer<JSX.IonDatetime, JSX.Io
'color',
'name',
'disabled',
'formatOptions',
'readonly',
'isDateEnabled',
'min',