Skip to content

Commit f6a6877

Browse files
mapsandappsIonitronaveryjohnston
authored
fix(datetime): allow calendar navigation in readonly mode; disallow keyboard navigation when disabled (#28336)
Issue number: #28121 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> It is not possible to navigate between months when ion-datetime is in readonly mode. This means that if there are multiple dates selected, the user cannot browse to view them all. Also, keyboard navigation is not prevented in `readonly` or `disabled` mode where it should be. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> When `readonly`: - Clicking the month-year button changes the month & year in readonly mode - Clicking the next & prev buttons changes the month in readonly mode - Left and right arrow keys change the month in readonly mode - Swiping/scrolling changes the month in readonly mode - The selected date does not change when doing any of the above - You cannot clear the value using keyboard navigation of the clear button in readonly mode When `disabled`: - You cannot navigate months via keyboard navigation of the month-year button in disabled mode - You cannot navigate months using keyboard navigation of the previous & next buttons in disabled mode - You cannot navigate months via the left and right arrow keys in disabled mode - The selected date does not change when doing any of the above - You cannot clear the value using keyboard navigation of the clear button in disabled mode Known bug: - It is still possible to navigate through dates in `prefers-wheel` when `disabled`. This bug existed prior to this PR. I created FW-5408 to track this. ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change, please describe the impact and migration path for existing applications below. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> --------- Co-authored-by: ionitron <[email protected]> Co-authored-by: Amanda Johnston <[email protected]>
1 parent 3b6e631 commit f6a6877

14 files changed

+638
-30
lines changed

core/src/components.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -915,7 +915,7 @@ export namespace Components {
915915
*/
916916
"presentation": DatetimePresentation;
917917
/**
918-
* If `true`, the datetime appears normal but is not interactive.
918+
* If `true`, the datetime appears normal but the selected date cannot be changed.
919919
*/
920920
"readonly": boolean;
921921
/**
@@ -5595,7 +5595,7 @@ declare namespace LocalJSX {
55955595
*/
55965596
"presentation"?: DatetimePresentation;
55975597
/**
5598-
* If `true`, the datetime appears normal but is not interactive.
5598+
* If `true`, the datetime appears normal but the selected date cannot be changed.
55995599
*/
56005600
"readonly"?: boolean;
56015601
/**

core/src/components/datetime/datetime.scss

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,13 +185,37 @@ ion-picker-column-internal {
185185
display: none;
186186
}
187187

188-
:host(.datetime-readonly),
189188
:host(.datetime-disabled) {
190189
pointer-events: none;
190+
191+
.calendar-days-of-week,
192+
.datetime-time {
193+
opacity: 0.4;
194+
}
191195
}
192196

193-
:host(.datetime-disabled) {
194-
opacity: 0.4;
197+
:host(.datetime-readonly) {
198+
pointer-events: none;
199+
200+
/**
201+
* Allow user to navigate months
202+
* while in readonly mode
203+
*/
204+
.calendar-action-buttons,
205+
.calendar-body,
206+
.datetime-year {
207+
pointer-events: initial;
208+
}
209+
210+
/**
211+
* Disabled buttons should have full opacity
212+
* in readonly mode
213+
*/
214+
215+
.calendar-day[disabled]:not(.calendar-day-constrained),
216+
.datetime-action-buttons ion-button[disabled] {
217+
opacity: 1;
218+
}
195219
}
196220

197221
/**

core/src/components/datetime/datetime.tsx

Lines changed: 80 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ export class Datetime implements ComponentInterface {
172172
@Prop() disabled = false;
173173

174174
/**
175-
* If `true`, the datetime appears normal but is not interactive.
175+
* If `true`, the datetime appears normal but the selected date cannot be changed.
176176
*/
177177
@Prop() readonly = false;
178178

@@ -599,6 +599,14 @@ export class Datetime implements ComponentInterface {
599599
};
600600

601601
private setActiveParts = (parts: DatetimeParts, removeDate = false) => {
602+
/** if the datetime component is in readonly mode,
603+
* allow browsing of the calendar without changing
604+
* the set value
605+
*/
606+
if (this.readonly) {
607+
return;
608+
}
609+
602610
const { multiple, minParts, maxParts, activeParts } = this;
603611

604612
/**
@@ -1414,7 +1422,13 @@ export class Datetime implements ComponentInterface {
14141422
*/
14151423

14161424
private renderFooter() {
1417-
const { showDefaultButtons, showClearButton } = this;
1425+
const { disabled, readonly, showDefaultButtons, showClearButton } = this;
1426+
/**
1427+
* The cancel, clear, and confirm buttons
1428+
* should not be interactive if the datetime
1429+
* is disabled or readonly.
1430+
*/
1431+
const isButtonDisabled = disabled || readonly;
14181432
const hasSlottedButtons = this.el.querySelector('[slot="buttons"]') !== null;
14191433
if (!hasSlottedButtons && !showDefaultButtons && !showClearButton) {
14201434
return;
@@ -1444,18 +1458,33 @@ export class Datetime implements ComponentInterface {
14441458
<slot name="buttons">
14451459
<ion-buttons>
14461460
{showDefaultButtons && (
1447-
<ion-button id="cancel-button" color={this.color} onClick={() => this.cancel(true)}>
1461+
<ion-button
1462+
id="cancel-button"
1463+
color={this.color}
1464+
onClick={() => this.cancel(true)}
1465+
disabled={isButtonDisabled}
1466+
>
14481467
{this.cancelText}
14491468
</ion-button>
14501469
)}
14511470
<div class="datetime-action-buttons-container">
14521471
{showClearButton && (
1453-
<ion-button id="clear-button" color={this.color} onClick={() => clearButtonClick()}>
1472+
<ion-button
1473+
id="clear-button"
1474+
color={this.color}
1475+
onClick={() => clearButtonClick()}
1476+
disabled={isButtonDisabled}
1477+
>
14541478
{this.clearText}
14551479
</ion-button>
14561480
)}
14571481
{showDefaultButtons && (
1458-
<ion-button id="confirm-button" color={this.color} onClick={() => this.confirm(true)}>
1482+
<ion-button
1483+
id="confirm-button"
1484+
color={this.color}
1485+
onClick={() => this.confirm(true)}
1486+
disabled={isButtonDisabled}
1487+
>
14591488
{this.doneText}
14601489
</ion-button>
14611490
)}
@@ -1957,11 +1986,12 @@ export class Datetime implements ComponentInterface {
19571986
*/
19581987

19591988
private renderCalendarHeader(mode: Mode) {
1989+
const { disabled } = this;
19601990
const expandedIcon = mode === 'ios' ? chevronDown : caretUpSharp;
19611991
const collapsedIcon = mode === 'ios' ? chevronForward : caretDownSharp;
19621992

1963-
const prevMonthDisabled = isPrevMonthDisabled(this.workingParts, this.minParts, this.maxParts);
1964-
const nextMonthDisabled = isNextMonthDisabled(this.workingParts, this.maxParts);
1993+
const prevMonthDisabled = disabled || isPrevMonthDisabled(this.workingParts, this.minParts, this.maxParts);
1994+
const nextMonthDisabled = disabled || isNextMonthDisabled(this.workingParts, this.maxParts);
19651995

19661996
// don't use the inheritAttributes util because it removes dir from the host, and we still need that
19671997
const hostDir = this.el.getAttribute('dir') || undefined;
@@ -1977,6 +2007,7 @@ export class Datetime implements ComponentInterface {
19772007
aria-label="Show year picker"
19782008
detail={false}
19792009
lines="none"
2010+
disabled={disabled}
19802011
onClick={() => {
19812012
this.toggleMonthAndYearView();
19822013
/**
@@ -2043,23 +2074,28 @@ export class Datetime implements ComponentInterface {
20432074
);
20442075
}
20452076
private renderMonth(month: number, year: number) {
2077+
const { disabled, readonly } = this;
2078+
20462079
const yearAllowed = this.parsedYearValues === undefined || this.parsedYearValues.includes(year);
20472080
const monthAllowed = this.parsedMonthValues === undefined || this.parsedMonthValues.includes(month);
20482081
const isCalMonthDisabled = !yearAllowed || !monthAllowed;
2049-
const swipeDisabled = isMonthDisabled(
2050-
{
2051-
month,
2052-
year,
2053-
day: null,
2054-
},
2055-
{
2056-
// The day is not used when checking if a month is disabled.
2057-
// Users should be able to access the min or max month, even if the
2058-
// min/max date is out of bounds (e.g. min is set to Feb 15, Feb should not be disabled).
2059-
minParts: { ...this.minParts, day: null },
2060-
maxParts: { ...this.maxParts, day: null },
2061-
}
2062-
);
2082+
const isDatetimeDisabled = disabled || readonly;
2083+
const swipeDisabled =
2084+
disabled ||
2085+
isMonthDisabled(
2086+
{
2087+
month,
2088+
year,
2089+
day: null,
2090+
},
2091+
{
2092+
// The day is not used when checking if a month is disabled.
2093+
// Users should be able to access the min or max month, even if the
2094+
// min/max date is out of bounds (e.g. min is set to Feb 15, Feb should not be disabled).
2095+
minParts: { ...this.minParts, day: null },
2096+
maxParts: { ...this.maxParts, day: null },
2097+
}
2098+
);
20632099
// The working month should never have swipe disabled.
20642100
// Otherwise the CSS scroll snap will not work and the user
20652101
// can free-scroll the calendar.
@@ -2083,7 +2119,14 @@ export class Datetime implements ComponentInterface {
20832119
const { el, highlightedDates, isDateEnabled, multiple } = this;
20842120
const referenceParts = { month, day, year };
20852121
const isCalendarPadding = day === null;
2086-
const { isActive, isToday, ariaLabel, ariaSelected, disabled, text } = getCalendarDayState(
2122+
const {
2123+
isActive,
2124+
isToday,
2125+
ariaLabel,
2126+
ariaSelected,
2127+
disabled: isDayDisabled,
2128+
text,
2129+
} = getCalendarDayState(
20872130
this.locale,
20882131
referenceParts,
20892132
this.activeParts,
@@ -2094,7 +2137,8 @@ export class Datetime implements ComponentInterface {
20942137
);
20952138

20962139
const dateIsoString = convertDataToISO(referenceParts);
2097-
let isCalDayDisabled = isCalMonthDisabled || disabled;
2140+
2141+
let isCalDayDisabled = isCalMonthDisabled || isDayDisabled;
20982142

20992143
if (!isCalDayDisabled && isDateEnabled !== undefined) {
21002144
try {
@@ -2113,6 +2157,15 @@ export class Datetime implements ComponentInterface {
21132157
}
21142158
}
21152159

2160+
/**
2161+
* Some days are constrained through max & min or allowed dates
2162+
* and also disabled because the component is readonly or disabled.
2163+
* These need to be displayed differently.
2164+
*/
2165+
const isCalDayConstrained = isCalDayDisabled && isDatetimeDisabled;
2166+
2167+
const isButtonDisabled = isCalDayDisabled || isDatetimeDisabled;
2168+
21162169
let dateStyle: DatetimeHighlightStyle | undefined = undefined;
21172170

21182171
/**
@@ -2158,11 +2211,12 @@ export class Datetime implements ComponentInterface {
21582211
data-year={year}
21592212
data-index={index}
21602213
data-day-of-week={dayOfWeek}
2161-
disabled={isCalDayDisabled}
2214+
disabled={isButtonDisabled}
21622215
class={{
21632216
'calendar-day-padding': isCalendarPadding,
21642217
'calendar-day': true,
21652218
'calendar-day-active': isActive,
2219+
'calendar-day-constrained': isCalDayConstrained,
21662220
'calendar-day-today': isToday,
21672221
}}
21682222
part={dateParts}
@@ -2237,7 +2291,7 @@ export class Datetime implements ComponentInterface {
22372291
}
22382292

22392293
private renderTimeOverlay() {
2240-
const { hourCycle, isTimePopoverOpen, locale } = this;
2294+
const { disabled, hourCycle, isTimePopoverOpen, locale } = this;
22412295
const computedHourCycle = getHourCycle(locale, hourCycle);
22422296
const activePart = this.getActivePartsWithFallback();
22432297

@@ -2251,6 +2305,7 @@ export class Datetime implements ComponentInterface {
22512305
part={`time-button${isTimePopoverOpen ? ' active' : ''}`}
22522306
aria-expanded="false"
22532307
aria-haspopup="true"
2308+
disabled={disabled}
22542309
onClick={async (ev) => {
22552310
const { popoverRef } = this;
22562311

core/src/components/datetime/test/a11y/datetime.e2e.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,102 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
3030
});
3131
});
3232
});
33+
34+
/**
35+
* This behavior does not differ across
36+
* modes/directions.
37+
*/
38+
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
39+
test.describe(title('datetime: a11y'), () => {
40+
test('datetime should be keyboard navigable', async ({ page, browserName }) => {
41+
await page.setContent(
42+
`
43+
<ion-datetime value="2022-02-22T16:30:00"></ion-datetime>
44+
`,
45+
config
46+
);
47+
const tabKey = browserName === 'webkit' ? 'Alt+Tab' : 'Tab';
48+
49+
const datetime = page.locator('ion-datetime');
50+
const monthYearButton = page.locator('.calendar-month-year ion-item');
51+
const prevButton = page.locator('.calendar-next-prev ion-button:nth-child(1)');
52+
const nextButton = page.locator('.calendar-next-prev ion-button:nth-child(2)');
53+
54+
await page.keyboard.press(tabKey);
55+
await expect(monthYearButton).toBeFocused();
56+
57+
await page.keyboard.press(tabKey);
58+
await expect(prevButton).toBeFocused();
59+
60+
await page.keyboard.press(tabKey);
61+
await expect(nextButton).toBeFocused();
62+
63+
// check value before & after selecting via keyboard
64+
const initialValue = await datetime.evaluate((el: HTMLIonDatetimeElement) => el.value);
65+
expect(initialValue).toBe('2022-02-22T16:30:00');
66+
67+
await page.keyboard.press(tabKey);
68+
await page.waitForChanges();
69+
70+
await page.keyboard.press('ArrowLeft');
71+
await page.waitForChanges();
72+
73+
await page.keyboard.press('Enter');
74+
75+
await page.waitForChanges();
76+
77+
const newValue = await datetime.evaluate((el: HTMLIonDatetimeElement) => el.value);
78+
expect(newValue).not.toBe('2022-02-22T16:30:00');
79+
});
80+
81+
test('buttons should be keyboard navigable', async ({ page }) => {
82+
await page.setContent(
83+
`
84+
85+
<ion-datetime value="2022-02-22T16:30:00" show-default-buttons="true" show-clear-button="true"></ion-datetime>
86+
`,
87+
config
88+
);
89+
90+
await page.waitForSelector('.datetime-ready');
91+
92+
const clearButton = page.locator('#clear-button button');
93+
const selectedDay = page.locator('.calendar-day-active');
94+
95+
await expect(selectedDay).toHaveText('22');
96+
97+
await clearButton.focus();
98+
await page.waitForChanges();
99+
100+
await expect(clearButton).toBeFocused();
101+
await page.keyboard.press('Enter');
102+
103+
await page.waitForChanges();
104+
105+
await expect(selectedDay).toHaveCount(0);
106+
});
107+
108+
test('should navigate through months via right arrow key', async ({ page }) => {
109+
await page.setContent(
110+
`
111+
112+
<ion-datetime value="2022-02-28"></ion-datetime>
113+
`,
114+
config
115+
);
116+
117+
await page.waitForSelector('.datetime-ready');
118+
const calendarMonthYear = page.locator('ion-datetime .calendar-month-year');
119+
const calendarBody = page.locator('.calendar-body');
120+
await expect(calendarMonthYear).toHaveText('February 2022');
121+
122+
await calendarBody.focus();
123+
await page.waitForChanges();
124+
125+
await page.keyboard.press('ArrowRight');
126+
await page.waitForChanges();
127+
128+
await expect(calendarMonthYear).toHaveText('March 2022');
129+
});
130+
});
131+
});

0 commit comments

Comments
 (0)