chore: sync with main

This commit is contained in:
Liam DeBeasi
2024-03-13 10:21:01 -04:00
32 changed files with 995 additions and 181 deletions

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, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
import { SpinnerTypes } from "./components/spinner/spinner-configs";
import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
import { MenuChangeEventDetail, MenuType, Side } from "./components/menu/menu-interface";
@@ -50,7 +50,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, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
export { SpinnerTypes } from "./components/spinner/spinner-configs";
export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
export { MenuChangeEventDetail, MenuType, Side } from "./components/menu/menu-interface";
@@ -852,6 +852,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 for dates and times. Should include a 'date' and/or 'time' object, each of which is of type [Intl.DateTimeFormatOptions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options).
*/
"formatOptions"?: FormatOptions;
/**
* 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"`.
*/
@@ -2523,6 +2527,10 @@ export namespace Components {
* If `true`, enable searchbar animation.
*/
"animated": boolean;
/**
* Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user. Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`.
*/
"autocapitalize": string;
/**
* Set the input's autocomplete property.
*/
@@ -2567,6 +2575,14 @@ export namespace Components {
* A hint to the browser for which keyboard to display. Possible values: `"none"`, `"text"`, `"tel"`, `"url"`, `"email"`, `"numeric"`, `"decimal"`, and `"search"`.
*/
"inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
/**
* This attribute specifies the maximum number of characters that the user can enter.
*/
"maxlength"?: number;
/**
* This attribute specifies the minimum number of characters that the user can enter.
*/
"minlength"?: number;
/**
* The mode determines which platform styles to use.
*/
@@ -5500,6 +5516,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 for dates and times. Should include a 'date' and/or 'time' object, each of which is of type [Intl.DateTimeFormatOptions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options).
*/
"formatOptions"?: FormatOptions;
/**
* 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"`.
*/
@@ -7196,6 +7216,10 @@ declare namespace LocalJSX {
* If `true`, enable searchbar animation.
*/
"animated"?: boolean;
/**
* Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user. Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`.
*/
"autocapitalize": string;
/**
* Set the input's autocomplete property.
*/
@@ -7236,6 +7260,14 @@ declare namespace LocalJSX {
* A hint to the browser for which keyboard to display. Possible values: `"none"`, `"text"`, `"tel"`, `"url"`, `"email"`, `"numeric"`, `"decimal"`, and `"search"`.
*/
"inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
/**
* This attribute specifies the maximum number of characters that the user can enter.
*/
"maxlength"?: number;
/**
* This attribute specifies the minimum number of characters that the user can enter.
*/
"minlength"?: number;
/**
* The mode determines which platform styles to use.
*/

View File

@@ -1,7 +1,7 @@
import type { ComponentInterface } from '@stencil/core';
import { Build, Component, Element, Host, Method, h } from '@stencil/core';
import type { FocusVisibleUtility } from '@utils/focus-visible';
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
import { shouldUseCloseWatcher } from '@utils/hardware-back-button';
import { printIonWarning } from '@utils/logging';
import { isPlatform } from '@utils/platform';
@@ -36,7 +36,7 @@ export class App implements ComponentInterface {
import('../../utils/input-shims/input-shims').then((module) => module.startInputShims(config, platform));
}
const hardwareBackButtonModule = await import('../../utils/hardware-back-button');
const supportsHardwareBackButtonEvents = isHybrid || shoudUseCloseWatcher();
const supportsHardwareBackButtonEvents = isHybrid || shouldUseCloseWatcher();
if (config.getBoolean('hardwareBackButton', supportsHardwareBackButtonEvents)) {
hardwareBackButtonModule.startHardwareBackButton();
} else {
@@ -44,7 +44,7 @@ export class App implements ComponentInterface {
* If an app sets hardwareBackButton: false and experimentalCloseWatcher: true
* then the close watcher will not be used.
*/
if (shoudUseCloseWatcher()) {
if (shouldUseCloseWatcher()) {
printIonWarning(
'experimentalCloseWatcher was set to `true`, but hardwareBackButton was set to `false`. Both config options must be `true` for the Close Watcher API to be used.'
);

View File

@@ -8,7 +8,7 @@ import { getIonMode } from '../../global/ionic-global';
import type { Color } from '../../interface';
import type { DatetimePresentation } from '../datetime/datetime-interface';
import { getToday } from '../datetime/utils/data';
import { getMonthAndYear, getMonthDayAndYear, getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format';
import { getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format';
import { getHourCycle } from '../datetime/utils/helpers';
import { parseDate } from '../datetime/utils/parse';
/**
@@ -196,7 +196,7 @@ export class DatetimeButton implements ComponentInterface {
return;
}
const { value, locale, hourCycle, preferWheel, multiple, titleSelectedDatesFormatter } = datetimeEl;
const { value, locale, formatOptions, hourCycle, preferWheel, multiple, titleSelectedDatesFormatter } = datetimeEl;
const parsedValues = this.getParsedDateValues(value);
@@ -225,8 +225,12 @@ export class DatetimeButton implements ComponentInterface {
switch (datetimePresentation) {
case 'date-time':
case 'time-date':
const dateText = getMonthDayAndYear(locale, firstParsedDatetime);
const timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle);
const dateText = getLocalizedDateTime(
locale,
firstParsedDatetime,
formatOptions?.date ?? { month: 'short', day: 'numeric', year: 'numeric' }
);
const timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle, formatOptions?.time);
if (preferWheel) {
this.dateText = `${dateText} ${timeText}`;
} else {
@@ -246,20 +250,28 @@ export class DatetimeButton implements ComponentInterface {
}
this.dateText = headerText;
} else {
this.dateText = getMonthDayAndYear(locale, firstParsedDatetime);
this.dateText = getLocalizedDateTime(
locale,
firstParsedDatetime,
formatOptions?.date ?? { month: 'short', day: 'numeric', year: 'numeric' }
);
}
break;
case 'time':
this.timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle);
this.timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle, formatOptions?.time);
break;
case 'month-year':
this.dateText = getMonthAndYear(locale, firstParsedDatetime);
this.dateText = getLocalizedDateTime(
locale,
firstParsedDatetime,
formatOptions?.date ?? { month: 'long', year: 'numeric' }
);
break;
case 'month':
this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, { month: 'long' });
this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, formatOptions?.time ?? { month: 'long' });
break;
case 'year':
this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, { year: 'numeric' });
this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, formatOptions?.time ?? { year: 'numeric' });
break;
}
};

View File

@@ -261,4 +261,87 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
await expect(page.locator('#time-button')).not.toBeVisible();
});
});
test.describe(title('datetime-button: formatOptions'), () => {
test('should include date and time for presentation date-time', async ({ page }) => {
await page.setContent(
`
<ion-datetime-button datetime="datetime"></ion-datetime-button>
<ion-datetime id="datetime" presentation="date-time" value="2023-11-02T01:22:00" locale="en-US"></ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {
date: {
weekday: "short",
month: "long",
day: "2-digit"
},
time: {
hour: "2-digit",
minute: "2-digit"
}
}
</script>
`,
config
);
await page.locator('.datetime-ready').waitFor();
await expect(page.locator('#date-button')).toContainText('Thu, November 02');
await expect(page.locator('#time-button')).toContainText('01:22 AM');
});
test('should include date for presentation date', async ({ page }) => {
await page.setContent(
`
<ion-datetime-button datetime="datetime"></ion-datetime-button>
<ion-datetime id="datetime" presentation="date" value="2023-11-02" locale="en-US"></ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {
date: {
weekday: "short",
month: "long",
day: "2-digit"
}
}
</script>
`,
config
);
await page.locator('.datetime-ready').waitFor();
await expect(page.locator('#date-button')).toContainText('Thu, November 02');
});
test('should include date and time in same button for preferWheel', async ({ page }) => {
await page.setContent(
`
<ion-datetime-button datetime="datetime"></ion-datetime-button>
<ion-datetime id="datetime" presentation="date-time" value="2023-11-02T01:22:00" locale="en-US" prefer-wheel="true"></ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {
date: {
weekday: "short",
month: "long",
day: "2-digit"
},
time: {
hour: "2-digit",
minute: "2-digit"
}
}
</script>
`,
config
);
await page.locator('.datetime-ready').waitFor();
await expect(page.locator('ion-datetime-button')).toContainText('Thu, November 02 01:22 AM');
});
});
});

View File

@@ -215,8 +215,41 @@
></ion-datetime>
</ion-popover>
</div>
<div class="grid-item">
<h2>formatOptions</h2>
<ion-item>
<ion-label>Start Date</ion-label>
<ion-datetime-button datetime="format-options" slot="end"></ion-datetime-button>
</ion-item>
<ion-popover arrow="false">
<ion-datetime
id="format-options"
presentation="date-time"
value="2023-11-02T01:22:00"
locale="en-US"
></ion-datetime>
</ion-popover>
</div>
</div>
</ion-content>
</ion-app>
</body>
<script>
const formatOptionsDatetime = document.querySelector('#format-options');
formatOptionsDatetime.formatOptions = {
date: {
weekday: 'short',
month: 'long',
day: '2-digit',
},
time: {
hour: '2-digit',
minute: '2-digit',
},
};
</script>
</html>

View File

@@ -36,3 +36,16 @@ export type DatetimeHighlight = { date: string } & DatetimeHighlightStyle;
export type DatetimeHighlightCallback = (dateIsoString: string) => DatetimeHighlightStyle | undefined;
export type DatetimeHourCycle = 'h11' | 'h12' | 'h23' | 'h24';
/**
* FormatOptions must include date and/or time; it cannot be an empty object
*/
export type FormatOptions =
| {
date: Intl.DateTimeFormatOptions;
time?: Intl.DateTimeFormatOptions;
}
| {
date?: Intl.DateTimeFormatOptions;
time: Intl.DateTimeFormatOptions;
};

View File

@@ -19,6 +19,7 @@ import type {
DatetimeHighlightStyle,
DatetimeHighlightCallback,
DatetimeHourCycle,
FormatOptions,
} from './datetime-interface';
import { isSameDay, warnIfValueOutOfBounds, isBefore, isAfter } from './utils/comparison';
import type { WheelColumnOption } from './utils/data';
@@ -33,7 +34,7 @@ import {
getTimeColumnsData,
getCombinedDateColumnData,
} from './utils/data';
import { formatValue, getLocalizedTime, getMonthAndDay, getMonthAndYear } from './utils/format';
import { formatValue, getLocalizedDateTime, getLocalizedTime, getMonthAndYear } from './utils/format';
import { isLocaleDayPeriodRTL, isMonthFirstLocale, getNumDaysInMonth, getHourCycle } from './utils/helpers';
import {
calculateHourFromAMPM,
@@ -68,6 +69,7 @@ import {
isNextMonthDisabled,
isPrevMonthDisabled,
} from './utils/state';
import { checkForPresentationFormatMismatch, warnIfTimeZoneProvided } from './utils/validate';
/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
@@ -170,6 +172,20 @@ export class Datetime implements ComponentInterface {
*/
@Prop() disabled = false;
/**
* Formatting options for dates and times.
* Should include a 'date' and/or 'time' object, each of which is of type [Intl.DateTimeFormatOptions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options).
*
*/
@Prop() formatOptions?: FormatOptions;
@Watch('formatOptions')
protected formatOptionsChanged() {
const { el, formatOptions, presentation } = this;
checkForPresentationFormatMismatch(el, presentation, formatOptions);
warnIfTimeZoneProvided(el, formatOptions);
}
/**
* If `true`, the datetime appears normal but the selected date cannot be changed.
*/
@@ -234,6 +250,12 @@ export class Datetime implements ComponentInterface {
*/
@Prop() presentation: DatetimePresentation = 'date-time';
@Watch('presentation')
protected presentationChanged() {
const { el, formatOptions, presentation } = this;
checkForPresentationFormatMismatch(el, presentation, formatOptions);
}
private get isGridStyle() {
const { presentation, preferWheel } = this;
const hasDatePresentation = presentation === 'date' || presentation === 'date-time' || presentation === 'time-date';
@@ -1356,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') {
@@ -1381,6 +1403,11 @@ export class Datetime implements ComponentInterface {
}
}
if (formatOptions) {
checkForPresentationFormatMismatch(el, presentation, formatOptions);
warnIfTimeZoneProvided(el, formatOptions);
}
const hourValues = (this.parsedHourValues = convertToArrayOfNumbers(this.hourValues));
const minuteValues = (this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues));
const monthValues = (this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues));
@@ -2362,7 +2389,7 @@ export class Datetime implements ComponentInterface {
}
private renderTimeOverlay() {
const { disabled, hourCycle, isTimePopoverOpen, locale } = this;
const { disabled, hourCycle, isTimePopoverOpen, locale, formatOptions } = this;
const computedHourCycle = getHourCycle(locale, hourCycle);
const activePart = this.getActivePartsWithFallback();
@@ -2397,7 +2424,7 @@ export class Datetime implements ComponentInterface {
}
}}
>
{getLocalizedTime(locale, activePart, computedHourCycle)}
{getLocalizedTime(locale, activePart, computedHourCycle, formatOptions?.time)}
</button>,
<ion-popover
alignment="center"
@@ -2432,7 +2459,7 @@ export class Datetime implements ComponentInterface {
}
private getHeaderSelectedDateText() {
const { activeParts, multiple, titleSelectedDatesFormatter } = this;
const { activeParts, formatOptions, multiple, titleSelectedDatesFormatter } = this;
const isArray = Array.isArray(activeParts);
let headerText: string;
@@ -2447,7 +2474,11 @@ 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 = getLocalizedDateTime(
this.locale,
this.getActivePartsWithFallback(),
formatOptions?.date ?? { weekday: 'short', month: 'short', day: 'numeric' }
);
}
return headerText;

View File

@@ -586,3 +586,107 @@ 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 misconfiguration errors'), () => {
test('should log 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" presentation="date">
<span slot="title">Select Date</span>
</ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {
date: { 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".'
);
});
test('should log a warning if the required formatOptions are not provided for a presentation', 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 = {}
</script>
`,
config
);
await page.locator('.datetime-ready').waitFor();
expect(logs.length).toBe(1);
expect(logs[0]).toContain(
"[Ionic Warning]: Datetime: The 'date-time' presentation requires either a date or time object (or both) in formatOptions."
);
});
});
});

View File

@@ -308,6 +308,13 @@
</ion-datetime>
</ion-modal>
</div>
<div class="grid-item">
<h2>formatOptions</h2>
<ion-datetime value="2020-03-14T14:23:00.000Z" id="format-options-datetime">
<span slot="title">Select Date</span>
</ion-datetime>
</div>
</div>
</ion-content>
<script>
@@ -403,6 +410,12 @@
app.appendChild(modalElement);
return modalElement;
};
const formatOptions = document.querySelector('#format-options-datetime');
formatOptions.formatOptions = {
time: { hour: '2-digit', minute: '2-digit' },
date: { day: '2-digit', month: 'long', era: 'short' },
};
</script>
</ion-app>
</body>

View File

@@ -1,12 +1,12 @@
import type { DatetimeParts } from '../datetime-interface';
import {
generateDayAriaLabel,
getMonthAndDay,
getFormattedHour,
addTimePadding,
getMonthAndYear,
getLocalizedDayPeriod,
getLocalizedTime,
stripTimeZone,
} from '../utils/format';
describe('generateDayAriaLabel()', () => {
@@ -37,24 +37,6 @@ describe('generateDayAriaLabel()', () => {
});
});
describe('getMonthAndDay()', () => {
it('should return Tue, May 11', () => {
expect(getMonthAndDay('en-US', { month: 5, day: 11, year: 2021 })).toEqual('Tue, May 11');
});
it('should return mar, 11 may', () => {
expect(getMonthAndDay('es-ES', { month: 5, day: 11, year: 2021 })).toEqual('mar, 11 may');
});
it('should return Sat, Apr 1', () => {
expect(getMonthAndDay('en-US', { month: 4, day: 1, year: 2006 })).toEqual('Sat, Apr 1');
});
it('should return sáb, 1 abr', () => {
expect(getMonthAndDay('es-ES', { month: 4, day: 1, year: 2006 })).toEqual('sáb, 1 abr');
});
});
describe('getFormattedHour()', () => {
it('should only add padding if using 24 hour time', () => {
expect(getFormattedHour(1, 'h11')).toEqual('1');
@@ -144,6 +126,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 +136,79 @@ 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',
timeZoneName: 'long',
hour: 'numeric',
minute: 'numeric',
};
expect(getLocalizedTime('en-US', datetimeParts, 'h12', formatOptions)).toEqual('9:40 AM');
});
it('should not include time zone name', () => {
const datetimeParts: DatetimeParts = {
day: 1,
month: 1,
year: 2022,
hour: 9,
minute: 40,
};
const formatOptions: Intl.DateTimeFormatOptions = {
timeZone: 'America/Los_Angeles',
timeZoneName: 'long',
hour: 'numeric',
minute: 'numeric',
};
expect(getLocalizedTime('en-US', datetimeParts, 'h12', formatOptions)).toEqual('9:40 AM');
});
});
describe('stripTimeZone', () => {
it('should remove the time zone name from the options and set the time zone to UTC', () => {
const formatOptions: Intl.DateTimeFormatOptions = {
timeZone: 'America/Los_Angeles',
timeZoneName: 'long',
hour: 'numeric',
minute: 'numeric',
};
expect(stripTimeZone(formatOptions)).toEqual({
timeZone: 'UTC',
hour: 'numeric',
minute: 'numeric',
});
});
});

View File

@@ -11,7 +11,33 @@ const getFormattedDayPeriod = (dayPeriod?: string) => {
return dayPeriod.toUpperCase();
};
export const getLocalizedTime = (locale: string, refParts: DatetimeParts, hourCycle: DatetimeHourCycle): string => {
/**
* Including time zone options may lead to the rendered text showing a
* different time from what was selected in the Datetime, which could cause
* confusion.
*/
export const stripTimeZone = (formatOptions: Intl.DateTimeFormatOptions): Intl.DateTimeFormatOptions => {
return {
...formatOptions,
/**
* Setting the time zone to UTC ensures that the value shown is always the
* same as what was selected and safeguards against older Safari bugs with
* Intl.DateTimeFormat.
*/
timeZone: 'UTC',
/**
* We do not want to display the time zone name
*/
timeZoneName: undefined,
};
};
export const getLocalizedTime = (
locale: string,
refParts: DatetimeParts,
hourCycle: DatetimeHourCycle,
formatOptions: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: 'numeric' }
): string => {
const timeParts: Pick<DatetimeParts, 'hour' | 'minute'> = {
hour: refParts.hour,
minute: refParts.minute,
@@ -22,15 +48,7 @@ export const getLocalizedTime = (locale: string, refParts: DatetimeParts, hourCy
}
return new Intl.DateTimeFormat(locale, {
hour: 'numeric',
minute: 'numeric',
/**
* Setting the timeZone to UTC prevents
* new Intl.DatetimeFormat from subtracting
* the user's current timezone offset
* when formatting the time.
*/
timeZone: 'UTC',
...stripTimeZone(formatOptions),
/**
* We use hourCycle here instead of hour12 due to:
* https://bugs.chromium.org/p/chromium/issues/detail?id=1347316&q=hour12&can=2
@@ -146,17 +164,6 @@ export const generateDayAriaLabel = (locale: string, today: boolean, refParts: D
return today ? `Today, ${labelString}` : labelString;
};
/**
* Gets the day of the week, month, and day
* Used for the header in MD mode.
*/
export const getMonthAndDay = (locale: string, refParts: DatetimeParts) => {
const date = getNormalizedDate(refParts);
return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(
date
);
};
/**
* Given a locale and a date object,
* return a formatted string that includes
@@ -168,16 +175,6 @@ export const getMonthAndYear = (locale: string, refParts: DatetimeParts) => {
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(date);
};
/**
* Given a locale and a date object,
* return a formatted string that includes
* the short month, numeric day, and full year.
* Example: Apr 22, 2021
*/
export const getMonthDayAndYear = (locale: string, refParts: DatetimeParts) => {
return getLocalizedDateTime(locale, refParts, { month: 'short', day: 'numeric', year: 'numeric' });
};
/**
* Given a locale and a date object,
* return a formatted string that includes
@@ -235,7 +232,7 @@ export const getLocalizedDateTime = (
options: Intl.DateTimeFormatOptions
): string => {
const date = getNormalizedDate(refParts);
return getDateTimeFormat(locale, options).format(date);
return getDateTimeFormat(locale, stripTimeZone(options)).format(date);
};
/**

View File

@@ -0,0 +1,54 @@
import { printIonWarning } from '@utils/logging';
import type { DatetimePresentation, FormatOptions } from '../datetime-interface';
/**
* If a time zone is provided in the format options, the rendered text could
* differ from what was selected in the Datetime, which could cause
* confusion.
*/
export const warnIfTimeZoneProvided = (el: HTMLElement, formatOptions?: FormatOptions) => {
if (
formatOptions?.date?.timeZone ||
formatOptions?.date?.timeZoneName ||
formatOptions?.time?.timeZone ||
formatOptions?.time?.timeZoneName
) {
printIonWarning('Datetime: "timeZone" and "timeZoneName" are not supported in "formatOptions".', el);
}
};
export const checkForPresentationFormatMismatch = (
el: HTMLElement,
presentation: DatetimePresentation,
formatOptions?: FormatOptions
) => {
// formatOptions is not required
if (!formatOptions) return;
// If formatOptions is provided, the date and/or time objects are required, depending on the presentation
switch (presentation) {
case 'date':
case 'month-year':
case 'month':
case 'year':
if (formatOptions.date === undefined) {
printIonWarning(`Datetime: The '${presentation}' presentation requires a date object in formatOptions.`, el);
}
break;
case 'time':
if (formatOptions.time === undefined) {
printIonWarning(`Datetime: The 'time' presentation requires a time object in formatOptions.`, el);
}
break;
case 'date-time':
case 'time-date':
if (formatOptions.date === undefined && formatOptions.time === undefined) {
printIonWarning(
`Datetime: The '${presentation}' presentation requires either a date or time object (or both) in formatOptions.`,
el
);
}
break;
}
};

View File

@@ -3,7 +3,7 @@ import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Wa
import { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
import { focusFirstDescendant, focusLastDescendant } from '@utils/focus-trap';
import { GESTURE_CONTROLLER } from '@utils/gesture';
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
import { shouldUseCloseWatcher } from '@utils/hardware-back-button';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers';
import { menuController } from '@utils/menu-controller';
@@ -782,7 +782,7 @@ export class Menu implements ComponentInterface, MenuI {
*/
return (
<Host
onKeyDown={shoudUseCloseWatcher() ? null : this.onKeydown}
onKeyDown={shouldUseCloseWatcher() ? null : this.onKeydown}
role="navigation"
aria-label={inheritedAttributes['aria-label'] || 'menu'}
class={{

View File

@@ -1,6 +1,7 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, forceUpdate, h } from '@stencil/core';
import { debounceEvent, raf, componentOnReady } from '@utils/helpers';
import { debounceEvent, raf, componentOnReady, inheritAttributes } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { isRTL } from '@utils/rtl';
import { createColorClasses } from '@utils/theme';
import { arrowBackSharp, closeCircle, closeSharp, searchOutline, searchSharp } from 'ionicons/icons';
@@ -28,6 +29,7 @@ export class Searchbar implements ComponentInterface {
private shouldAlignLeft = true;
private originalIonInput?: EventEmitter<SearchbarInputEventDetail>;
private inputId = `ion-searchbar-${searchbarIds++}`;
private inheritedAttributes: Attributes = {};
/**
* The value of the input when the textarea is focused.
@@ -39,6 +41,31 @@ export class Searchbar implements ComponentInterface {
@State() focused = false;
@State() noAnimate = true;
/**
* lang and dir are globally enumerated attributes.
* As a result, creating these as properties
* can have unintended side effects. Instead, we
* listen for attribute changes and inherit them
* to the inner `<input>` element.
*/
@Watch('lang')
onLangChanged(newValue: string) {
this.inheritedAttributes = {
...this.inheritedAttributes,
lang: newValue,
};
forceUpdate(this);
}
@Watch('dir')
onDirChanged(newValue: string) {
this.inheritedAttributes = {
...this.inheritedAttributes,
dir: newValue,
};
forceUpdate(this);
}
/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
@@ -51,6 +78,27 @@ export class Searchbar implements ComponentInterface {
*/
@Prop() animated = false;
/**
* Prior to the addition of this property
* autocapitalize was enabled by default on iOS
* and disabled by default on Android
* for Searchbar. The autocapitalize type on HTMLElement
* requires that it be a string and never undefined.
* However, setting it to a string value would be a breaking change
* in behavior, so we use "!" to tell TypeScript that this property
* is always defined so we can rely on the browser defaults. Browsers
* will automatically set a default value if the developer does not set one.
*
* In the future, this property will default to "off" to align with
* Input and Textarea, and the "!" will not be needed.
*/
/**
* Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user.
* Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`.
*/
@Prop() autocapitalize!: string;
/**
* Set the input's autocomplete property.
*/
@@ -112,6 +160,16 @@ export class Searchbar implements ComponentInterface {
*/
@Prop() enterkeyhint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
/**
* This attribute specifies the maximum number of characters that the user can enter.
*/
@Prop() maxlength?: number;
/**
* This attribute specifies the minimum number of characters that the user can enter.
*/
@Prop() minlength?: number;
/**
* If used in a form, set the name of the control, which is submitted with the form data.
*/
@@ -232,6 +290,12 @@ export class Searchbar implements ComponentInterface {
this.emitStyle();
}
componentWillLoad() {
this.inheritedAttributes = {
...inheritAttributes(this.el, ['lang', 'dir']),
};
}
componentDidLoad() {
this.originalIonInput = this.ionInput;
this.positionElements();
@@ -614,12 +678,16 @@ export class Searchbar implements ComponentInterface {
onChange={this.onChange}
onBlur={this.onBlur}
onFocus={this.onFocus}
minLength={this.minlength}
maxLength={this.maxlength}
placeholder={this.placeholder}
type={this.type}
value={this.getValue()}
autoCapitalize={this.autocapitalize}
autoComplete={this.autocomplete}
autoCorrect={this.autocorrect}
spellcheck={this.spellcheck}
{...this.inheritedAttributes}
/>
{mode === 'md' && cancelButton}

View File

@@ -3,13 +3,37 @@ import { newSpecPage } from '@stencil/core/testing';
import { Searchbar } from '../searchbar';
describe('searchbar: rendering', () => {
it('should inherit attributes', async () => {
it('should inherit properties on load', async () => {
const page = await newSpecPage({
components: [Searchbar],
html: '<ion-searchbar name="search"></ion-searchbar>',
html: '<ion-searchbar autocapitalize="off" maxlength="4" minlength="2" name="search"></ion-searchbar>',
});
const nativeEl = page.body.querySelector('ion-searchbar input')!;
expect(nativeEl.getAttribute('name')).toBe('search');
expect(nativeEl.getAttribute('maxlength')).toBe('4');
expect(nativeEl.getAttribute('minlength')).toBe('2');
expect(nativeEl.getAttribute('autocapitalize')).toBe('off');
});
it('should inherit watched attributes', async () => {
const page = await newSpecPage({
components: [Searchbar],
html: '<ion-searchbar dir="ltr" lang="en-US"></ion-searchbar>',
});
const searchbarEl = page.body.querySelector('ion-searchbar')!;
const nativeEl = searchbarEl.querySelector('input')!;
expect(nativeEl.getAttribute('lang')).toBe('en-US');
expect(nativeEl.getAttribute('dir')).toBe('ltr');
searchbarEl.setAttribute('lang', 'es-ES');
searchbarEl.setAttribute('dir', 'rtl');
await page.waitForChanges();
expect(nativeEl.getAttribute('lang')).toBe('es-ES');
expect(nativeEl.getAttribute('dir')).toBe('rtl');
});
});

View File

@@ -30,7 +30,7 @@ interface HandlerRegister {
* moment this file is evaluated which could be
* before the config is set.
*/
export const shoudUseCloseWatcher = () =>
export const shouldUseCloseWatcher = () =>
config.get('experimentalCloseWatcher', false) && win !== undefined && 'CloseWatcher' in win;
/**
@@ -109,7 +109,7 @@ export const startHardwareBackButton = () => {
* backbutton event otherwise we may get duplicate
* events firing.
*/
if (shoudUseCloseWatcher()) {
if (shouldUseCloseWatcher()) {
let watcher: CloseWatcher | undefined;
const configureWatcher = () => {

View File

@@ -1,7 +1,7 @@
import { doc } from '@utils/browser';
import { focusFirstDescendant, focusLastDescendant, focusableQueryString } from '@utils/focus-trap';
import type { BackButtonEvent } from '@utils/hardware-back-button';
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
import { shouldUseCloseWatcher } from '@utils/hardware-back-button';
import { config } from '../global/config';
import { getIonMode } from '../global/ionic-global';
@@ -398,7 +398,7 @@ const connectListeners = (doc: Document) => {
* this behavior will be handled via the ionBackButton
* event.
*/
if (!shoudUseCloseWatcher()) {
if (!shouldUseCloseWatcher()) {
doc.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') {
const lastOverlay = getPresentedOverlay(doc);
@@ -514,15 +514,7 @@ export const present = async <OverlayPresentOptions>(
document.body.classList.add(BACKDROP_NO_SCROLL);
/**
* Hide all other overlays from screen readers so only this one
* can be read. Note that presenting an overlay always makes
* it the topmost one.
*/
if (doc !== undefined) {
const presentedOverlays = getPresentedOverlays(doc);
presentedOverlays.forEach((o) => o.setAttribute('aria-hidden', 'true'));
}
hideOverlaysFromScreenReaders(overlay.el);
overlay.presented = true;
overlay.willPresent.emit();
@@ -699,13 +691,7 @@ export const dismiss = async <OverlayDismissOptions>(
overlay.el.remove();
/**
* If there are other overlays presented, unhide the new
* topmost one from screen readers.
*/
if (doc !== undefined) {
getPresentedOverlay(doc)?.removeAttribute('aria-hidden');
}
revealOverlaysToScreenReaders();
return true;
};
@@ -942,3 +928,65 @@ export const createTriggerController = () => {
removeClickListener,
};
};
/**
* Ensure that underlying overlays have aria-hidden if necessary so that screen readers
* cannot move focus to these elements. Note that we cannot rely on focus/focusin/focusout
* events here because those events do not fire when the screen readers moves to a non-focusable
* element such as text.
* Without this logic screen readers would be able to move focus outside of the top focus-trapped overlay.
*
* @param newTopMostOverlay - The overlay that is being presented. Since the overlay has not been
* fully presented yet at the time this function is called it will not be included in the getPresentedOverlays result.
*/
const hideOverlaysFromScreenReaders = (newTopMostOverlay: HTMLIonOverlayElement) => {
if (doc === undefined) return;
const overlays = getPresentedOverlays(doc);
for (let i = overlays.length - 1; i >= 0; i--) {
const presentedOverlay = overlays[i];
const nextPresentedOverlay = overlays[i + 1] ?? newTopMostOverlay;
/**
* If next overlay has aria-hidden then all remaining overlays will have it too.
* Or, if the next overlay is a Toast that does not have aria-hidden then current overlay
* should not have aria-hidden either so focus can remain in the current overlay.
*/
if (nextPresentedOverlay.hasAttribute('aria-hidden') || nextPresentedOverlay.tagName !== 'ION-TOAST') {
presentedOverlay.setAttribute('aria-hidden', 'true');
}
}
};
/**
* When dismissing an overlay we need to reveal the new top-most overlay to screen readers.
* If the top-most overlay is a Toast we potentially need to reveal more overlays since
* focus is never automatically moved to the Toast.
*/
const revealOverlaysToScreenReaders = () => {
if (doc === undefined) return;
const overlays = getPresentedOverlays(doc);
for (let i = overlays.length - 1; i >= 0; i--) {
const currentOverlay = overlays[i];
/**
* If the current we are looking at is a Toast then we can remove aria-hidden.
* However, we potentially need to keep looking at the overlay stack because there
* could be more Toasts underneath. Additionally, we need to unhide the closest non-Toast
* overlay too so focus can move there since focus is never automatically moved to the Toast.
*/
currentOverlay.removeAttribute('aria-hidden');
/**
* If we found a non-Toast element then we can just remove aria-hidden and stop searching entirely
* since this overlay should always receive focus. As a result, all underlying overlays should still
* be hidden from screen readers.
*/
if (currentOverlay.tagName !== 'ION-TOAST') {
break;
}
}
};

View File

@@ -1,6 +1,7 @@
import { newSpecPage } from '@stencil/core/testing';
import { Modal } from '../../../components/modal/modal';
import { Toast } from '../../../components/toast/toast';
import { Nav } from '../../../components/nav/nav';
import { RouterOutlet } from '../../../components/router-outlet/router-outlet';
import { setRootAriaHidden } from '../../overlays';
@@ -193,4 +194,70 @@ describe('aria-hidden on individual overlays', () => {
await modalOne.present();
expect(modalOne.hasAttribute('aria-hidden')).toEqual(false);
});
it('should not hide previous overlay if top-most overlay is toast', async () => {
const page = await newSpecPage({
components: [Modal, Toast],
html: `
<ion-modal id="m-one"></ion-modal>
<ion-modal id="m-two"></ion-modal>
<ion-toast id="t-one"></ion-toast>
<ion-toast id="t-two"></ion-toast>
`,
});
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-one')!;
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-two')!;
const toastOne = page.body.querySelector<HTMLIonModalElement>('ion-toast#t-one')!;
const toastTwo = page.body.querySelector<HTMLIonModalElement>('ion-toast#t-two')!;
await modalOne.present();
await modalTwo.present();
await toastOne.present();
await toastTwo.present();
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
expect(toastOne.hasAttribute('aria-hidden')).toEqual(false);
expect(toastTwo.hasAttribute('aria-hidden')).toEqual(false);
await toastTwo.dismiss();
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
expect(toastOne.hasAttribute('aria-hidden')).toEqual(false);
await toastOne.dismiss();
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
});
it('should hide previous overlay even with a toast that is not the top-most overlay', async () => {
const page = await newSpecPage({
components: [Modal, Toast],
html: `
<ion-modal id="m-one"></ion-modal>
<ion-toast id="t-one"></ion-toast>
<ion-modal id="m-two"></ion-modal>
`,
});
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-one')!;
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-two')!;
const toastOne = page.body.querySelector<HTMLIonModalElement>('ion-toast#t-one')!;
await modalOne.present();
await toastOne.present();
await modalTwo.present();
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
expect(toastOne.hasAttribute('aria-hidden')).toEqual(true);
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
await modalTwo.dismiss();
expect(modalOne.hasAttribute('aria-hidden')).toEqual(false);
expect(toastOne.hasAttribute('aria-hidden')).toEqual(false);
});
});