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
Show file tree
Hide file tree
Changes from 5 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
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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"`.
*/
Expand Down Expand Up @@ -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"`.
*/
Expand Down
4 changes: 4 additions & 0 deletions core/src/components/datetime/datetime-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Up @@ -20,6 +20,9 @@ import type {
DatetimeHighlightStyle,
DatetimeHighlightCallback,
DatetimeHourCycle,
DatetimeFormatOptions,
TimeFormatOptions,
DateFormatOptions,
} from './datetime-interface';
import { isSameDay, warnIfValueOutOfBounds, isBefore, isAfter } from './utils/comparison';
import {
Expand Down Expand Up @@ -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 {
mapsandapps marked this conversation as resolved.
Show resolved Hide resolved
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.
*/
Expand Down Expand Up @@ -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') {
Expand All @@ -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));
Expand Down Expand Up @@ -1409,6 +1434,18 @@ export class Datetime implements ComponentInterface {
this.emitStyle();
}

private errorIfTimeZoneProvided() {
liamdebeasi marked this conversation as resolved.
Show resolved Hide resolved
const { dateFormatOptions, timeFormatOptions } = this;
if (
dateFormatOptions?.timeZone ||
dateFormatOptions?.timeZoneName ||
timeFormatOptions?.timeZone ||
timeFormatOptions?.timeZoneName
) {
printIonWarning('Datetime: "timeZone" and "timeZoneName" are not supported in "formatOptions".');
averyjohnston marked this conversation as resolved.
Show resolved Hide resolved
}
}

private emitStyle() {
this.ionStyle.emit({
interactive: true,
Expand Down Expand Up @@ -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',
mapsandapps marked this conversation as resolved.
Show resolved Hide resolved
minute: 'numeric',
computedHourCycle,
};

return [
<div class="time-header">{this.renderTimeLabel()}</div>,
<button
Expand Down Expand Up @@ -2389,7 +2432,7 @@ export class Datetime implements ComponentInterface {
}
}}
>
{getLocalizedTime(locale, activePart, computedHourCycle)}
{getLocalizedTime(locale, activePart, computedHourCycle, timeButtonFormatOptions)}
</button>,
<ion-popover
alignment="center"
Expand Down Expand Up @@ -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;
Expand All @@ -2438,8 +2481,15 @@ export class Datetime implements ComponentInterface {
}
}
} else {
const headerFormatOptions: Intl.DateTimeFormatOptions = dateFormatOptions ?? {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
};
mapsandapps marked this conversation as resolved.
Show resolved Hide resolved

// 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;
Expand Down
86 changes: 86 additions & 0 deletions core/src/components/datetime/test/basic/datetime.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
})
);
mapsandapps marked this conversation as resolved.
Show resolved Hide resolved

await page.waitForChanges();
mapsandapps marked this conversation as resolved.
Show resolved Hide resolved

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'));
mapsandapps marked this conversation as resolved.
Show resolved Hide resolved
mapsandapps marked this conversation as resolved.
Show resolved Hide resolved
});
});
});

/**
* 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 }) => {
mapsandapps marked this conversation as resolved.
Show resolved Hide resolved
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>
mapsandapps marked this conversation as resolved.
Show resolved Hide resolved
`,
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".'
);
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
79 changes: 79 additions & 0 deletions core/src/components/datetime/test/format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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');
});
});
Loading
Loading