Skip to content

Commit 7f61d82

Browse files
committed
♿️ Enhance accessibility in YearDropdown component
Closes #6223
1 parent 1fc0893 commit 7f61d82

File tree

3 files changed

+159
-4
lines changed

3 files changed

+159
-4
lines changed

src/test/year_dropdown_test.test.tsx

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,25 @@ describe("YearDropdown", () => {
3434
});
3535

3636
describe("scroll mode", () => {
37+
const selectedYear = 2015;
38+
3739
beforeEach(function () {
38-
yearDropdown = getYearDropdown();
40+
yearDropdown = getYearDropdown({
41+
year: selectedYear,
42+
});
43+
});
44+
45+
it("read view has correct ARIA attributes and toggles aria-expanded", () => {
46+
const yearReadView = safeQuerySelector<HTMLButtonElement>(
47+
yearDropdown,
48+
".react-datepicker__year-read-view",
49+
);
50+
expect(yearReadView.getAttribute("aria-haspopup")).toBe("listbox");
51+
expect(yearReadView.getAttribute("aria-label")).toBe("Select Year");
52+
expect(yearReadView.getAttribute("aria-expanded")).toBe("false");
53+
54+
fireEvent.click(yearReadView);
55+
expect(yearReadView.getAttribute("aria-expanded")).toBe("true");
3956
});
4057

4158
it("shows the selected year in the initial view", () => {
@@ -138,15 +155,109 @@ describe("YearDropdown", () => {
138155
fireEvent.keyDown(document.activeElement!, { key: "Enter" });
139156
expect(lastOnChangeValue).toEqual(2016);
140157
});
158+
159+
it("options expose correct ARIA attributes", () => {
160+
const yearReadView = safeQuerySelector(
161+
yearDropdown,
162+
".react-datepicker__year-read-view",
163+
);
164+
fireEvent.click(yearReadView);
165+
166+
const yearOptions = safeQuerySelectorAll<HTMLDivElement>(
167+
yearDropdown,
168+
".react-datepicker__year-option",
169+
7,
170+
);
171+
172+
// Find the selected year option by text
173+
const selected = yearOptions.find((el) =>
174+
el.textContent?.includes(selectedYear.toString()),
175+
)!;
176+
expect(selected.getAttribute("aria-selected")).toBe("true");
177+
expect(selected.getAttribute("aria-label")).toBe(
178+
`Select Year ${selectedYear}`,
179+
);
180+
181+
// Find a non-selected year option and ensure aria-selected is not present
182+
const nonSelected =
183+
yearOptions.find(
184+
(el) => el.textContent?.trim() === (selectedYear - 1).toString(),
185+
) ??
186+
yearOptions.find(
187+
(el) => el.textContent?.trim() === (selectedYear + 1).toString(),
188+
);
189+
expect(nonSelected).toBeTruthy();
190+
expect(nonSelected!.getAttribute("aria-selected")).toBeNull();
191+
const nonSelectedYear = nonSelected!.textContent!.trim();
192+
expect(nonSelected!.getAttribute("aria-label")).toBe(
193+
`Select Year ${nonSelectedYear}`,
194+
);
195+
});
196+
197+
it("pressing Escape closes the dropdown (onCancel)", () => {
198+
const yearReadView = safeQuerySelector(
199+
yearDropdown,
200+
".react-datepicker__year-read-view",
201+
);
202+
fireEvent.click(yearReadView);
203+
204+
const yearOptions = safeQuerySelectorAll<HTMLDivElement>(
205+
yearDropdown,
206+
".react-datepicker__year-option",
207+
7,
208+
);
209+
// Focus the selected option and press Escape
210+
const selected = yearOptions.find((el) =>
211+
el.textContent?.includes("2015"),
212+
)!;
213+
selected.focus();
214+
fireEvent.keyDown(selected, { key: "Escape" });
215+
216+
const optionsView = yearDropdown?.querySelectorAll(
217+
"react-datepicker__year-dropdown",
218+
);
219+
expect(optionsView).toHaveLength(0);
220+
});
221+
222+
it("clicking 'Show later years' shifts the years forward by one", () => {
223+
const yearReadView = safeQuerySelector(
224+
yearDropdown,
225+
".react-datepicker__year-read-view",
226+
);
227+
fireEvent.click(yearReadView);
228+
229+
// The first option is the 'Show later years' control when no maxDate is provided
230+
const yearOptionsBefore = safeQuerySelectorAll<HTMLDivElement>(
231+
yearDropdown,
232+
".react-datepicker__year-option",
233+
7,
234+
);
235+
const firstYearBefore = Number(
236+
yearOptionsBefore[1]!.textContent?.trim(), // index 0 is the navigation control
237+
);
238+
239+
// Click the navigation control to shift years
240+
fireEvent.click(yearOptionsBefore[0]!);
241+
242+
const yearOptionsAfter = safeQuerySelectorAll<HTMLDivElement>(
243+
yearDropdown,
244+
".react-datepicker__year-option",
245+
7,
246+
);
247+
const firstYearAfter = Number(yearOptionsAfter[1]!.textContent?.trim());
248+
expect(firstYearAfter).toBe(firstYearBefore + 1);
249+
});
141250
});
142251

143252
describe("select mode", () => {
253+
const selectedYear = 2015;
254+
144255
it("renders a select with default year range options", () => {
145256
yearDropdown = getYearDropdown({ dropdownMode: "select" });
146257
const select: NodeListOf<HTMLSelectElement> =
147258
yearDropdown.querySelectorAll(".react-datepicker__year-select");
148259
expect(select).toHaveLength(1);
149-
expect(select[0]?.value).toBe("2015");
260+
expect(select[0]?.value).toBe(selectedYear.toString());
150261

151262
const options = select[0]?.querySelectorAll("option") ?? [];
152263
expect(Array.from(options).map((o) => o.textContent)).toEqual(
@@ -206,5 +317,32 @@ describe("YearDropdown", () => {
206317
expect(onSelectSpy).toHaveBeenCalledTimes(1);
207318
expect(setOpenSpy).toHaveBeenCalledTimes(1);
208319
});
320+
321+
it("select and options expose correct ARIA attributes", () => {
322+
yearDropdown = getYearDropdown({ dropdownMode: "select" });
323+
const select: HTMLSelectElement =
324+
yearDropdown.querySelector(".react-datepicker__year-select") ??
325+
new HTMLSelectElement();
326+
327+
expect(select.getAttribute("aria-label")).toBe("Select Year");
328+
329+
const options = Array.from(
330+
select.querySelectorAll("option"),
331+
) as HTMLOptionElement[];
332+
const opt2015 = options.find((o) => o.value === selectedYear.toString())!;
333+
const opt2014 = options.find(
334+
(o) => o.value === (selectedYear - 1).toString(),
335+
)!;
336+
337+
expect(opt2015.getAttribute("aria-selected")).toBe("true");
338+
expect(opt2015.getAttribute("aria-label")).toBe(
339+
`Select Year ${selectedYear}`,
340+
);
341+
342+
expect(opt2014.getAttribute("aria-selected")).toBe("false");
343+
expect(opt2014.getAttribute("aria-label")).toBe(
344+
`Select Year ${selectedYear - 1}`,
345+
);
346+
});
209347
});
210348
});

src/year_dropdown.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ export default class YearDropdown extends Component<
4242
const options: React.ReactElement[] = [];
4343
for (let i = minYear; i <= maxYear; i++) {
4444
options.push(
45-
<option key={i} value={i}>
45+
<option
46+
key={i}
47+
value={i}
48+
aria-label={`Select Year ${i}`}
49+
aria-selected={i === this.props.year ? "true" : "false"}
50+
>
4651
{i}
4752
</option>,
4853
);
@@ -59,6 +64,7 @@ export default class YearDropdown extends Component<
5964
value={this.props.year}
6065
className="react-datepicker__year-select"
6166
onChange={this.onSelectChange}
67+
aria-label="Select Year"
6268
>
6369
{this.renderSelectOptions()}
6470
</select>
@@ -71,8 +77,14 @@ export default class YearDropdown extends Component<
7177
style={{ visibility: visible ? "visible" : "hidden" }}
7278
className="react-datepicker__year-read-view"
7379
onClick={this.toggleDropdown}
80+
aria-label="Select Year"
81+
aria-expanded={this.state.dropdownVisible}
82+
aria-haspopup="listbox"
7483
>
75-
<span className="react-datepicker__year-read-view--down-arrow" />
84+
<span
85+
className="react-datepicker__year-read-view--down-arrow"
86+
aria-hidden="true"
87+
/>
7688
<span className="react-datepicker__year-read-view--selected-year">
7789
{this.props.year}
7890
</span>

src/year_dropdown_options.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export default class YearDropdownOptions extends Component<
136136
key={year}
137137
onClick={this.onChange.bind(this, year)}
138138
onKeyDown={this.handleOptionKeyDown.bind(this, year)}
139+
aria-label={`Select Year ${year}`}
139140
aria-selected={selectedYear === year ? "true" : undefined}
140141
>
141142
{selectedYear === year ? (
@@ -156,6 +157,8 @@ export default class YearDropdownOptions extends Component<
156157
className="react-datepicker__year-option"
157158
key={"upcoming"}
158159
onClick={this.incrementYears}
160+
role="button"
161+
aria-label="Show later years"
159162
>
160163
<a className="react-datepicker__navigation react-datepicker__navigation--years react-datepicker__navigation--years-upcoming" />
161164
</div>,
@@ -168,6 +171,8 @@ export default class YearDropdownOptions extends Component<
168171
className="react-datepicker__year-option"
169172
key={"previous"}
170173
onClick={this.decrementYears}
174+
role="button"
175+
aria-label="Show earlier years"
171176
>
172177
<a className="react-datepicker__navigation react-datepicker__navigation--years react-datepicker__navigation--years-previous" />
173178
</div>,

0 commit comments

Comments
 (0)