Skip to content

Commit ec4ce2c

Browse files
Merge pull request #5731 from qburst/issues-3797/fix/calendar-header-accessibility
♿️ Improve calendar header accessibility by adding screen reader text for week numbers
2 parents e7e26d1 + dec68cc commit ec4ce2c

File tree

4 files changed

+96
-23
lines changed

4 files changed

+96
-23
lines changed

src/calendar.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -477,8 +477,9 @@ export default class Calendar extends Component<CalendarProps, CalendarState> {
477477
const dayNames: React.ReactElement[] = [];
478478
if (this.props.showWeekNumbers) {
479479
dayNames.push(
480-
<div key="W" className="react-datepicker__day-name">
481-
{this.props.weekLabel || "#"}
480+
<div key="W" className="react-datepicker__day-name" role="columnheader">
481+
<span className="sr-only">Week number</span>
482+
<span aria-hidden="true">{this.props.weekLabel || "#"}</span>
482483
</div>,
483484
);
484485
}
@@ -494,10 +495,13 @@ export default class Calendar extends Component<CalendarProps, CalendarState> {
494495
return (
495496
<div
496497
key={offset}
497-
aria-label={formatDate(day, "EEEE", this.props.locale)}
498+
role="columnheader"
498499
className={clsx("react-datepicker__day-name", weekDayClassName)}
499500
>
500-
{weekDayName}
501+
<span className="sr-only">
502+
{formatDate(day, "EEEE", this.props.locale)}
503+
</span>
504+
<span aria-hidden="true">{weekDayName}</span>
501505
</div>
502506
);
503507
}),
@@ -852,7 +856,7 @@ export default class Calendar extends Component<CalendarProps, CalendarState> {
852856
{this.renderMonthYearDropdown(i !== 0)}
853857
{this.renderYearDropdown(i !== 0)}
854858
</div>
855-
<div className="react-datepicker__day-names">
859+
<div className="react-datepicker__day-names" role="row">
856860
{this.header(monthDate)}
857861
</div>
858862
</div>

src/stylesheets/datepicker.scss

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22
@use "variables" as *;
33
@use "mixins" as *;
44

5+
/* sr-only utility class for accessibility */
6+
.sr-only {
7+
position: absolute;
8+
width: 1px;
9+
height: 1px;
10+
padding: 0;
11+
margin: -1px;
12+
overflow: hidden;
13+
clip: rect(0, 0, 0, 0);
14+
white-space: nowrap;
15+
border: 0;
16+
}
17+
518
.react-datepicker-wrapper {
619
display: inline-block;
720
padding: 0;

src/test/calendar_test.test.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,9 @@ describe("Calendar", () => {
294294
it("should correctly format weekday using formatWeekDay prop", () => {
295295
const { calendar } = getCalendar({ formatWeekDay: (day) => day.charAt(0) });
296296
calendar
297-
.querySelectorAll(".react-datepicker__day-name")
297+
.querySelectorAll(
298+
".react-datepicker__day-name > span[aria-hidden='true']",
299+
)
298300
.forEach((dayName) => expect(dayName.textContent).toHaveLength(1));
299301
});
300302

@@ -1130,7 +1132,9 @@ describe("Calendar", () => {
11301132

11311133
it("should use a hash for week label if weekLabel is NOT provided", () => {
11321134
const { calendar } = getCalendar({ showWeekNumbers: true });
1133-
const weekLabel = calendar.querySelectorAll(".react-datepicker__day-name");
1135+
const weekLabel = calendar.querySelectorAll(
1136+
".react-datepicker__day-name > span[aria-hidden='true']",
1137+
);
11341138
expect(weekLabel[0]?.textContent).toBe("#");
11351139
});
11361140

@@ -1139,7 +1143,9 @@ describe("Calendar", () => {
11391143
showWeekNumbers: true,
11401144
weekLabel: "Foo",
11411145
});
1142-
const weekLabel = calendar.querySelectorAll(".react-datepicker__day-name");
1146+
const weekLabel = calendar.querySelectorAll(
1147+
".react-datepicker__day-name > span[aria-hidden='true']",
1148+
);
11431149
expect(weekLabel[0]?.textContent).toBe("Foo");
11441150
});
11451151

@@ -1252,13 +1258,13 @@ describe("Calendar", () => {
12521258
).container;
12531259

12541260
const daysNamesShort = calendarShort.querySelectorAll(
1255-
".react-datepicker__day-name",
1261+
".react-datepicker__day-name > span[aria-hidden='true']",
12561262
);
12571263
expect(daysNamesShort[0]?.textContent).toBe("Sun");
12581264
expect(daysNamesShort[6]?.textContent).toBe("Sat");
12591265

12601266
const daysNamesMin = calendarMin.querySelectorAll(
1261-
".react-datepicker__day-name",
1267+
".react-datepicker__day-name > span[aria-hidden='true']",
12621268
);
12631269
expect(daysNamesMin[0]?.textContent).toBe("Su");
12641270
expect(daysNamesMin[6]?.textContent).toBe("Sa");
@@ -1614,7 +1620,9 @@ describe("Calendar", () => {
16141620
calendarStartDay,
16151621
);
16161622
const firstWeekDayMin = getWeekdayMinInLocale(firstDateOfWeek, locale);
1617-
const firstHeader = calendar.querySelector(".react-datepicker__day-name");
1623+
const firstHeader = calendar.querySelector(
1624+
".react-datepicker__day-name > span[aria-hidden='true']",
1625+
);
16181626
expect(firstHeader?.textContent).toBe(firstWeekDayMin);
16191627
}
16201628

@@ -2236,13 +2244,11 @@ describe("Calendar", () => {
22362244

22372245
const header = container.querySelector(".react-datepicker__header");
22382246
const dayNameElements = header?.querySelectorAll(
2239-
".react-datepicker__day-name",
2247+
".react-datepicker__day-name > span.sr-only",
22402248
);
22412249

22422250
dayNameElements?.forEach((element, index) => {
2243-
expect(element.getAttribute("aria-label")).toBe(
2244-
expectedAriaLabels[index],
2245-
);
2251+
expect(element.textContent).toBe(expectedAriaLabels[index]);
22462252
});
22472253
});
22482254

@@ -2500,7 +2506,7 @@ describe("Calendar", () => {
25002506
it("should have default sunday as start day if No prop passed", () => {
25012507
const { calendar } = getCalendar();
25022508
const calendarDays = calendar.querySelectorAll(
2503-
".react-datepicker__day-name",
2509+
".react-datepicker__day-name > span[aria-hidden='true']",
25042510
);
25052511
expect(calendarDays[0]?.textContent).toBe("Su");
25062512
expect(calendarDays[6]?.textContent).toBe("Sa");
@@ -2509,7 +2515,7 @@ describe("Calendar", () => {
25092515
it("should have default wednesday as start day if No prop passed", () => {
25102516
const { calendar } = getCalendar({ calendarStartDay: 3 });
25112517
const calendarDays = calendar.querySelectorAll(
2512-
".react-datepicker__day-name",
2518+
".react-datepicker__day-name > span[aria-hidden='true']",
25132519
);
25142520
expect(calendarDays[0]?.textContent).toBe("We");
25152521
expect(calendarDays[6]?.textContent).toBe("Tu");

src/test/datepicker_test.test.tsx

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3794,6 +3794,54 @@ describe("DatePicker", () => {
37943794
});
37953795
});
37963796

3797+
describe("Calendar Header Accessibility", () => {
3798+
it("renders day names with sr-only full weekday and visible short name", () => {
3799+
const { container } = render(<DatePicker />);
3800+
const input = safeQuerySelector(container, "input");
3801+
fireEvent.focus(input);
3802+
3803+
const headers = container.querySelectorAll(
3804+
'.react-datepicker__day-names > [role="columnheader"]',
3805+
);
3806+
expect(headers.length).toBe(7);
3807+
3808+
headers.forEach((header) => {
3809+
// Should have a visually hidden span with the full weekday name
3810+
const srOnly = header.querySelector(".sr-only");
3811+
expect(srOnly).toBeTruthy();
3812+
expect(srOnly?.textContent?.length).toBeGreaterThan(2);
3813+
3814+
// Should have a visible short name
3815+
const visible = header.querySelector('span[aria-hidden="true"]');
3816+
expect(visible).toBeTruthy();
3817+
expect(visible?.textContent?.length).toBeLessThanOrEqual(3);
3818+
});
3819+
});
3820+
3821+
it("renders week number column header with sr-only label and visible #", () => {
3822+
const { container } = render(<DatePicker showWeekNumbers />);
3823+
const input = safeQuerySelector(container, "input");
3824+
fireEvent.focus(input);
3825+
3826+
const headers = container.querySelectorAll(
3827+
'.react-datepicker__day-names > [role="columnheader"]',
3828+
);
3829+
expect(headers.length).toBe(8);
3830+
3831+
const weekNumberHeader = headers[0] as Element;
3832+
const srOnly = weekNumberHeader.querySelector(".sr-only");
3833+
expect(srOnly).toBeTruthy();
3834+
expect(srOnly?.textContent?.trim()?.toLowerCase()).toEqual("week number");
3835+
3836+
// Should have a visible short name
3837+
const visible = weekNumberHeader.querySelector(
3838+
'span[aria-hidden="true"]',
3839+
);
3840+
expect(visible).toBeTruthy();
3841+
expect(visible?.textContent?.trim()?.toLowerCase()).toEqual("#");
3842+
});
3843+
});
3844+
37973845
it("should show the correct start of week for GB locale", () => {
37983846
registerLocale("en-GB", enGB);
37993847

@@ -3802,9 +3850,10 @@ describe("DatePicker", () => {
38023850
jest.spyOn(input, "focus");
38033851
fireEvent.focus(input);
38043852

3805-
const firstDay = container.querySelector(".react-datepicker__day-names")
3806-
?.childNodes[0]?.textContent;
3807-
expect(firstDay).toBe("Mo");
3853+
const firstDay = container.querySelector(
3854+
".react-datepicker__day-names > div[role='columnheader'] > span[aria-hidden='true']",
3855+
);
3856+
expect(firstDay?.textContent).toBe("Mo");
38083857
});
38093858

38103859
it("should show the correct start of week for US locale", () => {
@@ -3815,9 +3864,10 @@ describe("DatePicker", () => {
38153864
jest.spyOn(input, "focus");
38163865
fireEvent.focus(input);
38173866

3818-
const firstDay = container.querySelector(".react-datepicker__day-names")
3819-
?.childNodes[0]?.textContent;
3820-
expect(firstDay).toBe("Su");
3867+
const firstDay = container.querySelector(
3868+
".react-datepicker__day-names > div[role='columnheader'] > span[aria-hidden='true']",
3869+
);
3870+
expect(firstDay?.textContent).toBe("Su");
38213871
});
38223872

38233873
describe("when update the datepicker input text while props.showTimeSelectOnly is set and dateFormat has only time related format", () => {

0 commit comments

Comments
 (0)