diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 1575a207e45..81ccd9db231 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -915,7 +915,7 @@ export namespace Components { */ "presentation": DatetimePresentation; /** - * If `true`, the datetime appears normal but is not interactive. + * If `true`, the datetime appears normal but the selected date cannot be changed. */ "readonly": boolean; /** @@ -5595,7 +5595,7 @@ declare namespace LocalJSX { */ "presentation"?: DatetimePresentation; /** - * If `true`, the datetime appears normal but is not interactive. + * If `true`, the datetime appears normal but the selected date cannot be changed. */ "readonly"?: boolean; /** diff --git a/core/src/components/datetime/datetime.scss b/core/src/components/datetime/datetime.scss index 024506590c2..39449279d43 100644 --- a/core/src/components/datetime/datetime.scss +++ b/core/src/components/datetime/datetime.scss @@ -185,13 +185,37 @@ ion-picker-column-internal { display: none; } -:host(.datetime-readonly), :host(.datetime-disabled) { pointer-events: none; + + .calendar-days-of-week, + .datetime-time { + opacity: 0.4; + } } -:host(.datetime-disabled) { - opacity: 0.4; +:host(.datetime-readonly) { + pointer-events: none; + + /** + * Allow user to navigate months + * while in readonly mode + */ + .calendar-action-buttons, + .calendar-body, + .datetime-year { + pointer-events: initial; + } + + /** + * Disabled buttons should have full opacity + * in readonly mode + */ + + .calendar-day[disabled]:not(.calendar-day-constrained), + .datetime-action-buttons ion-button[disabled] { + opacity: 1; + } } /** diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 62ba8755d3f..cdf38597150 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -172,7 +172,7 @@ export class Datetime implements ComponentInterface { @Prop() disabled = false; /** - * If `true`, the datetime appears normal but is not interactive. + * If `true`, the datetime appears normal but the selected date cannot be changed. */ @Prop() readonly = false; @@ -599,6 +599,14 @@ export class Datetime implements ComponentInterface { }; private setActiveParts = (parts: DatetimeParts, removeDate = false) => { + /** if the datetime component is in readonly mode, + * allow browsing of the calendar without changing + * the set value + */ + if (this.readonly) { + return; + } + const { multiple, minParts, maxParts, activeParts } = this; /** @@ -1414,7 +1422,13 @@ export class Datetime implements ComponentInterface { */ private renderFooter() { - const { showDefaultButtons, showClearButton } = this; + const { disabled, readonly, showDefaultButtons, showClearButton } = this; + /** + * The cancel, clear, and confirm buttons + * should not be interactive if the datetime + * is disabled or readonly. + */ + const isButtonDisabled = disabled || readonly; const hasSlottedButtons = this.el.querySelector('[slot="buttons"]') !== null; if (!hasSlottedButtons && !showDefaultButtons && !showClearButton) { return; @@ -1444,18 +1458,33 @@ export class Datetime implements ComponentInterface { {showDefaultButtons && ( - this.cancel(true)}> + this.cancel(true)} + disabled={isButtonDisabled} + > {this.cancelText} )}
{showClearButton && ( - clearButtonClick()}> + clearButtonClick()} + disabled={isButtonDisabled} + > {this.clearText} )} {showDefaultButtons && ( - this.confirm(true)}> + this.confirm(true)} + disabled={isButtonDisabled} + > {this.doneText} )} @@ -1957,11 +1986,12 @@ export class Datetime implements ComponentInterface { */ private renderCalendarHeader(mode: Mode) { + const { disabled } = this; const expandedIcon = mode === 'ios' ? chevronDown : caretUpSharp; const collapsedIcon = mode === 'ios' ? chevronForward : caretDownSharp; - const prevMonthDisabled = isPrevMonthDisabled(this.workingParts, this.minParts, this.maxParts); - const nextMonthDisabled = isNextMonthDisabled(this.workingParts, this.maxParts); + const prevMonthDisabled = disabled || isPrevMonthDisabled(this.workingParts, this.minParts, this.maxParts); + const nextMonthDisabled = disabled || isNextMonthDisabled(this.workingParts, this.maxParts); // don't use the inheritAttributes util because it removes dir from the host, and we still need that const hostDir = this.el.getAttribute('dir') || undefined; @@ -1977,6 +2007,7 @@ export class Datetime implements ComponentInterface { aria-label="Show year picker" detail={false} lines="none" + disabled={disabled} onClick={() => { this.toggleMonthAndYearView(); /** @@ -2043,23 +2074,28 @@ export class Datetime implements ComponentInterface { ); } private renderMonth(month: number, year: number) { + const { disabled, readonly } = this; + const yearAllowed = this.parsedYearValues === undefined || this.parsedYearValues.includes(year); const monthAllowed = this.parsedMonthValues === undefined || this.parsedMonthValues.includes(month); const isCalMonthDisabled = !yearAllowed || !monthAllowed; - const swipeDisabled = isMonthDisabled( - { - month, - year, - day: null, - }, - { - // The day is not used when checking if a month is disabled. - // Users should be able to access the min or max month, even if the - // min/max date is out of bounds (e.g. min is set to Feb 15, Feb should not be disabled). - minParts: { ...this.minParts, day: null }, - maxParts: { ...this.maxParts, day: null }, - } - ); + const isDatetimeDisabled = disabled || readonly; + const swipeDisabled = + disabled || + isMonthDisabled( + { + month, + year, + day: null, + }, + { + // The day is not used when checking if a month is disabled. + // Users should be able to access the min or max month, even if the + // min/max date is out of bounds (e.g. min is set to Feb 15, Feb should not be disabled). + minParts: { ...this.minParts, day: null }, + maxParts: { ...this.maxParts, day: null }, + } + ); // The working month should never have swipe disabled. // Otherwise the CSS scroll snap will not work and the user // can free-scroll the calendar. @@ -2083,7 +2119,14 @@ export class Datetime implements ComponentInterface { const { el, highlightedDates, isDateEnabled, multiple } = this; const referenceParts = { month, day, year }; const isCalendarPadding = day === null; - const { isActive, isToday, ariaLabel, ariaSelected, disabled, text } = getCalendarDayState( + const { + isActive, + isToday, + ariaLabel, + ariaSelected, + disabled: isDayDisabled, + text, + } = getCalendarDayState( this.locale, referenceParts, this.activeParts, @@ -2094,7 +2137,8 @@ export class Datetime implements ComponentInterface { ); const dateIsoString = convertDataToISO(referenceParts); - let isCalDayDisabled = isCalMonthDisabled || disabled; + + let isCalDayDisabled = isCalMonthDisabled || isDayDisabled; if (!isCalDayDisabled && isDateEnabled !== undefined) { try { @@ -2113,6 +2157,15 @@ export class Datetime implements ComponentInterface { } } + /** + * Some days are constrained through max & min or allowed dates + * and also disabled because the component is readonly or disabled. + * These need to be displayed differently. + */ + const isCalDayConstrained = isCalDayDisabled && isDatetimeDisabled; + + const isButtonDisabled = isCalDayDisabled || isDatetimeDisabled; + let dateStyle: DatetimeHighlightStyle | undefined = undefined; /** @@ -2158,11 +2211,12 @@ export class Datetime implements ComponentInterface { data-year={year} data-index={index} data-day-of-week={dayOfWeek} - disabled={isCalDayDisabled} + disabled={isButtonDisabled} class={{ 'calendar-day-padding': isCalendarPadding, 'calendar-day': true, 'calendar-day-active': isActive, + 'calendar-day-constrained': isCalDayConstrained, 'calendar-day-today': isToday, }} part={dateParts} @@ -2237,7 +2291,7 @@ export class Datetime implements ComponentInterface { } private renderTimeOverlay() { - const { hourCycle, isTimePopoverOpen, locale } = this; + const { disabled, hourCycle, isTimePopoverOpen, locale } = this; const computedHourCycle = getHourCycle(locale, hourCycle); const activePart = this.getActivePartsWithFallback(); @@ -2251,6 +2305,7 @@ export class Datetime implements ComponentInterface { part={`time-button${isTimePopoverOpen ? ' active' : ''}`} aria-expanded="false" aria-haspopup="true" + disabled={disabled} onClick={async (ev) => { const { popoverRef } = this; diff --git a/core/src/components/datetime/test/a11y/datetime.e2e.ts b/core/src/components/datetime/test/a11y/datetime.e2e.ts index 8ebc6bb87fd..5f4e5ff9e3f 100644 --- a/core/src/components/datetime/test/a11y/datetime.e2e.ts +++ b/core/src/components/datetime/test/a11y/datetime.e2e.ts @@ -30,3 +30,102 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { }); }); }); + +/** + * This behavior does not differ across + * modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('datetime: a11y'), () => { + test('datetime should be keyboard navigable', async ({ page, browserName }) => { + await page.setContent( + ` + + `, + config + ); + const tabKey = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; + + const datetime = page.locator('ion-datetime'); + const monthYearButton = page.locator('.calendar-month-year ion-item'); + const prevButton = page.locator('.calendar-next-prev ion-button:nth-child(1)'); + const nextButton = page.locator('.calendar-next-prev ion-button:nth-child(2)'); + + await page.keyboard.press(tabKey); + await expect(monthYearButton).toBeFocused(); + + await page.keyboard.press(tabKey); + await expect(prevButton).toBeFocused(); + + await page.keyboard.press(tabKey); + await expect(nextButton).toBeFocused(); + + // check value before & after selecting via keyboard + const initialValue = await datetime.evaluate((el: HTMLIonDatetimeElement) => el.value); + expect(initialValue).toBe('2022-02-22T16:30:00'); + + await page.keyboard.press(tabKey); + await page.waitForChanges(); + + await page.keyboard.press('ArrowLeft'); + await page.waitForChanges(); + + await page.keyboard.press('Enter'); + + await page.waitForChanges(); + + const newValue = await datetime.evaluate((el: HTMLIonDatetimeElement) => el.value); + expect(newValue).not.toBe('2022-02-22T16:30:00'); + }); + + test('buttons should be keyboard navigable', async ({ page }) => { + await page.setContent( + ` + + + `, + config + ); + + await page.waitForSelector('.datetime-ready'); + + const clearButton = page.locator('#clear-button button'); + const selectedDay = page.locator('.calendar-day-active'); + + await expect(selectedDay).toHaveText('22'); + + await clearButton.focus(); + await page.waitForChanges(); + + await expect(clearButton).toBeFocused(); + await page.keyboard.press('Enter'); + + await page.waitForChanges(); + + await expect(selectedDay).toHaveCount(0); + }); + + test('should navigate through months via right arrow key', async ({ page }) => { + await page.setContent( + ` + + + `, + config + ); + + await page.waitForSelector('.datetime-ready'); + const calendarMonthYear = page.locator('ion-datetime .calendar-month-year'); + const calendarBody = page.locator('.calendar-body'); + await expect(calendarMonthYear).toHaveText('February 2022'); + + await calendarBody.focus(); + await page.waitForChanges(); + + await page.keyboard.press('ArrowRight'); + await page.waitForChanges(); + + await expect(calendarMonthYear).toHaveText('March 2022'); + }); + }); +}); diff --git a/core/src/components/datetime/test/disabled/datetime.e2e.ts b/core/src/components/datetime/test/disabled/datetime.e2e.ts new file mode 100644 index 00000000000..d794a2d2ef6 --- /dev/null +++ b/core/src/components/datetime/test/disabled/datetime.e2e.ts @@ -0,0 +1,103 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * This behavior does not differ across + * modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { + test.describe(title('datetime: disabled'), () => { + test('should not have visual regressions', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const datetime = page.locator('ion-datetime'); + await expect(datetime).toHaveScreenshot(screenshot(`datetime-disabled`)); + }); + + test('date should be disabled', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + await page.waitForSelector('.datetime-ready'); + + const febFirstButton = page.locator(`.calendar-day[data-day='1'][data-month='2']`); + + await expect(febFirstButton).toBeDisabled(); + }); + + test('month-year button should be disabled', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + await page.waitForSelector('.datetime-ready'); + const calendarMonthYear = page.locator('ion-datetime .calendar-month-year'); + await expect(calendarMonthYear.locator('button')).toBeDisabled(); + }); + + test('next and prev buttons should be disabled', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const prevMonthButton = page.locator('ion-datetime .calendar-next-prev ion-button:first-of-type button'); + const nextMonthButton = page.locator('ion-datetime .calendar-next-prev ion-button:last-of-type button'); + + await expect(prevMonthButton).toBeDisabled(); + await expect(nextMonthButton).toBeDisabled(); + }); + + test('clear button should be disabled', async ({ page }) => { + await page.setContent( + ` + + + `, + config + ); + + await page.waitForSelector('.datetime-ready'); + + const clearButton = page.locator('#clear-button button'); + + await expect(clearButton).toBeDisabled(); + }); + + test('should not navigate through months via right arrow key', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + await page.waitForSelector('.datetime-ready'); + const calendarMonthYear = page.locator('ion-datetime .calendar-month-year'); + const calendarBody = page.locator('.calendar-body'); + await expect(calendarMonthYear).toHaveText('February 2022'); + + await calendarBody.focus(); + await page.waitForChanges(); + + await page.keyboard.press('ArrowRight'); + await page.waitForChanges(); + + await expect(calendarMonthYear).toHaveText('February 2022'); + }); + }); +}); diff --git a/core/src/components/datetime/test/disabled/datetime.e2e.ts-snapshots/datetime-disabled-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/disabled/datetime.e2e.ts-snapshots/datetime-disabled-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..ebc36556d8a Binary files /dev/null and b/core/src/components/datetime/test/disabled/datetime.e2e.ts-snapshots/datetime-disabled-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/disabled/datetime.e2e.ts-snapshots/datetime-disabled-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/disabled/datetime.e2e.ts-snapshots/datetime-disabled-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..31622a2c526 Binary files /dev/null and b/core/src/components/datetime/test/disabled/datetime.e2e.ts-snapshots/datetime-disabled-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/disabled/datetime.e2e.ts-snapshots/datetime-disabled-ios-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/disabled/datetime.e2e.ts-snapshots/datetime-disabled-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..3581bacc250 Binary files /dev/null and b/core/src/components/datetime/test/disabled/datetime.e2e.ts-snapshots/datetime-disabled-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/disabled/index.html b/core/src/components/datetime/test/disabled/index.html new file mode 100644 index 00000000000..764d3a42ca1 --- /dev/null +++ b/core/src/components/datetime/test/disabled/index.html @@ -0,0 +1,77 @@ + + + + + Datetime - Disabled + + + + + + + + + + + + Datetime - Disabled + + + +
+
+

Inline - Default Value

+ +
+ +
+

Inline

+ +
+ +
+

Inline - No Default Value

+ +
+
+
+ +
+ + diff --git a/core/src/components/datetime/test/readonly/datetime.e2e.ts b/core/src/components/datetime/test/readonly/datetime.e2e.ts new file mode 100644 index 00000000000..f157bd36e32 --- /dev/null +++ b/core/src/components/datetime/test/readonly/datetime.e2e.ts @@ -0,0 +1,167 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * This behavior does not differ across + * modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { + test.describe(title('datetime: readonly'), () => { + test('should not have visual regressions', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const datetime = page.locator('ion-datetime'); + await expect(datetime).toHaveScreenshot(screenshot(`datetime-readonly`)); + }); + + test('date should be disabled', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + await page.waitForSelector('.datetime-ready'); + + const febFirstButton = page.locator(`.calendar-day[data-day='1'][data-month='2']`); + + await expect(febFirstButton).toBeDisabled(); + }); + + test('should navigate months via month-year button', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const ionChange = await page.spyOnEvent('ionChange'); + await page.waitForSelector('.datetime-ready'); + const calendarMonthYear = page.locator('ion-datetime .calendar-month-year'); + await expect(calendarMonthYear).toHaveText('February 2022'); + + await calendarMonthYear.click(); + await page.waitForChanges(); + await page.locator('.month-column .picker-item[data-value="3"]').click(); + await page.waitForChanges(); + await expect(calendarMonthYear).toHaveText('March 2022'); + + await expect(ionChange).not.toHaveReceivedEvent(); + }); + + test('should open picker using keyboard navigation', async ({ page, browserName }) => { + await page.setContent( + ` + + `, + config + ); + + const tabKey = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; + + await page.waitForSelector('.datetime-ready'); + const calendarMonthYear = page.locator('ion-datetime .calendar-month-year'); + const monthYearButton = page.locator('.calendar-month-year ion-item'); + await expect(calendarMonthYear).toHaveText('February 2022'); + + await page.keyboard.press(tabKey); + await expect(monthYearButton).toBeFocused(); + await page.waitForChanges(); + + await page.keyboard.press('Enter'); + await page.waitForChanges(); + + const marchPickerItem = page.locator('.month-column .picker-item[data-value="3"]'); + await expect(marchPickerItem).toBeVisible(); + }); + + test('should view next month via next button', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const ionChange = await page.spyOnEvent('ionChange'); + + const calendarMonthYear = page.locator('ion-datetime .calendar-month-year'); + await expect(calendarMonthYear).toHaveText('February 2022'); + + const nextMonthButton = page.locator('ion-datetime .calendar-next-prev ion-button + ion-button'); + await nextMonthButton.click(); + await page.waitForChanges(); + + await expect(calendarMonthYear).toHaveText('March 2022'); + await expect(ionChange).not.toHaveReceivedEvent(); + }); + + test('should not change value when the month is changed via keyboard navigation', async ({ page, browserName }) => { + await page.setContent( + ` + + `, + config + ); + + const tabKey = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; + + const datetime = page.locator('ion-datetime'); + const monthYearButton = page.locator('.calendar-month-year ion-item'); + const prevButton = page.locator('.calendar-next-prev ion-button:nth-child(1)'); + const nextButton = page.locator('.calendar-next-prev ion-button:nth-child(2)'); + const calendarMonthYear = page.locator('ion-datetime .calendar-month-year'); + + await page.keyboard.press(tabKey); + await expect(monthYearButton).toBeFocused(); + + await page.keyboard.press(tabKey); + await expect(prevButton).toBeFocused(); + + await page.keyboard.press(tabKey); + await expect(nextButton).toBeFocused(); + + // check value before & after selecting via keyboard + const initialValue = await datetime.evaluate((el: HTMLIonDatetimeElement) => el.value); + expect(initialValue).toBe('2022-02-22T16:30:00'); + await expect(calendarMonthYear).toHaveText('February 2022'); + + await page.keyboard.press(tabKey); + await page.waitForChanges(); + + await page.keyboard.press('ArrowLeft'); + await page.waitForChanges(); + + await expect(calendarMonthYear).toHaveText('January 2022'); + await page.keyboard.press('Enter'); + await page.waitForChanges(); + + const newValue = await datetime.evaluate((el: HTMLIonDatetimeElement) => el.value); + // should not have changed + expect(newValue).toBe('2022-02-22T16:30:00'); + }); + + test('clear button should be disabled', async ({ page }) => { + await page.setContent( + ` + + + `, + config + ); + + await page.waitForSelector('.datetime-ready'); + + const clearButton = page.locator('#clear-button button'); + + await expect(clearButton).toBeDisabled(); + }); + }); +}); diff --git a/core/src/components/datetime/test/readonly/datetime.e2e.ts-snapshots/datetime-readonly-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/datetime/test/readonly/datetime.e2e.ts-snapshots/datetime-readonly-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..b82687e3738 Binary files /dev/null and b/core/src/components/datetime/test/readonly/datetime.e2e.ts-snapshots/datetime-readonly-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/datetime/test/readonly/datetime.e2e.ts-snapshots/datetime-readonly-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/datetime/test/readonly/datetime.e2e.ts-snapshots/datetime-readonly-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..176a846fad0 Binary files /dev/null and b/core/src/components/datetime/test/readonly/datetime.e2e.ts-snapshots/datetime-readonly-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/datetime/test/readonly/datetime.e2e.ts-snapshots/datetime-readonly-ios-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/readonly/datetime.e2e.ts-snapshots/datetime-readonly-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..f58f00143a9 Binary files /dev/null and b/core/src/components/datetime/test/readonly/datetime.e2e.ts-snapshots/datetime-readonly-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/readonly/index.html b/core/src/components/datetime/test/readonly/index.html new file mode 100644 index 00000000000..2af9a2e4772 --- /dev/null +++ b/core/src/components/datetime/test/readonly/index.html @@ -0,0 +1,83 @@ + + + + + Datetime - Readonly + + + + + + + + + + + + Datetime - Readonly + + + +
+
+

Inline

+ +
+ +
+

Inline - No Default Value

+ +
+
+
+ +
+ +