From cf8640b31217d3a62243a3b7418ac751b1787e64 Mon Sep 17 00:00:00 2001 From: Martijn Russchen Date: Tue, 19 Aug 2025 22:56:21 +0200 Subject: [PATCH 1/2] feat: add jest-axe accessibility testing and fix ARIA structure issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add jest-axe dependency and comprehensive accessibility test suite - Fix ARIA structure violations in calendar components: * Update Day component: role="option" → role="gridcell" * Add Week component: role="row" for proper table structure * Update WeekNumber component: add role="gridcell" * Enhance Month component with conditional roles: - Use role="listbox" for month/year/quarter pickers - Use role="table" structure for regular calendar view * Restructure Calendar to use proper table hierarchy - Add comprehensive test coverage for all DatePicker variants - Ensure compatibility with screen readers and assistive technologies - All 22 accessibility tests now pass 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package.json | 2 + src/calendar.tsx | 10 +- src/day.tsx | 2 +- src/month.tsx | 51 +++++-- src/test/axe.test.tsx | 340 ++++++++++++++++++++++++++++++++++++++++++ src/test/index.ts | 3 + src/week.tsx | 2 +- src/week_number.tsx | 1 + yarn.lock | 98 ++++++++++-- 9 files changed, 480 insertions(+), 29 deletions(-) create mode 100644 src/test/axe.test.tsx diff --git a/package.json b/package.json index 650c9a7bad..8bc2f968e1 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@testing-library/user-event": "14.6.1", "@types/eslint": "^9.6.1", "@types/jest": "^30.0.0", + "@types/jest-axe": "^3.5.9", "@types/node": "22.15.30", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.2", @@ -73,6 +74,7 @@ "eslint-plugin-unused-imports": "^4.1.4", "husky": "9.1.7", "jest": "^30.0.5", + "jest-axe": "^10.0.0", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^29.7.0", "lint-staged": "^16.0.0", diff --git a/src/calendar.tsx b/src/calendar.tsx index c667c9e39f..f054906180 100644 --- a/src/calendar.tsx +++ b/src/calendar.tsx @@ -839,6 +839,12 @@ export default class Calendar extends Component { ); }; + renderDayNamesHeader = (monthDate: Date) => ( +
+ {this.header(monthDate)} +
+ ); + renderDefaultHeader = ({ monthDate, i }: { monthDate: Date; i: number }) => (
{ {this.renderMonthYearDropdown(i !== 0)} {this.renderYearDropdown(i !== 0)}
-
- {this.header(monthDate)} -
); @@ -1030,6 +1033,7 @@ export default class Calendar extends Component { selectingDate={this.state.selectingDate} monthShowsDuplicateDaysEnd={monthShowsDuplicateDaysEnd} monthShowsDuplicateDaysStart={monthShowsDuplicateDaysStart} + dayNamesHeader={this.renderDayNamesHeader(monthDate)} /> , ); diff --git a/src/day.tsx b/src/day.tsx index 612c2222a0..4413c88730 100644 --- a/src/day.tsx +++ b/src/day.tsx @@ -597,7 +597,7 @@ export default class Day extends Component { } tabIndex={this.getTabIndex()} aria-label={this.getAriaLabel()} - role="option" + role="gridcell" title={this.getTitle()} aria-disabled={this.isDisabled()} aria-current={this.isCurrentDay() ? "date" : undefined} diff --git a/src/month.tsx b/src/month.tsx index b5370d95cf..3a8a9979a1 100644 --- a/src/month.tsx +++ b/src/month.tsx @@ -140,6 +140,7 @@ interface MonthProps weekAriaLabelPrefix?: WeekProps["ariaLabelPrefix"]; chooseDayAriaLabelPrefix?: WeekProps["chooseDayAriaLabelPrefix"]; disabledDayAriaLabelPrefix?: WeekProps["disabledDayAriaLabelPrefix"]; + dayNamesHeader?: React.ReactNode; } /** @@ -1101,23 +1102,47 @@ export default class Month extends Component { ? ariaLabelPrefix.trim() + " " : ""; + const shouldUseListboxRole = showMonthYearPicker || showQuarterYearPicker; + + if (shouldUseListboxRole) { + return ( +
+ {showMonthYearPicker ? this.renderMonths() : this.renderQuarters()} +
+ ); + } + + // For regular calendar view, use table structure return (
- {showMonthYearPicker - ? this.renderMonths() - : showQuarterYearPicker - ? this.renderQuarters() - : this.renderWeeks()} + {this.props.dayNamesHeader && ( +
{this.props.dayNamesHeader}
+ )} +
+ {this.renderWeeks()} +
); } diff --git a/src/test/axe.test.tsx b/src/test/axe.test.tsx new file mode 100644 index 0000000000..e9b857b9af --- /dev/null +++ b/src/test/axe.test.tsx @@ -0,0 +1,340 @@ +import { render } from "@testing-library/react"; +import { axe, toHaveNoViolations } from "jest-axe"; +import React from "react"; +import DatePicker from "../index"; +import { newDate, addDays } from "../date_utils"; + +expect.extend(toHaveNoViolations); + +describe("Accessibility Tests", () => { + describe("Basic DatePicker", () => { + it("should work with proper labeling", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work with placeholder", async () => { + const { container } = render( + , + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work with aria-label", async () => { + const { container } = render( + , + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work when disabled", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work when readonly", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); + + describe("Opened DatePicker", () => { + it("should not have violations when calendar is open", async () => { + // FAILING: ARIA structure issues - role="row" needs proper parent container + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work with inline calendar", async () => { + // FAILING: ARIA structure issues - role="row" needs proper parent container + const { container } = render( +
+

Select a date

+ +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); + + describe("Date Range Picker", () => { + it("should work with date range picker", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work with open range picker", async () => { + // FAILING: ARIA structure issues - role="row" needs proper parent container + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); + + describe("Time Selection", () => { + it("should work with time selection", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work with time input", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work with time only", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); + + describe("Month and Year Pickers", () => { + it("should work with month picker", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work with year picker", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work with quarter picker", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); + + describe("Week Selection", () => { + it("should work with week picker", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work with week numbers shown", async () => { + // FAILING: ARIA children requirements - role="listbox" has incorrect child elements + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); + + describe("Multiple Months", () => { + it("should work with multiple months", async () => { + // FAILING: ARIA structure issues - role="row" needs proper parent container + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); + + describe("Custom Components", () => { + it("should work with clear button", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work with calendar icon", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); + + describe("Edge Cases", () => { + it("should work with no selected date", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should work with portal", async () => { + const { container } = render( +
+ + +
, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); +}); diff --git a/src/test/index.ts b/src/test/index.ts index 0be99411fd..7c238e3d7c 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -1 +1,4 @@ import "jest-canvas-mock"; +import { toHaveNoViolations } from "jest-axe"; + +expect.extend(toHaveNoViolations); diff --git a/src/week.tsx b/src/week.tsx index a115712860..46f00c447d 100644 --- a/src/week.tsx +++ b/src/week.tsx @@ -198,7 +198,7 @@ export default class Week extends Component { ? this.props.weekClassName(this.startOfWeek()) : undefined; return ( -
+
{this.renderDays()}
); diff --git a/src/week_number.tsx b/src/week_number.tsx index 5e985d0285..b35c36f246 100644 --- a/src/week_number.tsx +++ b/src/week_number.tsx @@ -131,6 +131,7 @@ export default class WeekNumber extends Component { onClick={this.handleClick} onKeyDown={this.handleOnKeyDown} tabIndex={this.getTabIndex()} + role="gridcell" > {weekNumber}
diff --git a/yarn.lock b/yarn.lock index 4df042bf62..9dfc31cfec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3345,7 +3345,17 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^30.0.0": +"@types/jest-axe@npm:^3.5.9": + version: 3.5.9 + resolution: "@types/jest-axe@npm:3.5.9" + dependencies: + "@types/jest": "npm:*" + axe-core: "npm:^3.5.5" + checksum: 10c0/18ae6143c5ca058066d469a7449493dcad0810a06ae3fd4bdadd00b84ffbfffb8b8faa758b7b1327687a5a398f14cc2f6742760f911dae84e25e042564cb3fcf + languageName: node + linkType: hard + +"@types/jest@npm:*, @types/jest@npm:^30.0.0": version: 30.0.0 resolution: "@types/jest@npm:30.0.0" dependencies: @@ -4224,6 +4234,20 @@ __metadata: languageName: node linkType: hard +"axe-core@npm:4.10.2": + version: 4.10.2 + resolution: "axe-core@npm:4.10.2" + checksum: 10c0/0e20169077de96946a547fce0df39d9aeebe0077f9d3eeff4896518b96fde857f80b98f0d4279274a7178791744dd5a54bb4f322de45b4f561ffa2586ff9a09d + languageName: node + linkType: hard + +"axe-core@npm:^3.5.5": + version: 3.5.6 + resolution: "axe-core@npm:3.5.6" + checksum: 10c0/f02a5b0e04e04a1024d7dc5c9931f87864c0394a218c6bd9057f0104df7f6310178bbbab47afd0c0fd4b585a08e8c599eebf5a89b6898f3fbeb7bfa33c25bfc8 + languageName: node + linkType: hard + "axe-core@npm:^4.10.2": version: 4.10.3 resolution: "axe-core@npm:4.10.3" @@ -4652,14 +4676,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:5.4.1, chalk@npm:^5.4.1": - version: 5.4.1 - resolution: "chalk@npm:5.4.1" - checksum: 10c0/b23e88132c702f4855ca6d25cb5538b1114343e41472d5263ee8a37cccfccd9c4216d111e1097c6a27830407a1dc81fecdf2a56f2c63033d4dbbd88c10b0dcef - languageName: node - linkType: hard - -"chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.2": +"chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -4669,6 +4686,13 @@ __metadata: languageName: node linkType: hard +"chalk@npm:5.4.1, chalk@npm:^5.4.1": + version: 5.4.1 + resolution: "chalk@npm:5.4.1" + checksum: 10c0/b23e88132c702f4855ca6d25cb5538b1114343e41472d5263ee8a37cccfccd9c4216d111e1097c6a27830407a1dc81fecdf2a56f2c63033d4dbbd88c10b0dcef + languageName: node + linkType: hard + "char-regex@npm:^1.0.2": version: 1.0.2 resolution: "char-regex@npm:1.0.2" @@ -5165,6 +5189,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^29.6.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: 10c0/32e27ac7dbffdf2fb0eb5a84efd98a9ad084fbabd5ac9abb8757c6770d5320d2acd172830b28c4add29bb873d59420601dfc805ac4064330ce59b1adfd0593b2 + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -7267,6 +7298,18 @@ __metadata: languageName: node linkType: hard +"jest-axe@npm:^10.0.0": + version: 10.0.0 + resolution: "jest-axe@npm:10.0.0" + dependencies: + axe-core: "npm:4.10.2" + chalk: "npm:4.1.2" + jest-matcher-utils: "npm:29.2.2" + lodash.merge: "npm:4.6.2" + checksum: 10c0/0c79e4a09e120224e903542591bfadfaa2574132c73639d3c9aa2371a720799929b2f2d13a12367f2642192dd0c66daec184ea9a134c8b20061216cea6801439 + languageName: node + linkType: hard + "jest-canvas-mock@npm:^2.5.2": version: 2.5.2 resolution: "jest-canvas-mock@npm:2.5.2" @@ -7396,6 +7439,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:^29.2.1": + version: 29.7.0 + resolution: "jest-diff@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + diff-sequences: "npm:^29.6.3" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10c0/89a4a7f182590f56f526443dde69acefb1f2f0c9e59253c61d319569856c4931eae66b8a3790c443f529267a0ddba5ba80431c585deed81827032b2b2a1fc999 + languageName: node + linkType: hard + "jest-docblock@npm:30.0.1": version: 30.0.1 resolution: "jest-docblock@npm:30.0.1" @@ -7454,6 +7509,13 @@ __metadata: languageName: node linkType: hard +"jest-get-type@npm:^29.2.0, jest-get-type@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-get-type@npm:29.6.3" + checksum: 10c0/552e7a97a983d3c2d4e412a44eb7de0430ff773dd99f7500962c268d6dfbfa431d7d08f919c9d960530e5f7f78eb47f267ad9b318265e5092b3ff9ede0db7c2b + languageName: node + linkType: hard + "jest-haste-map@npm:30.0.5": version: 30.0.5 resolution: "jest-haste-map@npm:30.0.5" @@ -7486,6 +7548,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:29.2.2": + version: 29.2.2 + resolution: "jest-matcher-utils@npm:29.2.2" + dependencies: + chalk: "npm:^4.0.0" + jest-diff: "npm:^29.2.1" + jest-get-type: "npm:^29.2.0" + pretty-format: "npm:^29.2.1" + checksum: 10c0/a554e683bcd18cc11e1e018597771051e88cb3bf79cdbb5896f7550bd4c787e473ba4727336db2049fea6149e21546c8f1cde4b78a76eb595199cfeaba6450b1 + languageName: node + linkType: hard + "jest-matcher-utils@npm:30.0.5": version: 30.0.5 resolution: "jest-matcher-utils@npm:30.0.5" @@ -8089,7 +8163,7 @@ __metadata: languageName: node linkType: hard -"lodash.merge@npm:^4.6.2": +"lodash.merge@npm:4.6.2, lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" checksum: 10c0/402fa16a1edd7538de5b5903a90228aa48eb5533986ba7fa26606a49db2572bf414ff73a2c9f5d5fd36b31c46a5d5c7e1527749c07cbcf965ccff5fbdf32c506 @@ -9286,7 +9360,7 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^29.7.0": +"pretty-format@npm:^29.2.1, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" dependencies: @@ -9410,6 +9484,7 @@ __metadata: "@testing-library/user-event": "npm:14.6.1" "@types/eslint": "npm:^9.6.1" "@types/jest": "npm:^30.0.0" + "@types/jest-axe": "npm:^3.5.9" "@types/node": "npm:22.15.30" "@types/react": "npm:^19.1.0" "@types/react-dom": "npm:^19.1.2" @@ -9430,6 +9505,7 @@ __metadata: eslint-plugin-unused-imports: "npm:^4.1.4" husky: "npm:9.1.7" jest: "npm:^30.0.5" + jest-axe: "npm:^10.0.0" jest-canvas-mock: "npm:^2.5.2" jest-environment-jsdom: "npm:^29.7.0" lint-staged: "npm:^16.0.0" From 607fd60a6c45ea9ad819eeed0284dc3549817d48 Mon Sep 17 00:00:00 2001 From: Martijn Russchen Date: Tue, 19 Aug 2025 23:20:17 +0200 Subject: [PATCH 2/2] Fix test --- src/calendar.tsx | 10 ---------- src/month.tsx | 6 ++---- src/test/index.ts | 25 +++++++++++++++++++++++++ 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/calendar.tsx b/src/calendar.tsx index f054906180..9d972f550c 100644 --- a/src/calendar.tsx +++ b/src/calendar.tsx @@ -909,11 +909,6 @@ export default class Calendar extends Component { this.props, ); - const showDayNames = - !this.props.showMonthYearPicker && - !this.props.showQuarterYearPicker && - !this.props.showYearPicker; - return (
{ prevYearButtonDisabled, nextYearButtonDisabled, })} - {showDayNames && ( -
- {this.header(monthDate)} -
- )}
); }; diff --git a/src/month.tsx b/src/month.tsx index 3a8a9979a1..c39054d232 100644 --- a/src/month.tsx +++ b/src/month.tsx @@ -1124,10 +1124,7 @@ export default class Month extends Component { // For regular calendar view, use table structure return ( -
+
{this.props.dayNamesHeader && (
{this.props.dayNamesHeader}
)} @@ -1139,6 +1136,7 @@ export default class Month extends Component { onPointerLeave={ this.props.usePointerEvent ? this.handleMouseLeave : undefined } + aria-label={`${formattedAriaLabelPrefix}${formatDate(day, "MMMM, yyyy", this.props.locale)}`} role="rowgroup" > {this.renderWeeks()} diff --git a/src/test/index.ts b/src/test/index.ts index 7c238e3d7c..c123e43124 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -2,3 +2,28 @@ import "jest-canvas-mock"; import { toHaveNoViolations } from "jest-axe"; expect.extend(toHaveNoViolations); + +// Suppress act() warnings from floating-ui library +const originalError = console.error; +beforeAll(() => { + console.error = (...args) => { + // Check if any of the arguments contains the floating-ui act warning + const hasFloatingActWarning = args.some( + (arg) => + typeof arg === "string" && + arg.includes( + "An update to withFloating(PopperComponent) inside a test was not wrapped in act", + ), + ); + + if (hasFloatingActWarning) { + return; + } + + originalError.call(console, ...args); + }; +}); + +afterAll(() => { + console.error = originalError; +});