Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/month_dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ export default class MonthDropdown extends Component<
renderSelectOptions = (monthNames: string[]): React.ReactElement[] =>
monthNames.map<React.ReactElement>(
(m: string, i: number): React.ReactElement => (
<option key={m} value={i}>
<option
key={m}
value={i}
aria-label={`Select Month ${m}`}
aria-selected={i === this.props.month ? "true" : "false"}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aria-label is not necessary here, the value is already inside this element and the context this appears in already communicates that this is an option that can be selected.
The same with aria-selected I believe, the option element is inside a select element, both have built-in roles and meaning stating what is selected.
Adding aria to elements like these that already have the semantics and meaning built-in can make things less accessible because it is announced differently than the user might expect when encountering the native HTML elements.

>
{m}
</option>
),
Expand All @@ -47,6 +52,7 @@ export default class MonthDropdown extends Component<
value={this.props.month}
className="react-datepicker__month-select"
onChange={(e) => this.onChange(parseInt(e.target.value))}
aria-label="Select Month"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what the referred issue points to, and also what I’m after, but this should be a prop so it can use i18n. If a default is needed, I would recommend only "Month" since "select" is already given by the element type. The entire date dialog is also announced as "choose date", so there is no need to repeat "select/choose" too many places.

>
{this.renderSelectOptions(monthNames)}
</select>
Expand All @@ -62,8 +68,14 @@ export default class MonthDropdown extends Component<
style={{ visibility: visible ? "visible" : "hidden" }}
className="react-datepicker__month-read-view"
onClick={this.toggleDropdown}
aria-label="Select Month"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would make the element always announce "Select month" even when a month has been selected.

aria-expanded={this.state.dropdownVisible}
aria-haspopup="listbox"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also needs a reference to what element it opens and what element has keyboard focus when the element is open. An aria-controls that takes the id of the related element and an aria-activedescendant with the focused element.

>
<span className="react-datepicker__month-read-view--down-arrow" />
<span
className="react-datepicker__month-read-view--down-arrow"
aria-hidden="true"
/>
<span className="react-datepicker__month-read-view--selected-month">
{monthNames[this.props.month]}
</span>
Expand Down
1 change: 1 addition & 0 deletions src/month_dropdown_options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export default class MonthDropdownOptions extends Component<MonthDropdownOptions
}
}}
role="button"
aria-label={`Select Month ${month}`}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The role of this element should most likely be changed to "option" rather than adding an aria-label, if the parent element is a listbox. I would also change the parent to a ul element and this from a div to a li.

tabIndex={0}
className={
this.isSelectedMonth(i)
Expand Down
60 changes: 60 additions & 0 deletions src/test/month_dropdown_test.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,40 @@ describe("MonthDropdown", () => {
monthDropdown = getMonthDropdown();
});

it("sets proper ARIA on read view button and toggles aria-expanded", () => {
const monthReadView = safeQuerySelector(
monthDropdown,
".react-datepicker__month-read-view",
);
expect(monthReadView.getAttribute("aria-label")).toBe("Select Month");
expect(monthReadView.getAttribute("aria-haspopup")).toBe("listbox");
expect(monthReadView.getAttribute("aria-expanded")).toBe("false");

fireEvent.click(monthReadView);

const monthReadViewAfterOpen = safeQuerySelector(
monthDropdown,
".react-datepicker__month-read-view",
);
expect(monthReadViewAfterOpen.getAttribute("aria-expanded")).toBe("true");
});

it("applies aria-label to each month option in scroll dropdown", () => {
const monthReadView = safeQuerySelector(
monthDropdown,
".react-datepicker__month-read-view",
);
fireEvent.click(monthReadView);

const firstOption = safeQuerySelector(
monthDropdown,
".react-datepicker__month-option",
);
expect(firstOption.getAttribute("aria-label")).toBe(
"Select Month January",
);
});

it("shows the selected month in the initial view", () => {
expect(monthDropdown?.textContent).toContain("December");
});
Expand Down Expand Up @@ -307,6 +341,14 @@ describe("MonthDropdown", () => {
);
});

it("adds aria-label to select element", () => {
monthDropdown = getMonthDropdown({ dropdownMode: "select" });
const select = monthDropdown.querySelector<HTMLSelectElement>(
".react-datepicker__month-select",
);
expect(select?.getAttribute("aria-label")).toBe("Select Month");
});

it("renders month options with default locale", () => {
monthDropdown = getMonthDropdown({ dropdownMode: "select" });
const options = monthDropdown.querySelectorAll("option");
Expand All @@ -325,6 +367,24 @@ describe("MonthDropdown", () => {
"December",
]);
});
// Accessibility of options
it("adds aria-label and aria-selected to options in select mode", () => {
monthDropdown = getMonthDropdown({ dropdownMode: "select", month: 11 });
const select = monthDropdown.querySelector<HTMLSelectElement>(
".react-datepicker__month-select",
);
const options = Array.from(
select?.querySelectorAll("option") ?? [],
) as HTMLOptionElement[];
expect(options[0]?.getAttribute("aria-label")).toBe(
"Select Month January",
);
expect(options[11]?.getAttribute("aria-label")).toBe(
"Select Month December",
);
expect(options[11]?.getAttribute("aria-selected")).toBe("true");
expect(options[0]?.getAttribute("aria-selected")).toBe("false");
});
// Short Month Names
it("renders month options with short name and default locale", () => {
monthDropdown = getMonthDropdown({
Expand Down
142 changes: 140 additions & 2 deletions src/test/year_dropdown_test.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,25 @@ describe("YearDropdown", () => {
});

describe("scroll mode", () => {
const selectedYear = 2015;

beforeEach(function () {
yearDropdown = getYearDropdown();
yearDropdown = getYearDropdown({
year: selectedYear,
});
});

it("read view has correct ARIA attributes and toggles aria-expanded", () => {
const yearReadView = safeQuerySelector<HTMLButtonElement>(
yearDropdown,
".react-datepicker__year-read-view",
);
expect(yearReadView.getAttribute("aria-haspopup")).toBe("listbox");
expect(yearReadView.getAttribute("aria-label")).toBe("Select Year");
expect(yearReadView.getAttribute("aria-expanded")).toBe("false");

fireEvent.click(yearReadView);
expect(yearReadView.getAttribute("aria-expanded")).toBe("true");
});

it("shows the selected year in the initial view", () => {
Expand Down Expand Up @@ -138,15 +155,109 @@ describe("YearDropdown", () => {
fireEvent.keyDown(document.activeElement!, { key: "Enter" });
expect(lastOnChangeValue).toEqual(2016);
});

it("options expose correct ARIA attributes", () => {
const yearReadView = safeQuerySelector(
yearDropdown,
".react-datepicker__year-read-view",
);
fireEvent.click(yearReadView);

const yearOptions = safeQuerySelectorAll<HTMLDivElement>(
yearDropdown,
".react-datepicker__year-option",
7,
);

// Find the selected year option by text
const selected = yearOptions.find((el) =>
el.textContent?.includes(selectedYear.toString()),
)!;
expect(selected.getAttribute("aria-selected")).toBe("true");
expect(selected.getAttribute("aria-label")).toBe(
`Select Year ${selectedYear}`,
);

// Find a non-selected year option and ensure aria-selected is not present
const nonSelected =
yearOptions.find(
(el) => el.textContent?.trim() === (selectedYear - 1).toString(),
) ??
yearOptions.find(
(el) => el.textContent?.trim() === (selectedYear + 1).toString(),
);
expect(nonSelected).toBeTruthy();
expect(nonSelected!.getAttribute("aria-selected")).toBeNull();
const nonSelectedYear = nonSelected!.textContent!.trim();
expect(nonSelected!.getAttribute("aria-label")).toBe(
`Select Year ${nonSelectedYear}`,
);
});

it("pressing Escape closes the dropdown (onCancel)", () => {
const yearReadView = safeQuerySelector(
yearDropdown,
".react-datepicker__year-read-view",
);
fireEvent.click(yearReadView);

const yearOptions = safeQuerySelectorAll<HTMLDivElement>(
yearDropdown,
".react-datepicker__year-option",
7,
);
// Focus the selected option and press Escape
const selected = yearOptions.find((el) =>
el.textContent?.includes("2015"),
)!;
selected.focus();
fireEvent.keyDown(selected, { key: "Escape" });

const optionsView = yearDropdown?.querySelectorAll(
"react-datepicker__year-dropdown",
);
expect(optionsView).toHaveLength(0);
});

it("clicking 'Show later years' shifts the years forward by one", () => {
const yearReadView = safeQuerySelector(
yearDropdown,
".react-datepicker__year-read-view",
);
fireEvent.click(yearReadView);

// The first option is the 'Show later years' control when no maxDate is provided
const yearOptionsBefore = safeQuerySelectorAll<HTMLDivElement>(
yearDropdown,
".react-datepicker__year-option",
7,
);
const firstYearBefore = Number(
yearOptionsBefore[1]!.textContent?.trim(), // index 0 is the navigation control
);

// Click the navigation control to shift years
fireEvent.click(yearOptionsBefore[0]!);

const yearOptionsAfter = safeQuerySelectorAll<HTMLDivElement>(
yearDropdown,
".react-datepicker__year-option",
7,
);
const firstYearAfter = Number(yearOptionsAfter[1]!.textContent?.trim());
expect(firstYearAfter).toBe(firstYearBefore + 1);
});
});

describe("select mode", () => {
const selectedYear = 2015;

it("renders a select with default year range options", () => {
yearDropdown = getYearDropdown({ dropdownMode: "select" });
const select: NodeListOf<HTMLSelectElement> =
yearDropdown.querySelectorAll(".react-datepicker__year-select");
expect(select).toHaveLength(1);
expect(select[0]?.value).toBe("2015");
expect(select[0]?.value).toBe(selectedYear.toString());

const options = select[0]?.querySelectorAll("option") ?? [];
expect(Array.from(options).map((o) => o.textContent)).toEqual(
Expand Down Expand Up @@ -206,5 +317,32 @@ describe("YearDropdown", () => {
expect(onSelectSpy).toHaveBeenCalledTimes(1);
expect(setOpenSpy).toHaveBeenCalledTimes(1);
});

it("select and options expose correct ARIA attributes", () => {
yearDropdown = getYearDropdown({ dropdownMode: "select" });
const select: HTMLSelectElement =
yearDropdown.querySelector(".react-datepicker__year-select") ??
new HTMLSelectElement();

expect(select.getAttribute("aria-label")).toBe("Select Year");

const options = Array.from(
select.querySelectorAll("option"),
) as HTMLOptionElement[];
const opt2015 = options.find((o) => o.value === selectedYear.toString())!;
const opt2014 = options.find(
(o) => o.value === (selectedYear - 1).toString(),
)!;

expect(opt2015.getAttribute("aria-selected")).toBe("true");
expect(opt2015.getAttribute("aria-label")).toBe(
`Select Year ${selectedYear}`,
);

expect(opt2014.getAttribute("aria-selected")).toBe("false");
expect(opt2014.getAttribute("aria-label")).toBe(
`Select Year ${selectedYear - 1}`,
);
});
});
});
16 changes: 14 additions & 2 deletions src/year_dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ export default class YearDropdown extends Component<
const options: React.ReactElement[] = [];
for (let i = minYear; i <= maxYear; i++) {
options.push(
<option key={i} value={i}>
<option
key={i}
value={i}
aria-label={`Select Year ${i}`}
aria-selected={i === this.props.year ? "true" : "false"}
>
{i}
</option>,
);
Expand All @@ -59,6 +64,7 @@ export default class YearDropdown extends Component<
value={this.props.year}
className="react-datepicker__year-select"
onChange={this.onSelectChange}
aria-label="Select Year"
>
{this.renderSelectOptions()}
</select>
Expand All @@ -71,8 +77,14 @@ export default class YearDropdown extends Component<
style={{ visibility: visible ? "visible" : "hidden" }}
className="react-datepicker__year-read-view"
onClick={this.toggleDropdown}
aria-label="Select Year"
aria-expanded={this.state.dropdownVisible}
aria-haspopup="listbox"
>
<span className="react-datepicker__year-read-view--down-arrow" />
<span
className="react-datepicker__year-read-view--down-arrow"
aria-hidden="true"
/>
<span className="react-datepicker__year-read-view--selected-year">
{this.props.year}
</span>
Expand Down
5 changes: 5 additions & 0 deletions src/year_dropdown_options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export default class YearDropdownOptions extends Component<
key={year}
onClick={this.onChange.bind(this, year)}
onKeyDown={this.handleOptionKeyDown.bind(this, year)}
aria-label={`Select Year ${year}`}
aria-selected={selectedYear === year ? "true" : undefined}
>
{selectedYear === year ? (
Expand All @@ -156,6 +157,8 @@ export default class YearDropdownOptions extends Component<
className="react-datepicker__year-option"
key={"upcoming"}
onClick={this.incrementYears}
role="button"
aria-label="Show later years"
>
<a className="react-datepicker__navigation react-datepicker__navigation--years react-datepicker__navigation--years-upcoming" />
</div>,
Expand All @@ -168,6 +171,8 @@ export default class YearDropdownOptions extends Component<
className="react-datepicker__year-option"
key={"previous"}
onClick={this.decrementYears}
role="button"
aria-label="Show earlier years"
>
<a className="react-datepicker__navigation react-datepicker__navigation--years react-datepicker__navigation--years-previous" />
</div>,
Expand Down
Loading