From ef1063c1edd0a74af728565070454393f37e924b Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Fri, 17 Oct 2025 11:31:09 -0600 Subject: [PATCH 1/8] Implement DatePickerSingle --- .../dash-core-components/jest.config.js | 21 +- components/dash-core-components/jest.setup.js | 11 +- .../dash-core-components/package-lock.json | 1845 +++++++++++++++-- components/dash-core-components/package.json | 3 + .../src/components/DatePickerSingle.tsx | 72 + .../src/components/Input.tsx | 2 +- .../src/components/css/calendar.css | 64 + .../src/components/css/datepickers.css | 218 ++ .../src/components/css/dcc.css | 1 + .../src/components/css/dropdown.css | 1 - .../src/components/css/input.css | 2 +- .../src/fragments/DatePickerRange.tsx | 73 + .../src/fragments/DatePickerSingle.tsx | 225 ++ components/dash-core-components/src/index.ts | 5 +- components/dash-core-components/src/types.ts | 221 +- ...ersistence.js => DatePickerPersistence.ts} | 4 +- ...atePickerSingle.js => datePickerSingle.ts} | 3 +- .../src/utils/calendar/Calendar.tsx | 356 ++++ .../src/utils/calendar/CalendarDay.tsx | 79 + .../src/utils/calendar/CalendarMonth.tsx | 277 +++ .../src/utils/calendar/DateSet.ts | 107 + .../src/utils/calendar/createMonthGrid.ts | 30 + .../src/utils/calendar/helpers.ts | 191 ++ .../src/utils/optionTypes.js | 26 - .../tests/dash_core_components_page.py | 9 +- .../tests/integration/calendar/test_a11y.py | 365 ++++ .../calendar/test_date_picker_single.py | 349 +++- .../tests/unit/.eslintrc.js | 5 + .../tests/unit/calendar/Calendar.test.tsx | 276 +++ .../tests/unit/calendar/CalendarDay.test.tsx | 85 + .../unit/calendar/CalendarMonth.test.tsx | 255 +++ .../unit/calendar/DatePickerSingle.test.tsx | 157 ++ .../tests/unit/calendar/DateSet.test.ts | 281 +++ .../unit/calendar/createMonthGrid.test.ts | 102 + .../tests/unit/calendar/helpers.test.ts | 387 ++++ package-lock.json | 476 +++++ package.json | 1 + 37 files changed, 6371 insertions(+), 214 deletions(-) create mode 100644 components/dash-core-components/src/components/DatePickerSingle.tsx create mode 100644 components/dash-core-components/src/components/css/calendar.css create mode 100644 components/dash-core-components/src/components/css/datepickers.css create mode 100644 components/dash-core-components/src/fragments/DatePickerRange.tsx create mode 100644 components/dash-core-components/src/fragments/DatePickerSingle.tsx rename components/dash-core-components/src/utils/{DatePickerPersistence.js => DatePickerPersistence.ts} (72%) rename components/dash-core-components/src/utils/LazyLoader/{datePickerSingle.js => datePickerSingle.ts} (58%) create mode 100644 components/dash-core-components/src/utils/calendar/Calendar.tsx create mode 100644 components/dash-core-components/src/utils/calendar/CalendarDay.tsx create mode 100644 components/dash-core-components/src/utils/calendar/CalendarMonth.tsx create mode 100644 components/dash-core-components/src/utils/calendar/DateSet.ts create mode 100644 components/dash-core-components/src/utils/calendar/createMonthGrid.ts create mode 100644 components/dash-core-components/src/utils/calendar/helpers.ts delete mode 100644 components/dash-core-components/src/utils/optionTypes.js create mode 100644 components/dash-core-components/tests/integration/calendar/test_a11y.py create mode 100644 components/dash-core-components/tests/unit/.eslintrc.js create mode 100644 components/dash-core-components/tests/unit/calendar/Calendar.test.tsx create mode 100644 components/dash-core-components/tests/unit/calendar/CalendarDay.test.tsx create mode 100644 components/dash-core-components/tests/unit/calendar/CalendarMonth.test.tsx create mode 100644 components/dash-core-components/tests/unit/calendar/DatePickerSingle.test.tsx create mode 100644 components/dash-core-components/tests/unit/calendar/DateSet.test.ts create mode 100644 components/dash-core-components/tests/unit/calendar/createMonthGrid.test.ts create mode 100644 components/dash-core-components/tests/unit/calendar/helpers.test.ts diff --git a/components/dash-core-components/jest.config.js b/components/dash-core-components/jest.config.js index c92c83b0ff..7855b5f84a 100644 --- a/components/dash-core-components/jest.config.js +++ b/components/dash-core-components/jest.config.js @@ -1,14 +1,29 @@ module.exports = { preset: 'ts-jest', - testEnvironment: 'node', + testEnvironment: 'jsdom', roots: ['/tests'], - testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], + testMatch: ['**/__tests__/**/*.{ts,tsx}', '**/?(*.)+(spec|test).{ts,tsx}'], transform: { - '^.+\\.ts$': 'ts-jest', + '^.+\\.(ts|tsx)$': ['ts-jest', { + tsconfig: { + jsx: 'react', + esModuleInterop: true, + allowSyntheticDefaultImports: true, + types: ['jest', '@testing-library/jest-dom'], + }, + }], + '^.+\\.js$': ['ts-jest', { + tsconfig: { + allowJs: true, + }, + }], }, collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'], moduleNameMapper: { '^@/(.*)$': '/src/$1', + '\\.(css|less|scss|sass)$': 'identity-obj-proxy', }, setupFilesAfterEnv: ['/jest.setup.js'], + // Disable caching to ensure TypeScript type changes are always picked up + cache: false, }; diff --git a/components/dash-core-components/jest.setup.js b/components/dash-core-components/jest.setup.js index 71a3d440b4..48c2075115 100644 --- a/components/dash-core-components/jest.setup.js +++ b/components/dash-core-components/jest.setup.js @@ -1,2 +1,11 @@ // Jest setup file -// This file is loaded before every test file \ No newline at end of file +// This file is loaded before every test file +import '@testing-library/jest-dom'; + +// Mock window.dash_component_api for components that use Dash context +global.window = global.window || {}; +global.window.dash_component_api = { + useDashContext: () => ({ + useLoading: () => false, + }), +}; \ No newline at end of file diff --git a/components/dash-core-components/package-lock.json b/components/dash-core-components/package-lock.json index f48b534638..e32047f9c9 100644 --- a/components/dash-core-components/package-lock.json +++ b/components/dash-core-components/package-lock.json @@ -49,6 +49,8 @@ "@babel/preset-typescript": "^7.27.1", "@plotly/dash-component-plugins": "^1.2.3", "@plotly/webpack-dash-dynamic-import": "^1.3.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^12.1.5", "@types/d3-format": "^3.0.4", "@types/fast-isnumeric": "^1.1.2", "@types/jest": "^29.5.0", @@ -69,6 +71,7 @@ "eslint-plugin-react": "^7.32.2", "identity-obj-proxy": "^3.0.0", "jest": "^29.5.0", + "jest-environment-jsdom": "^30.2.0", "npm-run-all": "^4.1.5", "prettier": "^2.8.8", "react": "^16.14.0", @@ -99,6 +102,13 @@ "node": ">=0.10.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", @@ -111,6 +121,27 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/cli": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.0.tgz", @@ -2054,6 +2085,121 @@ "node": ">=0.1.90" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@dabh/diagnostics": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", @@ -2754,112 +2900,118 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.2.0.tgz", + "integrity": "sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==", "dev": true, "license": "MIT", "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3" + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "dev": true, "license": "MIT", "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/reporters/node_modules/ansi-styles": { + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", @@ -2875,7 +3027,7 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/reporters/node_modules/chalk": { + "node_modules/@jest/environment-jsdom-abstract/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", @@ -2892,7 +3044,23 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/reporters/node_modules/color-convert": { + "node_modules/@jest/environment-jsdom-abstract/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", @@ -2905,14 +3073,14 @@ "node": ">=7.0.0" } }, - "node_modules/@jest/reporters/node_modules/color-name": { + "node_modules/@jest/environment-jsdom-abstract/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, - "node_modules/@jest/reporters/node_modules/has-flag": { + "node_modules/@jest/environment-jsdom-abstract/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", @@ -2922,39 +3090,109 @@ "node": ">=8" } }, - "node_modules/@jest/reporters/node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", "dependencies": { + "@jest/types": "30.2.0", "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/reporters/node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/reporters/node_modules/slash": { + "node_modules/@jest/environment-jsdom-abstract/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", @@ -2964,7 +3202,7 @@ "node": ">=8" } }, - "node_modules/@jest/reporters/node_modules/supports-color": { + "node_modules/@jest/environment-jsdom-abstract/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", @@ -2977,12 +3215,259 @@ "node": ">=8" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/reporters/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/reporters/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/reporters/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/reporters/node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@jest/reporters/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/reporters/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -4053,6 +4538,200 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@testing-library/dom": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/dom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "12.1.5", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", + "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.0.0", + "@types/react-dom": "<18.0.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "<18.0.0", + "react-dom": "<18.0.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4192,6 +4871,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4273,6 +4964,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -4802,6 +5500,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/airbnb-prop-types": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz", @@ -4960,6 +5668,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", @@ -6402,6 +7120,13 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -6414,6 +7139,20 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -6424,6 +7163,20 @@ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -6441,6 +7194,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", @@ -6456,6 +7216,39 @@ } } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6610,6 +7403,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -6930,6 +7730,27 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-iterator-helpers": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", @@ -8255,6 +9076,19 @@ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -8291,11 +9125,39 @@ "entities": "^4.5.0" } }, - "node_modules/https-browserify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", - "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==" - }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -8306,6 +9168,19 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", @@ -8466,6 +9341,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -8481,13 +9366,14 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/internal-slot": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", - "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.2", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -8830,6 +9716,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -9775,64 +10668,376 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-each/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-each/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-each/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.2.0.tgz", + "integrity": "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/environment-jsdom-abstract": "30.2.0", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/jest-each/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-environment-jsdom/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/jest-environment-jsdom/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, "engines": { - "node": ">=7.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-each/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/jest-environment-jsdom/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, - "node_modules/jest-each/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/jest-environment-jsdom/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/jest-each/node_modules/supports-color": { + "node_modules/jest-environment-jsdom/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", @@ -11079,6 +12284,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -11324,6 +12569,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -11836,6 +13091,13 @@ "node": ">=8" } }, + "node_modules/nwsapi": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11845,9 +13107,13 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -12110,6 +13376,32 @@ "node": ">=4" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -13168,6 +14460,20 @@ "node": ">= 10.13.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -13504,6 +14810,13 @@ "inherits": "^2.0.1" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13593,6 +14906,19 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", @@ -13763,13 +15089,72 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -13921,6 +15306,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -14226,6 +15625,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tapable": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", @@ -14376,6 +15782,26 @@ "node": ">=0.6.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -14410,6 +15836,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/trim": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", @@ -15040,6 +16492,19 @@ "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -15063,6 +16528,16 @@ "node": ">=10.13.0" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/webpack": { "version": "5.101.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.0.tgz", @@ -15238,6 +16713,43 @@ "node": ">=4.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -15522,11 +17034,50 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/x-is-string": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz", "integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w==" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/components/dash-core-components/package.json b/components/dash-core-components/package.json index 8eee9b6868..7d24ef7972 100644 --- a/components/dash-core-components/package.json +++ b/components/dash-core-components/package.json @@ -76,6 +76,8 @@ "@babel/preset-typescript": "^7.27.1", "@plotly/dash-component-plugins": "^1.2.3", "@plotly/webpack-dash-dynamic-import": "^1.3.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^12.1.5", "@types/d3-format": "^3.0.4", "@types/fast-isnumeric": "^1.1.2", "@types/jest": "^29.5.0", @@ -96,6 +98,7 @@ "eslint-plugin-react": "^7.32.2", "identity-obj-proxy": "^3.0.0", "jest": "^29.5.0", + "jest-environment-jsdom": "^30.2.0", "npm-run-all": "^4.1.5", "prettier": "^2.8.8", "react": "^16.14.0", diff --git a/components/dash-core-components/src/components/DatePickerSingle.tsx b/components/dash-core-components/src/components/DatePickerSingle.tsx new file mode 100644 index 0000000000..388618d2c8 --- /dev/null +++ b/components/dash-core-components/src/components/DatePickerSingle.tsx @@ -0,0 +1,72 @@ +import React, {lazy, Suspense} from 'react'; +import datePickerSingle from '../utils/LazyLoader/datePickerSingle'; +import transformDate from '../utils/DatePickerPersistence'; +import { + DatePickerSingleProps, + PersistedProps, + PersistenceTypes, +} from '../types'; + +const RealDatePickerSingle = lazy(datePickerSingle); + +/** + * DatePickerSingle is a tailor made component designed for selecting + * a single day off of a calendar. + * + * The DatePicker integrates well with the Python datetime module with the + * startDate and endDate being returned in a string format suitable for + * creating datetime objects. + */ +export default function DatePickerSingle({ + placeholder = 'Select Date', + calendar_orientation = 'horizontal', + is_RTL = false, + // eslint-disable-next-line no-magic-numbers + day_size = 34, + with_portal = false, + with_full_screen_portal = false, + show_outside_days = true, + first_day_of_week = 0, + number_of_months_shown = 1, + stay_open_on_select = false, + reopen_calendar_on_clear = false, + clearable = false, + disabled = false, + disabled_days = [], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persisted_props = [PersistedProps.date], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persistence_type = PersistenceTypes.local, + ...props +}: DatePickerSingleProps) { + return ( + + + + ); +} + +DatePickerSingle.dashPersistence = { + persisted_props: [PersistedProps.date], + persistence_type: PersistenceTypes.local, +}; + +DatePickerSingle.persistenceTransforms = { + date: transformDate, +}; diff --git a/components/dash-core-components/src/components/Input.tsx b/components/dash-core-components/src/components/Input.tsx index 32ccdacd28..533a8e1a21 100644 --- a/components/dash-core-components/src/components/Input.tsx +++ b/components/dash-core-components/src/components/Input.tsx @@ -20,7 +20,7 @@ const convert = (val: unknown) => (isNumeric(val) ? +val : NaN); const isEquivalent = (v1: number, v2: number) => v1 === v2 || (isNaN(v1) && isNaN(v2)); -enum HTMLInputTypes { +export enum HTMLInputTypes { // Only allowing the input types with wide browser compatibility 'text' = 'text', 'number' = 'number', diff --git a/components/dash-core-components/src/components/css/calendar.css b/components/dash-core-components/src/components/css/calendar.css new file mode 100644 index 0000000000..6b74dbea57 --- /dev/null +++ b/components/dash-core-components/src/components/css/calendar.css @@ -0,0 +1,64 @@ +.dash-datepicker-calendar { + padding: 0; + border-collapse: collapse; +} + +.dash-datepicker-calendar th > *, +.dash-datepicker-calendar td > * { + display: flex; + align-items: center; + justify-content: center; +} + +.dash-datepicker-calendar th, +.dash-datepicker-calendar td { + padding: 0; + font-weight: normal; + color: var(--Dash-Text-Weak); + text-align: center; + border-radius: 4px; + cursor: default; + user-select: none; + width: var(--day-size, 36px); + height: var(--day-size, 36px); +} + +.dash-datepicker-calendar td.dash-datepicker-calendar-date-inside { + color: var(--Dash-Text-Strong); +} + +.dash-datepicker-calendar td:hover { + cursor: pointer; + background-color: var(--Dash-Fill-Weak); +} + +.dash-datepicker-calendar td:focus { + outline: 2px solid var(--Dash-Fill-Interactive-Strong); + outline-offset: -2px; + z-index: 1; + position: relative; +} + +.dash-datepicker-calendar td.dash-datepicker-calendar-date-highlighted { + background-color: color-mix( + in srgb, + var(--Dash-Fill-Interactive-Strong) 5%, + transparent 95% + ); + color: var(--Dash-Fill-Interactive-Strong); +} + +.dash-datepicker-calendar td.dash-datepicker-calendar-date-selected { + background-color: var(--Dash-Fill-Interactive-Strong); + color: var(--Dash-Fill-Inverse-Strong); +} + +.dash-datepicker-calendar td.dash-datepicker-calendar-date-disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: inherit; +} + +.dash-datepicker-calendar td input { + display: none; +} diff --git a/components/dash-core-components/src/components/css/datepickers.css b/components/dash-core-components/src/components/css/datepickers.css new file mode 100644 index 0000000000..137e474ed5 --- /dev/null +++ b/components/dash-core-components/src/components/css/datepickers.css @@ -0,0 +1,218 @@ +.dash-datepicker { + display: block; + flex: 1; + box-sizing: border-box; + margin: calc(var(--Dash-Spacing) * 2) 0; + padding: 0; + background: inherit; + border: none; + outline: none; + width: 100%; + font-size: inherit; + overflow: hidden; + accent-color: var(--Dash-Fill-Interactive-Strong); + outline-color: var(--Dash-Fill-Interactive-Strong); +} + +.dash-datepicker-input-wrapper { + display: grid; + grid-template-columns: auto 1fr; + justify-items: start; + align-items: center; + gap: calc(var(--Dash-Spacing) * 2); + box-sizing: border-box; + padding: 0 calc(var(--Dash-Spacing) * 2); +} + +.dash-datepicker-input-wrapper:has(:nth-child(3)) { + grid-template-columns: auto 1fr auto; +} + +.dash-datepicker-input-wrapper:has(:nth-child(4)) { + grid-template-columns: auto 1fr auto auto; +} + +.dash-datepicker-input-wrapper, +.dash-datepicker-content { + border-radius: var(--Dash-Spacing); + border: 1px solid var(--Dash-Stroke-Strong); + color: inherit; + text-align: left; +} + +.dash-datepicker-input { + height: 34px; + width: 100%; + border: none; + outline: none; + border-radius: 4px; +} + +.dash-datepicker-input-wrapper:focus-within { + border: 1px solid var(--Dash-Fill-Interactive-Strong); +} + +.dash-datepicker-input-wrapper-disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.dash-datepicker-input-wrapper-disabled .dash-datepicker-input { + cursor: not-allowed; +} + +.dash-datepicker-input:focus { + outline: none; +} + +.dash-datepicker-input::placeholder { + color: var(--Dash-Text-Disabled); +} + +.dash-datepicker-trigger-button { + padding: var(--Dash-Spacing); + background: transparent; + border: none; + border-radius: 4px; + color: var(--Dash-Text-Strong); + cursor: pointer; +} + +.dash-datepicker-trigger-button:hover { + background: var(--Dash-Fill-Weak); +} + +.dash-datepicker-trigger-button:focus-visible { + outline: 2px solid var(--Dash-Fill-Interactive-Strong); + outline-offset: 2px; +} + +.dash-datepicker-trigger-button[aria-expanded='true'] { + background: var(--Dash-Fill-Weak); +} + +.dash-datepicker-trigger-icon { + width: 16px; + height: 16px; +} + +.dash-datepicker-caret-icon { + color: var(--Dash-Text-Strong); + fill: var(--Dash-Text-Strong); + white-space: nowrap; + justify-self: end; + transition: transform 0.15s; +} + +.dash-datepicker-input-wrapper[aria-expanded='true'] + .dash-datepicker-caret-icon { + transform: rotate(180deg); +} + +.dash-datepicker-clear { + background: none; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + justify-self: end; + color: var(--Dash-Text-Strong); + width: calc(var(--Dash-Spacing) * 3); + height: calc(var(--Dash-Spacing) * 3); +} + +.dash-datepicker-clear:hover { + color: var(--Dash-Fill-Interactive-Strong); +} + +.dash-datepicker-clear:focus { + outline: 2px solid var(--Dash-Fill-Interactive-Strong); + outline-offset: 1px; + border-radius: 2px; +} + +.dash-datepicker-content { + padding: 8px; + background: var(--Dash-Fill-Inverse-Strong); + width: fit-content; + max-width: 98vw; + overflow-y: auto; + z-index: 500; + box-shadow: 0px 10px 38px -10px var(--Dash-Shading-Strong), + 0px 10px 20px -15px var(--Dash-Shading-Weak); +} + +.dash-datepicker-calendar-wrapper { + display: flex; + flex-direction: column; + align-items: center; +} + +.dash-datepicker-calendar-container { + display: flex; + align-items: flex-start; + gap: calc(var(--Dash-Spacing) * 4); +} + +.dash-datepicker-controls { + display: flex; + align-items: center; + justify-content: center; + gap: var(--Dash-Spacing); + margin-bottom: calc(var(--Dash-Spacing) * 2); + font-size: 14px; +} + +.dash-datepicker-controls .dash-dropdown { + flex: 0 0 auto; + min-width: 122px; + width: auto; + margin: 0; +} + +.dash-datepicker-controls .dash-input { + flex-shrink: 0; + flex-grow: 0; + width: 102px; +} + +.dash-datepicker-controls .dash-input-stepper { + width: 20px; + height: 100%; +} + +.dash-datepicker-month-nav { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 32px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: var(--Dash-Fill-Interactive-Strong); + cursor: pointer; + flex-shrink: 0; +} + +.dash-datepicker-month-nav:hover:not(:disabled) { + background: var(--Dash-Fill-Weak); +} + +.dash-datepicker-month-nav:focus-visible { + outline: 2px solid var(--Dash-Fill-Interactive-Strong); + outline-offset: 2px; +} + +.dash-datepicker-month-nav:disabled { + color: var(--Dash-Text-Disabled); + cursor: not-allowed; +} + +.dash-datepicker-month-nav svg { + width: 20px; + height: 20px; +} diff --git a/components/dash-core-components/src/components/css/dcc.css b/components/dash-core-components/src/components/css/dcc.css index 66579edab0..9eb28ed724 100644 --- a/components/dash-core-components/src/components/css/dcc.css +++ b/components/dash-core-components/src/components/css/dcc.css @@ -7,6 +7,7 @@ --Dash-Fill-Inverse-Strong: #fff; --Dash-Text-Primary: rgba(0, 18, 77, 0.87); --Dash-Text-Strong: rgba(0, 9, 38, 0.9); + --Dash-Text-Weak: rgba(0, 12, 51, 0.65); --Dash-Text-Disabled: rgba(0, 21, 89, 0.3); --Dash-Fill-Primary-Hover: rgba(0, 18, 77, 0.04); --Dash-Fill-Primary-Active: rgba(0, 18, 77, 0.08); diff --git a/components/dash-core-components/src/components/css/dropdown.css b/components/dash-core-components/src/components/css/dropdown.css index 7746c49a75..6ca9984c6a 100644 --- a/components/dash-core-components/src/components/css/dropdown.css +++ b/components/dash-core-components/src/components/css/dropdown.css @@ -2,7 +2,6 @@ display: block; flex: 1; box-sizing: border-box; - margin: calc(var(--Dash-Spacing) * 2) 0; padding: 0; background: inherit; border: none; diff --git a/components/dash-core-components/src/components/css/input.css b/components/dash-core-components/src/components/css/input.css index 6ca5d898fe..9de267a4ca 100644 --- a/components/dash-core-components/src/components/css/input.css +++ b/components/dash-core-components/src/components/css/input.css @@ -4,7 +4,7 @@ display: inline-flex; align-items: center; width: 170px; /* default input width */ - height: 32px; + height: 34px; background: var(--Dash-Fill-Inverse-Strong); border-radius: var(--Dash-Spacing); border: 1px solid var(--Dash-Stroke-Strong); diff --git a/components/dash-core-components/src/fragments/DatePickerRange.tsx b/components/dash-core-components/src/fragments/DatePickerRange.tsx new file mode 100644 index 0000000000..d397f38e1b --- /dev/null +++ b/components/dash-core-components/src/fragments/DatePickerRange.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import Calendar from '../utils/calendar/Calendar'; +import {DatePickerRangeProps, CalendarDirection} from '../types'; +import {dateAsStr, strAsDate} from '../utils/calendar/helpers'; +import '../components/css/datepickers.css'; + +const DatePickerRange = ({ + start_date, + end_date, + first_day_of_week, + show_outside_days, + is_RTL = false, + setProps, +}: DatePickerRangeProps) => { + // Convert boolean is_RTL to CalendarDirection enum + const direction = is_RTL ? CalendarDirection.RightToLeft : CalendarDirection.LeftToRight; + + const startDate = strAsDate(start_date); + const endDate = strAsDate(end_date); + + // Helper to ensure dates are always sorted (start before end) + const sortDates = (newStart?: Date, newEnd?: Date) => { + // If both undefined or only one defined, return single date as start + if (!newStart || !newEnd) { + const singleDate = newStart || newEnd; + return { + start_date: singleDate ? dateAsStr(singleDate) : undefined, + end_date: undefined, + }; + } + + // Both defined - ensure proper order + const [start, end] = + newStart <= newEnd ? [newStart, newEnd] : [newEnd, newStart]; + return { + start_date: dateAsStr(start), + end_date: dateAsStr(end), + }; + }; + + return ( +
+ { + setProps(sortDates(selection, endDate)); + }} + firstDayOfWeek={first_day_of_week} + showOutsideDays={show_outside_days} + direction={direction} + /> + { + setProps(sortDates(startDate, selection)); + }} + firstDayOfWeek={first_day_of_week} + showOutsideDays={show_outside_days} + direction={direction} + /> +
+ ); +}; + +export default DatePickerRange; diff --git a/components/dash-core-components/src/fragments/DatePickerSingle.tsx b/components/dash-core-components/src/fragments/DatePickerSingle.tsx new file mode 100644 index 0000000000..6888fa04d6 --- /dev/null +++ b/components/dash-core-components/src/fragments/DatePickerSingle.tsx @@ -0,0 +1,225 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import * as Popover from '@radix-ui/react-popover'; +import {CalendarIcon, CaretDownIcon, Cross1Icon} from '@radix-ui/react-icons'; +import Calendar from '../utils/calendar/Calendar'; +import {DatePickerSingleProps, CalendarDirection} from '../types'; +import { + dateAsStr, + strAsDate, + formatDate, + isDateDisabled, +} from '../utils/calendar/helpers'; +import {DateSet} from '../utils/calendar/DateSet'; +import '../components/css/datepickers.css'; +import uuid from 'uniqid'; + +const DatePickerSingle = ({ + id, + className, + date, + initial_visible_month = date, + min_date_allowed, + max_date_allowed, + disabled_days, + first_day_of_week, + show_outside_days, + placeholder = 'Select Date', + clearable, + reopen_calendar_on_clear, + disabled, + display_format, + month_format = 'MMMM YYYY', + stay_open_on_select, + is_RTL = false, + setProps, + style, + // eslint-disable-next-line no-magic-numbers + day_size = 34, + number_of_months_shown = 1, + calendar_orientation, +}: DatePickerSingleProps) => { + const dateObj = strAsDate(date); + const direction = is_RTL + ? CalendarDirection.RightToLeft + : CalendarDirection.LeftToRight; + const initialMonth = strAsDate(initial_visible_month); + const minDate = strAsDate(min_date_allowed); + const maxDate = strAsDate(max_date_allowed); + const disabledDates = useMemo( + () => new DateSet(disabled_days), + [disabled_days] + ); + + const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const [inputValue, setInputValue] = useState( + (dateObj && formatDate(dateObj, display_format)) ?? '' + ); + + const containerRef = useRef(null); + const inputRef = useRef(null); + const calendarRef = useRef(null); + + useEffect(() => { + if (date) { + const parsed = strAsDate(date); + if (parsed) { + setInputValue(formatDate(parsed, display_format)); + } else { + setInputValue(date); + } + } else { + setInputValue(''); + } + }, [date, display_format]); + + useEffect(() => { + if (!isCalendarOpen) { + inputRef.current?.focus(); + } + }, [isCalendarOpen]); + + const sendInputAsDate = useCallback(() => { + const parsed = strAsDate(inputValue, display_format); + const isValid = + parsed && !isDateDisabled(parsed, minDate, maxDate, disabledDates); + + if (isValid) { + setProps({date: dateAsStr(parsed)}); + } else { + // Invalid or disabled input: revert to previous valid date with proper formatting + const previousDate = strAsDate(date); + setInputValue( + previousDate ? formatDate(previousDate, display_format) : '' + ); + } + }, [ + inputValue, + display_format, + date, + setProps, + minDate, + maxDate, + disabledDates, + ]); + + const clearSelection = useCallback(() => { + setProps({date: undefined}); + setInputValue(''); + if (reopen_calendar_on_clear) { + setIsCalendarOpen(true); + } else { + inputRef.current?.focus(); + } + }, [reopen_calendar_on_clear, setProps]); + + const handleInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!isCalendarOpen) { + sendInputAsDate(); + // open the calendar after resolving prop changes, so that + // it opens with the correct date showing + setTimeout(() => setIsCalendarOpen(true), 0); + } + } else if (e.key === 'Enter') { + sendInputAsDate(); + } + }, + [isCalendarOpen, inputValue] + ); + + const accessibleId = id ?? uuid(); + let classNames = 'dash-datepicker-input-wrapper'; + if (disabled) { + classNames += ' dash-datepicker-input-wrapper-disabled'; + } + if (className) { + classNames += ' ' + className; + } + + return ( +
+ + +
+ + setInputValue(e.target.value)} + onKeyDown={handleInputKeyDown} + onBlur={sendInputAsDate} + placeholder={placeholder} + disabled={disabled} + dir={direction} + /> + {clearable && !disabled && !!date && ( + { + e.preventDefault(); + clearSelection(); + }} + aria-label="Clear date" + > + + + )} + + +
+
+ + + e.preventDefault()} + > +
+ { + const dateStr = dateAsStr(selection); + setProps({date: dateStr}); + if (!stay_open_on_select) { + setIsCalendarOpen(false); + } + }} + /> +
+
+
+
+
+ ); +}; + +export default DatePickerSingle; diff --git a/components/dash-core-components/src/index.ts b/components/dash-core-components/src/index.ts index e95ae69301..e109fe94de 100644 --- a/components/dash-core-components/src/index.ts +++ b/components/dash-core-components/src/index.ts @@ -3,8 +3,9 @@ import Checklist from './components/Checklist'; import Clipboard from './components/Clipboard.react'; import ConfirmDialog from './components/ConfirmDialog.react'; import ConfirmDialogProvider from './components/ConfirmDialogProvider.react'; -import DatePickerRange from './components/DatePickerRange.react'; -import DatePickerSingle from './components/DatePickerSingle.react'; +// import DatePickerRange from './components/DatePickerRange.react'; +import DatePickerRange from './fragments/DatePickerRange'; +import DatePickerSingle from './components/DatePickerSingle'; import Download from './components/Download.react'; import Dropdown from './components/Dropdown'; import Geolocation from './components/Geolocation.react'; diff --git a/components/dash-core-components/src/types.ts b/components/dash-core-components/src/types.ts index 7efa7cafcf..64c1ee6ef6 100644 --- a/components/dash-core-components/src/types.ts +++ b/components/dash-core-components/src/types.ts @@ -9,9 +9,13 @@ export enum PersistenceTypes { export enum PersistedProps { 'value' = 'value', + 'date' = 'date', + 'start_date' = 'start_date', + 'end_date' = 'end_date', } -export interface BaseDccProps extends BaseDashProps { +export interface BaseDccProps + extends Pick { /** * Additional CSS class for the root DOM node */ @@ -880,7 +884,7 @@ export interface TabsProps extends BaseDccProps { }; } -// Note a quirk in how this extends the BaseComponentProps: `setProps` is shared +// Note a quirk in how this extends the BaseDccProps: `setProps` is shared // with `TabsProps` (plural!) due to how tabs are implemented. This is // intentional. export interface TabProps extends BaseDccProps { @@ -945,3 +949,216 @@ export interface TabProps extends BaseDccProps { */ width?: string | number; } + +export enum DayOfWeek { + Sunday = 0, + Monday = 1, + Tuesday = 2, + Wednesday = 3, + Thursday = 4, + Friday = 5, + Saturday = 6, +} + +export enum CalendarDirection { + LeftToRight = 'ltr', + RightToLeft = 'rtl', +} + +export interface DatePickerSingleProps + extends BaseDccProps { + /** + * Specifies the starting date for the component, best practice is to pass + * value via datetime object + */ + date?: `${string}-${string}-${string}`; + + /** + * Specifies the lowest selectable date for the component. + * Accepts datetime.datetime objects or strings + * in the format 'YYYY-MM-DD' + */ + min_date_allowed?: string; + + /** + * Specifies the highest selectable date for the component. + * Accepts datetime.datetime objects or strings + * in the format 'YYYY-MM-DD' + */ + max_date_allowed?: string; + + /** + * Specifies additional days between min_date_allowed and max_date_allowed + * that should be disabled. Accepted datetime.datetime objects or strings + * in the format 'YYYY-MM-DD' + */ + disabled_days?: string[]; + + /** + * Text that will be displayed in the input + * box of the date picker when no date is selected. + */ + placeholder?: string; + + /** + * Specifies the month that is initially presented when the user + * opens the calendar. Accepts datetime.datetime objects or strings + * in the format 'YYYY-MM-DD' + * + */ + initial_visible_month?: string; + + /** + * Whether or not the dropdown is "clearable", that is, whether or + * not a small "x" appears on the right of the dropdown that removes + * the selected value. + */ + clearable?: boolean; + + /** + * If True, the calendar will automatically open when cleared + */ + reopen_calendar_on_clear?: boolean; + + /** + * Specifies the format that the selected dates will be displayed + * valid formats are variations of "MM YY DD". For example: + * "MM YY DD" renders as '05 10 97' for May 10th 1997 + * "MMMM, YY" renders as 'May, 1997' for May 10th 1997 + * "M, D, YYYY" renders as '07, 10, 1997' for September 10th 1997 + * "MMMM" renders as 'May' for May 10 1997 + */ + display_format?: string; + + /** + * Specifies the format that the month will be displayed in the calendar, + * valid formats are variations of "MM YY". For example: + * "MM YY" renders as '05 97' for May 1997 + * "MMMM, YYYY" renders as 'May, 1997' for May 1997 + * "MMM, YY" renders as 'Sep, 97' for September 1997 + */ + month_format?: string; + + /** + * Specifies what day is the first day of the week, values must be + * from [0, ..., 6] with 0 denoting Sunday and 6 denoting Saturday + */ + first_day_of_week?: DayOfWeek; + + /** + * If True the calendar will display days that rollover into + * the next month + */ + show_outside_days?: boolean; + + /** + * If True the calendar will not close when the user has selected a value + * and will wait until the user clicks off the calendar + */ + stay_open_on_select?: boolean; + + /** + * Orientation of calendar, either vertical or horizontal. + * Valid options are 'vertical' or 'horizontal'. + */ + calendar_orientation?: 'vertical' | 'horizontal'; + + /** + * Number of calendar months that are shown when calendar is opened + */ + number_of_months_shown?: number; + + /** + * If True, calendar will open in a screen overlay portal, + * not supported on vertical calendar + */ + with_portal?: boolean; + + /** + * If True, calendar will open in a full screen overlay portal, will + * take precedent over 'withPortal' if both are set to True, + * not supported on vertical calendar + */ + with_full_screen_portal?: boolean; + + /** + * Size of rendered calendar days, higher number + * means bigger day size and larger calendar overall + */ + day_size?: number; + + /** + * Determines whether the calendar and days operate + * from left to right or from right to left + */ + is_RTL?: boolean; + + /** + * If True, no dates can be selected. + */ + disabled?: boolean; + + /** + * CSS styles appended to wrapper div + */ + style?: React.CSSProperties; +} + +export interface DatePickerRangeProps + extends Omit { + /** + * Specifies the starting date for the component. + * Accepts datetime.datetime objects or strings + * in the format 'YYYY-MM-DD' + */ + start_date?: `${string}-${string}-${string}`; + + /** + * Specifies the ending date for the component. + * Accepts datetime.datetime objects or strings + * in the format 'YYYY-MM-DD' + */ + end_date?: `${string}-${string}-${string}`; + + /** + * Specifies a minimum number of nights that must be selected between + * the startDate and the endDate + */ + minimum_nights?: number; + + /** + * Determines when the component should update + * its value. If `bothdates`, then the DatePicker + * will only trigger its value when the user has + * finished picking both dates. If `singledate`, then + * the DatePicker will update its value + * as one date is picked. + */ + updatemode?: 'singledate' | 'bothdates'; + + /** + * Text that will be displayed in the first input + * box of the date picker when no date is selected. Default value is 'Start Date' + */ + start_date_placeholder_text?: string; + + /** + * Text that will be displayed in the second input + * box of the date picker when no date is selected. Default value is 'End Date' + */ + end_date_placeholder_text?: string; + + /** + * The HTML element ID of the start date input field. + * Not used by Dash, only by CSS. + */ + start_date_id?: string; + + /** + * The HTML element ID of the end date input field. + * Not used by Dash, only by CSS. + */ + end_date_id?: string; + + setProps: (props: Partial) => void; +} diff --git a/components/dash-core-components/src/utils/DatePickerPersistence.js b/components/dash-core-components/src/utils/DatePickerPersistence.ts similarity index 72% rename from components/dash-core-components/src/utils/DatePickerPersistence.js rename to components/dash-core-components/src/utils/DatePickerPersistence.ts index 7c4a157ce7..f9efdd2eda 100644 --- a/components/dash-core-components/src/utils/DatePickerPersistence.js +++ b/components/dash-core-components/src/utils/DatePickerPersistence.ts @@ -2,11 +2,11 @@ import moment from 'moment'; import {isNil} from 'ramda'; export default { - extract: propValue => { + extract: (propValue?: string) => { if (!isNil(propValue)) { return moment(propValue).startOf('day').format('YYYY-MM-DD'); } return propValue; }, - apply: storedValue => storedValue, + apply: (storedValue?: string) => storedValue, }; diff --git a/components/dash-core-components/src/utils/LazyLoader/datePickerSingle.js b/components/dash-core-components/src/utils/LazyLoader/datePickerSingle.ts similarity index 58% rename from components/dash-core-components/src/utils/LazyLoader/datePickerSingle.js rename to components/dash-core-components/src/utils/LazyLoader/datePickerSingle.ts index 6079054e26..c6230b028b 100644 --- a/components/dash-core-components/src/utils/LazyLoader/datePickerSingle.js +++ b/components/dash-core-components/src/utils/LazyLoader/datePickerSingle.ts @@ -1,2 +1 @@ -export default () => import(/* webpackChunkName: "datepicker" */ '../../fragments/DatePickerSingle.react'); - +export default () => import(/* webpackChunkName: "datepicker" */ '../../fragments/DatePickerSingle'); diff --git a/components/dash-core-components/src/utils/calendar/Calendar.tsx b/components/dash-core-components/src/utils/calendar/Calendar.tsx new file mode 100644 index 0000000000..3708aa1a5a --- /dev/null +++ b/components/dash-core-components/src/utils/calendar/Calendar.tsx @@ -0,0 +1,356 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import moment from 'moment'; +import { + ArrowUpIcon, + ArrowDownIcon, + ArrowLeftIcon, + ArrowRightIcon, +} from '@radix-ui/react-icons'; +import Input, {HTMLInputTypes} from '../../components/Input'; +import Dropdown from '../../fragments/Dropdown'; +import {DayOfWeek, CalendarDirection} from '../../types'; +import {CalendarMonth} from './CalendarMonth'; +import {DateSet} from './DateSet'; +import {getMonthOptions, formatYear, parseYear, isDateInRange} from './helpers'; + +type CalendarProps = { + onSelectionChange: (selectionStart: Date, selectionEnd?: Date) => void; + selectionStart?: Date; + selectionEnd?: Date; + highlightStart?: Date; + highlightEnd?: Date; + initialVisibleDate?: Date; + minDateAllowed?: Date; + maxDateAllowed?: Date; + disabledDates?: DateSet; + firstDayOfWeek?: DayOfWeek; + showOutsideDays?: boolean; + monthFormat?: string; + calendarOrientation?: 'vertical' | 'horizontal'; + numberOfMonthsShown?: number; + daySize?: number; + direction?: CalendarDirection; +}; + +const Calendar = ({ + initialVisibleDate = new Date(), + onSelectionChange, + selectionStart, + selectionEnd, + highlightStart, + highlightEnd, + minDateAllowed, + maxDateAllowed, + disabledDates, + firstDayOfWeek, + showOutsideDays, + monthFormat, + calendarOrientation, + numberOfMonthsShown = 1, + daySize, + direction = CalendarDirection.LeftToRight, +}: CalendarProps) => { + const [activeYear, setActiveYear] = useState(() => + initialVisibleDate.getFullYear() + ); + const [activeMonth, setActiveMonth] = useState(() => + initialVisibleDate.getMonth() + ); + + // Initialize focused date: use selectionStart if it's in the visible month, otherwise first of month + const [focusedDate, setFocusedDate] = useState(() => { + if ( + selectionStart && + selectionStart.getMonth() === initialVisibleDate.getMonth() && + selectionStart.getFullYear() === initialVisibleDate.getFullYear() + ) { + return selectionStart; + } + return new Date( + initialVisibleDate.getFullYear(), + initialVisibleDate.getMonth(), + 1 + ); + }); + const [highlightedDates, setHighlightedDates] = useState(new DateSet()); + const calendarContainerRef = useRef(document.createElement('div')); + const scrollAccumulatorRef = useRef(0); + const prevFocusedDateRef = useRef(focusedDate); + + // Compute display year as a number based on month_format + const displayYear = useMemo(() => { + const formatted = formatYear(activeYear, monthFormat); + return parseInt(formatted, 10); + }, [activeYear, monthFormat]); + + // First day of the active month (used for navigation and range calculations) + const activeMonthStart = useMemo( + () => moment([activeYear, activeMonth, 1]), + [activeYear, activeMonth] + ); + + useEffect(() => { + // Syncs activeMonth/activeYear to focusedDate when focusedDate changes + if (focusedDate.getTime() === prevFocusedDateRef.current.getTime()) { + return; + } + prevFocusedDateRef.current = focusedDate; + + // Calculate visible month range (centered on activeMonth/activeYear) + const halfRange = Math.floor((numberOfMonthsShown - 1) / 2); + const activeMonthStart = moment([activeYear, activeMonth, 1]); + const visibleStart = activeMonthStart + .clone() + .subtract(halfRange, 'months') + .toDate(); + const visibleEnd = activeMonthStart + .clone() + .add(halfRange, 'months') + .toDate(); + + // Sync activeMonth/activeYear to focusedDate if focused month is outside visible range + const focusedMonthStart = new Date( + focusedDate.getFullYear(), + focusedDate.getMonth(), + 1 + ); + if (!isDateInRange(focusedMonthStart, visibleStart, visibleEnd)) { + setActiveMonth(focusedDate.getMonth()); + setActiveYear(focusedDate.getFullYear()); + } + }, [focusedDate, activeMonth, activeYear, numberOfMonthsShown]); + + useEffect(() => { + setHighlightedDates(DateSet.fromRange(highlightStart, highlightEnd)); + }, [highlightStart, highlightEnd]); + + const selectedDates = useMemo( + () => DateSet.fromRange(selectionStart, selectionEnd), + [selectionStart, selectionEnd] + ); + + const monthOptions = useMemo( + () => + getMonthOptions( + activeYear, + monthFormat, + minDateAllowed, + maxDateAllowed + ), + [activeYear, monthFormat, minDateAllowed, maxDateAllowed] + ); + + const handleWheel = useCallback( + (e: WheelEvent) => { + e.preventDefault(); + + // Accumulate scroll delta until threshold is reached, then change the active month + // This respects OS scroll speed settings and works well with trackpads + const threshold = 100; // Adjust this to control sensitivity + + scrollAccumulatorRef.current += e.deltaY; + + if (Math.abs(scrollAccumulatorRef.current) >= threshold) { + const currentDate = moment([activeYear, activeMonth, 1]); + const newDate = + scrollAccumulatorRef.current > 0 + ? currentDate.clone().add(1, 'month') + : currentDate.clone().subtract(1, 'month'); + + // Check if the new month is within allowed range + const newMonth = newDate.toDate(); + const isWithinRange = + (!minDateAllowed || + newMonth >= + moment(minDateAllowed).startOf('month').toDate()) && + (!maxDateAllowed || + newMonth <= + moment(maxDateAllowed).startOf('month').toDate()); + + if (isWithinRange) { + setActiveYear(newDate.year()); + setActiveMonth(newDate.month()); + } + scrollAccumulatorRef.current = 0; // Reset accumulator after month change + } + }, + [activeYear, activeMonth, minDateAllowed, maxDateAllowed] + ); + + useEffect(() => { + // Add listener with passive: false to allow preventDefault + calendarContainerRef.current.addEventListener('wheel', handleWheel, { + passive: false, + }); + + return () => { + calendarContainerRef.current?.removeEventListener( + 'wheel', + handleWheel + ); + }; + }, [handleWheel]); + + const handlePreviousMonth = useCallback(() => { + const currentDate = moment([activeYear, activeMonth, 1]); + // In RTL mode, "previous" button actually goes to next month + const newDate = + direction === CalendarDirection.RightToLeft + ? currentDate.clone().add(1, 'month') + : currentDate.clone().subtract(1, 'month'); + const newMonth = newDate.toDate(); + + const isWithinRange = + !minDateAllowed || + newMonth >= moment(minDateAllowed).startOf('month').toDate(); + + if (isWithinRange) { + setActiveYear(newDate.year()); + setActiveMonth(newDate.month()); + } + }, [activeYear, activeMonth, minDateAllowed, direction]); + + const handleNextMonth = useCallback(() => { + const currentDate = moment([activeYear, activeMonth, 1]); + // In RTL mode, "next" button actually goes to previous month + const newDate = + direction === CalendarDirection.RightToLeft + ? currentDate.clone().subtract(1, 'month') + : currentDate.clone().add(1, 'month'); + const newMonth = newDate.toDate(); + + const isWithinRange = + !maxDateAllowed || + newMonth <= moment(maxDateAllowed).startOf('month').toDate(); + + if (isWithinRange) { + setActiveYear(newDate.year()); + setActiveMonth(newDate.month()); + } + }, [activeYear, activeMonth, maxDateAllowed, direction]); + + const isPreviousMonthDisabled = useMemo(() => { + if (!minDateAllowed) { + return false; + } + const currentDate = moment([activeYear, activeMonth, 1]); + const prevMonth = currentDate.clone().subtract(1, 'month').toDate(); + return prevMonth < moment(minDateAllowed).startOf('month').toDate(); + }, [activeYear, activeMonth, minDateAllowed]); + + const isNextMonthDisabled = useMemo(() => { + if (!maxDateAllowed) { + return false; + } + const currentDate = moment([activeYear, activeMonth, 1]); + const nextMonth = currentDate.clone().add(1, 'month').toDate(); + return nextMonth > moment(maxDateAllowed).startOf('month').toDate(); + }, [activeYear, activeMonth, maxDateAllowed]); + + const isVertical = calendarOrientation === 'vertical'; + const PreviousMonthIcon = isVertical ? ArrowUpIcon : ArrowLeftIcon; + const NextMonthIcon = isVertical ? ArrowDownIcon : ArrowRightIcon; + + return ( +
+
+ + { + if (Number.isInteger(value)) { + setActiveMonth(value as number); + } + }} + /> + { + if (typeof value === 'number') { + const parsed = parseYear(String(value)); + if (parsed !== undefined) { + setActiveYear(parsed); + } + } + }} + /> + +
+
+ {Array.from({length: numberOfMonthsShown}, (_, i) => { + // Center the view: start from (numberOfMonthsShown - 1) / 2 months before activeMonth + const offset = + i - Math.floor((numberOfMonthsShown - 1) / 2); + const monthDate = moment([activeYear, activeMonth, 1]).add( + offset, + 'months' + ); + return ( + 1} + direction={direction} + /> + ); + })} +
+
+ ); +}; + +export default Calendar; diff --git a/components/dash-core-components/src/utils/calendar/CalendarDay.tsx b/components/dash-core-components/src/utils/calendar/CalendarDay.tsx new file mode 100644 index 0000000000..c02c9205db --- /dev/null +++ b/components/dash-core-components/src/utils/calendar/CalendarDay.tsx @@ -0,0 +1,79 @@ +import React, { + DetailedHTMLProps, + TdHTMLAttributes, + useEffect, + useRef, +} from 'react'; + +type CalendarDayProps = DetailedHTMLProps< + TdHTMLAttributes, + HTMLTableCellElement +> & { + label: string; + isOutside: boolean; + isSelected?: boolean; + isHighlighted?: boolean; + isFocused?: boolean; + isDisabled?: boolean; +}; + +const CalendarDay = ({ + label, + isOutside, + isSelected = false, + isHighlighted = false, + isFocused = false, + isDisabled = false, + className, + ...passThruProps +}: CalendarDayProps): JSX.Element => { + let extraClasses = ''; + if (isOutside) { + extraClasses += ' dash-datepicker-calendar-date-outside'; + } else { + extraClasses += ' dash-datepicker-calendar-date-inside'; + } + if (isSelected && !(isOutside && !label)) { + // does not apply this class to a blank cell in another month + // (relevant when `number_of_months_shown > 1`) + extraClasses += ' dash-datepicker-calendar-date-selected'; + } + if (isHighlighted) { + extraClasses += ' dash-datepicker-calendar-date-highlighted'; + } + if (isDisabled) { + extraClasses += ' dash-datepicker-calendar-date-disabled'; + } + className = (className ?? '') + extraClasses; + + const ref = useRef(document.createElement('td')); + + useEffect(() => { + if (isFocused) { + ref.current.focus(); + } + }, [isFocused]); + + // If disabled, filter out all event handlers from passThruProps + const filteredProps = isDisabled + ? Object.fromEntries( + Object.entries(passThruProps).filter( + ([key]) => !key.startsWith('on') + ) + ) + : passThruProps; + + return ( + + {label} + + ); +}; + +export default CalendarDay; diff --git a/components/dash-core-components/src/utils/calendar/CalendarMonth.tsx b/components/dash-core-components/src/utils/calendar/CalendarMonth.tsx new file mode 100644 index 0000000000..eec78aea5a --- /dev/null +++ b/components/dash-core-components/src/utils/calendar/CalendarMonth.tsx @@ -0,0 +1,277 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import moment from 'moment'; +import CalendarDay from './CalendarDay'; +import {createMonthGrid} from './createMonthGrid'; +import {formatDate, isDateInRange, isDateDisabled} from './helpers'; +import {DateSet} from './DateSet'; +import {CalendarDirection} from '../../types'; +import '../../components/css/calendar.css'; + +export enum NavigationDirection { + Backward = -1, + Forward = 1, +} + +const EmptyRow = () => ( + + {Array.from({length: 7}, (_, i) => ( + + + + ))} + +); + +type CalendarMonthProps = { + year: number; + month: number; // 0-11 representing January-December; + dateFocused?: Date; + datesSelected?: DateSet; + datesHighlighted?: DateSet; + minDateAllowed?: Date; + maxDateAllowed?: Date; + disabledDates?: DateSet; + onDaySelected?: (date: Date) => void; + onDayFocused?: (date: Date) => void; + onDaysHighlighted?: (days: DateSet) => void; + firstDayOfWeek?: number; // 0-7 + showOutsideDays?: boolean; + daySize?: number; + monthFormat?: string; + showMonthHeader?: boolean; + direction?: CalendarDirection; +}; + +export const CalendarMonth = ({ + year, + month, + onDaySelected, + onDayFocused, + onDaysHighlighted, + datesSelected, + datesHighlighted, + minDateAllowed, + maxDateAllowed, + disabledDates, + firstDayOfWeek = 0, + showOutsideDays = false, + // eslint-disable-next-line no-magic-numbers + daySize = 36, + monthFormat, + showMonthHeader = false, + direction = CalendarDirection.LeftToRight, + ...props +}: CalendarMonthProps): JSX.Element => { + // Generate grid of dates + const gridDates = useMemo( + () => createMonthGrid(year, month, firstDayOfWeek), + [year, month, firstDayOfWeek] + ); + + // Helper to compute if a date is disabled + const computeIsDisabled = useCallback( + (date: Date): boolean => { + return isDateDisabled( + date, + minDateAllowed, + maxDateAllowed, + disabledDates + ); + }, + [minDateAllowed, maxDateAllowed, disabledDates] + ); + + const computeIsOutside = useCallback( + (date: Date): boolean => { + return date.getMonth() !== month; + }, + [month] + ); + + const computeLabel = useCallback( + (date: Date): string => { + const isOutside = computeIsOutside(date); + if (!showOutsideDays && isOutside) { + return ''; + } + return String(date.getDate()); + }, + [showOutsideDays, computeIsOutside] + ); + + const daysOfTheWeek = useMemo(() => { + return Array.from({length: 7}, (_, i) => + moment() + .day((i + firstDayOfWeek) % 7) + .format('dd') + ); + }, [firstDayOfWeek]); + + const [selectionStart, setSelectionStart] = useState(); + + const confirmSelection = useCallback( + (date: Date) => { + setSelectionStart(undefined); + const isOutside = computeIsOutside(date); + if (isOutside && !showOutsideDays) { + return; + } + onDaySelected?.(date); + }, + [onDaySelected, showOutsideDays, computeIsOutside] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent, date: Date) => { + const m = moment(date); + let newDate: moment.Moment | null = null; + + switch (e.key) { + case ' ': + case 'Enter': + e.preventDefault(); + confirmSelection(date); + return; + case 'ArrowRight': + newDate = + direction === CalendarDirection.RightToLeft + ? m.subtract(1, 'day') + : m.add(1, 'day'); + break; + case 'ArrowLeft': + newDate = + direction === CalendarDirection.RightToLeft + ? m.add(1, 'day') + : m.subtract(1, 'day'); + break; + case 'ArrowDown': + newDate = m.add(1, 'week'); + break; + case 'ArrowUp': + newDate = m.subtract(1, 'week'); + break; + case 'PageDown': + newDate = m.add(1, 'month'); + break; + case 'PageUp': + newDate = m.subtract(1, 'month'); + break; + case 'Home': + // Navigate to week start (respecting firstDayOfWeek) + newDate = m.clone().day(firstDayOfWeek); + // If we went forward, adjust backward to current week + if (newDate.isAfter(m, 'day')) { + newDate.subtract(1, 'week'); + } + break; + case 'End': + // Navigate to week end (respecting firstDayOfWeek) + newDate = m.clone().day((firstDayOfWeek + 6) % 7); + // If we went backward, adjust forward to current week + if (newDate.isBefore(m, 'day')) { + newDate.add(1, 'week'); + } + break; + default: + return; + } + + if (newDate) { + e.preventDefault(); + const newDateObj = newDate.toDate(); + // Only focus the new date if it's within the allowed range + if (isDateInRange(newDateObj, minDateAllowed, maxDateAllowed)) { + onDayFocused?.(newDateObj); + } + } + }, + [ + onDayFocused, + confirmSelection, + minDateAllowed, + maxDateAllowed, + direction, + firstDayOfWeek, + ] + ); + + // Calculate calendar width: 7 days * daySize + some padding + const calendarWidth = daySize * 7 + 16; // 16px for table padding + + // Format the month/year header + const monthYearLabel = useMemo(() => { + return formatDate(new Date(year, month, 1), monthFormat); + }, [year, month, monthFormat]); + + return ( + + + {showMonthHeader && ( + + + + )} + + {daysOfTheWeek.map((day, i) => ( + + ))} + + + + {gridDates.map((week, i) => ( + + {week.map((date, j) => ( + { + setSelectionStart(date); + onDaysHighlighted?.(new DateSet([date])); + }} + onMouseUp={() => confirmSelection(date)} + onMouseEnter={() => { + if (!selectionStart) { + return; + } + const selectionRange = DateSet.fromRange( + selectionStart, + date + ); + onDaysHighlighted?.(selectionRange); + }} + onFocus={() => onDayFocused?.(date)} + onKeyDown={e => handleKeyDown(e, date)} + isFocused={ + props.dateFocused !== undefined && + moment(date).isSame( + props.dateFocused, + 'day' + ) + } + isSelected={datesSelected?.has(date) ?? false} + isHighlighted={ + datesHighlighted?.has(date) ?? false + } + isDisabled={computeIsDisabled(date)} + /> + ))} + + ))} + {Array.from({length: 6 - gridDates.length}, (_, i) => ( + + ))} + +
+ {monthYearLabel} +
+ {day} +
+ ); +}; diff --git a/components/dash-core-components/src/utils/calendar/DateSet.ts b/components/dash-core-components/src/utils/calendar/DateSet.ts new file mode 100644 index 0000000000..e6fcaa1f51 --- /dev/null +++ b/components/dash-core-components/src/utils/calendar/DateSet.ts @@ -0,0 +1,107 @@ +import {dateAsNum, numAsDate, strAsDate} from './helpers'; + +/** + * A Set-like collection for Date objects that provides O(1) lookup performance. + * Internally uses date keys (days since Unix epoch) for efficient storage and comparison. + */ +export class DateSet { + private keys: Set; + + constructor(dates?: (string | Date)[] | DateSet) { + if (dates instanceof DateSet) { + // Copy constructor + this.keys = new Set(dates.keys); + } else if (dates) { + const parsedDates = dates + .map(d => (typeof d === 'string' ? strAsDate(d) : d)) + .filter((d): d is Date => d !== undefined) + .map(dateAsNum); + this.keys = new Set(parsedDates); + } else { + this.keys = new Set(); + } + } + + has(date: Date): boolean { + return this.keys.has(dateAsNum(date)); + } + + add(date: Date): this { + this.keys.add(dateAsNum(date)); + return this; + } + + delete(date: Date): boolean { + return this.keys.delete(dateAsNum(date)); + } + + clear(): void { + this.keys.clear(); + } + + get size(): number { + return this.keys.size; + } + + /** + * Iterate over dates in the set (in chronological order). + */ + *[Symbol.iterator](): Iterator { + // Sort keys to provide chronological iteration + const sortedKeys = Array.from(this.keys).sort((a, b) => a - b); + for (const key of sortedKeys) { + yield numAsDate(key); + } + } + + /** + * Create a DateSet from a date range. + * @param start - Start date (inclusive) + * @param end - End date (inclusive) + * @param excluded - Optional array of dates to exclude from the range + */ + static fromRange(start?: Date, end?: Date, excluded?: Date[]): DateSet { + if (!start && !end) { + return new DateSet(); + } + + if (!start || !end) { + const singleDate = start ?? end; + return singleDate ? new DateSet([singleDate]) : new DateSet(); + } + + const k1 = dateAsNum(start); + const k2 = dateAsNum(end); + const [startKey, endKey] = [k1, k2].sort((a, b) => a - b); + + const dateSet = new DateSet(); + for (let key = startKey; key <= endKey; key++) { + dateSet.keys.add(key); + } + + excluded?.forEach(date => dateSet.delete(date)); + return dateSet; + } + + /** + * Get the earliest date in the set. + */ + min(): Date | undefined { + if (this.keys.size === 0) { + return undefined; + } + const minKey = Math.min(...this.keys); + return numAsDate(minKey); + } + + /** + * Get the latest date in the set. + */ + max(): Date | undefined { + if (this.keys.size === 0) { + return undefined; + } + const maxKey = Math.max(...this.keys); + return numAsDate(maxKey); + } +} diff --git a/components/dash-core-components/src/utils/calendar/createMonthGrid.ts b/components/dash-core-components/src/utils/calendar/createMonthGrid.ts new file mode 100644 index 0000000000..2c400475be --- /dev/null +++ b/components/dash-core-components/src/utils/calendar/createMonthGrid.ts @@ -0,0 +1,30 @@ +import moment from 'moment'; + +/** + * Creates a 2D array of Date objects representing a calendar month grid. + * @param year - The year + * @param month - The month (0-11) + * @param firstDayOfWeek - The first day of week (0=Sunday, 1=Monday, etc.) + * @returns 2D array where each inner array is a week of Date objects + */ +export const createMonthGrid = ( + year: number, + month: number, + firstDayOfWeek: number +): Date[][] => { + const firstDay = moment([year, month, 1]); + const offset = (firstDay.day() - firstDayOfWeek + 7) % 7; + const totalCells = Math.ceil((offset + firstDay.daysInMonth()) / 7) * 7; + const startDate = firstDay.clone().subtract(offset, 'days'); + + const grid: Date[][] = []; + for (let i = 0; i < totalCells; i += 7) { + grid.push( + Array.from({length: 7}, (_, j) => + startDate.clone().add(i + j, 'days').toDate() + ) + ); + } + + return grid; +}; diff --git a/components/dash-core-components/src/utils/calendar/helpers.ts b/components/dash-core-components/src/utils/calendar/helpers.ts new file mode 100644 index 0000000000..57687adbf0 --- /dev/null +++ b/components/dash-core-components/src/utils/calendar/helpers.ts @@ -0,0 +1,191 @@ +import moment from 'moment'; + +/** + * Converts a date to a numeric key (days since Unix epoch) for use in Sets/Objects. + * Normalizes to midnight for consistent comparison. + * This allows arithmetic operations: key + 1 = next day, key - 7 = previous week + */ +export function dateAsNum(date: Date): number { + const normalized = new Date(date); + normalized.setHours(0, 0, 0, 0); + // eslint-disable-next-line no-magic-numbers + return Math.floor(normalized.getTime() / (1000 * 60 * 60 * 24)); +} + +/** + * Converts a number of days since Unix epoch back into a Date object. + * Inverse of dateAsNum. Always returns midnight (00:00:00) in local timezone. + */ +export function numAsDate(key: number): Date { + // Convert key to milliseconds (UTC timestamp) + // eslint-disable-next-line no-magic-numbers + const utcDate = new Date(key * 24 * 60 * 60 * 1000); + // Extract UTC date components and create local date + return new Date( + utcDate.getUTCFullYear(), + utcDate.getUTCMonth(), + utcDate.getUTCDate() + ); +} + +export function strAsDate(date?: string, format?: string): Date | undefined { + if (!date) { + return undefined; + } + const parsed = format ? moment(date, format, true) : moment(date); + if (!parsed.isValid()) { + return undefined; + } + // Normalize to midnight in local timezone (strip time component) + return new Date(parsed.year(), parsed.month(), parsed.date()); +} + +export function dateAsStr( + date?: Date +): `${string}-${string}-${string}` | undefined { + if (!date) { + return undefined; + } + return formatDate(date, 'YYYY-MM-DD') as `${string}-${string}-${string}`; +} + +export function isDateInRange( + targetDate: Date, + minDate?: Date, + maxDate?: Date +): boolean { + const targetKey = dateAsNum(targetDate); + + if (minDate && targetKey < dateAsNum(minDate)) { + return false; + } + + if (maxDate && targetKey > dateAsNum(maxDate)) { + return false; + } + + return true; +} + +export function formatDate(date: Date, format = 'YYYY-MM-DD'): string { + return moment(date).format(format); +} + +export function formatMonth( + year: number, + month: number, + format?: string +): string { + const {monthFormat} = extractFormats(format); + return moment(new Date(year, month, 1)).format(monthFormat); +} + +/** + * Extracts separate month and year format strings from a combined month_format. + * Used when month and year are displayed in separate controls. + * + * @param format - The combined format string (e.g., "MMMM, YYYY") + * @returns Object with monthFormat and yearFormat + */ +export function extractFormats(format?: string): { + monthFormat: string; + yearFormat: string; +} { + if (!format) { + return {monthFormat: 'MMMM', yearFormat: 'YYYY'}; + } + + // Extract month tokens (MMMM, MMM, MM, M) + const monthMatch = format.match(/M{1,4}/); + const monthFormat = monthMatch ? monthMatch[0] : 'MMMM'; + + // Extract year tokens (YYYY, YY) + const yearMatch = format.match(/Y{2,4}/); + const yearFormat = yearMatch ? yearMatch[0] : 'YYYY'; + + return {monthFormat, yearFormat}; +} + +/** + * Generates month options for a dropdown based on a format string. + * Extracts only the month portion from the format and generates all 12 months. + * + * @param year - The current year (used for formatting context) + * @param format - The combined month/year format (e.g., "MMMM, YYYY") + * @returns Array of {label, value} options for months 0-11 + */ +export function getMonthOptions( + year: number, + format?: string, + minDate?: Date, + maxDate?: Date +): Array<{label: string; value: number; disabled?: boolean}> { + const {monthFormat} = extractFormats(format); + + return Array.from({length: 12}, (_, i) => { + const date = new Date(year, i, 1); + const label = moment(date).format(monthFormat); + + // Check if this month is outside the allowed range + const disabled = + (minDate && + moment(date).isBefore(moment(minDate).startOf('month'))) || + (maxDate && moment(date).isAfter(moment(maxDate).startOf('month'))); + + return {label, value: i, disabled}; + }); +} + +/** + * Formats a year according to the year format extracted from month_format. + * Supports YYYY (4-digit) and YY (2-digit) formats. + * + * @param year - The full 4-digit year (e.g., 1997) + * @param format - The combined month/year format (e.g., "MMMM, YY") + * @returns Formatted year string (e.g., "97" for YY, "1997" for YYYY) + */ +export function formatYear(year: number, format?: string): string { + const {yearFormat} = extractFormats(format); + return moment(new Date(year, 0, 1)).format(yearFormat); +} + +/** + * Parses a year string and converts it to a full 4-digit year. + * Handles both 2-digit (YY) and 4-digit (YYYY) inputs. + * For 2-digit years, uses moment.js rules: 00-68 → 2000-2068, 69-99 → 1969-1999 + * + * @param yearStr - The year string to parse (e.g., "97", "1997", "23") + * @returns Full 4-digit year, or undefined if invalid + */ +export function parseYear(yearStr: string): number | undefined { + const parsed = moment(yearStr, ['YY', 'YYYY']); + return parsed.isValid() ? parsed.year() : undefined; +} + +/** + * Checks if a date is disabled based on min/max constraints and disabled dates set. + * + * @param date - The date to check + * @param minDate - Minimum allowed date (optional) + * @param maxDate - Maximum allowed date (optional) + * @param disabledDates - DateSet of disabled dates (optional) + * @returns true if the date is disabled, false otherwise + */ +export function isDateDisabled( + date: Date, + minDate?: Date, + maxDate?: Date, + disabledDates?: {has: (date: Date) => boolean} +): boolean { + // Check if date is outside min/max range + if (!isDateInRange(date, minDate, maxDate)) { + return true; + } + + // Check if date is in the disabled dates set (O(1) lookup) + if (disabledDates) { + return disabledDates.has(date); + } + + return false; +} diff --git a/components/dash-core-components/src/utils/optionTypes.js b/components/dash-core-components/src/utils/optionTypes.js deleted file mode 100644 index ca092ff12e..0000000000 --- a/components/dash-core-components/src/utils/optionTypes.js +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import {type} from 'ramda'; - -export const sanitizeOptions = options => { - if (type(options) === 'Object') { - return Object.entries(options).map(([value, label]) => ({ - label: React.isValidElement(label) ? label : String(label), - value, - })); - } - - if (type(options) === 'Array') { - if ( - options.length > 0 && - ['String', 'Number', 'Bool'].includes(type(options[0])) - ) { - return options.map(option => ({ - label: String(option), - value: option, - })); - } - return options; - } - - return options; -}; diff --git a/components/dash-core-components/tests/dash_core_components_page.py b/components/dash-core-components/tests/dash_core_components_page.py index 14ee0777be..bb2ce4279d 100644 --- a/components/dash-core-components/tests/dash_core_components_page.py +++ b/components/dash-core-components/tests/dash_core_components_page.py @@ -16,14 +16,15 @@ def select_date_single(self, compid, index=0, day="", outside_month=False): outside_month: used in conjunction with day. indicates if the day out the scope of current month. default False. """ - date = self.find_element(f"#{compid} input") + date = self.find_element(f"#{compid}") date.click() def is_month_valid(elem): + classes = elem.get_attribute("class") or "" return ( - "__outside" in elem.get_attribute("class") + "dash-datepicker-calendar-date-outside" in classes if outside_month - else "__outside" not in elem.get_attribute("class") + else "dash-datepicker-calendar-date-outside" not in classes ) self._wait_until_day_is_clickable() @@ -92,7 +93,7 @@ def _wait_until_day_is_clickable(self, timeout=1): @property def date_picker_day_locator(self): - return 'div[data-visible="true"] td.CalendarDay' + return '.dash-datepicker-calendar-date-inside, .dash-datepicker-calendar-date-outside' def click_and_hold_at_coord_fractions(self, elem_or_selector, fx, fy): elem = self._get_element(elem_or_selector) diff --git a/components/dash-core-components/tests/integration/calendar/test_a11y.py b/components/dash-core-components/tests/integration/calendar/test_a11y.py new file mode 100644 index 0000000000..8de8e3b3c0 --- /dev/null +++ b/components/dash-core-components/tests/integration/calendar/test_a11y.py @@ -0,0 +1,365 @@ +import pytest +from datetime import datetime +from dash import Dash +from dash.dcc import DatePickerSingle +from dash.html import Div, Label, P +from selenium.common.exceptions import TimeoutException +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.common.action_chains import ActionChains + + +# Helper functions +def send_keys(driver, key): + """Send keyboard keys to the browser""" + actions = ActionChains(driver) + actions.send_keys(key) + actions.perform() + + +def get_focused_text(driver): + """Get the text content of the currently focused element""" + return driver.execute_script("return document.activeElement.textContent;") + + +def create_date_picker_app(date_picker_props): + """Create a Dash app with a DatePickerSingle component""" + app = Dash(__name__) + app.layout = Div([DatePickerSingle(id="date-picker", **date_picker_props)]) + return app + + +def open_calendar(dash_dcc, date_picker): + """Open the calendar and wait for it to be visible""" + date_picker.click() + dash_dcc.wait_for_element(".dash-datepicker-calendar-container") + + +def close_calendar(dash_dcc, driver): + """Close the calendar with Escape and wait for it to disappear""" + send_keys(driver, Keys.ESCAPE) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + + +def test_a11y001_label_focuses_date_picker(dash_dcc): + app = Dash(__name__) + app.layout = Label( + [ + P("Click me", id="label"), + DatePickerSingle( + id="date-picker", + initial_visible_month=datetime(2021, 1, 1), + ), + ], + ) + + dash_dcc.start_server(app) + + dash_dcc.wait_for_element("#date-picker") + + with pytest.raises(TimeoutException): + dash_dcc.wait_for_element(".dash-datepicker-calendar-container", timeout=0.25) + + dash_dcc.find_element("#label").click() + dash_dcc.wait_for_element(".dash-datepicker-calendar-container") + + assert dash_dcc.get_logs() == [] + + +def test_a11y002_label_with_htmlFor_can_focus_date_picker(dash_dcc): + app = Dash(__name__) + app.layout = Div( + [ + Label("Click me", htmlFor="date-picker", id="label"), + DatePickerSingle( + id="date-picker", + initial_visible_month=datetime(2021, 1, 1), + ), + ], + ) + + dash_dcc.start_server(app) + + dash_dcc.wait_for_element("#date-picker") + + with pytest.raises(TimeoutException): + dash_dcc.wait_for_element(".dash-datepicker-calendar-container", timeout=0.25) + + dash_dcc.find_element("#label").click() + dash_dcc.wait_for_element(".dash-datepicker-calendar-container") + + assert dash_dcc.get_logs() == [] + + +def test_a11y003_keyboard_navigation_arrows(dash_dcc): + app = create_date_picker_app({ + "date": "2021-01-15", + "initial_visible_month": datetime(2021, 1, 1), + }) + dash_dcc.start_server(app) + + date_picker = dash_dcc.find_element("#date-picker") + open_calendar(dash_dcc, date_picker) + + # Get the focused date element (should be Jan 15, 2021) + assert get_focused_text(dash_dcc.driver) == "15" + + # Test ArrowRight - should move to Jan 16 + send_keys(dash_dcc.driver, Keys.ARROW_RIGHT) + assert get_focused_text(dash_dcc.driver) == "16" + + # Test ArrowLeft - should move back to Jan 15 + send_keys(dash_dcc.driver, Keys.ARROW_LEFT) + assert get_focused_text(dash_dcc.driver) == "15" + + # Test ArrowDown - should move to Jan 22 (one week down) + send_keys(dash_dcc.driver, Keys.ARROW_DOWN) + assert get_focused_text(dash_dcc.driver) == "22" + + # Test ArrowUp - should move back to Jan 15 (one week up) + send_keys(dash_dcc.driver, Keys.ARROW_UP) + assert get_focused_text(dash_dcc.driver) == "15" + + # Test PageDown - should move to Feb 15 (one month forward) + send_keys(dash_dcc.driver, Keys.PAGE_DOWN) + assert get_focused_text(dash_dcc.driver) == "15" + + # Test PageUp - should move back to Jan 15 (one month back) + send_keys(dash_dcc.driver, Keys.PAGE_UP) + assert get_focused_text(dash_dcc.driver) == "15" + + # Test Enter - should select the date and close calendar + send_keys(dash_dcc.driver, Keys.ENTER) + with pytest.raises(TimeoutException): + dash_dcc.wait_for_element(".dash-datepicker-calendar-container", timeout=0.25) + + assert dash_dcc.get_logs() == [] + + +def test_a11y004_keyboard_navigation_home_end(dash_dcc): + app = create_date_picker_app({ + "date": "2021-01-15", # Friday, Jan 15, 2021 + "initial_visible_month": datetime(2021, 1, 1), + "first_day_of_week": 0, # Sunday + }) + dash_dcc.start_server(app) + + date_picker = dash_dcc.find_element("#date-picker") + open_calendar(dash_dcc, date_picker) + + # Get the focused date element (should be Jan 15, 2021 - Friday) + assert get_focused_text(dash_dcc.driver) == "15" + + # Test Home key - should move to week start (Sunday, Jan 10) + send_keys(dash_dcc.driver, Keys.HOME) + assert get_focused_text(dash_dcc.driver) == "10" + + # Test End key - should move to week end (Saturday, Jan 16) + send_keys(dash_dcc.driver, Keys.END) + assert get_focused_text(dash_dcc.driver) == "16" + + # Test Home key again - should move to week start (Sunday, Jan 10) + send_keys(dash_dcc.driver, Keys.HOME) + assert get_focused_text(dash_dcc.driver) == "10" + + assert dash_dcc.get_logs() == [] + + +def test_a11y005_keyboard_navigation_home_end_monday_start(dash_dcc): + app = create_date_picker_app({ + "date": "2021-01-15", # Friday, Jan 15, 2021 + "initial_visible_month": datetime(2021, 1, 1), + "first_day_of_week": 1, # Monday + }) + dash_dcc.start_server(app) + + date_picker = dash_dcc.find_element("#date-picker") + open_calendar(dash_dcc, date_picker) + + # Get the focused date element (should be Jan 15, 2021 - Friday) + assert get_focused_text(dash_dcc.driver) == "15" + + # Test Home key - should move to week start (Monday, Jan 11) + send_keys(dash_dcc.driver, Keys.HOME) + assert get_focused_text(dash_dcc.driver) == "11" + + # Test End key - should move to week end (Sunday, Jan 17) + send_keys(dash_dcc.driver, Keys.END) + assert get_focused_text(dash_dcc.driver) == "17" + + assert dash_dcc.get_logs() == [] + + +def test_a11y006_keyboard_navigation_rtl(dash_dcc): + app = create_date_picker_app({ + "date": "2021-01-15", + "initial_visible_month": datetime(2021, 1, 1), + "is_RTL": True, + }) + dash_dcc.start_server(app) + + date_picker = dash_dcc.find_element("#date-picker") + open_calendar(dash_dcc, date_picker) + + # Get the focused date element (should be Jan 15, 2021) + assert get_focused_text(dash_dcc.driver) == "15" + + # Test ArrowRight in RTL - should move to Jan 14 (reversed) + send_keys(dash_dcc.driver, Keys.ARROW_RIGHT) + assert get_focused_text(dash_dcc.driver) == "14" + + # Test ArrowLeft in RTL - should move to Jan 15 (reversed) + send_keys(dash_dcc.driver, Keys.ARROW_LEFT) + assert get_focused_text(dash_dcc.driver) == "15" + + # Test Home key in RTL - should move to week start (semantic, not visual) + send_keys(dash_dcc.driver, Keys.HOME) + assert get_focused_text(dash_dcc.driver) == "10" # Sunday (week start) - semantic behavior + + # Test End key in RTL - should move to week end (semantic, not visual) + send_keys(dash_dcc.driver, Keys.END) + assert get_focused_text(dash_dcc.driver) == "16" # Saturday (week end) - semantic behavior + + assert dash_dcc.get_logs() == [] + + +def test_a11y007_all_keyboard_keys_respect_min_max(dash_dcc): + """Test that all keyboard navigation keys respect min/max date boundaries""" + app = create_date_picker_app({ + "date": "2021-02-15", # Monday + "min_date_allowed": datetime(2021, 2, 15), # Monday - same as start date + "max_date_allowed": datetime(2021, 2, 20), # Sat + "initial_visible_month": datetime(2021, 2, 1), + "first_day_of_week": 0, # Sunday + }) + dash_dcc.start_server(app) + + date_picker = dash_dcc.find_element("#date-picker") + initial_value = "2021-02-15" + + # Test Arrow Down (would go to Feb 22, beyond max) + open_calendar(dash_dcc, date_picker) + send_keys(dash_dcc.driver, Keys.ARROW_DOWN) + send_keys(dash_dcc.driver, Keys.ENTER) + assert date_picker.get_attribute("value") == initial_value, "ArrowDown: Should not select date after max" + + # Test Arrow Up (would go to Feb 8, before min) + close_calendar(dash_dcc, dash_dcc.driver) + open_calendar(dash_dcc, date_picker) + send_keys(dash_dcc.driver, Keys.ARROW_UP) + send_keys(dash_dcc.driver, Keys.ENTER) + assert date_picker.get_attribute("value") == initial_value, "ArrowUp: Should not select date before min" + + # Test Home (would go to Feb 14 Sunday, before min Feb 15) + close_calendar(dash_dcc, dash_dcc.driver) + open_calendar(dash_dcc, date_picker) + send_keys(dash_dcc.driver, Keys.HOME) + send_keys(dash_dcc.driver, Keys.ENTER) + assert date_picker.get_attribute("value") == initial_value, "Home: Should not select date before min" + + # Test End (would go to Feb 20 Saturday, at max - should succeed) + close_calendar(dash_dcc, dash_dcc.driver) + open_calendar(dash_dcc, date_picker) + send_keys(dash_dcc.driver, Keys.END) + send_keys(dash_dcc.driver, Keys.ENTER) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + assert date_picker.get_attribute("value") == "2021-02-20", "End: Should select valid date at max" + + # Reset and test PageUp (would go to Jan 20, before min) + open_calendar(dash_dcc, date_picker) + send_keys(dash_dcc.driver, Keys.PAGE_UP) + send_keys(dash_dcc.driver, Keys.ENTER) + assert date_picker.get_attribute("value") == "2021-02-20", "PageUp: Should not select date before min" + + # Test PageDown (would go to Mar 20, after max) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + open_calendar(dash_dcc, date_picker) + send_keys(dash_dcc.driver, Keys.PAGE_DOWN) + send_keys(dash_dcc.driver, Keys.ENTER) + assert date_picker.get_attribute("value") == "2021-02-20", "PageDown: Should not select date after max" + + assert dash_dcc.get_logs() == [] + + +def test_a11y008_all_keyboard_keys_respect_disabled_days(dash_dcc): + """Test that all keyboard navigation keys respect disabled dates""" + app = create_date_picker_app({ + "date": "2021-02-15", # Monday + "disabled_days": [ + datetime(2021, 2, 14), # Sunday - week start + datetime(2021, 2, 16), # Tuesday - ArrowRight target + datetime(2021, 2, 20), # Saturday - week end + datetime(2021, 2, 22), # Monday - ArrowDown target + datetime(2021, 1, 15), # PageUp target + datetime(2021, 3, 15), # PageDown target + ], + "initial_visible_month": datetime(2021, 2, 1), + "first_day_of_week": 0, # Sunday + }) + dash_dcc.start_server(app) + + date_picker = dash_dcc.find_element("#date-picker") + + # Test Arrow Right (would go to Feb 16, disabled) + open_calendar(dash_dcc, date_picker) + send_keys(dash_dcc.driver, Keys.ARROW_RIGHT) + send_keys(dash_dcc.driver, Keys.ENTER) + assert date_picker.get_attribute("value") != "2021-02-16", "ArrowRight: Should not select disabled date" + + # Test Arrow Down (would go to Feb 22, disabled) + open_calendar(dash_dcc, date_picker) + send_keys(dash_dcc.driver, Keys.ARROW_DOWN) + send_keys(dash_dcc.driver, Keys.ENTER) + assert date_picker.get_attribute("value") != "2021-02-22", "ArrowDown: Should not select disabled date" + + # Test Home (would go to Feb 14 Sunday, disabled) + open_calendar(dash_dcc, date_picker) + send_keys(dash_dcc.driver, Keys.HOME) + send_keys(dash_dcc.driver, Keys.ENTER) + assert date_picker.get_attribute("value") != "2021-02-14", "Home: Should not select disabled week start" + + # Test End (would go to Feb 20 Saturday, disabled) + open_calendar(dash_dcc, date_picker) + send_keys(dash_dcc.driver, Keys.END) + send_keys(dash_dcc.driver, Keys.ENTER) + assert date_picker.get_attribute("value") != "2021-02-20", "End: Should not select disabled week end" + + # Test PageUp (navigates to Jan 15, disabled) + open_calendar(dash_dcc, date_picker) + send_keys(dash_dcc.driver, Keys.PAGE_UP) + send_keys(dash_dcc.driver, Keys.ENTER) + assert date_picker.get_attribute("value") != "2021-01-15", "PageUp: Should not select disabled date" + + # Test PageDown (navigates to Mar 15, disabled) + open_calendar(dash_dcc, date_picker) + send_keys(dash_dcc.driver, Keys.PAGE_DOWN) + send_keys(dash_dcc.driver, Keys.ENTER) + assert date_picker.get_attribute("value") != "2021-03-15", "PageDown: Should not select disabled date" + + assert dash_dcc.get_logs() == [] + + +def test_a11y009_keyboard_space_selects_date(dash_dcc): + app = create_date_picker_app({ + "date": "2021-01-15", + "initial_visible_month": datetime(2021, 1, 1), + }) + dash_dcc.start_server(app) + + date_picker = dash_dcc.find_element("#date-picker") + open_calendar(dash_dcc, date_picker) + + # Move to a different date + send_keys(dash_dcc.driver, Keys.ARROW_RIGHT) + assert get_focused_text(dash_dcc.driver) == "16" + + # Select with Space key + send_keys(dash_dcc.driver, Keys.SPACE) + + # Calendar should close + with pytest.raises(TimeoutException): + dash_dcc.wait_for_element(".dash-datepicker-calendar-container", timeout=0.25) + + # Date should be updated to Jan 16 + assert date_picker.get_attribute("value") == "2021-01-16" + + assert dash_dcc.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/calendar/test_date_picker_single.py b/components/dash-core-components/tests/integration/calendar/test_date_picker_single.py index d13194511f..0975800c3d 100644 --- a/components/dash-core-components/tests/integration/calendar/test_date_picker_single.py +++ b/components/dash-core-components/tests/integration/calendar/test_date_picker_single.py @@ -3,6 +3,7 @@ import time import pytest +from selenium.webdriver.common.keys import Keys from dash import Dash, Input, Output, html, dcc, no_update @@ -30,7 +31,7 @@ def test_dtps001_simple_click(dash_dcc): }, ) dash_dcc.start_server(app) - date = dash_dcc.find_element("#dps input") + date = dash_dcc.find_element("#dps") assert not date.get_attribute("value") assert dash_dcc.select_date_single( "dps", index=3 @@ -55,9 +56,9 @@ def test_dtps010_local_and_session_persistence(dash_dcc): dash_dcc.start_server(app) - assert not dash_dcc.find_element("#dps-local input").get_attribute( + assert not dash_dcc.find_element("#dps-local").get_attribute( "value" - ) and not dash_dcc.find_element("#dps-session input").get_attribute( + ) and not dash_dcc.find_element("#dps-session").get_attribute( "value" ), "component should contain no initial date" @@ -66,9 +67,8 @@ def test_dtps010_local_and_session_persistence(dash_dcc): session = dash_dcc.select_date_single("dps-session", index=idx) dash_dcc.wait_for_page() assert ( - dash_dcc.find_element("#dps-local input").get_attribute("value") == local - and dash_dcc.find_element("#dps-session input").get_attribute("value") - == session + dash_dcc.find_element("#dps-local").get_attribute("value") == local + and dash_dcc.find_element("#dps-session").get_attribute("value") == session ), "the date value should be consistent after refresh" assert dash_dcc.get_logs() == [] @@ -117,40 +117,92 @@ def cb(clicks): switch.click() assert dash_dcc.wait_for_text_to_equal("#out", "switched") switch.click() - assert ( - dash_dcc.find_element("#dps-memory input").get_attribute("value") == memorized - ) - switched = dash_dcc.find_element("#dps-none input").get_attribute("value") + assert dash_dcc.find_element("#dps-memory").get_attribute("value") == memorized + switched = dash_dcc.find_element("#dps-none").get_attribute("value") assert switched != amnesiaed and switched == "" assert dash_dcc.get_logs() == [] -def test_dtps012_initial_month(dash_dcc): +def test_dtps012_initial_visible_month(dash_dcc): app = Dash(__name__) app.layout = html.Div( [ dcc.DatePickerSingle( - id="dps-initial-month", - min_date_allowed=datetime(2010, 1, 1), - max_date_allowed=datetime(2099, 12, 31), + id="dps", + date="2020-06-15", + initial_visible_month=datetime(2010, 1, 1), ) ] ) dash_dcc.start_server(app) - date_picker = dash_dcc.find_element("#dps-initial-month") + date_picker = dash_dcc.find_element("#dps") date_picker.click() - dash_dcc.wait_for_text_to_equal( - "#dps-initial-month .CalendarMonth.CalendarMonth_1[data-visible=true] strong", - "January 2010", + + # Wait for calendar to open + dash_dcc.wait_for_element(".dash-datepicker-calendar-container") + + # Check that calendar shows January 2010 (initial_visible_month), not June 2020 (date) + month_dropdown = dash_dcc.find_element(".dash-datepicker-controls .dash-dropdown") + year_input = dash_dcc.find_element(".dash-datepicker-controls input") + + assert "January" in month_dropdown.text, "Calendar should show January" + assert year_input.get_attribute("value") == "2010", "Calendar should show year 2010" + + assert dash_dcc.get_logs() == [] + + +def test_dtps013_min_max_date_allowed(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerSingle( + id="dps", + min_date_allowed=datetime(2021, 1, 5), + max_date_allowed=datetime(2021, 1, 25), + initial_visible_month=datetime(2021, 1, 1), + ), + html.Div(id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input("dps", "date"), ) + def update_output(date): + return f"Selected: {date}" + + dash_dcc.start_server(app) + + # Initially no date selected + dash_dcc.wait_for_text_to_equal("#output", "Selected: None") + + # Try to select date before min_date_allowed - should not update + dash_dcc.select_date_single("dps", day=3) + dash_dcc.wait_for_text_to_equal("#output", "Selected: None") + # Close calendar + date_input = dash_dcc.find_element("#dps") + date_input.send_keys(Keys.ESCAPE) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + + # Try to select date after max_date_allowed - should not update + dash_dcc.select_date_single("dps", day=28) + dash_dcc.wait_for_text_to_equal("#output", "Selected: None") + # Close calendar + date_input.send_keys(Keys.ESCAPE) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + + # Select date within allowed range - should update + dash_dcc.select_date_single("dps", day=10) + dash_dcc.wait_for_text_to_equal("#output", "Selected: 2021-01-10") assert dash_dcc.get_logs() == [] -def test_dtps013_disabled_days_arent_clickable(dash_dcc): +def test_dtps014_disabled_days_arent_clickable(dash_dcc): app = Dash(__name__) app.layout = html.Div( [ @@ -172,16 +224,18 @@ def test_dtps013_disabled_days_arent_clickable(dash_dcc): }, ) dash_dcc.start_server(app) - date = dash_dcc.find_element("#dps input") + date = dash_dcc.find_element("#dps") assert not date.get_attribute("value") assert not dash_dcc.select_date_single( "dps", day=10 ), "Disabled days should not be clickable" + # Close calendar + date.send_keys(Keys.ESCAPE) assert dash_dcc.select_date_single("dps", day=1), "Other days should be clickable" # open datepicker to take snapshot date.click() - dash_dcc.percy_snapshot("dtps013 - disabled days") + dash_dcc.percy_snapshot("dtps014 - disabled days") def test_dtps0014_disabed_days_timeout(dash_dcc): @@ -215,5 +269,256 @@ def test_dtps0014_disabed_days_timeout(dash_dcc): date.click() assert time.time() - start_time < 5 - dash_dcc.wait_for_element(".SingleDatePicker_picker", timeout=5) + dash_dcc.wait_for_element(".dash-datepicker-calendar-container", timeout=5) + assert dash_dcc.get_logs() == [] + + +def test_dtps020_renders_date_picker(dash_dcc): + """Test that DatePickerSingle renders correctly.""" + app = Dash(__name__) + app.layout = html.Div([dcc.DatePickerSingle(id="dps")]) + + dash_dcc.start_server(app) + + # Check that the datepicker element exists + datepicker = dash_dcc.find_element(".dash-datepicker") + assert datepicker is not None, "DatePickerSingle should render" + + # Check that input exists + input_element = dash_dcc.find_element(".dash-datepicker-input") + assert input_element is not None, "DatePickerSingle should have an input element" + + assert dash_dcc.get_logs() == [] + + +def test_dtps022_custom_display_format(dash_dcc): + """Test that dates display in custom format.""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerSingle( + id="dps", + display_format="MM/DD/YYYY", + date="2025-10-17", + ), + ] + ) + + dash_dcc.start_server(app) + + # Check that input shows the date in custom format + input_element = dash_dcc.find_element(".dash-datepicker-input") + assert ( + input_element.get_attribute("value") == "10/17/2025" + ), "Date should display in MM/DD/YYYY format" + + assert dash_dcc.get_logs() == [] + + +def test_dtps023_default_display_format(dash_dcc): + """Test that dates default to YYYY-MM-DD format and can be changed via callback.""" + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("Change Format", id="btn"), + dcc.DatePickerSingle( + id="dps", + date="2025-01-10", + ), + ] + ) + + @app.callback( + Output("dps", "display_format"), + Input("btn", "n_clicks"), + prevent_initial_call=True, + ) + def change_format(n_clicks): + return "DD/MM/YYYY" + + dash_dcc.start_server(app) + + # Check that input shows the date in default YYYY-MM-DD format + input_element = dash_dcc.find_element(".dash-datepicker-input") + assert ( + input_element.get_attribute("value") == "2025-01-10" + ), "Date should display in default YYYY-MM-DD format" + + # Click button to change format + btn = dash_dcc.find_element("#btn") + btn.click() + + # Wait for format to change and verify new format + dash_dcc.wait_for_text_to_equal(".dash-datepicker-input", "10/01/2025", timeout=4) + + assert dash_dcc.get_logs() == [] + + +def test_dtps023b_input_validation_and_blur(dash_dcc): + """Test that typing into the input and blurring validates and reformats the date.""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerSingle( + id="dps", + display_format="MM/DD/YYYY", + ), + html.Div(id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input("dps", "date"), + ) + def update_output(date): + return f"Date: {date}" + + dash_dcc.start_server(app) + + # Initially no date + dash_dcc.wait_for_text_to_equal("#output", "Date: None") + + input_element = dash_dcc.find_element("#dps") + + # Type a valid date in the custom format + input_element.clear() + input_element.send_keys("01/15/2025") + input_element.send_keys(Keys.TAB) # Blur the input + + # Should parse and set the date + dash_dcc.wait_for_text_to_equal("#output", "Date: 2025-01-15") + + # Input should still show in custom format after blur + assert ( + input_element.get_attribute("value") == "01/15/2025" + ), "Input should maintain custom format after blur" + + # Type an invalid date + input_element.clear() + input_element.send_keys("invalid") + input_element.send_keys(Keys.TAB) # Blur the input + + # Should revert to previous valid date + dash_dcc.wait_for_text_to_equal("#output", "Date: 2025-01-15") + assert ( + input_element.get_attribute("value") == "01/15/2025" + ), "Invalid input should revert to previous valid date in display format on blur" + + assert dash_dcc.get_logs() == [] + + +def test_dtps024_rtl_directionality(dash_dcc): + """Test that is_RTL prop applies correct directionality to input and calendar.""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerSingle(id="dps-rtl", is_RTL=True), + dcc.DatePickerSingle(id="dps-ltr", is_RTL=False), + dcc.DatePickerSingle(id="dps-default"), + ] + ) + + dash_dcc.start_server(app) + + # Wait for components to render and check dir attribute on input elements + rtl_input = dash_dcc.wait_for_element(".dash-datepicker-input") + assert ( + rtl_input.get_attribute("dir") == "rtl" + ), "is_RTL=True should set dir='rtl' on input element" + + all_inputs = dash_dcc.find_elements(".dash-datepicker-input") + assert len(all_inputs) == 3, "Should have 3 date picker inputs" + + ltr_input = all_inputs[1] + assert ( + ltr_input.get_attribute("dir") == "ltr" + ), "is_RTL=False should set dir='ltr' on input element" + + default_input = all_inputs[2] + assert ( + default_input.get_attribute("dir") == "ltr" + ), "Default (no is_RTL) should set dir='ltr' on input element" + + # Test calendar directionality when opened + all_wrappers = dash_dcc.find_elements(".dash-datepicker-input-wrapper") + all_wrappers[0].click() + + rtl_calendar = dash_dcc.wait_for_element(".dash-datepicker-calendar-container") + assert ( + rtl_calendar.get_attribute("dir") == "rtl" + ), "is_RTL=True should set dir='rtl' on calendar container" + + rtl_input.send_keys(Keys.ESCAPE) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + + all_wrappers[1].click() + ltr_calendar = dash_dcc.wait_for_element(".dash-datepicker-calendar-container") + assert ( + ltr_calendar.get_attribute("dir") == "ltr" + ), "is_RTL=False should set dir='ltr' on calendar container" + + ltr_input.send_keys(Keys.ESCAPE) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + + all_wrappers[2].click() + default_calendar = dash_dcc.wait_for_element(".dash-datepicker-calendar-container") + assert ( + default_calendar.get_attribute("dir") == "ltr" + ), "Default (no is_RTL) should set dir='ltr' on calendar container" + + assert dash_dcc.get_logs() == [] + + +def test_dtps025_typing_disabled_day_should_not_trigger_callback(dash_dcc): + """Test that manually typing a disabled day into the input does not set that date in callback.""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerSingle( + id="dps", + date="2025-01-10", + display_format="MM/DD/YYYY", + disabled_days=[datetime(2025, 1, 15)], + ), + html.Div(id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input("dps", "date"), + ) + def update_output(date): + return f"Date: {date}" + + dash_dcc.start_server(app) + + # Initially has a valid date + dash_dcc.wait_for_text_to_equal("#output", "Date: 2025-01-10") + + input_element = dash_dcc.find_element("#dps") + + # Verify initial display format is respected + assert ( + input_element.get_attribute("value") == "01/10/2025" + ), "Initial date should be displayed in custom format" + + # Type a disabled date (in the correct display format) + input_element.clear() + input_element.send_keys("01/15/2025") + input_element.send_keys(Keys.TAB) # Blur the input + + # The callback should NOT receive the disabled date + time.sleep(0.5) # Give it time to potentially (incorrectly) update + output_text = dash_dcc.find_element("#output").text + assert ( + output_text != "Date: 2025-01-15" + ), f"Typing a disabled day should not trigger callback with that date, but got: {output_text}" + + # The input should revert to the previous valid date in the correct display format + assert ( + input_element.get_attribute("value") == "01/10/2025" + ), f"Input should revert to previous valid date in display format (MM/DD/YYYY), but got: {input_element.get_attribute('value')}" + assert dash_dcc.get_logs() == [] diff --git a/components/dash-core-components/tests/unit/.eslintrc.js b/components/dash-core-components/tests/unit/.eslintrc.js new file mode 100644 index 0000000000..90eed0cc15 --- /dev/null +++ b/components/dash-core-components/tests/unit/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + rules: { + 'no-magic-numbers': 'off', + }, +}; diff --git a/components/dash-core-components/tests/unit/calendar/Calendar.test.tsx b/components/dash-core-components/tests/unit/calendar/Calendar.test.tsx new file mode 100644 index 0000000000..5e71374ffe --- /dev/null +++ b/components/dash-core-components/tests/unit/calendar/Calendar.test.tsx @@ -0,0 +1,276 @@ +import React from 'react'; +import {render, waitFor, act} from '@testing-library/react'; +import Calendar from '../../../src/utils/calendar/Calendar'; +import {CalendarDirection} from '../../../src/types'; +import {DateSet} from '../../../src/utils/calendar/DateSet'; + +// Mock LoadingElement to avoid Dash context issues in tests +jest.mock('../../../src/utils/_LoadingElement', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react'); + return function LoadingElement({ + children, + }: { + children: (props: any) => React.ReactNode; + }) { + return children({}); + }; +}); + +// Helper to count cells with a specific CSS class +const countCellsWithClass = ( + container: HTMLElement, + className: string +): number => { + const allCells = container.querySelectorAll('td'); + return Array.from(allCells).filter(td => td.classList.contains(className)) + .length; +}; + +describe('Calendar', () => { + let mockOnSelectionChange: jest.Mock; + + beforeEach(() => { + mockOnSelectionChange = jest.fn(); + }); + + it('renders a calendar', () => { + const {container} = render( + + ); + + const calendarWrapper = container.querySelector( + '.dash-datepicker-calendar-wrapper' + ); + expect(calendarWrapper).toBeInTheDocument(); + }); + + it('marks disabled dates correctly', () => { + const disabledDates = new DateSet([ + new Date(2025, 0, 10), + new Date(2025, 0, 15), + ]); + + const {container} = render( + + ); + + expect( + countCellsWithClass( + container, + 'dash-datepicker-calendar-date-disabled' + ) + ).toBeGreaterThan(0); + }); + + it('marks selected dates from selectionStart and selectionEnd', () => { + const {container} = render( + + ); + + // Should have 6 selected days (Jan 10-15 inclusive) + expect( + countCellsWithClass( + container, + 'dash-datepicker-calendar-date-selected' + ) + ).toBe(6); + }); + + it('marks highlighted dates from highlightStart and highlightEnd', () => { + const {container} = render( + + ); + + // Should have 6 highlighted days (Jan 5-10 inclusive) + expect( + countCellsWithClass( + container, + 'dash-datepicker-calendar-date-highlighted' + ) + ).toBe(6); + }); + + it('handles single date selection', () => { + const {container} = render( + + ); + + // Should have 1 selected day + expect( + countCellsWithClass( + container, + 'dash-datepicker-calendar-date-selected' + ) + ).toBe(1); + }); + + it.each([ + { + description: 'default format (YYYY)', + date: new Date(1997, 4, 10), + monthFormat: undefined, + expectedYear: '1997', + expectedMonth: /May/, + }, + { + description: 'YY format', + date: new Date(1997, 4, 10), + monthFormat: 'MMMM YY', + expectedYear: '97', + expectedMonth: /May/, + }, + { + description: 'YYYY format with January', + date: new Date(2023, 0, 15), + monthFormat: undefined, + expectedYear: '2023', + expectedMonth: /January/, + }, + ])( + 'formats year and month according to month_format: $description', + ({date, monthFormat, expectedYear, expectedMonth}) => { + const {container} = render( + + ); + + const yearInput = container.querySelector( + '.dash-input-element' + ) as HTMLInputElement; + expect(yearInput.value).toBe(expectedYear); + + const monthButton = container.querySelector( + '.dash-dropdown-trigger' + ); + expect(monthButton?.textContent).toMatch(expectedMonth); + } + ); + + it('parses year input with moment.js rules (1-digit, 2-digit, 4-digit)', async () => { + const mockOnSelectionChange = jest.fn(); + + const {container} = render( + + ); + + const yearInput = container.querySelector( + '.dash-input-element' + ) as HTMLInputElement; + expect(yearInput.value).toBe('2000'); + + // Test 2-digit year: "97" → 1997 + act(() => { + yearInput.value = '97'; + yearInput.dispatchEvent(new Event('change', {bubbles: true})); + }); + await waitFor(() => expect(yearInput.value).toBe('97'), { + timeout: 1000, + }); + + // Test 4-digit year: "2025" → 2025 + act(() => { + yearInput.value = '2025'; + yearInput.dispatchEvent(new Event('change', {bubbles: true})); + }); + await waitFor(() => expect(yearInput.value).toBe('2025'), { + timeout: 1000, + }); + + // Test single-digit year: "5" → 2005 + act(() => { + yearInput.value = '5'; + yearInput.dispatchEvent(new Event('change', {bubbles: true})); + }); + await waitFor(() => expect(yearInput.value).toBe('5'), {timeout: 1000}); + }); + + it.each([ + { + description: 'selected date when visible in current month', + visibleMonth: new Date(2020, 0, 1), + selectedDate: new Date(2020, 0, 23), + expectedFocusedDay: '23', + }, + { + description: 'first day when selected date is not visible', + visibleMonth: new Date(2020, 0, 1), + selectedDate: new Date(2025, 9, 17), + expectedFocusedDay: '1', + }, + { + description: 'first day when no date is selected', + visibleMonth: new Date(2020, 0, 1), + selectedDate: undefined, + expectedFocusedDay: '1', + }, + ])( + 'focuses $description', + ({visibleMonth, selectedDate, expectedFocusedDay}) => { + render( + + ); + + const focusedElement = document.activeElement; + expect(focusedElement?.tagName).toBe('TD'); + expect(focusedElement?.textContent).toBe(expectedFocusedDay); + } + ); + + describe('RTL support', () => { + it('applies RTL directionality to calendar container', () => { + const {container: rtlContainer} = render( + + ); + + const {container: ltrContainer} = render( + + ); + + // dir attribute should be on calendar-container, not wrapper (to avoid reversing controls) + const rtlContainer_div = rtlContainer.querySelector( + '.dash-datepicker-calendar-container' + ); + const ltrContainer_div = ltrContainer.querySelector( + '.dash-datepicker-calendar-container' + ); + + expect(rtlContainer_div).toHaveAttribute('dir', 'rtl'); + expect(ltrContainer_div).toHaveAttribute('dir', 'ltr'); + }); + }); +}); diff --git a/components/dash-core-components/tests/unit/calendar/CalendarDay.test.tsx b/components/dash-core-components/tests/unit/calendar/CalendarDay.test.tsx new file mode 100644 index 0000000000..54f46b8978 --- /dev/null +++ b/components/dash-core-components/tests/unit/calendar/CalendarDay.test.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import {render} from '@testing-library/react'; +import CalendarDay from '../../../src/utils/calendar/CalendarDay'; + +describe('CalendarDay', () => { + it('renders a basic minimal calendar day', () => { + const {container} = render( + + + + + + +
+ ); + + const td = container.querySelector('td'); + expect(td).toBeInTheDocument(); + expect(td?.textContent).toBe('test'); + expect(td?.classList.contains('dash-datepicker-calendar-date-inside')).toBe(true); + }); + + it('renders outside day with correct className', () => { + const {container} = render( + + + + + + +
+ ); + + const td = container.querySelector('td'); + expect(td).toBeInTheDocument(); + expect(td?.textContent).toBe('31'); + expect(td?.classList.contains('dash-datepicker-calendar-date-outside')).toBe(true); + expect(td?.classList.contains('dash-datepicker-calendar-date-inside')).toBe(false); + }); + + it('prevents interaction with disabled calendar day', () => { + const mockOnClick = jest.fn(); + + const {container} = render( + + + + + + +
+ ); + + const td = container.querySelector('td'); + expect(td).toBeInTheDocument(); + expect(td?.classList.contains('dash-datepicker-calendar-date-disabled')).toBe(true); + expect(td?.getAttribute('aria-disabled')).toBe('true'); + + // Click the disabled day and verify the event is prevented + td?.click(); + expect(mockOnClick).not.toHaveBeenCalled(); + + // Verify disabled day has no tabIndex (cannot be focused via keyboard) + expect(td?.getAttribute('tabIndex')).toBeNull(); + + // Try to focus the element and verify it doesn't receive focus + td?.focus(); + expect(document.activeElement).not.toBe(td); + }); + + it('focuses the element when isFocused is true', () => { + const {container} = render( + + + + + + +
+ ); + + const td = container.querySelector('td'); + expect(td).toBe(document.activeElement); + }); +}); diff --git a/components/dash-core-components/tests/unit/calendar/CalendarMonth.test.tsx b/components/dash-core-components/tests/unit/calendar/CalendarMonth.test.tsx new file mode 100644 index 0000000000..7628744335 --- /dev/null +++ b/components/dash-core-components/tests/unit/calendar/CalendarMonth.test.tsx @@ -0,0 +1,255 @@ +import React from 'react'; +import {render, fireEvent} from '@testing-library/react'; +import {CalendarMonth} from '../../../src/utils/calendar/CalendarMonth'; +import {DateSet} from '../../../src/utils/calendar/DateSet'; +import {CalendarDirection} from '../../../src/types'; + +describe('CalendarMonth', () => { + it('renders a calendar month with correct structure', () => { + const {container} = render( + + ); + + const table = container.querySelector('table'); + expect(table).toBeInTheDocument(); + + // Should have 7 day-of-week headers + const headers = container.querySelectorAll('thead th'); + expect(headers.length).toBeGreaterThanOrEqual(7); + }); + + describe('when showOutsideDays=false', () => { + it.each([ + { + month: 5, // June + monthName: 'June', + daysInMonth: 30, + startsOnDay: 0, // Sunday + expectedFirstIndex: 0, + expectedEmptyCellsBefore: 0, + }, + { + month: 0, // January + monthName: 'January', + daysInMonth: 31, + startsOnDay: 3, // Wednesday + expectedFirstIndex: 3, + expectedEmptyCellsBefore: 3, + }, + ])('renders $monthName 2025 with correct labeled and unlabeled cells', ({ + month, + monthName, + daysInMonth, + expectedFirstIndex, + expectedEmptyCellsBefore, + }) => { + const {container} = render( + + ); + + const allCells = container.querySelectorAll('td'); + const cellTexts = Array.from(allCells).map(td => td.textContent?.trim() || ''); + const labeledCells = cellTexts.filter(text => text !== ''); + + // All days in the month should be labeled in correct order + expect(labeledCells.length).toBe(daysInMonth); + const days = labeledCells.map(text => parseInt(text, 10)); + expect(days).toEqual(Array.from({length: daysInMonth}, (_, i) => i + 1)); + + // First day should appear at expected position + const firstDayIndex = cellTexts.findIndex(text => text === '1'); + expect(firstDayIndex).toBe(expectedFirstIndex); + + // Cells before first day should be unlabeled + if (expectedEmptyCellsBefore > 0) { + expect(cellTexts.slice(0, expectedEmptyCellsBefore)).toEqual( + Array(expectedEmptyCellsBefore).fill('') + ); + } + + // Cells after last day in the same week should be unlabeled + const lastDayIndex = cellTexts.lastIndexOf(String(daysInMonth)); + const remainingInWeek = 6 - (lastDayIndex % 7); + for (let i = 1; i <= remainingInWeek; i++) { + expect(cellTexts[lastDayIndex + i]).toBe(''); + } + }); + }); + + it('shows outside day labels when showOutsideDays=true with Monday first', () => { + // January 2025: starts on Wednesday (day 3) + // With Monday as first day of week, we show: Mon Dec 30, Tue Dec 31, then Wed Jan 1 + const {container} = render( + + ); + + const allCells = container.querySelectorAll('td'); + const cellTexts = Array.from(allCells).map(td => td.textContent?.trim() || ''); + + // Behavior 1: When showOutsideDays=true, all cells should be labeled (no empty labels) + // Find cells that are in the actual calendar rows (not ghost rows) + const labeledCells = cellTexts.filter(text => text !== ''); + + // Behavior 2: Days before January 1 should be labeled (December days) + // First 2 cells should be December days (30, 31) + // January 1, 2025 is Wednesday, which is 2 days after Monday + expect(cellTexts[0]).toBe('30'); + expect(cellTexts[1]).toBe('31'); + + // 3rd cell should be January 1 + expect(cellTexts[2]).toBe('1'); + + // Verify January days continue in sequence + expect(cellTexts[3]).toBe('2'); + expect(cellTexts[4]).toBe('3'); + }); + + it('marks selected dates with DateSet', () => { + const selectedDates = new DateSet([ + new Date(2025, 0, 5), + new Date(2025, 0, 10), + new Date(2025, 0, 15), + ]); + + const {container} = render( + + ); + + const allCells = container.querySelectorAll('td'); + const selectedCells = Array.from(allCells).filter(td => + td.classList.contains('dash-datepicker-calendar-date-selected') + ); + + expect(selectedCells.length).toBe(3); + }); + + it('marks highlighted dates with DateSet', () => { + const highlightedDates = DateSet.fromRange( + new Date(2025, 0, 10), + new Date(2025, 0, 15) + ); + + const {container} = render( + + ); + + const allCells = container.querySelectorAll('td'); + const highlightedCells = Array.from(allCells).filter(td => + td.classList.contains('dash-datepicker-calendar-date-highlighted') + ); + + expect(highlightedCells.length).toBe(6); // Jan 10-15 = 6 days + }); + + it('handles empty DateSet for selected dates', () => { + const {container} = render( + + ); + + const allCells = container.querySelectorAll('td'); + const selectedCells = Array.from(allCells).filter(td => + td.classList.contains('dash-datepicker-calendar-date-selected') + ); + + expect(selectedCells.length).toBe(0); + }); + + it('handles undefined DateSet props', () => { + const {container} = render( + + ); + + const table = container.querySelector('table'); + expect(table).toBeInTheDocument(); + }); + + describe('RTL support', () => { + it('reverses keyboard navigation for ArrowLeft/ArrowRight in RTL', () => { + const mockOnDayFocused = jest.fn(); + + render( + + ); + + // Use document.activeElement to find the actually focused cell + const focusedCell = document.activeElement as HTMLElement; + expect(focusedCell?.tagName).toBe('TD'); + expect(focusedCell?.textContent).toBe('15'); + + mockOnDayFocused.mockClear(); // Clear any initial focus calls + + // Press ArrowRight - in RTL this should go to January 14 (backwards) + fireEvent.keyDown(focusedCell!, {key: 'ArrowRight'}); + expect(mockOnDayFocused).toHaveBeenLastCalledWith(new Date(2025, 0, 14)); + + mockOnDayFocused.mockClear(); + + // Press ArrowLeft - in RTL this should go to January 16 (forwards) + fireEvent.keyDown(focusedCell!, {key: 'ArrowLeft'}); + expect(mockOnDayFocused).toHaveBeenLastCalledWith(new Date(2025, 0, 16)); + }); + + it('keeps ArrowUp/ArrowDown unchanged in RTL', () => { + const mockOnDayFocused = jest.fn(); + + render( + + ); + + const focusedCell = document.activeElement as HTMLElement; + expect(focusedCell?.tagName).toBe('TD'); + expect(focusedCell?.textContent).toBe('15'); + + mockOnDayFocused.mockClear(); // Clear any initial focus calls + + // ArrowDown should still go forward 1 week + fireEvent.keyDown(focusedCell!, {key: 'ArrowDown'}); + expect(mockOnDayFocused).toHaveBeenLastCalledWith(new Date(2025, 0, 22)); + + mockOnDayFocused.mockClear(); + + // ArrowUp should still go backward 1 week + fireEvent.keyDown(focusedCell!, {key: 'ArrowUp'}); + expect(mockOnDayFocused).toHaveBeenLastCalledWith(new Date(2025, 0, 8)); + }); + }); +}); diff --git a/components/dash-core-components/tests/unit/calendar/DatePickerSingle.test.tsx b/components/dash-core-components/tests/unit/calendar/DatePickerSingle.test.tsx new file mode 100644 index 0000000000..6db2610bc4 --- /dev/null +++ b/components/dash-core-components/tests/unit/calendar/DatePickerSingle.test.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import {render, waitFor, fireEvent} from '@testing-library/react'; +import DatePickerSingle from '../../../src/fragments/DatePickerSingle'; +import {CalendarDirection} from '../../../src/types'; + +describe('DatePickerSingle', () => { + it('renders a date picker', () => { + const mockSetProps = jest.fn(); + + const {container, unmount} = render( + + ); + + const datepicker = container.querySelector('.dash-datepicker'); + expect(datepicker).toBeInTheDocument(); + + unmount(); + }); + + it('marks disabled days correctly', async () => { + const mockSetProps = jest.fn(); + const disabledDays = ['2025-01-10', '2025-01-15']; + + const {container, unmount} = render( + + ); + + // Click the trigger to open the calendar + const trigger = container.querySelector('.dash-datepicker-input-wrapper'); + trigger?.dispatchEvent(new MouseEvent('click', {bubbles: true})); + + // Wait for calendar to render + await waitFor(() => { + const allDays = container.querySelectorAll('td'); + expect(allDays.length).toBeGreaterThan(0); + }); + + const allDays = container.querySelectorAll('td'); + const disabledDays_rendered = Array.from(allDays).filter(td => + td.classList.contains('dash-datepicker-calendar-date-disabled') + ); + + expect(disabledDays_rendered.length).toBeGreaterThan(0); + + unmount(); + }); + + it('displays date in custom display_format on initial mount', () => { + const mockSetProps = jest.fn(); + + // Render with a pre-selected date and custom format + const {container, unmount} = render( + + ); + + // Check that input shows the date in custom format immediately on mount + const input = container.querySelector('.dash-datepicker-input') as HTMLInputElement; + expect(input.value).toBe('10 17 25'); // MM DD YY format, not YYYY-MM-DD + + unmount(); + }); + + it('displays date in custom display_format after blur', async () => { + const mockSetProps = jest.fn(); + + // Render with a pre-selected date and custom format + const {container, unmount} = render( + + ); + + // Check that input shows the date in custom format + const input = container.querySelector('.dash-datepicker-input') as HTMLInputElement; + expect(input.value).toBe('01/15/2025'); // MM/DD/YYYY format + + // Blur the input to trigger sendInputAsDate + fireEvent.blur(input); + + // Wait for any state updates + await waitFor(() => { + const inputAfterBlur = container.querySelector('.dash-datepicker-input') as HTMLInputElement; + // Should still be in custom format after blur + expect(inputAfterBlur.value).toBe('01/15/2025'); + }); + + unmount(); + }); + + it('defaults to YYYY-MM-DD format when no display_format provided', async () => { + const mockSetProps = jest.fn(); + + // Render with a pre-selected date and no display_format + const {container, unmount} = render( + + ); + + // Check that input shows the date in default YYYY-MM-DD format + const input = container.querySelector('.dash-datepicker-input') as HTMLInputElement; + expect(input.value).toBe('2025-01-10'); // YYYY-MM-DD format + + // Blur the input + fireEvent.blur(input); + + // Wait for any state updates + await waitFor(() => { + const inputAfterBlur = container.querySelector('.dash-datepicker-input') as HTMLInputElement; + // Should still be in default format after blur + expect(inputAfterBlur.value).toBe('2025-01-10'); + }); + + unmount(); + }); + + describe('RTL support', () => { + it('applies directionality based on is_RTL prop', () => { + const mockSetProps = jest.fn(); + + const {container: rtlContainer, unmount: unmountRtl} = render( + + ); + + const {container: ltrContainer, unmount: unmountLtr} = render( + + ); + + const {container: defaultContainer, unmount: unmountDefault} = render( + + ); + + // dir attribute should be on input element + expect(rtlContainer.querySelector('.dash-datepicker-input')) + .toHaveAttribute('dir', CalendarDirection.RightToLeft); + expect(ltrContainer.querySelector('.dash-datepicker-input')) + .toHaveAttribute('dir', CalendarDirection.LeftToRight); + expect(defaultContainer.querySelector('.dash-datepicker-input')) + .toHaveAttribute('dir', CalendarDirection.LeftToRight); + + unmountRtl(); + unmountLtr(); + unmountDefault(); + }); + }); +}); diff --git a/components/dash-core-components/tests/unit/calendar/DateSet.test.ts b/components/dash-core-components/tests/unit/calendar/DateSet.test.ts new file mode 100644 index 0000000000..3b1575ffed --- /dev/null +++ b/components/dash-core-components/tests/unit/calendar/DateSet.test.ts @@ -0,0 +1,281 @@ +import {DateSet} from '../../../src/utils/calendar/DateSet'; + +describe('DateSet', () => { + describe('constructor', () => { + it('creates empty DateSet with no arguments', () => { + const dateSet = new DateSet(); + expect(dateSet.size).toBe(0); + }); + + it('creates DateSet from array of dates', () => { + const dates = [ + new Date(2025, 0, 1), + new Date(2025, 0, 2), + new Date(2025, 0, 3), + ]; + const dateSet = new DateSet(dates); + + expect(dateSet.size).toBe(3); + expect(dateSet.has(new Date(2025, 0, 1))).toBe(true); + expect(dateSet.has(new Date(2025, 0, 2))).toBe(true); + expect(dateSet.has(new Date(2025, 0, 3))).toBe(true); + }); + + it('creates DateSet from another DateSet (copy constructor)', () => { + const original = new DateSet([new Date(2025, 0, 1), new Date(2025, 0, 2)]); + const copy = new DateSet(original); + + expect(copy.size).toBe(2); + expect(copy.has(new Date(2025, 0, 1))).toBe(true); + + // Verify it's a copy, not a reference + copy.add(new Date(2025, 0, 3)); + expect(copy.size).toBe(3); + expect(original.size).toBe(2); + }); + + it('handles duplicate dates in array', () => { + const dates = [ + new Date(2025, 0, 1), + new Date(2025, 0, 1), // duplicate + new Date(2025, 0, 2), + ]; + const dateSet = new DateSet(dates); + + expect(dateSet.size).toBe(2); // duplicates removed + }); + }); + + describe('has()', () => { + it('returns true for dates in the set', () => { + const dateSet = new DateSet([new Date(2025, 0, 15)]); + expect(dateSet.has(new Date(2025, 0, 15))).toBe(true); + }); + + it('returns false for dates not in the set', () => { + const dateSet = new DateSet([new Date(2025, 0, 15)]); + expect(dateSet.has(new Date(2025, 0, 16))).toBe(false); + }); + + it('normalizes dates to midnight for comparison', () => { + const dateSet = new DateSet([new Date(2025, 0, 15, 14, 30)]); + expect(dateSet.has(new Date(2025, 0, 15, 0, 0))).toBe(true); + expect(dateSet.has(new Date(2025, 0, 15, 23, 59))).toBe(true); + }); + }); + + describe('add() and delete()', () => { + it('adds a date to the set', () => { + const dateSet = new DateSet(); + dateSet.add(new Date(2025, 0, 1)); + + expect(dateSet.size).toBe(1); + expect(dateSet.has(new Date(2025, 0, 1))).toBe(true); + }); + + it('supports chaining with add()', () => { + const dateSet = new DateSet() + .add(new Date(2025, 0, 1)) + .add(new Date(2025, 0, 2)) + .add(new Date(2025, 0, 3)); + + expect(dateSet.size).toBe(3); + }); + + it('deletes a date from the set', () => { + const dateSet = new DateSet([new Date(2025, 0, 1), new Date(2025, 0, 2)]); + const wasDeleted = dateSet.delete(new Date(2025, 0, 1)); + + expect(wasDeleted).toBe(true); + expect(dateSet.size).toBe(1); + expect(dateSet.has(new Date(2025, 0, 1))).toBe(false); + }); + + it('returns false when deleting non-existent date', () => { + const dateSet = new DateSet([new Date(2025, 0, 1)]); + const wasDeleted = dateSet.delete(new Date(2025, 0, 2)); + + expect(wasDeleted).toBe(false); + expect(dateSet.size).toBe(1); + }); + }); + + describe('clear()', () => { + it('removes all dates from the set', () => { + const dateSet = new DateSet([ + new Date(2025, 0, 1), + new Date(2025, 0, 2), + new Date(2025, 0, 3), + ]); + + dateSet.clear(); + + expect(dateSet.size).toBe(0); + }); + }); + + describe('static factories', () => { + + it('creates range with fromRange()', () => { + const dateSet = DateSet.fromRange( + new Date(2025, 0, 1), + new Date(2025, 0, 5) + ); + + expect(dateSet.size).toBe(5); + expect(dateSet.has(new Date(2025, 0, 1))).toBe(true); + expect(dateSet.has(new Date(2025, 0, 3))).toBe(true); + expect(dateSet.has(new Date(2025, 0, 5))).toBe(true); + expect(dateSet.has(new Date(2025, 0, 6))).toBe(false); + }); + + it('fromRange() works with reversed start/end', () => { + const dateSet = DateSet.fromRange( + new Date(2025, 0, 5), + new Date(2025, 0, 1) + ); + + expect(dateSet.size).toBe(5); + }); + + it('fromRange() excludes specified dates', () => { + const dateSet = DateSet.fromRange( + new Date(2025, 0, 1), + new Date(2025, 0, 5), + [new Date(2025, 0, 2), new Date(2025, 0, 4)] + ); + + expect(dateSet.size).toBe(3); + expect(dateSet.has(new Date(2025, 0, 1))).toBe(true); + expect(dateSet.has(new Date(2025, 0, 2))).toBe(false); + expect(dateSet.has(new Date(2025, 0, 3))).toBe(true); + expect(dateSet.has(new Date(2025, 0, 4))).toBe(false); + expect(dateSet.has(new Date(2025, 0, 5))).toBe(true); + }); + + it('fromRange() with only start date', () => { + const dateSet = DateSet.fromRange(new Date(2025, 0, 15), undefined); + + expect(dateSet.size).toBe(1); + expect(dateSet.has(new Date(2025, 0, 15))).toBe(true); + }); + + it('fromRange() with only end date', () => { + const dateSet = DateSet.fromRange(undefined, new Date(2025, 0, 15)); + + expect(dateSet.size).toBe(1); + expect(dateSet.has(new Date(2025, 0, 15))).toBe(true); + }); + + it('fromRange() with no dates returns empty', () => { + const dateSet = DateSet.fromRange(undefined, undefined); + + expect(dateSet.size).toBe(0); + }); + }); + + describe('iteration', () => { + it('iterates over dates in chronological order', () => { + const dateSet = new DateSet([ + new Date(2025, 0, 3), + new Date(2025, 0, 1), + new Date(2025, 0, 2), + ]); + + const dates = Array.from(dateSet); + + expect(dates).toHaveLength(3); + expect(dates[0]).toEqual(new Date(2025, 0, 1)); + expect(dates[1]).toEqual(new Date(2025, 0, 2)); + expect(dates[2]).toEqual(new Date(2025, 0, 3)); + }); + + it('can be spread into array', () => { + const dateSet = new DateSet([ + new Date(2025, 0, 1), + new Date(2025, 0, 2), + ]); + + const dates = [...dateSet]; + expect(dates).toHaveLength(2); + }); + }); + + describe('min() and max()', () => { + it('returns earliest date with min()', () => { + const dateSet = new DateSet([ + new Date(2025, 0, 15), + new Date(2025, 0, 1), + new Date(2025, 0, 30), + ]); + + const min = dateSet.min(); + expect(min).toEqual(new Date(2025, 0, 1)); + }); + + it('returns latest date with max()', () => { + const dateSet = new DateSet([ + new Date(2025, 0, 15), + new Date(2025, 0, 1), + new Date(2025, 0, 30), + ]); + + const max = dateSet.max(); + expect(max).toEqual(new Date(2025, 0, 30)); + }); + + it('returns undefined for empty set', () => { + const dateSet = new DateSet(); + + expect(dateSet.min()).toBeUndefined(); + expect(dateSet.max()).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('handles dates across month boundaries', () => { + const dateSet = DateSet.fromRange( + new Date(2025, 0, 30), + new Date(2025, 1, 2) + ); + + expect(dateSet.size).toBe(4); + expect(dateSet.has(new Date(2025, 0, 30))).toBe(true); + expect(dateSet.has(new Date(2025, 0, 31))).toBe(true); + expect(dateSet.has(new Date(2025, 1, 1))).toBe(true); + expect(dateSet.has(new Date(2025, 1, 2))).toBe(true); + }); + + it('handles dates across year boundaries', () => { + const dateSet = DateSet.fromRange( + new Date(2024, 11, 30), + new Date(2025, 0, 2) + ); + + expect(dateSet.size).toBe(4); + expect(dateSet.has(new Date(2024, 11, 31))).toBe(true); + expect(dateSet.has(new Date(2025, 0, 1))).toBe(true); + }); + + it('handles leap year dates', () => { + const dateSet = DateSet.fromRange( + new Date(2024, 1, 28), + new Date(2024, 2, 1) + ); + + expect(dateSet.size).toBe(3); // Feb 28, 29, Mar 1 + expect(dateSet.has(new Date(2024, 1, 29))).toBe(true); + }); + + it('handles DST transitions', () => { + // March 9, 2025: DST starts in US + const dateSet = DateSet.fromRange( + new Date(2025, 2, 8), + new Date(2025, 2, 10) + ); + + expect(dateSet.size).toBe(3); + expect(dateSet.has(new Date(2025, 2, 9))).toBe(true); + }); + }); +}); diff --git a/components/dash-core-components/tests/unit/calendar/createMonthGrid.test.ts b/components/dash-core-components/tests/unit/calendar/createMonthGrid.test.ts new file mode 100644 index 0000000000..e83aa15926 --- /dev/null +++ b/components/dash-core-components/tests/unit/calendar/createMonthGrid.test.ts @@ -0,0 +1,102 @@ +import {createMonthGrid} from '../../../src/utils/calendar/createMonthGrid'; + +describe('createMonthGrid', () => { + it('creates grid with correct dimensions', () => { + const grid = createMonthGrid(2025, 0, 0); // January 2025, Sunday first + + // Should have complete weeks (4-6 rows, 7 columns each) + expect(grid.length).toBeGreaterThanOrEqual(4); + expect(grid.length).toBeLessThanOrEqual(6); + + grid.forEach(week => { + expect(week.length).toBe(7); + }); + }); + + it('has consecutive dates within grid', () => { + const grid = createMonthGrid(2025, 0, 0); // January 2025 + + // Flatten grid and verify dates are consecutive (1 day apart) + const allDates = grid.flat(); + for (let i = 1; i < allDates.length; i++) { + const prevDate = allDates[i - 1]; + const currDate = allDates[i]; + const dayDiff = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24); + expect(dayDiff).toBe(1); + } + }); + + it('adjusts for different first day of week', () => { + const sundayFirst = createMonthGrid(2025, 0, 0); // Sunday = 0 + const mondayFirst = createMonthGrid(2025, 0, 1); // Monday = 1 + + // Grids should have different starting dates + expect(sundayFirst[0][0]).not.toEqual(mondayFirst[0][0]); + + // January 2025 starts on Wednesday + // Sunday-first shows: Sun, Mon, Tue (3 days from prev month) + // Monday-first shows: Mon, Tue (2 days from prev month) + // So Monday-first should start later + expect(mondayFirst[0][0].getTime()).toBeGreaterThan(sundayFirst[0][0].getTime()); + }); + + it('handles months starting on different weekdays', () => { + // January 2025 starts on Wednesday + const jan2025 = createMonthGrid(2025, 0, 0); // Sunday first + + // February 2025 starts on Saturday + const feb2025 = createMonthGrid(2025, 1, 0); // Sunday first + + // Both should have valid grids + expect(jan2025.length).toBeGreaterThanOrEqual(4); + expect(feb2025.length).toBeGreaterThanOrEqual(4); + }); + + it('handles February in leap and non-leap years', () => { + const feb2024 = createMonthGrid(2024, 1, 0); // Leap year + const feb2025 = createMonthGrid(2025, 1, 0); // Non-leap year + + // Both should be valid grids + expect(feb2024.length).toBeGreaterThanOrEqual(4); + expect(feb2025.length).toBeGreaterThanOrEqual(4); + + // Verify all dates are consecutive + const checkConsecutive = (dates: Date[]) => { + for (let i = 1; i < dates.length; i++) { + const dayDiff = (dates[i].getTime() - dates[i - 1].getTime()) / (1000 * 60 * 60 * 24); + expect(dayDiff).toBe(1); + } + }; + + checkConsecutive(feb2024.flat()); + checkConsecutive(feb2025.flat()); + }); + + it('creates dates at midnight in local timezone', () => { + const grid = createMonthGrid(2025, 0, 0); + const firstDate = grid[0][0]; + + expect(firstDate.getHours()).toBe(0); + expect(firstDate.getMinutes()).toBe(0); + expect(firstDate.getSeconds()).toBe(0); + expect(firstDate.getMilliseconds()).toBe(0); + }); + + it('includes correct dates for January 2025', () => { + const grid = createMonthGrid(2025, 0, 0); // January 2025, Sunday first + const allDates = grid.flat(); + + // January 1, 2025 is a Wednesday + // So grid starts on Sunday, December 29, 2024 + expect(allDates[0]).toEqual(new Date(2024, 11, 29)); + + // Find January 1 (should be 4th cell: Sun, Mon, Tue, Wed) + expect(allDates[3]).toEqual(new Date(2025, 0, 1)); + + // Find January 31 + const jan31Index = allDates.findIndex(d => + d.getMonth() === 0 && d.getDate() === 31 + ); + expect(allDates[jan31Index]).toEqual(new Date(2025, 0, 31)); + }); +}); diff --git a/components/dash-core-components/tests/unit/calendar/helpers.test.ts b/components/dash-core-components/tests/unit/calendar/helpers.test.ts new file mode 100644 index 0000000000..14cc8994ca --- /dev/null +++ b/components/dash-core-components/tests/unit/calendar/helpers.test.ts @@ -0,0 +1,387 @@ +import { + dateAsNum, + dateAsStr, + isDateInRange, + numAsDate, + strAsDate, + formatDate, + formatMonth, + extractFormats, + getMonthOptions, + formatYear, + parseYear, +} from '../../../src/utils/calendar/helpers'; + +describe('dateAsKey and keyAsDate', () => { + it('supports arithmetic operations', () => { + const baseKey = dateAsNum(new Date(2025, 0, 15)); + + const nextDay = numAsDate(baseKey + 1); + expect(nextDay).toEqual(new Date(2025, 0, 16)); + + const prevDay = numAsDate(baseKey - 1); + expect(prevDay).toEqual(new Date(2025, 0, 14)); + + const nextWeek = numAsDate(baseKey + 7); + expect(nextWeek).toEqual(new Date(2025, 0, 22)); + + const prevWeek = numAsDate(baseKey - 7); + expect(prevWeek).toEqual(new Date(2025, 0, 8)); + }); + + it('maintains perfect symmetry through multiple round-trips', () => { + // Test edge cases that might reveal timezone bugs: + // - DST transitions (spring forward/fall back) + // - Month/year boundaries + // - Leap year days + // - Dates before epoch + const edgeCaseDates = [ + // DST spring forward in US (March 2025, 2am -> 3am) + new Date(2025, 2, 8), // Day before + new Date(2025, 2, 9), // Day of spring forward + new Date(2025, 2, 10), // Day after + + // DST fall back in US (November 2025, 2am -> 1am) + new Date(2025, 10, 1), // Day before + new Date(2025, 10, 2), // Day of fall back + new Date(2025, 10, 3), // Day after + + // Month boundaries + new Date(2025, 0, 31), // Jan 31 -> Feb 1 + new Date(2025, 1, 1), // Feb 1 + + // Year boundaries + new Date(2024, 11, 31), // Dec 31, 2024 + new Date(2025, 0, 1), // Jan 1, 2025 + + // Leap year + new Date(2024, 1, 28), // Feb 28 (leap year) + new Date(2024, 1, 29), // Feb 29 (leap day) + new Date(2024, 2, 1), // Mar 1 after leap day + new Date(2025, 1, 28), // Feb 28 (non-leap year) + new Date(2025, 2, 1), // Mar 1 (non-leap year) + + // Before epoch + new Date(1969, 11, 31), // Dec 31, 1969 + new Date(1969, 11, 30), // Dec 30, 1969 + new Date(1969, 0, 1), // Jan 1, 1969 + ]; + + for (const originalDate of edgeCaseDates) { + // Do multiple round trips to catch any accumulating errors + const key1 = dateAsNum(originalDate); + const date1 = numAsDate(key1); + const key2 = dateAsNum(date1); + const date2 = numAsDate(key2); + const key3 = dateAsNum(date2); + + // All keys should be identical + expect(key1).toBe(key2); + expect(key2).toBe(key3); + + // All dates should be identical + expect(date1).toEqual(originalDate); + expect(date2).toEqual(originalDate); + expect(date2).toEqual(date1); + } + }); + + it('dateAsKey supports arithmetic operations on keys', () => { + const baseDate = new Date(2025, 0, 15); // Jan 15, 2025 + const baseKey = dateAsNum(baseDate); + + const nextDay = dateAsNum(new Date(2025, 0, 16)); + expect(baseKey + 1).toBe(nextDay); + + const prevDay = dateAsNum(new Date(2025, 0, 14)); + expect(baseKey - 1).toBe(prevDay); + + const nextWeek = dateAsNum(new Date(2025, 0, 22)); + expect(baseKey + 7).toBe(nextWeek); + + const prevWeek = dateAsNum(new Date(2025, 0, 8)); + expect(baseKey - 7).toBe(prevWeek); + + const nextMonth = dateAsNum(new Date(2025, 1, 14)); // 30 days later + expect(baseKey + 30).toBe(nextMonth); + + const prevMonth = dateAsNum(new Date(2024, 11, 16)); // 30 days before + expect(baseKey - 30).toBe(prevMonth); + + const nextYear = dateAsNum(new Date(2026, 0, 15)); // 365 days in 2025 + expect(baseKey + 365).toBe(nextYear); + + const prevYear = dateAsNum(new Date(2024, 0, 15)); // 366 days in 2024 (leap year) + expect(baseKey - 366).toBe(prevYear); + }); + + it('handles dates before Unix epoch (negative keys)', () => { + const date1 = new Date(1969, 11, 31); // Dec 31, 1969 (day before epoch) + const date2 = new Date(1969, 11, 30); // Dec 30, 1969 + + const key1 = dateAsNum(date1); + const key2 = dateAsNum(date2); + + expect(key1).toBe(-1); + expect(key2).toBe(-2); + expect(key1 - 1).toBe(key2); + }); + + it('handles DST transitions correctly', () => { + // In US, DST typically happens in March (spring forward) and November (fall back) + // March 9, 2025 -> March 10, 2025 (spring forward at 2am) + const beforeDST = new Date(2025, 2, 9); + const afterDST = new Date(2025, 2, 10); + + const key1 = dateAsNum(beforeDST); + const key2 = dateAsNum(afterDST); + + // Should still be exactly 1 day apart despite DST + expect(key1 + 1).toBe(key2); + }); + + it('handles leap year edge cases', () => { + // Feb 28 -> Feb 29 in a leap year + const feb28 = new Date(2024, 1, 28); + const feb29 = new Date(2024, 1, 29); + const mar1 = new Date(2024, 2, 1); + + expect(dateAsNum(feb28) + 1).toBe(dateAsNum(feb29)); + expect(dateAsNum(feb29) + 1).toBe(dateAsNum(mar1)); + expect(dateAsNum(feb28) + 2).toBe(dateAsNum(mar1)); + }); + + it('handles BC dates and year 0 boundary', () => { + // JavaScript Date constructor interprets 0-99 as 1900-1999 + // Must use setFullYear() to create actual year 0 and year 1 + const year1AD = new Date(); + year1AD.setFullYear(1, 0, 1); // Jan 1, 1 AD + + const year0 = new Date(); + year0.setFullYear(0, 11, 31); // Dec 31, 1 BC (year 0 in ISO 8601) + + const year1BC = new Date(); + year1BC.setFullYear(-1, 11, 31); // Dec 31, 2 BC (year -1 in ISO 8601) + + const key1AD = dateAsNum(year1AD); + const key0 = dateAsNum(year0); + const key1BC = dateAsNum(year1BC); + + // Should be consecutive days + expect(key0 + 1).toBe(key1AD); + expect(key1BC + 366).toBe(key0); // Year 0 (1 BC) is a leap year + }); +}); + +describe('strAsDate and dateAsStr', () => { + it('converts between date strings and Date objects as inverse operations', () => { + // strAsDate converts "YYYY-MM-DD" strings to Date objects + // dateAsStr converts Date objects to "YYYY-MM-DD" strings + // Test a variety of dates including edge cases + const testDates = [ + new Date(2025, 0, 15), // Jan 15, 2025 (regular date) + new Date(2025, 0, 1), // Jan 1 (start of year) + new Date(2024, 1, 29), // Feb 29, 2024 (leap year) + new Date(2025, 11, 31), // Dec 31 (end of year) + new Date(1969, 11, 31), // Dec 31, 1969 (before Unix epoch) + new Date(1900, 0, 1), // Jan 1, 1900 (far past) + new Date(2100, 5, 15), // Jun 15, 2100 (far future) + ]; + + for (const date of testDates) { + const str = dateAsStr(date); + const roundTrip = strAsDate(str); + expect(roundTrip).toEqual(date); + + // Verify proper formatting with zero-padding + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + expect(str).toBe(`${year}-${month}-${day}`); + } + }); + + it('handles undefined and invalid inputs correctly', () => { + // Strange formatting + expect(strAsDate('2025-01')).toEqual(new Date(2025, 0, 1)); + expect(strAsDate('2025/01/15')).toEqual(new Date(2025, 0, 15)); + expect(strAsDate('2025-01-15T14:30:45')).toEqual(new Date(2025, 0, 15)); + expect(strAsDate(`${new Date(2025, 0, 1)}`)).toEqual(new Date(2025, 0, 1)); + + // Undefined values + expect(dateAsStr(undefined)).toBeUndefined(); + expect(strAsDate(undefined)).toBeUndefined(); + expect(strAsDate('')).toBeUndefined(); + + // Invalid formats + expect(strAsDate('invalid')).toBeUndefined(); + }); + + it('accepts Python datetime string representations', () => { + // Python datetime.datetime objects stringify with time components + // e.g., datetime(2025, 1, 15, 14, 30, 45, 123456) -> "2025-01-15 14:30:45.123456" + + // With full precision (microseconds) + const fullPrecision = strAsDate('2025-01-15 14:30:45.123456'); + expect(fullPrecision).toEqual(new Date(2025, 0, 15)); + + // With seconds only + const withSeconds = strAsDate('2025-01-15 14:30:45'); + expect(withSeconds).toEqual(new Date(2025, 0, 15)); + + // With minutes only + const withMinutes = strAsDate('2025-01-15 14:30'); + expect(withMinutes).toEqual(new Date(2025, 0, 15)); + + // Edge cases + const midnight = strAsDate('2025-01-15 00:00:00'); + expect(midnight).toEqual(new Date(2025, 0, 15)); + + const endOfDay = strAsDate('2025-01-15 23:59:59.999999'); + expect(endOfDay).toEqual(new Date(2025, 0, 15)); + }); +}); + +describe('isDateInRange', () => { + it('checks if date is within range (inclusive boundaries, normalized to midnight)', () => { + const minDate = new Date(2025, 0, 10, 14, 30, 0); // Jan 10, 2025 at 2:30 PM + const maxDate = new Date(2025, 0, 20, 9, 15, 0); // Jan 20, 2025 at 9:15 AM + + // Within range (time components ignored) + expect(isDateInRange(new Date(2025, 0, 10, 0, 0, 1), minDate, maxDate)).toBe(true); // min boundary + expect(isDateInRange(new Date(2025, 0, 15, 23, 59, 59), minDate, maxDate)).toBe(true); // middle + expect(isDateInRange(new Date(2025, 0, 20, 23, 59, 59), minDate, maxDate)).toBe(true); // max boundary + + // Outside range + expect(isDateInRange(new Date(2025, 0, 9), minDate, maxDate)).toBe(false); // before min + expect(isDateInRange(new Date(2025, 0, 21), minDate, maxDate)).toBe(false); // after max + }); + + it('handles undefined min/max dates (no range restrictions)', () => { + const someDate = new Date(2025, 5, 15); + + expect(isDateInRange(someDate, undefined, undefined)).toBe(true); + + const minDate = new Date(2025, 0, 1); + const dateAfterMin = new Date(2025, 5, 15); + const dateBeforeMin = new Date(2024, 11, 31); + + expect(isDateInRange(dateAfterMin, minDate, undefined)).toBe(true); + expect(isDateInRange(dateBeforeMin, minDate, undefined)).toBe(false); + + const maxDate = new Date(2025, 11, 31); + const dateBeforeMax = new Date(2025, 5, 15); + const dateAfterMax = new Date(2026, 0, 1); + + expect(isDateInRange(dateBeforeMax, undefined, maxDate)).toBe(true); + expect(isDateInRange(dateAfterMax, undefined, maxDate)).toBe(false); + }); +}); + +describe('formatDate', () => { + const testDate = new Date(1997, 4, 10); // May 10, 1997 + + it('formats dates using moment.js format strings', () => { + expect(formatDate(testDate, 'YYYY-MM-DD')).toBe('1997-05-10'); + expect(formatDate(testDate, 'MM DD YY')).toBe('05 10 97'); + expect(formatDate(testDate, 'M, D, YYYY')).toBe('5, 10, 1997'); + expect(formatDate(testDate)).toBeTruthy(); // default format + }); +}); + +describe('formatMonth', () => { + it('extracts and formats only month tokens from combined format strings', () => { + // Accepts combined month/year formats but only returns month portion + expect(formatMonth(1997, 4, 'MM YY')).toBe('05'); + expect(formatMonth(1997, 4, 'M/YYYY')).toBe('5'); + expect(formatMonth(1997, 4, 'MMMM, YYYY')).toMatch(/May/); + + // Also works with month-only formats + expect(formatMonth(1997, 4, 'MMMM')).toMatch(/May/); + expect(formatMonth(1997, 4, 'MMM')).toMatch(/May/); + }); +}); + +describe('extractFormats', () => { + it('extracts month and year format tokens separately', () => { + expect(extractFormats('MMMM, YYYY')).toEqual({monthFormat: 'MMMM', yearFormat: 'YYYY'}); + expect(extractFormats('MM YY')).toEqual({monthFormat: 'MM', yearFormat: 'YY'}); + expect(extractFormats('M/YYYY')).toEqual({monthFormat: 'M', yearFormat: 'YYYY'}); + }); + + it('uses defaults when format not provided or tokens not found', () => { + expect(extractFormats()).toEqual({monthFormat: 'MMMM', yearFormat: 'YYYY'}); + expect(extractFormats('invalid')).toEqual({monthFormat: 'MMMM', yearFormat: 'YYYY'}); + }); +}); + +describe('getMonthOptions', () => { + it('generates 12 month options formatted according to month_format', () => { + const options = getMonthOptions(1997); + expect(options).toHaveLength(12); + expect(options[0].value).toBe(0); + expect(options[11].value).toBe(11); + + // Numeric formats + expect(getMonthOptions(1997, 'MM')[0].label).toBe('01'); + expect(getMonthOptions(1997, 'M')[0].label).toBe('1'); + + // Name formats (use regex due to locale variations) + expect(getMonthOptions(1997, 'MMMM')[0].label).toMatch(/January/); + expect(getMonthOptions(1997, 'MMM')[0].label).toMatch(/Jan/); + + // Combined format - extracts only month portion + const combined = getMonthOptions(1997, 'MMMM, YYYY'); + expect(combined[0].label).toMatch(/January/); + expect(combined[0].label).not.toMatch(/1997/); + }); +}); + +describe('formatYear', () => { + it('formats year as YYYY or YY based on extracted year format', () => { + // Default YYYY + expect(formatYear(1997)).toBe('1997'); + expect(formatYear(2023)).toBe('2023'); + + // YY format + expect(formatYear(1997, 'MMMM, YY')).toBe('97'); + expect(formatYear(2023, 'MM YY')).toBe('23'); + expect(formatYear(2005, 'M/YY')).toBe('05'); + + // YYYY format + expect(formatYear(1997, 'MMMM, YYYY')).toBe('1997'); + expect(formatYear(2023, 'MM YYYY')).toBe('2023'); + }); +}); + +describe('parseYear', () => { + it('parses 4-digit years as-is', () => { + expect(parseYear('1997')).toBe(1997); + expect(parseYear('2023')).toBe(2023); + expect(parseYear('2000')).toBe(2000); + }); + + it('parses 2-digit years using moment.js pivot (00-68 → 2000s, 69-99 → 1900s)', () => { + expect(parseYear('97')).toBe(1997); + expect(parseYear('23')).toBe(2023); + expect(parseYear('68')).toBe(2068); + expect(parseYear('69')).toBe(1969); + expect(parseYear('00')).toBe(2000); + }); + + it('handles single-digit years', () => { + expect(parseYear('5')).toBe(2005); + expect(parseYear('0')).toBe(2000); + }); + + it('returns undefined for invalid inputs', () => { + expect(parseYear('')).toBeUndefined(); + expect(parseYear(' ')).toBeUndefined(); + expect(parseYear('abc')).toBeUndefined(); + }); + + it('handles whitespace trimming', () => { + expect(parseYear(' 1997 ')).toBe(1997); + expect(parseYear(' 97 ')).toBe(1997); + }); +}); diff --git a/package-lock.json b/package-lock.json index e12968e719..9533b93ad9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@lerna/filter-options": "^6.4.1", + "@types/jest": "^30.0.0", "husky": "8.0.3", "lerna": "^8.2.3", "lint-staged": "^16.1.0" @@ -179,6 +180,53 @@ "dev": true, "license": "ISC" }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -192,6 +240,45 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types/node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, "node_modules/@lerna/child-process": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/@lerna/child-process/-/child-process-6.4.1.tgz", @@ -1329,6 +1416,92 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -1342,6 +1515,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", + "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -1349,6 +1532,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.34", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", + "integrity": "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -2917,6 +3124,24 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", @@ -4520,6 +4745,227 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7984,6 +8430,29 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -8528,6 +8997,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/unique-filename": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", diff --git a/package.json b/package.json index e78e279c1b..6532530bd1 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ }, "devDependencies": { "@lerna/filter-options": "^6.4.1", + "@types/jest": "^30.0.0", "husky": "8.0.3", "lerna": "^8.2.3", "lint-staged": "^16.1.0" From bf30769f04dfe9c5c5f803ba5d834eb6be661733 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Mon, 3 Nov 2025 10:10:19 -0700 Subject: [PATCH 2/8] Implement DatePickerRange --- .../src/components/DatePickerRange.tsx | 68 ++++ .../src/components/css/datepickers.css | 10 +- .../src/fragments/DatePickerRange.tsx | 372 +++++++++++++++--- .../src/fragments/DatePickerSingle.tsx | 74 ++-- components/dash-core-components/src/index.ts | 2 +- .../src/utils/LazyLoader/datePickerRange.js | 4 - .../src/utils/LazyLoader/datePickerRange.ts | 4 + .../src/utils/calendar/Calendar.tsx | 101 ++++- .../src/utils/calendar/CalendarDay.tsx | 1 - .../src/utils/calendar/CalendarMonth.tsx | 58 ++- .../src/utils/calendar/DateSet.ts | 2 +- .../src/utils/calendar/helpers.ts | 11 +- .../tests/dash_core_components_page.py | 9 +- .../calendar/test_a11y_date_picker_range.py | 177 +++++++++ ...11y.py => test_a11y_date_picker_single.py} | 226 ++++++----- .../calendar/test_calendar_props.py | 5 +- .../calendar/test_date_picker_range.py | 66 ++-- .../calendar/test_multi_month_selection.py | 306 ++++++++++++++ .../tests/unit/calendar/Calendar.test.tsx | 4 +- 19 files changed, 1208 insertions(+), 292 deletions(-) create mode 100644 components/dash-core-components/src/components/DatePickerRange.tsx delete mode 100644 components/dash-core-components/src/utils/LazyLoader/datePickerRange.js create mode 100644 components/dash-core-components/src/utils/LazyLoader/datePickerRange.ts create mode 100644 components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py rename components/dash-core-components/tests/integration/calendar/{test_a11y.py => test_a11y_date_picker_single.py} (64%) create mode 100644 components/dash-core-components/tests/integration/calendar/test_multi_month_selection.py diff --git a/components/dash-core-components/src/components/DatePickerRange.tsx b/components/dash-core-components/src/components/DatePickerRange.tsx new file mode 100644 index 0000000000..a2cabb5b95 --- /dev/null +++ b/components/dash-core-components/src/components/DatePickerRange.tsx @@ -0,0 +1,68 @@ +import React, {lazy, Suspense} from 'react'; +import datePickerRange from '../utils/LazyLoader/datePickerRange'; +import transformDate from '../utils/DatePickerPersistence'; +import {DatePickerRangeProps, PersistedProps, PersistenceTypes} from '../types'; + +const RealDatePickerRange = lazy(datePickerRange); + +/** + * DatePickerRange is a tailor made component designed for selecting + * timespan across multiple days off of a calendar. + * + * The DatePicker integrates well with the Python datetime module with the + * startDate and endDate being returned in a string format suitable for + * creating datetime objects. + * + */ +export default function DatePickerRange({ + calendar_orientation = 'horizontal', + is_RTL = false, + // eslint-disable-next-line no-magic-numbers + day_size = 34, + with_portal = false, + with_full_screen_portal = false, + first_day_of_week = 0, + number_of_months_shown = 2, + stay_open_on_select = false, + reopen_calendar_on_clear = false, + clearable = false, + disabled = false, + updatemode = 'singledate', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persisted_props = [PersistedProps.start_date, PersistedProps.end_date], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persistence_type = PersistenceTypes.local, + disabled_days = [], + ...props +}: DatePickerRangeProps) { + return ( + + + + ); +} + +DatePickerRange.dashPersistence = { + persisted_props: [PersistedProps.start_date, PersistedProps.end_date], + persistence_type: PersistenceTypes.local, +}; + +DatePickerRange.persistenceTransforms = { + end_date: transformDate, + start_date: transformDate, +}; diff --git a/components/dash-core-components/src/components/css/datepickers.css b/components/dash-core-components/src/components/css/datepickers.css index 137e474ed5..f9a5761fa0 100644 --- a/components/dash-core-components/src/components/css/datepickers.css +++ b/components/dash-core-components/src/components/css/datepickers.css @@ -32,6 +32,14 @@ grid-template-columns: auto 1fr auto auto; } +.dash-datepicker-input-wrapper:has(:nth-child(5)) { + grid-template-columns: auto auto auto 1fr auto; +} + +.dash-datepicker-input-wrapper:has(:nth-child(6)) { + grid-template-columns: auto auto auto 1fr auto auto; +} + .dash-datepicker-input-wrapper, .dash-datepicker-content { border-radius: var(--Dash-Spacing); @@ -42,7 +50,7 @@ .dash-datepicker-input { height: 34px; - width: 100%; + width: fit-content; border: none; outline: none; border-radius: 4px; diff --git a/components/dash-core-components/src/fragments/DatePickerRange.tsx b/components/dash-core-components/src/fragments/DatePickerRange.tsx index d397f38e1b..a924074eec 100644 --- a/components/dash-core-components/src/fragments/DatePickerRange.tsx +++ b/components/dash-core-components/src/fragments/DatePickerRange.tsx @@ -1,71 +1,339 @@ -import React from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import * as Popover from '@radix-ui/react-popover'; +import { + CalendarIcon, + CaretDownIcon, + Cross1Icon, + ArrowRightIcon, +} from '@radix-ui/react-icons'; import Calendar from '../utils/calendar/Calendar'; import {DatePickerRangeProps, CalendarDirection} from '../types'; -import {dateAsStr, strAsDate} from '../utils/calendar/helpers'; +import { + dateAsStr, + strAsDate, + formatDate, + isDateDisabled, + isSameDay, +} from '../utils/calendar/helpers'; +import {DateSet} from '../utils/calendar/DateSet'; import '../components/css/datepickers.css'; +import uuid from 'uniqid'; const DatePickerRange = ({ + id, + className, start_date, end_date, + min_date_allowed, + max_date_allowed, + initial_visible_month = start_date ?? min_date_allowed ?? max_date_allowed, + disabled_days, first_day_of_week, show_outside_days, + clearable, + reopen_calendar_on_clear, + disabled, + display_format, + month_format = 'MMMM YYYY', + stay_open_on_select, is_RTL = false, setProps, + style, + // eslint-disable-next-line no-magic-numbers + day_size = 34, + number_of_months_shown = 1, + calendar_orientation, + updatemode, + start_date_id, + end_date_id, + start_date_placeholder_text = 'Start Date', + end_date_placeholder_text = 'End Date', }: DatePickerRangeProps) => { - // Convert boolean is_RTL to CalendarDirection enum - const direction = is_RTL ? CalendarDirection.RightToLeft : CalendarDirection.LeftToRight; - - const startDate = strAsDate(start_date); - const endDate = strAsDate(end_date); - - // Helper to ensure dates are always sorted (start before end) - const sortDates = (newStart?: Date, newEnd?: Date) => { - // If both undefined or only one defined, return single date as start - if (!newStart || !newEnd) { - const singleDate = newStart || newEnd; - return { - start_date: singleDate ? dateAsStr(singleDate) : undefined, - end_date: undefined, - }; - } + const [internalStartDate, setInternalStartDate] = useState( + strAsDate(start_date) + ); + const [internalEndDate, setInternalEndDate] = useState(strAsDate(end_date)); + const direction = is_RTL + ? CalendarDirection.RightToLeft + : CalendarDirection.LeftToRight; + const initialMonth = strAsDate(initial_visible_month); + const minDate = strAsDate(min_date_allowed); + const maxDate = strAsDate(max_date_allowed); + const disabledDates = useMemo( + () => new DateSet(disabled_days), + [disabled_days] + ); + + const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const [startInputValue, setStartInputValue] = useState( + (internalStartDate && formatDate(internalStartDate, display_format)) ?? + '' + ); + const [endInputValue, setEndInputValue] = useState( + (internalEndDate && formatDate(internalEndDate, display_format)) ?? '' + ); + + const containerRef = useRef(null); + const startInputRef = useRef(null); + const endInputRef = useRef(null); + const calendarRef = useRef(null); + + useEffect(() => { + setInternalStartDate(strAsDate(start_date)); + }, [start_date]); - // Both defined - ensure proper order - const [start, end] = - newStart <= newEnd ? [newStart, newEnd] : [newEnd, newStart]; - return { - start_date: dateAsStr(start), - end_date: dateAsStr(end), + useEffect(() => { + setInternalEndDate(strAsDate(end_date)); + }, [end_date]); + + useEffect(() => { + setStartInputValue(formatDate(internalStartDate, display_format)); + }, [internalStartDate, display_format]); + + useEffect(() => { + setEndInputValue(formatDate(internalEndDate, display_format)); + }, [internalEndDate, display_format]); + + useEffect(() => { + // Controls whether or not to call `setProps` + const startChanged = !isSameDay(start_date, internalStartDate); + const endChanged = !isSameDay(end_date, internalEndDate); + + const newDates: Partial = { + ...(startChanged && {start_date: dateAsStr(internalStartDate)}), + ...(endChanged && {end_date: dateAsStr(internalEndDate)}), }; - }; + + const numPropsRequiredForUpdate = updatemode === 'bothdates' ? 2 : 1; + if (Object.keys(newDates).length >= numPropsRequiredForUpdate) { + setProps(newDates); + } + }, [start_date, internalStartDate, end_date, internalEndDate, updatemode]); + + useEffect(() => { + // Keeps focus on the component when the calendar closes + if (!isCalendarOpen) { + if (!startInputValue) { + startInputRef.current?.focus(); + } else { + endInputRef.current?.focus(); + } + } + }, [isCalendarOpen]); + + const sendStartInputAsDate = useCallback(() => { + const parsed = strAsDate(startInputValue, display_format); + const isValid = + parsed && !isDateDisabled(parsed, minDate, maxDate, disabledDates); + + if (isValid) { + setInternalStartDate(parsed); + } else { + // Invalid or disabled input: revert to previous valid date with proper formatting + const previousDate = strAsDate(start_date); + setStartInputValue( + previousDate ? formatDate(previousDate, display_format) : '' + ); + } + }, [ + startInputValue, + display_format, + start_date, + minDate, + maxDate, + disabledDates, + ]); + + const sendEndInputAsDate = useCallback(() => { + const parsed = strAsDate(endInputValue, display_format); + const isValid = + parsed && !isDateDisabled(parsed, minDate, maxDate, disabledDates); + + if (isValid) { + setInternalEndDate(parsed); + } else { + // Invalid or disabled input: revert to previous valid date with proper formatting + const previousDate = strAsDate(end_date); + setEndInputValue( + previousDate ? formatDate(previousDate, display_format) : '' + ); + } + }, [ + endInputValue, + display_format, + end_date, + minDate, + maxDate, + disabledDates, + ]); + + const clearSelection = useCallback( + e => { + e.preventDefault(); + setInternalStartDate(undefined); + setInternalEndDate(undefined); + if (reopen_calendar_on_clear) { + setIsCalendarOpen(true); + } else { + startInputRef.current?.focus(); + } + }, + [reopen_calendar_on_clear] + ); + + const handleStartInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!isCalendarOpen) { + sendStartInputAsDate(); + // open the calendar after resolving prop changes, so that + // it opens with the correct date showing + setTimeout(() => setIsCalendarOpen(true), 0); + } + } else if (e.key === 'Enter') { + sendStartInputAsDate(); + } + }, + [isCalendarOpen, sendStartInputAsDate] + ); + + const handleEndInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!isCalendarOpen) { + sendEndInputAsDate(); + // open the calendar after resolving prop changes, so that + // it opens with the correct date showing + setTimeout(() => setIsCalendarOpen(true), 0); + } + } else if (e.key === 'Enter') { + sendEndInputAsDate(); + } + }, + [isCalendarOpen, sendEndInputAsDate] + ); + + const accessibleId = id ?? uuid(); + let classNames = 'dash-datepicker-input-wrapper'; + if (disabled) { + classNames += ' dash-datepicker-input-wrapper-disabled'; + } + if (className) { + classNames += ' ' + className; + } return ( -
- { - setProps(sortDates(selection, endDate)); - }} - firstDayOfWeek={first_day_of_week} - showOutsideDays={show_outside_days} - direction={direction} - /> - { - setProps(sortDates(startDate, selection)); - }} - firstDayOfWeek={first_day_of_week} - showOutsideDays={show_outside_days} - direction={direction} - /> +
+ + +
+ + setStartInputValue(e.target.value)} + onKeyDown={handleStartInputKeyDown} + onBlur={sendStartInputAsDate} + placeholder={start_date_placeholder_text} + disabled={disabled} + dir={direction} + aria-label={start_date_placeholder_text} + /> + + setEndInputValue(e.target.value)} + onKeyDown={handleEndInputKeyDown} + onBlur={sendEndInputAsDate} + placeholder={end_date_placeholder_text} + disabled={disabled} + dir={direction} + aria-label={end_date_placeholder_text} + /> + {clearable && !disabled && ( + + + + )} + +
+
+ + + e.preventDefault()} + > +
+ { + const isNewSelection = + isSameDay(start, end) && + ((!internalStartDate && + !internalEndDate) || + (internalStartDate && + internalEndDate)); + + if (isNewSelection) { + setInternalStartDate(start); + setInternalEndDate(undefined); + } else { + setInternalStartDate(start); + setInternalEndDate(end); + + if (end && !stay_open_on_select) { + setIsCalendarOpen(false); + } + } + }} + /> +
+
+
+
); }; diff --git a/components/dash-core-components/src/fragments/DatePickerSingle.tsx b/components/dash-core-components/src/fragments/DatePickerSingle.tsx index 6888fa04d6..7914bcb2aa 100644 --- a/components/dash-core-components/src/fragments/DatePickerSingle.tsx +++ b/components/dash-core-components/src/fragments/DatePickerSingle.tsx @@ -8,6 +8,7 @@ import { strAsDate, formatDate, isDateDisabled, + isSameDay, } from '../utils/calendar/helpers'; import {DateSet} from '../utils/calendar/DateSet'; import '../components/css/datepickers.css'; @@ -17,9 +18,9 @@ const DatePickerSingle = ({ id, className, date, - initial_visible_month = date, min_date_allowed, max_date_allowed, + initial_visible_month = date ?? min_date_allowed, disabled_days, first_day_of_week, show_outside_days, @@ -38,7 +39,7 @@ const DatePickerSingle = ({ number_of_months_shown = 1, calendar_orientation, }: DatePickerSingleProps) => { - const dateObj = strAsDate(date); + const [internalDate, setInternalDate] = useState(strAsDate(date)); const direction = is_RTL ? CalendarDirection.RightToLeft : CalendarDirection.LeftToRight; @@ -52,39 +53,46 @@ const DatePickerSingle = ({ const [isCalendarOpen, setIsCalendarOpen] = useState(false); const [inputValue, setInputValue] = useState( - (dateObj && formatDate(dateObj, display_format)) ?? '' + (internalDate && formatDate(internalDate, display_format)) ?? '' ); const containerRef = useRef(null); const inputRef = useRef(null); - const calendarRef = useRef(null); useEffect(() => { - if (date) { - const parsed = strAsDate(date); - if (parsed) { - setInputValue(formatDate(parsed, display_format)); - } else { - setInputValue(date); - } + setInternalDate(strAsDate(date)); + }, [date]); + + useEffect(() => { + if (internalDate) { + setInputValue(formatDate(internalDate, display_format)); } else { setInputValue(''); } - }, [date, display_format]); + }, [internalDate, display_format]); + + useEffect(() => { + const dateChanged = !(date && isSameDay(date, internalDate)); + + if (dateChanged) { + setProps({date: dateAsStr(internalDate)}); + } + }, [date, internalDate, setProps]); useEffect(() => { + // Keep focus on the component when the calendar closes if (!isCalendarOpen) { inputRef.current?.focus(); } }, [isCalendarOpen]); - const sendInputAsDate = useCallback(() => { + const parseUserInput = useCallback(() => { const parsed = strAsDate(inputValue, display_format); const isValid = parsed && !isDateDisabled(parsed, minDate, maxDate, disabledDates); if (isValid) { - setProps({date: dateAsStr(parsed)}); + setInternalDate(parsed); } else { // Invalid or disabled input: revert to previous valid date with proper formatting const previousDate = strAsDate(date); @@ -92,38 +100,29 @@ const DatePickerSingle = ({ previousDate ? formatDate(previousDate, display_format) : '' ); } - }, [ - inputValue, - display_format, - date, - setProps, - minDate, - maxDate, - disabledDates, - ]); + }, [inputValue, display_format, date, minDate, maxDate, disabledDates]); const clearSelection = useCallback(() => { - setProps({date: undefined}); - setInputValue(''); + setInternalDate(undefined); if (reopen_calendar_on_clear) { setIsCalendarOpen(true); } else { inputRef.current?.focus(); } - }, [reopen_calendar_on_clear, setProps]); + }, [reopen_calendar_on_clear]); const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); if (!isCalendarOpen) { - sendInputAsDate(); + parseUserInput(); // open the calendar after resolving prop changes, so that // it opens with the correct date showing setTimeout(() => setIsCalendarOpen(true), 0); } } else if (e.key === 'Enter') { - sendInputAsDate(); + parseUserInput(); } }, [isCalendarOpen, inputValue] @@ -163,7 +162,7 @@ const DatePickerSingle = ({ value={inputValue} onChange={e => setInputValue(e.target.value)} onKeyDown={handleInputKeyDown} - onBlur={sendInputAsDate} + onBlur={parseUserInput} placeholder={placeholder} disabled={disabled} dir={direction} @@ -192,10 +191,13 @@ const DatePickerSingle = ({ sideOffset={5} onOpenAutoFocus={e => e.preventDefault()} > -
+
{ - const dateStr = dateAsStr(selection); - setProps({date: dateStr}); + onSelectionChange={(_, selection) => { + if (!selection) { + return; + } + setInternalDate(selection); if (!stay_open_on_select) { setIsCalendarOpen(false); } diff --git a/components/dash-core-components/src/index.ts b/components/dash-core-components/src/index.ts index e109fe94de..fbf7859e21 100644 --- a/components/dash-core-components/src/index.ts +++ b/components/dash-core-components/src/index.ts @@ -3,8 +3,8 @@ import Checklist from './components/Checklist'; import Clipboard from './components/Clipboard.react'; import ConfirmDialog from './components/ConfirmDialog.react'; import ConfirmDialogProvider from './components/ConfirmDialogProvider.react'; +import DatePickerRange from './components/DatePickerRange'; // import DatePickerRange from './components/DatePickerRange.react'; -import DatePickerRange from './fragments/DatePickerRange'; import DatePickerSingle from './components/DatePickerSingle'; import Download from './components/Download.react'; import Dropdown from './components/Dropdown'; diff --git a/components/dash-core-components/src/utils/LazyLoader/datePickerRange.js b/components/dash-core-components/src/utils/LazyLoader/datePickerRange.js deleted file mode 100644 index c01da5e22f..0000000000 --- a/components/dash-core-components/src/utils/LazyLoader/datePickerRange.js +++ /dev/null @@ -1,4 +0,0 @@ -export default () => - import(/* webpackChunkName: "datepicker" */ '../../fragments/DatePickerRange.react'); - - diff --git a/components/dash-core-components/src/utils/LazyLoader/datePickerRange.ts b/components/dash-core-components/src/utils/LazyLoader/datePickerRange.ts new file mode 100644 index 0000000000..450a1ce66a --- /dev/null +++ b/components/dash-core-components/src/utils/LazyLoader/datePickerRange.ts @@ -0,0 +1,4 @@ +export default () => + import( + /* webpackChunkName: "datepicker" */ '../../fragments/DatePickerRange' + ); diff --git a/components/dash-core-components/src/utils/calendar/Calendar.tsx b/components/dash-core-components/src/utils/calendar/Calendar.tsx index 3708aa1a5a..710ad3b582 100644 --- a/components/dash-core-components/src/utils/calendar/Calendar.tsx +++ b/components/dash-core-components/src/utils/calendar/Calendar.tsx @@ -57,7 +57,6 @@ const Calendar = ({ initialVisibleDate.getMonth() ); - // Initialize focused date: use selectionStart if it's in the visible month, otherwise first of month const [focusedDate, setFocusedDate] = useState(() => { if ( selectionStart && @@ -77,18 +76,11 @@ const Calendar = ({ const scrollAccumulatorRef = useRef(0); const prevFocusedDateRef = useRef(focusedDate); - // Compute display year as a number based on month_format const displayYear = useMemo(() => { const formatted = formatYear(activeYear, monthFormat); return parseInt(formatted, 10); }, [activeYear, monthFormat]); - // First day of the active month (used for navigation and range calculations) - const activeMonthStart = useMemo( - () => moment([activeYear, activeMonth, 1]), - [activeYear, activeMonth] - ); - useEffect(() => { // Syncs activeMonth/activeYear to focusedDate when focusedDate changes if (focusedDate.getTime() === prevFocusedDateRef.current.getTime()) { @@ -96,7 +88,6 @@ const Calendar = ({ } prevFocusedDateRef.current = focusedDate; - // Calculate visible month range (centered on activeMonth/activeYear) const halfRange = Math.floor((numberOfMonthsShown - 1) / 2); const activeMonthStart = moment([activeYear, activeMonth, 1]); const visibleStart = activeMonthStart @@ -108,7 +99,6 @@ const Calendar = ({ .add(halfRange, 'months') .toDate(); - // Sync activeMonth/activeYear to focusedDate if focused month is outside visible range const focusedMonthStart = new Date( focusedDate.getFullYear(), focusedDate.getMonth(), @@ -124,8 +114,87 @@ const Calendar = ({ setHighlightedDates(DateSet.fromRange(highlightStart, highlightEnd)); }, [highlightStart, highlightEnd]); - const selectedDates = useMemo( - () => DateSet.fromRange(selectionStart, selectionEnd), + useEffect(() => { + if (selectionStart && selectionEnd) { + setHighlightedDates( + DateSet.fromRange(selectionStart, selectionEnd) + ); + } + }, [selectionStart, selectionEnd]); + + const selectedDates = useMemo(() => { + return new DateSet([selectionStart, selectionEnd]); + }, [selectionStart, selectionEnd]); + + const handleSelectionStart = useCallback( + (date: Date) => { + // Only start a new selection if there isn't already an incomplete selection + // This allows click-based range selection: first click sets start, second click sets end + if (!selectionStart || selectionEnd) { + // No selection yet, or previous selection is complete → start new selection + onSelectionChange(date, undefined); + } + // If selectionStart exists and selectionEnd is undefined, we're in the middle of a selection + // Don't reset the start date - let mouseUp handle completing it + }, + [selectionStart, selectionEnd, onSelectionChange] + ); + + const handleSelectionEnd = useCallback( + (date: Date) => { + // Complete the selection with an end date + if (selectionStart && !selectionEnd) { + // Incomplete selection exists (range picker mid-selection) + // Only complete if date is different from start (prevent same-date on single click) + if (!moment(selectionStart).isSame(date, 'day')) { + onSelectionChange(selectionStart, date); + } + } else { + // No selection, or complete selection exists (single date picker) + // Replace/set with new date (keyboard selection or standalone) + onSelectionChange(date, date); + } + }, + [selectionStart, selectionEnd, onSelectionChange] + ); + + const handleDaysHighlighted = useCallback( + (days: DateSet) => { + // When both selectionStart and selectionEnd are defined (selection complete), + // highlight all dates between them + if (selectionStart && selectionEnd) { + setHighlightedDates( + DateSet.fromRange(selectionStart, selectionEnd) + ); + return; + } + // When selectionStart is defined but selectionEnd is not, + // extend the highlight to include the range from selectionStart to the hovered date + if (selectionStart && !selectionEnd && days.size > 0) { + // Get the last date from the DateSet (the hovered date) + const hoveredDate = days.max(); + if (hoveredDate) { + setHighlightedDates( + DateSet.fromRange(selectionStart, hoveredDate) + ); + return; + } + } + // Otherwise, just use the days as-is (for single date hover) + setHighlightedDates(days); + }, + [selectionStart, selectionEnd] + ); + + const handleDayFocused = useCallback( + (date: Date) => { + setFocusedDate(date); + // When navigating with keyboard during range selection, + // highlight the range from start to focused date + if (selectionStart && !selectionEnd) { + setHighlightedDates(DateSet.fromRange(selectionStart, date)); + } + }, [selectionStart, selectionEnd] ); @@ -157,7 +226,6 @@ const Calendar = ({ ? currentDate.clone().add(1, 'month') : currentDate.clone().subtract(1, 'month'); - // Check if the new month is within allowed range const newMonth = newDate.toDate(); const isWithinRange = (!minDateAllowed || @@ -334,11 +402,12 @@ const Calendar = ({ maxDateAllowed={maxDateAllowed} disabledDates={disabledDates} dateFocused={focusedDate} - onDayFocused={setFocusedDate} + onDayFocused={handleDayFocused} datesSelected={selectedDates} - onDaySelected={onSelectionChange} + onSelectionStart={handleSelectionStart} + onSelectionEnd={handleSelectionEnd} datesHighlighted={highlightedDates} - onDaysHighlighted={setHighlightedDates} + onDaysHighlighted={handleDaysHighlighted} firstDayOfWeek={firstDayOfWeek} showOutsideDays={showOutsideDays} daySize={daySize} diff --git a/components/dash-core-components/src/utils/calendar/CalendarDay.tsx b/components/dash-core-components/src/utils/calendar/CalendarDay.tsx index c02c9205db..30b64cb2b0 100644 --- a/components/dash-core-components/src/utils/calendar/CalendarDay.tsx +++ b/components/dash-core-components/src/utils/calendar/CalendarDay.tsx @@ -54,7 +54,6 @@ const CalendarDay = ({ } }, [isFocused]); - // If disabled, filter out all event handlers from passThruProps const filteredProps = isDisabled ? Object.fromEntries( Object.entries(passThruProps).filter( diff --git a/components/dash-core-components/src/utils/calendar/CalendarMonth.tsx b/components/dash-core-components/src/utils/calendar/CalendarMonth.tsx index eec78aea5a..cc3190ca20 100644 --- a/components/dash-core-components/src/utils/calendar/CalendarMonth.tsx +++ b/components/dash-core-components/src/utils/calendar/CalendarMonth.tsx @@ -31,7 +31,8 @@ type CalendarMonthProps = { minDateAllowed?: Date; maxDateAllowed?: Date; disabledDates?: DateSet; - onDaySelected?: (date: Date) => void; + onSelectionStart?: (date: Date) => void; + onSelectionEnd?: (date: Date) => void; onDayFocused?: (date: Date) => void; onDaysHighlighted?: (days: DateSet) => void; firstDayOfWeek?: number; // 0-7 @@ -45,7 +46,8 @@ type CalendarMonthProps = { export const CalendarMonth = ({ year, month, - onDaySelected, + onSelectionStart, + onSelectionEnd, onDayFocused, onDaysHighlighted, datesSelected, @@ -62,13 +64,11 @@ export const CalendarMonth = ({ direction = CalendarDirection.LeftToRight, ...props }: CalendarMonthProps): JSX.Element => { - // Generate grid of dates const gridDates = useMemo( () => createMonthGrid(year, month, firstDayOfWeek), [year, month, firstDayOfWeek] ); - // Helper to compute if a date is disabled const computeIsDisabled = useCallback( (date: Date): boolean => { return isDateDisabled( @@ -107,20 +107,6 @@ export const CalendarMonth = ({ ); }, [firstDayOfWeek]); - const [selectionStart, setSelectionStart] = useState(); - - const confirmSelection = useCallback( - (date: Date) => { - setSelectionStart(undefined); - const isOutside = computeIsOutside(date); - if (isOutside && !showOutsideDays) { - return; - } - onDaySelected?.(date); - }, - [onDaySelected, showOutsideDays, computeIsOutside] - ); - const handleKeyDown = useCallback( (e: React.KeyboardEvent, date: Date) => { const m = moment(date); @@ -128,10 +114,17 @@ export const CalendarMonth = ({ switch (e.key) { case ' ': - case 'Enter': + case 'Enter': { e.preventDefault(); - confirmSelection(date); + const isOutside = computeIsOutside(date); + const isDisabled = computeIsDisabled(date); + if (!isDisabled && (!isOutside || showOutsideDays)) { + // Keyboard selection: only call onSelectionEnd + // Calendar will handle completing immediately by setting both start and end + onSelectionEnd?.(date); + } return; + } case 'ArrowRight': newDate = direction === CalendarDirection.RightToLeft @@ -179,7 +172,6 @@ export const CalendarMonth = ({ if (newDate) { e.preventDefault(); const newDateObj = newDate.toDate(); - // Only focus the new date if it's within the allowed range if (isDateInRange(newDateObj, minDateAllowed, maxDateAllowed)) { onDayFocused?.(newDateObj); } @@ -187,7 +179,11 @@ export const CalendarMonth = ({ }, [ onDayFocused, - confirmSelection, + onSelectionStart, + onSelectionEnd, + computeIsOutside, + computeIsDisabled, + showOutsideDays, minDateAllowed, maxDateAllowed, direction, @@ -195,10 +191,8 @@ export const CalendarMonth = ({ ] ); - // Calculate calendar width: 7 days * daySize + some padding const calendarWidth = daySize * 7 + 16; // 16px for table padding - // Format the month/year header const monthYearLabel = useMemo(() => { return formatDate(new Date(year, month, 1), monthFormat); }, [year, month, monthFormat]); @@ -236,21 +230,15 @@ export const CalendarMonth = ({ label={computeLabel(date)} isOutside={computeIsOutside(date)} onMouseDown={() => { - setSelectionStart(date); onDaysHighlighted?.(new DateSet([date])); + onSelectionStart?.(date); + }} + onMouseUp={() => { + onSelectionEnd?.(date); }} - onMouseUp={() => confirmSelection(date)} onMouseEnter={() => { - if (!selectionStart) { - return; - } - const selectionRange = DateSet.fromRange( - selectionStart, - date - ); - onDaysHighlighted?.(selectionRange); + onDaysHighlighted?.(new DateSet([date])); }} - onFocus={() => onDayFocused?.(date)} onKeyDown={e => handleKeyDown(e, date)} isFocused={ props.dateFocused !== undefined && diff --git a/components/dash-core-components/src/utils/calendar/DateSet.ts b/components/dash-core-components/src/utils/calendar/DateSet.ts index e6fcaa1f51..c354f95db9 100644 --- a/components/dash-core-components/src/utils/calendar/DateSet.ts +++ b/components/dash-core-components/src/utils/calendar/DateSet.ts @@ -7,7 +7,7 @@ import {dateAsNum, numAsDate, strAsDate} from './helpers'; export class DateSet { private keys: Set; - constructor(dates?: (string | Date)[] | DateSet) { + constructor(dates?: (string | Date | undefined)[] | DateSet) { if (dates instanceof DateSet) { // Copy constructor this.keys = new Set(dates.keys); diff --git a/components/dash-core-components/src/utils/calendar/helpers.ts b/components/dash-core-components/src/utils/calendar/helpers.ts index 57687adbf0..c1a9151ef6 100644 --- a/components/dash-core-components/src/utils/calendar/helpers.ts +++ b/components/dash-core-components/src/utils/calendar/helpers.ts @@ -1,4 +1,5 @@ import moment from 'moment'; +import {DatePickerSingleProps} from '../../types'; /** * Converts a date to a numeric key (days since Unix epoch) for use in Sets/Objects. @@ -28,6 +29,11 @@ export function numAsDate(key: number): Date { ); } +type AnyDayFormat = string | Date | DatePickerSingleProps['date']; +export function isSameDay(day1: AnyDayFormat, day2: AnyDayFormat) { + return moment(day1).isSame(day2, 'day'); +} + export function strAsDate(date?: string, format?: string): Date | undefined { if (!date) { return undefined; @@ -67,7 +73,10 @@ export function isDateInRange( return true; } -export function formatDate(date: Date, format = 'YYYY-MM-DD'): string { +export function formatDate(date?: Date, format = 'YYYY-MM-DD'): string { + if (!date) { + return ''; + } return moment(date).format(format); } diff --git a/components/dash-core-components/tests/dash_core_components_page.py b/components/dash-core-components/tests/dash_core_components_page.py index bb2ce4279d..3bea980ac5 100644 --- a/components/dash-core-components/tests/dash_core_components_page.py +++ b/components/dash-core-components/tests/dash_core_components_page.py @@ -68,14 +68,14 @@ def select_date_range(self, compid, day_range, start_first=True): return prefix = "Start" if start_first else "End" - date = self.find_element(f'#{compid} input[aria-label="{prefix} Date"]') + date = self.find_element(f'#{compid}[aria-label="{prefix} Date"]') date.click() for day in day_range: self._wait_until_day_is_clickable() matched = [ _ for _ in self.find_elements(self.date_picker_day_locator) - if _.text == str(day) + if _.find_element(By.CSS_SELECTOR, "span").text == str(day) ] matched[0].click() @@ -83,7 +83,8 @@ def select_date_range(self, compid, day_range, start_first=True): def get_date_range(self, compid): return tuple( - _.get_attribute("value") for _ in self.find_elements(f"#{compid} input") + _.get_attribute("value") + for _ in self.find_elements(f"#{compid}-wrapper .dash-datepicker-input") ) def _wait_until_day_is_clickable(self, timeout=1): @@ -93,7 +94,7 @@ def _wait_until_day_is_clickable(self, timeout=1): @property def date_picker_day_locator(self): - return '.dash-datepicker-calendar-date-inside, .dash-datepicker-calendar-date-outside' + return ".dash-datepicker-calendar-date-inside, .dash-datepicker-calendar-date-outside" def click_and_hold_at_coord_fractions(self, elem_or_selector, fx, fy): elem = self._get_element(elem_or_selector) diff --git a/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py b/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py new file mode 100644 index 0000000000..e0035b7cf4 --- /dev/null +++ b/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py @@ -0,0 +1,177 @@ +from datetime import datetime +from dash import Dash, Input, Output +from dash.dcc import DatePickerRange +from dash.html import Div +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.common.action_chains import ActionChains + + +def send_keys(driver, key): + """Send keyboard keys to the browser""" + actions = ActionChains(driver) + actions.send_keys(key) + actions.perform() + + +def get_focused_text(driver): + """Get the text content of the currently focused element""" + return driver.execute_script("return document.activeElement.textContent;") + + +def test_a11y_range_001_keyboard_range_selection_with_highlights(dash_dcc): + """Test keyboard-based range selection with highlight verification""" + app = Dash(__name__) + app.layout = Div( + [ + DatePickerRange( + id="date-picker-range", + initial_visible_month=datetime(2021, 1, 1), + ), + Div(id="output-dates"), + ] + ) + + @app.callback( + Output("output-dates", "children"), + Input("date-picker-range", "start_date"), + Input("date-picker-range", "end_date"), + ) + def update_output(start_date, end_date): + if start_date and end_date: + return f"{start_date} to {end_date}" + elif start_date: + return f"Start: {start_date}" + return "" + + dash_dcc.start_server(app) + + # Find the first input field and open calendar with keyboard + date_picker_input = dash_dcc.find_element(".dash-datepicker-input") + date_picker_input.click() + dash_dcc.wait_for_element(".dash-datepicker-calendar-container") + + # Calendar opens with Jan 1 focused (first day of month since no dates selected) + # Navigate: Arrow Down (Jan 1 -> 8) + send_keys(dash_dcc.driver, Keys.ARROW_DOWN) + + # Verify focused date is Jan 8 + assert get_focused_text(dash_dcc.driver) == "8" + + # Press Space to select the first date (Jan 8) + send_keys(dash_dcc.driver, Keys.SPACE) + + # Verify first date was selected (only start_date, no end_date yet) + dash_dcc.wait_for_text_to_equal("#output-dates", "Start: 2021-01-08") + + # Navigate to another date: Arrow Down (1 week) + Arrow Right (1 day) + # Jan 8 -> Jan 15 -> Jan 16 + send_keys(dash_dcc.driver, Keys.ARROW_DOWN) + send_keys(dash_dcc.driver, Keys.ARROW_RIGHT) + + # Verify focused date is Jan 16 + assert get_focused_text(dash_dcc.driver) == "16" + + # Verify that days between Jan 8 and Jan 16 are highlighted + # The highlighted dates should have the class 'dash-datepicker-calendar-date-highlighted' + highlighted_dates = dash_dcc.driver.find_elements( + "css selector", ".dash-datepicker-calendar-date-highlighted" + ) + # Should have 9 highlighted dates (Jan 8 through Jan 16 inclusive) + assert ( + len(highlighted_dates) == 9 + ), f"Expected 9 highlighted dates, got {len(highlighted_dates)}" + + # Press Enter to select the second date + send_keys(dash_dcc.driver, Keys.ENTER) + + # Calendar should close + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=0.25) + + # Verify both dates were selected in the output + dash_dcc.wait_for_text_to_equal("#output-dates", "2021-01-08 to 2021-01-16") + + assert dash_dcc.get_logs() == [] + + +def test_a11y_range_002_keyboard_update_existing_range(dash_dcc): + """Test keyboard-based updating of an existing date range""" + app = Dash(__name__) + app.layout = Div( + [ + DatePickerRange( + id="date-picker-range", + start_date="2021-01-10", + end_date="2021-01-20", + initial_visible_month=datetime(2021, 1, 1), + ), + Div(id="output-dates"), + ] + ) + + @app.callback( + Output("output-dates", "children"), + Input("date-picker-range", "start_date"), + Input("date-picker-range", "end_date"), + ) + def update_output(start_date, end_date): + if start_date and end_date: + return f"{start_date} to {end_date}" + elif start_date: + return f"Start: {start_date}" + return "" + + dash_dcc.start_server(app) + + # Verify initial range is displayed + dash_dcc.wait_for_text_to_equal("#output-dates", "2021-01-10 to 2021-01-20") + + # Find the first input field and open calendar with keyboard + date_picker_input = dash_dcc.find_element(".dash-datepicker-input") + date_picker_input.click() + dash_dcc.wait_for_element(".dash-datepicker-calendar-container") + + # Calendar opens with Jan 10 focused (the current start date) + # Navigate: Arrow Down (Jan 10 -> 17), then 5x Arrow Left (17 -> 16 -> 15 -> 14 -> 13 -> 12) + send_keys(dash_dcc.driver, Keys.ARROW_DOWN) + send_keys(dash_dcc.driver, Keys.ARROW_LEFT) + send_keys(dash_dcc.driver, Keys.ARROW_LEFT) + send_keys(dash_dcc.driver, Keys.ARROW_LEFT) + send_keys(dash_dcc.driver, Keys.ARROW_LEFT) + send_keys(dash_dcc.driver, Keys.ARROW_LEFT) + + # Verify focused date is Jan 12 + assert get_focused_text(dash_dcc.driver) == "12" + + # Press Space to start a NEW range selection with Jan 12 as start_date + # This should clear end_date and set only start_date + send_keys(dash_dcc.driver, Keys.SPACE) + + # Verify new start date was selected (only start_date, no end_date) + dash_dcc.wait_for_text_to_equal("#output-dates", "Start: 2021-01-12") + + # Navigate to new end date: Arrow Down + Arrow Right (Jan 12 -> 19 -> 20) + send_keys(dash_dcc.driver, Keys.ARROW_DOWN) + send_keys(dash_dcc.driver, Keys.ARROW_RIGHT) + + # Verify focused date is Jan 20 + assert get_focused_text(dash_dcc.driver) == "20" + + # Verify that days between Jan 12 and Jan 20 are highlighted + highlighted_dates = dash_dcc.driver.find_elements( + "css selector", ".dash-datepicker-calendar-date-highlighted" + ) + # Should have 9 highlighted dates (Jan 12 through 20 inclusive) + assert ( + len(highlighted_dates) == 9 + ), f"Expected 9 highlighted dates, got {len(highlighted_dates)}" + + # Press Enter to select the new end date + send_keys(dash_dcc.driver, Keys.ENTER) + + # Calendar should close + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=0.25) + + # Verify both dates were updated in the output + dash_dcc.wait_for_text_to_equal("#output-dates", "2021-01-12 to 2021-01-20") + + assert dash_dcc.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/calendar/test_a11y.py b/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_single.py similarity index 64% rename from components/dash-core-components/tests/integration/calendar/test_a11y.py rename to components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_single.py index 8de8e3b3c0..db30478775 100644 --- a/components/dash-core-components/tests/integration/calendar/test_a11y.py +++ b/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_single.py @@ -1,14 +1,11 @@ -import pytest from datetime import datetime -from dash import Dash -from dash.dcc import DatePickerSingle +from dash import Dash, Input, Output +from dash.dcc import DatePickerSingle, DatePickerRange from dash.html import Div, Label, P -from selenium.common.exceptions import TimeoutException from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.action_chains import ActionChains -# Helper functions def send_keys(driver, key): """Send keyboard keys to the browser""" actions = ActionChains(driver) @@ -22,9 +19,16 @@ def get_focused_text(driver): def create_date_picker_app(date_picker_props): - """Create a Dash app with a DatePickerSingle component""" + """Create a Dash app with a DatePickerSingle component and output callback""" app = Dash(__name__) - app.layout = Div([DatePickerSingle(id="date-picker", **date_picker_props)]) + app.layout = Div( + [DatePickerSingle(id="date-picker", **date_picker_props), Div(id="output-date")] + ) + + @app.callback(Output("output-date", "children"), Input("date-picker", "date")) + def update_output(date): + return date or "" + return app @@ -37,7 +41,7 @@ def open_calendar(dash_dcc, date_picker): def close_calendar(dash_dcc, driver): """Close the calendar with Escape and wait for it to disappear""" send_keys(driver, Keys.ESCAPE) - dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container") def test_a11y001_label_focuses_date_picker(dash_dcc): @@ -56,8 +60,8 @@ def test_a11y001_label_focuses_date_picker(dash_dcc): dash_dcc.wait_for_element("#date-picker") - with pytest.raises(TimeoutException): - dash_dcc.wait_for_element(".dash-datepicker-calendar-container", timeout=0.25) + # Calendar should be closed initially + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=0.25) dash_dcc.find_element("#label").click() dash_dcc.wait_for_element(".dash-datepicker-calendar-container") @@ -81,8 +85,8 @@ def test_a11y002_label_with_htmlFor_can_focus_date_picker(dash_dcc): dash_dcc.wait_for_element("#date-picker") - with pytest.raises(TimeoutException): - dash_dcc.wait_for_element(".dash-datepicker-calendar-container", timeout=0.25) + # Calendar should be closed initially + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=0.25) dash_dcc.find_element("#label").click() dash_dcc.wait_for_element(".dash-datepicker-calendar-container") @@ -91,10 +95,12 @@ def test_a11y002_label_with_htmlFor_can_focus_date_picker(dash_dcc): def test_a11y003_keyboard_navigation_arrows(dash_dcc): - app = create_date_picker_app({ - "date": "2021-01-15", - "initial_visible_month": datetime(2021, 1, 1), - }) + app = create_date_picker_app( + { + "date": "2021-01-15", + "initial_visible_month": datetime(2021, 1, 1), + } + ) dash_dcc.start_server(app) date_picker = dash_dcc.find_element("#date-picker") @@ -129,18 +135,19 @@ def test_a11y003_keyboard_navigation_arrows(dash_dcc): # Test Enter - should select the date and close calendar send_keys(dash_dcc.driver, Keys.ENTER) - with pytest.raises(TimeoutException): - dash_dcc.wait_for_element(".dash-datepicker-calendar-container", timeout=0.25) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=0.25) assert dash_dcc.get_logs() == [] def test_a11y004_keyboard_navigation_home_end(dash_dcc): - app = create_date_picker_app({ - "date": "2021-01-15", # Friday, Jan 15, 2021 - "initial_visible_month": datetime(2021, 1, 1), - "first_day_of_week": 0, # Sunday - }) + app = create_date_picker_app( + { + "date": "2021-01-15", # Friday, Jan 15, 2021 + "initial_visible_month": datetime(2021, 1, 1), + "first_day_of_week": 0, # Sunday + } + ) dash_dcc.start_server(app) date_picker = dash_dcc.find_element("#date-picker") @@ -165,11 +172,13 @@ def test_a11y004_keyboard_navigation_home_end(dash_dcc): def test_a11y005_keyboard_navigation_home_end_monday_start(dash_dcc): - app = create_date_picker_app({ - "date": "2021-01-15", # Friday, Jan 15, 2021 - "initial_visible_month": datetime(2021, 1, 1), - "first_day_of_week": 1, # Monday - }) + app = create_date_picker_app( + { + "date": "2021-01-15", # Friday, Jan 15, 2021 + "initial_visible_month": datetime(2021, 1, 1), + "first_day_of_week": 1, # Monday + } + ) dash_dcc.start_server(app) date_picker = dash_dcc.find_element("#date-picker") @@ -190,176 +199,187 @@ def test_a11y005_keyboard_navigation_home_end_monday_start(dash_dcc): def test_a11y006_keyboard_navigation_rtl(dash_dcc): - app = create_date_picker_app({ - "date": "2021-01-15", - "initial_visible_month": datetime(2021, 1, 1), - "is_RTL": True, - }) + app = create_date_picker_app( + { + "date": "2021-01-15", + "initial_visible_month": datetime(2021, 1, 1), + "is_RTL": True, + } + ) dash_dcc.start_server(app) date_picker = dash_dcc.find_element("#date-picker") open_calendar(dash_dcc, date_picker) - # Get the focused date element (should be Jan 15, 2021) assert get_focused_text(dash_dcc.driver) == "15" - # Test ArrowRight in RTL - should move to Jan 14 (reversed) + # Moves to Jan 14 (reversed) send_keys(dash_dcc.driver, Keys.ARROW_RIGHT) assert get_focused_text(dash_dcc.driver) == "14" - # Test ArrowLeft in RTL - should move to Jan 15 (reversed) + # Moves to Jan 15 (reversed) send_keys(dash_dcc.driver, Keys.ARROW_LEFT) assert get_focused_text(dash_dcc.driver) == "15" - # Test Home key in RTL - should move to week start (semantic, not visual) + # Moves to week start send_keys(dash_dcc.driver, Keys.HOME) - assert get_focused_text(dash_dcc.driver) == "10" # Sunday (week start) - semantic behavior + assert get_focused_text(dash_dcc.driver) == "10" - # Test End key in RTL - should move to week end (semantic, not visual) + # Moves to week end send_keys(dash_dcc.driver, Keys.END) - assert get_focused_text(dash_dcc.driver) == "16" # Saturday (week end) - semantic behavior + assert get_focused_text(dash_dcc.driver) == "16" assert dash_dcc.get_logs() == [] def test_a11y007_all_keyboard_keys_respect_min_max(dash_dcc): - """Test that all keyboard navigation keys respect min/max date boundaries""" - app = create_date_picker_app({ - "date": "2021-02-15", # Monday - "min_date_allowed": datetime(2021, 2, 15), # Monday - same as start date - "max_date_allowed": datetime(2021, 2, 20), # Sat - "initial_visible_month": datetime(2021, 2, 1), - "first_day_of_week": 0, # Sunday - }) + app = create_date_picker_app( + { + "date": "2021-02-15", # Monday + "min_date_allowed": datetime(2021, 2, 15), # Monday - same as start date + "max_date_allowed": datetime(2021, 2, 20), # Sat + "initial_visible_month": datetime(2021, 2, 1), + "first_day_of_week": 0, # Sunday + } + ) dash_dcc.start_server(app) date_picker = dash_dcc.find_element("#date-picker") initial_value = "2021-02-15" - + + dash_dcc.wait_for_text_to_equal("#output-date", initial_value) + # Test Arrow Down (would go to Feb 22, beyond max) open_calendar(dash_dcc, date_picker) send_keys(dash_dcc.driver, Keys.ARROW_DOWN) send_keys(dash_dcc.driver, Keys.ENTER) - assert date_picker.get_attribute("value") == initial_value, "ArrowDown: Should not select date after max" - + dash_dcc.wait_for_text_to_equal("#output-date", initial_value) + # Test Arrow Up (would go to Feb 8, before min) close_calendar(dash_dcc, dash_dcc.driver) open_calendar(dash_dcc, date_picker) send_keys(dash_dcc.driver, Keys.ARROW_UP) send_keys(dash_dcc.driver, Keys.ENTER) - assert date_picker.get_attribute("value") == initial_value, "ArrowUp: Should not select date before min" - + dash_dcc.wait_for_text_to_equal("#output-date", initial_value) + # Test Home (would go to Feb 14 Sunday, before min Feb 15) close_calendar(dash_dcc, dash_dcc.driver) open_calendar(dash_dcc, date_picker) send_keys(dash_dcc.driver, Keys.HOME) send_keys(dash_dcc.driver, Keys.ENTER) - assert date_picker.get_attribute("value") == initial_value, "Home: Should not select date before min" - + dash_dcc.wait_for_text_to_equal("#output-date", initial_value) + # Test End (would go to Feb 20 Saturday, at max - should succeed) close_calendar(dash_dcc, dash_dcc.driver) open_calendar(dash_dcc, date_picker) send_keys(dash_dcc.driver, Keys.END) send_keys(dash_dcc.driver, Keys.ENTER) - dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) - assert date_picker.get_attribute("value") == "2021-02-20", "End: Should select valid date at max" - + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container") + dash_dcc.wait_for_text_to_equal("#output-date", "2021-02-20") + # Reset and test PageUp (would go to Jan 20, before min) open_calendar(dash_dcc, date_picker) send_keys(dash_dcc.driver, Keys.PAGE_UP) send_keys(dash_dcc.driver, Keys.ENTER) - assert date_picker.get_attribute("value") == "2021-02-20", "PageUp: Should not select date before min" - + dash_dcc.wait_for_text_to_equal("#output-date", "2021-02-20") + # Test PageDown (would go to Mar 20, after max) - dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) - open_calendar(dash_dcc, date_picker) send_keys(dash_dcc.driver, Keys.PAGE_DOWN) send_keys(dash_dcc.driver, Keys.ENTER) - assert date_picker.get_attribute("value") == "2021-02-20", "PageDown: Should not select date after max" + dash_dcc.wait_for_text_to_equal("#output-date", "2021-02-20") assert dash_dcc.get_logs() == [] def test_a11y008_all_keyboard_keys_respect_disabled_days(dash_dcc): - """Test that all keyboard navigation keys respect disabled dates""" - app = create_date_picker_app({ - "date": "2021-02-15", # Monday - "disabled_days": [ - datetime(2021, 2, 14), # Sunday - week start - datetime(2021, 2, 16), # Tuesday - ArrowRight target - datetime(2021, 2, 20), # Saturday - week end - datetime(2021, 2, 22), # Monday - ArrowDown target - datetime(2021, 1, 15), # PageUp target - datetime(2021, 3, 15), # PageDown target - ], - "initial_visible_month": datetime(2021, 2, 1), - "first_day_of_week": 0, # Sunday - }) + initial_value = "2021-02-15" + app = create_date_picker_app( + { + "date": initial_value, # Monday + "disabled_days": [ + datetime(2021, 2, 14), # Sunday - week start + datetime(2021, 2, 16), # Tuesday - ArrowRight target + datetime(2021, 2, 20), # Saturday - week end + datetime(2021, 2, 22), # Monday - ArrowDown target + datetime(2021, 1, 15), # PageUp target + datetime(2021, 3, 15), # PageDown target + ], + "initial_visible_month": datetime(2021, 2, 1), + "first_day_of_week": 0, # Sunday + } + ) dash_dcc.start_server(app) date_picker = dash_dcc.find_element("#date-picker") - + + # Wait for initial date to be set in output + dash_dcc.wait_for_text_to_equal("#output-date", initial_value) + # Test Arrow Right (would go to Feb 16, disabled) open_calendar(dash_dcc, date_picker) send_keys(dash_dcc.driver, Keys.ARROW_RIGHT) send_keys(dash_dcc.driver, Keys.ENTER) - assert date_picker.get_attribute("value") != "2021-02-16", "ArrowRight: Should not select disabled date" - + # Should remain at Feb 15 since Feb 16 is disabled + dash_dcc.wait_for_text_to_equal("#output-date", initial_value) + # Test Arrow Down (would go to Feb 22, disabled) open_calendar(dash_dcc, date_picker) send_keys(dash_dcc.driver, Keys.ARROW_DOWN) send_keys(dash_dcc.driver, Keys.ENTER) - assert date_picker.get_attribute("value") != "2021-02-22", "ArrowDown: Should not select disabled date" - + # Should remain at Feb 15 since Feb 22 is disabled + dash_dcc.wait_for_text_to_equal("#output-date", initial_value) + # Test Home (would go to Feb 14 Sunday, disabled) open_calendar(dash_dcc, date_picker) send_keys(dash_dcc.driver, Keys.HOME) send_keys(dash_dcc.driver, Keys.ENTER) - assert date_picker.get_attribute("value") != "2021-02-14", "Home: Should not select disabled week start" - + # Should remain at Feb 15 since Feb 14 is disabled + dash_dcc.wait_for_text_to_equal("#output-date", initial_value) + # Test End (would go to Feb 20 Saturday, disabled) open_calendar(dash_dcc, date_picker) send_keys(dash_dcc.driver, Keys.END) send_keys(dash_dcc.driver, Keys.ENTER) - assert date_picker.get_attribute("value") != "2021-02-20", "End: Should not select disabled week end" - - # Test PageUp (navigates to Jan 15, disabled) + # Should remain at Feb 15 since Feb 20 is disabled + dash_dcc.wait_for_text_to_equal("#output-date", initial_value) + + # Test PageUp (navigates to previous month, but not a disabled day within that month) open_calendar(dash_dcc, date_picker) send_keys(dash_dcc.driver, Keys.PAGE_UP) send_keys(dash_dcc.driver, Keys.ENTER) - assert date_picker.get_attribute("value") != "2021-01-15", "PageUp: Should not select disabled date" - - # Test PageDown (navigates to Mar 15, disabled) + output_text = dash_dcc.find_element("#output-date").text + assert output_text != "2021-01-15", "PageUp: Should not select disabled date" + + # Test PageDown (navigates to next month, but not a disabled day within that month) open_calendar(dash_dcc, date_picker) send_keys(dash_dcc.driver, Keys.PAGE_DOWN) send_keys(dash_dcc.driver, Keys.ENTER) - assert date_picker.get_attribute("value") != "2021-03-15", "PageDown: Should not select disabled date" + output_text = dash_dcc.find_element("#output-date").text + assert output_text != "2021-03-15", "PageDown: Should not select disabled date" assert dash_dcc.get_logs() == [] def test_a11y009_keyboard_space_selects_date(dash_dcc): - app = create_date_picker_app({ - "date": "2021-01-15", - "initial_visible_month": datetime(2021, 1, 1), - }) + app = create_date_picker_app( + { + "date": "2021-01-15", + "initial_visible_month": datetime(2021, 1, 1), + } + ) dash_dcc.start_server(app) date_picker = dash_dcc.find_element("#date-picker") + dash_dcc.wait_for_text_to_equal("#output-date", "2021-01-15") + open_calendar(dash_dcc, date_picker) - # Move to a different date send_keys(dash_dcc.driver, Keys.ARROW_RIGHT) assert get_focused_text(dash_dcc.driver) == "16" - # Select with Space key send_keys(dash_dcc.driver, Keys.SPACE) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=0.25) - # Calendar should close - with pytest.raises(TimeoutException): - dash_dcc.wait_for_element(".dash-datepicker-calendar-container", timeout=0.25) - - # Date should be updated to Jan 16 - assert date_picker.get_attribute("value") == "2021-01-16" + dash_dcc.wait_for_text_to_equal("#output-date", "2021-01-16") assert dash_dcc.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/calendar/test_calendar_props.py b/components/dash-core-components/tests/integration/calendar/test_calendar_props.py index ab6e5a0888..fabc78578e 100644 --- a/components/dash-core-components/tests/integration/calendar/test_calendar_props.py +++ b/components/dash-core-components/tests/integration/calendar/test_calendar_props.py @@ -20,7 +20,7 @@ def test_cdpr001_date_clearable_true_works(dash_dcc): # DPR start_date, end_date = dash_dcc.select_date_range("dpr", (1, 28)) - close_btn = dash_dcc.wait_for_element('button[aria-label="Clear Dates"]') + close_btn = dash_dcc.wait_for_element("#dpr-wrapper .dash-datepicker-clear") assert ( "1" in start_date and "28" in end_date @@ -34,7 +34,7 @@ def test_cdpr001_date_clearable_true_works(dash_dcc): selected = dash_dcc.select_date_single("dps", day="1") assert selected, "single date should get a value" - close_btn = dash_dcc.wait_for_element("#dps button") + close_btn = dash_dcc.wait_for_element("#dps-wrapper .dash-datepicker-clear") close_btn.click() (single_date,) = dash_dcc.get_date_range("dps") assert not single_date, "date should be cleared" @@ -49,6 +49,7 @@ def test_cdpr002_updatemodes(dash_dcc): [ dcc.DatePickerRange( id="date-picker-range", + display_format="MM/DD/YYYY", start_date_id="startDate", end_date_id="endDate", start_date_placeholder_text="Select a start date!", diff --git a/components/dash-core-components/tests/integration/calendar/test_date_picker_range.py b/components/dash-core-components/tests/integration/calendar/test_date_picker_range.py index 50709fdd39..2515272db0 100644 --- a/components/dash-core-components/tests/integration/calendar/test_date_picker_range.py +++ b/components/dash-core-components/tests/integration/calendar/test_date_picker_range.py @@ -18,17 +18,18 @@ def test_dtpr001_initial_month_provided(dash_dcc): dash_dcc.start_server(app) - date_picker_start = dash_dcc.find_element( - '#dps-initial-month .DateInput_input.DateInput_input_1[placeholder="Start Date"]' - ) - date_picker_start.click() + date_picker = dash_dcc.find_element("#dps-initial-month") + date_picker.click() dash_dcc.wait_for_text_to_equal( - "#dps-initial-month .CalendarMonth.CalendarMonth_1[data-visible=true] strong", - "October 2019", + ".dash-datepicker .dash-dropdown-value", + "October", 1, ) + year_input = dash_dcc.find_element(".dash-datepicker .dash-input-container input") + assert year_input.get_attribute("value") == "2019" + assert dash_dcc.get_logs() == [] @@ -46,16 +47,18 @@ def test_dtpr002_no_initial_month_min_date(dash_dcc): dash_dcc.start_server(app) - date_picker_start = dash_dcc.find_element( - '#dps-initial-month .DateInput_input.DateInput_input_1[placeholder="Start Date"]' - ) - date_picker_start.click() + date_picker = dash_dcc.find_element("#dps-initial-month") + date_picker.click() dash_dcc.wait_for_text_to_equal( - "#dps-initial-month .CalendarMonth.CalendarMonth_1[data-visible=true] strong", - "January 2010", + ".dash-datepicker .dash-dropdown-value", + "January", + 1, ) + year_input = dash_dcc.find_element(".dash-datepicker .dash-input-container input") + assert year_input.get_attribute("value") == "2010" + assert dash_dcc.get_logs() == [] @@ -73,16 +76,18 @@ def test_dtpr003_no_initial_month_no_min_date_start_date(dash_dcc): dash_dcc.start_server(app) - date_picker_start = dash_dcc.find_element( - '#dps-initial-month .DateInput_input.DateInput_input_1[placeholder="Start Date"]' - ) - date_picker_start.click() + date_picker = dash_dcc.find_element("#dps-initial-month") + date_picker.click() dash_dcc.wait_for_text_to_equal( - "#dps-initial-month .CalendarMonth.CalendarMonth_1[data-visible=true] strong", - "August 2019", + ".dash-datepicker .dash-dropdown-value", + "August", + 1, ) + year_input = dash_dcc.find_element(".dash-datepicker .dash-input-container input") + assert year_input.get_attribute("value") == "2019" + assert dash_dcc.get_logs() == [] @@ -92,6 +97,7 @@ def test_dtpr004_max_and_min_dates_are_clickable(dash_dcc): [ dcc.DatePickerRange( id="dps-initial-month", + display_format="MM/DD/YYYY", start_date=datetime(2021, 1, 11), end_date=datetime(2021, 1, 19), max_date_allowed=datetime(2021, 1, 20), @@ -104,15 +110,11 @@ def test_dtpr004_max_and_min_dates_are_clickable(dash_dcc): dash_dcc.select_date_range("dps-initial-month", (10, 20)) - dash_dcc.wait_for_text_to_equal( - '#dps-initial-month .DateInput_input.DateInput_input_1[placeholder="Start Date"]', - "01/10/2021", - ) + start_date = dash_dcc.find_element(".dash-datepicker-start-date") + assert start_date.get_attribute("value") == "01/10/2021" - dash_dcc.wait_for_text_to_equal( - '#dps-initial-month .DateInput_input.DateInput_input_1[placeholder="End Date"]', - "01/20/2021", - ) + end_date = dash_dcc.find_element(".dash-datepicker-end-date") + assert end_date.get_attribute("value") == "01/20/2021" assert dash_dcc.get_logs() == [] @@ -130,20 +132,16 @@ def test_dtpr005_disabled_days_arent_clickable(dash_dcc): disabled_days=[datetime(2021, 1, 10), datetime(2021, 1, 11)], ), ], - style={ - "width": "10%", - "display": "inline-block", - "marginLeft": 10, - "marginRight": 10, - "marginBottom": 10, - }, + style={"width": "50%"}, ) dash_dcc.start_server(app) - date = dash_dcc.find_element("#dpr input") + date = dash_dcc.find_element("#dpr") assert not date.get_attribute("value") assert not any( dash_dcc.select_date_range("dpr", day_range=(10, 11)) ), "Disabled days should not be clickable" + + date.click() assert all( dash_dcc.select_date_range("dpr", day_range=(1, 2)) ), "Other days should be clickable" diff --git a/components/dash-core-components/tests/integration/calendar/test_multi_month_selection.py b/components/dash-core-components/tests/integration/calendar/test_multi_month_selection.py new file mode 100644 index 0000000000..6afe8a5c13 --- /dev/null +++ b/components/dash-core-components/tests/integration/calendar/test_multi_month_selection.py @@ -0,0 +1,306 @@ +from datetime import datetime +from selenium.webdriver.common.action_chains import ActionChains + +from dash import Dash, Input, Output, html, dcc + + +def test_dtps_multi_month_click_second_month(dash_dcc): + """Test clicking a date in the second month with number_of_months_shown=2""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerSingle( + id="dps", + initial_visible_month=datetime(2021, 1, 1), + number_of_months_shown=2, + stay_open_on_select=True, + ), + html.Div(id="output"), + ] + ) + + @app.callback(Output("output", "children"), Input("dps", "date")) + def update_output(date): + return date or "No date selected" + + dash_dcc.start_server(app) + + # Click the date picker to open it + date_picker = dash_dcc.find_element("#dps") + date_picker.click() + + dash_dcc._wait_until_day_is_clickable() + + # Get all visible dates across both months + days = dash_dcc.find_elements(dash_dcc.date_picker_day_locator) + + # Find a date in the second month (February 2021) + # We're looking for day "15" in the second month + second_month_days = [ + day + for day in days + if day.text == "15" + and "dash-datepicker-calendar-date-outside" not in day.get_attribute("class") + ] + + # There should be two "15"s visible (Jan 15 and Feb 15) + # We want the second one (Feb 15) + assert len(second_month_days) >= 1, "Should find at least one day 15" + + # Click on a date in the second visible month + if len(second_month_days) > 1: + second_month_days[1].click() + expected_date = "2021-02-15" + else: + # Fallback: just click the first one + second_month_days[0].click() + expected_date = "2021-01-15" + + # Check the output + output = dash_dcc.find_element("#output") + assert output.text == expected_date, f"Expected {expected_date}, got {output.text}" + + +def test_dtpr_multi_month_drag_in_second_month(dash_dcc): + """Test drag selection entirely within the second month with number_of_months_shown=2""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerRange( + id="dpr", + initial_visible_month=datetime(2021, 1, 1), + number_of_months_shown=2, + ), + html.Div(id="output-start"), + html.Div(id="output-end"), + ] + ) + + @app.callback( + Output("output-start", "children"), + Output("output-end", "children"), + Input("dpr", "start_date"), + Input("dpr", "end_date"), + ) + def update_output(start_date, end_date): + return start_date or "No start", end_date or "No end" + + dash_dcc.start_server(app) + + # Click to open the calendar + date_picker = dash_dcc.find_element("#dpr") + date_picker.click() + + dash_dcc._wait_until_day_is_clickable() + + # Get all visible dates + days = dash_dcc.find_elements(dash_dcc.date_picker_day_locator) + + # Find all day "10"s and "17"s (both should appear in Jan and Feb) + all_10s = [ + day + for day in days + if day.text == "10" + and "dash-datepicker-calendar-date-outside" not in day.get_attribute("class") + ] + all_17s = [ + day + for day in days + if day.text == "17" + and "dash-datepicker-calendar-date-outside" not in day.get_attribute("class") + ] + + # Use the last occurrence of each (should be February) + feb_10 = all_10s[-1] if len(all_10s) > 1 else all_10s[0] + feb_17 = all_17s[-1] if len(all_17s) > 1 else all_17s[0] + + # Perform drag operation: mouse down on Feb 10, drag to Feb 17, mouse up + actions = ActionChains(dash_dcc.driver) + actions.click_and_hold(feb_10).move_to_element(feb_17).release().perform() + + # Wait for the callback to fire + dash_dcc.wait_for_text_to_equal("#output-start", "2021-02-10", timeout=2) + + # Check the outputs + output_start = dash_dcc.find_element("#output-start") + output_end = dash_dcc.find_element("#output-end") + + assert ( + output_start.text == "2021-02-10" + ), f"Expected 2021-02-10 as start, got {output_start.text}" + assert ( + output_end.text == "2021-02-17" + ), f"Expected 2021-02-17 as end, got {output_end.text}" + + +def test_dtpr_multi_month_click_in_second_month(dash_dcc): + """Test click selection entirely within the second month with number_of_months_shown=2 + This should produce the same result as the drag test above""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerRange( + id="dpr", + initial_visible_month=datetime(2021, 1, 1), + number_of_months_shown=2, + stay_open_on_select=True, + ), + html.Div(id="output-start"), + html.Div(id="output-end"), + ] + ) + + @app.callback( + Output("output-start", "children"), + Output("output-end", "children"), + Input("dpr", "start_date"), + Input("dpr", "end_date"), + ) + def update_output(start_date, end_date): + return start_date or "No start", end_date or "No end" + + dash_dcc.start_server(app) + + # Open calendar + dash_dcc.find_element("#dpr").click() + dash_dcc._wait_until_day_is_clickable() + + # Find and click Feb 10 and Feb 17 + days = dash_dcc.find_elements(dash_dcc.date_picker_day_locator) + all_10s = [ + d + for d in days + if d.text == "10" + and "dash-datepicker-calendar-date-outside" not in d.get_attribute("class") + ] + all_17s = [ + d + for d in days + if d.text == "17" + and "dash-datepicker-calendar-date-outside" not in d.get_attribute("class") + ] + + all_10s[-1].click() # Feb 10 (last occurrence) + all_17s[-1].click() # Feb 17 (last occurrence) + + # Verify output + dash_dcc.wait_for_text_to_equal("#output-start", "2021-02-10", timeout=2) + dash_dcc.wait_for_text_to_equal("#output-end", "2021-02-17", timeout=2) + + +def test_dtpr_cross_month_drag_selection(dash_dcc): + """Test drag selection from 15th of first month (Jan) to 15th of second month (Feb)""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerRange( + id="dpr", + initial_visible_month=datetime(2021, 1, 1), + number_of_months_shown=2, + ), + html.Div(id="output-start"), + html.Div(id="output-end"), + ] + ) + + @app.callback( + Output("output-start", "children"), + Output("output-end", "children"), + Input("dpr", "start_date"), + Input("dpr", "end_date"), + ) + def update_output(start_date, end_date): + return start_date or "No start", end_date or "No end" + + dash_dcc.start_server(app) + + # Click to open the calendar + date_picker = dash_dcc.find_element("#dpr") + date_picker.click() + + dash_dcc._wait_until_day_is_clickable() + + # Get all visible dates + days = dash_dcc.find_elements(dash_dcc.date_picker_day_locator) + + # Find all day "15"s (both Jan 15 and Feb 15) + all_15s = [ + day + for day in days + if day.text == "15" + and "dash-datepicker-calendar-date-outside" not in day.get_attribute("class") + ] + + # Should have at least 2 instances of day 15 (Jan and Feb) + assert len(all_15s) >= 2, "Should find at least two day 15s (Jan and Feb)" + + # First occurrence is Jan 15, second is Feb 15 + jan_15 = all_15s[0] + feb_15 = all_15s[1] + + # Perform drag operation: mouse down on Jan 15, drag to Feb 15, mouse up + actions = ActionChains(dash_dcc.driver) + actions.click_and_hold(jan_15).move_to_element(feb_15).release().perform() + + # Wait for the callback to fire + dash_dcc.wait_for_text_to_equal("#output-start", "2021-01-15", timeout=2) + + # Check the outputs + output_start = dash_dcc.find_element("#output-start") + output_end = dash_dcc.find_element("#output-end") + + assert ( + output_start.text == "2021-01-15" + ), f"Expected 2021-01-15 as start, got {output_start.text}" + assert ( + output_end.text == "2021-02-15" + ), f"Expected 2021-02-15 as end, got {output_end.text}" + + +def test_dtpr_cross_month_click_selection(dash_dcc): + """Test click selection from 15th of first month (Jan) to 15th of second month (Feb) + This should produce the same result as the drag test above""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerRange( + id="dpr", + initial_visible_month=datetime(2021, 1, 1), + number_of_months_shown=2, + stay_open_on_select=True, + ), + html.Div(id="output-start"), + html.Div(id="output-end"), + ] + ) + + @app.callback( + Output("output-start", "children"), + Output("output-end", "children"), + Input("dpr", "start_date"), + Input("dpr", "end_date"), + ) + def update_output(start_date, end_date): + return start_date or "No start", end_date or "No end" + + dash_dcc.start_server(app) + + # Open calendar + dash_dcc.find_element("#dpr").click() + dash_dcc._wait_until_day_is_clickable() + + # Find and click Jan 15 and Feb 15 + days = dash_dcc.find_elements(dash_dcc.date_picker_day_locator) + all_15s = [ + d + for d in days + if d.text == "15" + and "dash-datepicker-calendar-date-outside" not in d.get_attribute("class") + ] + + all_15s[0].click() # Jan 15 (first occurrence) + all_15s[1].click() # Feb 15 (second occurrence) + + # Verify output + dash_dcc.wait_for_text_to_equal("#output-start", "2021-01-15", timeout=2) + dash_dcc.wait_for_text_to_equal("#output-end", "2021-02-15", timeout=2) diff --git a/components/dash-core-components/tests/unit/calendar/Calendar.test.tsx b/components/dash-core-components/tests/unit/calendar/Calendar.test.tsx index 5e71374ffe..224778f2da 100644 --- a/components/dash-core-components/tests/unit/calendar/Calendar.test.tsx +++ b/components/dash-core-components/tests/unit/calendar/Calendar.test.tsx @@ -77,13 +77,13 @@ describe('Calendar', () => { /> ); - // Should have 6 selected days (Jan 10-15 inclusive) + // Should have 2 selected days (only the start and end dates, not the dates in between) expect( countCellsWithClass( container, 'dash-datepicker-calendar-date-selected' ) - ).toBe(6); + ).toBe(2); }); it('marks highlighted dates from highlightStart and highlightEnd', () => { From eeaea3cca426e1399114671f3c318d56b3ef51c3 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Tue, 4 Nov 2025 10:56:18 -0700 Subject: [PATCH 3/8] Code cleanup --- .../dash-core-components/package-lock.json | 33 +- components/dash-core-components/package.json | 2 + .../src/components/DatePickerRange.tsx | 2 + .../src/components/css/calendar.css | 1 + .../src/fragments/DatePickerRange.tsx | 173 +++++++---- .../src/fragments/DatePickerSingle.tsx | 83 +++--- .../src/utils/calendar/Calendar.tsx | 210 +++++-------- .../src/utils/calendar/CalendarDay.tsx | 25 +- .../src/utils/calendar/CalendarDayPadding.tsx | 15 + .../src/utils/calendar/CalendarMonth.tsx | 140 ++++----- .../utils/calendar/CalendarMonthHeader.tsx | 23 ++ .../src/utils/calendar/DateSet.ts | 107 ------- .../src/utils/calendar/createMonthGrid.ts | 33 +- .../src/utils/calendar/helpers.ts | 160 ++++------ .../calendar/test_calendar_props.py | 3 +- .../calendar/test_date_picker_range.py | 14 +- .../calendar/test_date_picker_single.py | 51 ++-- .../tests/unit/calendar/Calendar.test.tsx | 6 +- .../tests/unit/calendar/CalendarDay.test.tsx | 126 ++++---- .../unit/calendar/CalendarDayPadding.test.tsx | 32 ++ .../unit/calendar/CalendarMonth.test.tsx | 182 ++++++------ .../tests/unit/calendar/DateSet.test.ts | 281 ------------------ .../unit/calendar/createMonthGrid.test.ts | 260 ++++++++++------ .../tests/unit/calendar/helpers.test.ts | 216 +++----------- 24 files changed, 905 insertions(+), 1273 deletions(-) create mode 100644 components/dash-core-components/src/utils/calendar/CalendarDayPadding.tsx create mode 100644 components/dash-core-components/src/utils/calendar/CalendarMonthHeader.tsx delete mode 100644 components/dash-core-components/src/utils/calendar/DateSet.ts create mode 100644 components/dash-core-components/tests/unit/calendar/CalendarDayPadding.test.tsx delete mode 100644 components/dash-core-components/tests/unit/calendar/DateSet.test.ts diff --git a/components/dash-core-components/package-lock.json b/components/dash-core-components/package-lock.json index e32047f9c9..f96631a9a2 100644 --- a/components/dash-core-components/package-lock.json +++ b/components/dash-core-components/package-lock.json @@ -34,6 +34,7 @@ "react-docgen": "^5.4.3", "react-dropzone": "^4.1.2", "react-fast-compare": "^3.2.2", + "react-input-autosize": "^3.0.0", "react-markdown": "^4.3.1", "react-virtualized-select": "^3.1.3", "remark-math": "^3.0.1", @@ -58,6 +59,7 @@ "@types/ramda": "^0.31.0", "@types/react": "^16.14.8", "@types/react-dom": "^16.9.13", + "@types/react-input-autosize": "^2.2.4", "@types/uniqid": "^5.3.4", "@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/parser": "^5.59.7", @@ -4943,6 +4945,16 @@ "@types/react": "^16.0.0" } }, + "node_modules/@types/react-input-autosize": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@types/react-input-autosize/-/react-input-autosize-2.2.4.tgz", + "integrity": "sha512-7O028jRZHZo3mj63h3HSvB0WpvPXNWN86sajHTi0+CtjA4Ym+DFzO9RzrSbfFURe5ZWsq6P72xk7MInI6aGWJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", @@ -14138,14 +14150,15 @@ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" }, "node_modules/react-input-autosize": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-2.2.2.tgz", - "integrity": "sha512-jQJgYCA3S0j+cuOwzuCd1OjmBmnZLdqQdiLKRYrsMMzbjUrVDS5RvJUDwJqA7sKuksDuzFtm6hZGKFu7Mjk5aw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-3.0.0.tgz", + "integrity": "sha512-nL9uS7jEs/zu8sqwFE5MAPx6pPkNAriACQ2rGLlqmKr2sPGtN7TXTyDdQt4lbNXVx7Uzadb40x8qotIuru6Rhg==", + "license": "MIT", "dependencies": { "prop-types": "^15.5.8" }, "peerDependencies": { - "react": "^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0" + "react": "^16.3.0 || ^17.0.0" } }, "node_modules/react-is": { @@ -14302,6 +14315,18 @@ "react-dom": "^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0" } }, + "node_modules/react-select/node_modules/react-input-autosize": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-2.2.2.tgz", + "integrity": "sha512-jQJgYCA3S0j+cuOwzuCd1OjmBmnZLdqQdiLKRYrsMMzbjUrVDS5RvJUDwJqA7sKuksDuzFtm6hZGKFu7Mjk5aw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.8" + }, + "peerDependencies": { + "react": "^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", diff --git a/components/dash-core-components/package.json b/components/dash-core-components/package.json index 7d24ef7972..74069c8364 100644 --- a/components/dash-core-components/package.json +++ b/components/dash-core-components/package.json @@ -61,6 +61,7 @@ "react-docgen": "^5.4.3", "react-dropzone": "^4.1.2", "react-fast-compare": "^3.2.2", + "react-input-autosize": "^3.0.0", "react-markdown": "^4.3.1", "react-virtualized-select": "^3.1.3", "remark-math": "^3.0.1", @@ -85,6 +86,7 @@ "@types/ramda": "^0.31.0", "@types/react": "^16.14.8", "@types/react-dom": "^16.9.13", + "@types/react-input-autosize": "^2.2.4", "@types/uniqid": "^5.3.4", "@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/parser": "^5.59.7", diff --git a/components/dash-core-components/src/components/DatePickerRange.tsx b/components/dash-core-components/src/components/DatePickerRange.tsx index a2cabb5b95..f1240f2daa 100644 --- a/components/dash-core-components/src/components/DatePickerRange.tsx +++ b/components/dash-core-components/src/components/DatePickerRange.tsx @@ -25,6 +25,7 @@ export default function DatePickerRange({ number_of_months_shown = 2, stay_open_on_select = false, reopen_calendar_on_clear = false, + show_outside_days = false, clearable = false, disabled = false, updatemode = 'singledate', @@ -41,6 +42,7 @@ export default function DatePickerRange({ calendar_orientation={calendar_orientation} is_RTL={is_RTL} day_size={day_size} + show_outside_days={show_outside_days} with_portal={with_portal} with_full_screen_portal={with_full_screen_portal} first_day_of_week={first_day_of_week} diff --git a/components/dash-core-components/src/components/css/calendar.css b/components/dash-core-components/src/components/css/calendar.css index 6b74dbea57..d6f8c4d9bf 100644 --- a/components/dash-core-components/src/components/css/calendar.css +++ b/components/dash-core-components/src/components/css/calendar.css @@ -57,6 +57,7 @@ opacity: 0.6; cursor: not-allowed; background-color: inherit; + pointer-events: none; } .dash-datepicker-calendar td input { diff --git a/components/dash-core-components/src/fragments/DatePickerRange.tsx b/components/dash-core-components/src/fragments/DatePickerRange.tsx index a924074eec..2d0a648b35 100644 --- a/components/dash-core-components/src/fragments/DatePickerRange.tsx +++ b/components/dash-core-components/src/fragments/DatePickerRange.tsx @@ -4,8 +4,10 @@ import { CalendarIcon, CaretDownIcon, Cross1Icon, + ArrowLeftIcon, ArrowRightIcon, } from '@radix-ui/react-icons'; +import AutosizeInput from 'react-input-autosize'; import Calendar from '../utils/calendar/Calendar'; import {DatePickerRangeProps, CalendarDirection} from '../types'; import { @@ -15,9 +17,9 @@ import { isDateDisabled, isSameDay, } from '../utils/calendar/helpers'; -import {DateSet} from '../utils/calendar/DateSet'; import '../components/css/datepickers.css'; import uuid from 'uniqid'; +import moment from 'moment'; const DatePickerRange = ({ id, @@ -28,6 +30,7 @@ const DatePickerRange = ({ max_date_allowed, initial_visible_month = start_date ?? min_date_allowed ?? max_date_allowed, disabled_days, + minimum_nights, first_day_of_week, show_outside_days, clearable, @@ -59,24 +62,45 @@ const DatePickerRange = ({ const initialMonth = strAsDate(initial_visible_month); const minDate = strAsDate(min_date_allowed); const maxDate = strAsDate(max_date_allowed); - const disabledDates = useMemo( - () => new DateSet(disabled_days), - [disabled_days] - ); + const disabledDates = useMemo(() => { + const baseDates = + disabled_days + ?.map(d => strAsDate(d)) + .filter((d): d is Date => d !== undefined) || []; + + // Add minimum_nights constraint: disable dates within the minimum nights range + if ( + internalStartDate && + minimum_nights && + minimum_nights > 0 && + !internalEndDate + ) { + const minimumNightsDates: Date[] = []; + for (let i = 1; i < minimum_nights; i++) { + minimumNightsDates.push( + moment(internalStartDate).add(i, 'day').toDate() + ); + minimumNightsDates.push( + moment(internalStartDate).subtract(i, 'day').toDate() + ); + } + return [...baseDates, ...minimumNightsDates]; + } + + return baseDates; + }, [disabled_days, internalStartDate, internalEndDate, minimum_nights]); const [isCalendarOpen, setIsCalendarOpen] = useState(false); - const [startInputValue, setStartInputValue] = useState( - (internalStartDate && formatDate(internalStartDate, display_format)) ?? - '' + const [startInputValue, setStartInputValue] = useState( + formatDate(internalStartDate, display_format) ); - const [endInputValue, setEndInputValue] = useState( - (internalEndDate && formatDate(internalEndDate, display_format)) ?? '' + const [endInputValue, setEndInputValue] = useState( + formatDate(internalEndDate, display_format) ); const containerRef = useRef(null); - const startInputRef = useRef(null); - const endInputRef = useRef(null); - const calendarRef = useRef(null); + const startInputRef = useRef(null); + const endInputRef = useRef(null); useEffect(() => { setInternalStartDate(strAsDate(start_date)); @@ -119,7 +143,7 @@ const DatePickerRange = ({ endInputRef.current?.focus(); } } - }, [isCalendarOpen]); + }, [isCalendarOpen, startInputValue]); const sendStartInputAsDate = useCallback(() => { const parsed = strAsDate(startInputValue, display_format); @@ -224,6 +248,42 @@ const DatePickerRange = ({ classNames += ' ' + className; } + const initialCalendarDate = + initialMonth || internalStartDate || internalEndDate; + + const ArrowIcon = + direction === CalendarDirection.LeftToRight + ? ArrowRightIcon + : ArrowLeftIcon; + + const handleSelectionChange = useCallback( + (start?: Date, end?: Date) => { + const isNewSelection = + isSameDay(start, end) && + ((!internalStartDate && !internalEndDate) || + (internalStartDate && internalEndDate)); + + if (isNewSelection) { + setInternalStartDate(start); + setInternalEndDate(undefined); + } else { + // Normalize dates: ensure start <= end + if (start && end && start > end) { + setInternalStartDate(end); + setInternalEndDate(start); + } else { + setInternalStartDate(start); + setInternalEndDate(end); + } + + if (end && !stay_open_on_select) { + setIsCalendarOpen(false); + } + } + }, + [internalStartDate, internalEndDate, stay_open_on_select] + ); + return (
- { + startInputRef.current = node; + }} type="text" id={start_date_id || accessibleId} - className="dash-datepicker-input dash-datepicker-start-date" + inputClassName="dash-datepicker-input dash-datepicker-start-date" value={startInputValue} onChange={e => setStartInputValue(e.target.value)} onKeyDown={handleStartInputKeyDown} onBlur={sendStartInputAsDate} + onClick={() => { + if (!isCalendarOpen && !disabled) { + setIsCalendarOpen(true); + } + }} placeholder={start_date_placeholder_text} disabled={disabled} dir={direction} aria-label={start_date_placeholder_text} /> - - + { + endInputRef.current = node; + }} type="text" id={end_date_id || accessibleId + '-end-date'} - className="dash-datepicker-input dash-datepicker-end-date" + inputClassName="dash-datepicker-input dash-datepicker-end-date" value={endInputValue} onChange={e => setEndInputValue(e.target.value)} onKeyDown={handleEndInputKeyDown} onBlur={sendEndInputAsDate} + onClick={() => { + if (!isCalendarOpen && !disabled) { + setIsCalendarOpen(true); + } + }} placeholder={end_date_placeholder_text} disabled={disabled} dir={direction} @@ -290,47 +364,22 @@ const DatePickerRange = ({ sideOffset={5} onOpenAutoFocus={e => e.preventDefault()} > -
- { - const isNewSelection = - isSameDay(start, end) && - ((!internalStartDate && - !internalEndDate) || - (internalStartDate && - internalEndDate)); - - if (isNewSelection) { - setInternalStartDate(start); - setInternalEndDate(undefined); - } else { - setInternalStartDate(start); - setInternalEndDate(end); - - if (end && !stay_open_on_select) { - setIsCalendarOpen(false); - } - } - }} - /> -
+
diff --git a/components/dash-core-components/src/fragments/DatePickerSingle.tsx b/components/dash-core-components/src/fragments/DatePickerSingle.tsx index 7914bcb2aa..64320c573d 100644 --- a/components/dash-core-components/src/fragments/DatePickerSingle.tsx +++ b/components/dash-core-components/src/fragments/DatePickerSingle.tsx @@ -10,9 +10,9 @@ import { isDateDisabled, isSameDay, } from '../utils/calendar/helpers'; -import {DateSet} from '../utils/calendar/DateSet'; import '../components/css/datepickers.css'; import uuid from 'uniqid'; +import AutosizeInput from 'react-input-autosize'; const DatePickerSingle = ({ id, @@ -46,10 +46,11 @@ const DatePickerSingle = ({ const initialMonth = strAsDate(initial_visible_month); const minDate = strAsDate(min_date_allowed); const maxDate = strAsDate(max_date_allowed); - const disabledDates = useMemo( - () => new DateSet(disabled_days), - [disabled_days] - ); + const disabledDates = useMemo(() => { + return disabled_days + ?.map(d => strAsDate(d)) + .filter((d): d is Date => d !== undefined); + }, [disabled_days]); const [isCalendarOpen, setIsCalendarOpen] = useState(false); const [inputValue, setInputValue] = useState( @@ -57,18 +58,14 @@ const DatePickerSingle = ({ ); const containerRef = useRef(null); - const inputRef = useRef(null); + const inputRef = useRef(null); useEffect(() => { setInternalDate(strAsDate(date)); }, [date]); useEffect(() => { - if (internalDate) { - setInputValue(formatDate(internalDate, display_format)); - } else { - setInputValue(''); - } + setInputValue(formatDate(internalDate, display_format)); }, [internalDate, display_format]); useEffect(() => { @@ -154,18 +151,26 @@ const DatePickerSingle = ({ aria-disabled={disabled} > - { + inputRef.current = node; + }} type="text" id={accessibleId} - className="dash-datepicker-input" + inputClassName="dash-datepicker-input dash-datepicker-end-date" value={inputValue} onChange={e => setInputValue(e.target.value)} onKeyDown={handleInputKeyDown} onBlur={parseUserInput} + onClick={() => { + if (!isCalendarOpen && !disabled) { + setIsCalendarOpen(true); + } + }} placeholder={placeholder} disabled={disabled} dir={direction} + aria-label={placeholder} /> {clearable && !disabled && !!date && ( e.preventDefault()} > -
- { + if (!selection) { + return; } - selectionStart={internalDate} - selectionEnd={internalDate} - minDateAllowed={minDate} - maxDateAllowed={maxDate} - disabledDates={disabledDates} - firstDayOfWeek={first_day_of_week} - showOutsideDays={show_outside_days} - monthFormat={month_format} - numberOfMonthsShown={number_of_months_shown} - calendarOrientation={calendar_orientation} - daySize={day_size} - direction={direction} - onSelectionChange={(_, selection) => { - if (!selection) { - return; - } - setInternalDate(selection); - if (!stay_open_on_select) { - setIsCalendarOpen(false); - } - }} - /> -
+ setInternalDate(selection); + if (!stay_open_on_select) { + setIsCalendarOpen(false); + } + }} + /> diff --git a/components/dash-core-components/src/utils/calendar/Calendar.tsx b/components/dash-core-components/src/utils/calendar/Calendar.tsx index 710ad3b582..a8ddc2da45 100644 --- a/components/dash-core-components/src/utils/calendar/Calendar.tsx +++ b/components/dash-core-components/src/utils/calendar/Calendar.tsx @@ -10,8 +10,13 @@ import Input, {HTMLInputTypes} from '../../components/Input'; import Dropdown from '../../fragments/Dropdown'; import {DayOfWeek, CalendarDirection} from '../../types'; import {CalendarMonth} from './CalendarMonth'; -import {DateSet} from './DateSet'; -import {getMonthOptions, formatYear, parseYear, isDateInRange} from './helpers'; +import { + getMonthOptions, + formatYear, + parseYear, + isDateInRange, + isSameDay, +} from './helpers'; type CalendarProps = { onSelectionChange: (selectionStart: Date, selectionEnd?: Date) => void; @@ -22,7 +27,7 @@ type CalendarProps = { initialVisibleDate?: Date; minDateAllowed?: Date; maxDateAllowed?: Date; - disabledDates?: DateSet; + disabledDates?: Date[]; firstDayOfWeek?: DayOfWeek; showOutsideDays?: boolean; monthFormat?: string; @@ -71,7 +76,7 @@ const Calendar = ({ 1 ); }); - const [highlightedDates, setHighlightedDates] = useState(new DateSet()); + const [highlightedDates, setHighlightedDates] = useState<[Date, Date]>(); const calendarContainerRef = useRef(document.createElement('div')); const scrollAccumulatorRef = useRef(0); const prevFocusedDateRef = useRef(focusedDate); @@ -111,31 +116,34 @@ const Calendar = ({ }, [focusedDate, activeMonth, activeYear, numberOfMonthsShown]); useEffect(() => { - setHighlightedDates(DateSet.fromRange(highlightStart, highlightEnd)); + if (highlightStart && highlightEnd) { + setHighlightedDates([highlightStart, highlightEnd]); + } else if (highlightStart) { + setHighlightedDates([highlightStart, highlightStart]); + } else { + setHighlightedDates(undefined); + } }, [highlightStart, highlightEnd]); useEffect(() => { if (selectionStart && selectionEnd) { - setHighlightedDates( - DateSet.fromRange(selectionStart, selectionEnd) - ); + setHighlightedDates([selectionStart, selectionEnd]); } }, [selectionStart, selectionEnd]); - const selectedDates = useMemo(() => { - return new DateSet([selectionStart, selectionEnd]); + const selectedDates = useMemo((): Date[] => { + return [selectionStart, selectionEnd].filter( + (d): d is Date => d !== undefined + ); }, [selectionStart, selectionEnd]); const handleSelectionStart = useCallback( (date: Date) => { - // Only start a new selection if there isn't already an incomplete selection - // This allows click-based range selection: first click sets start, second click sets end if (!selectionStart || selectionEnd) { // No selection yet, or previous selection is complete → start new selection + setHighlightedDates(undefined); onSelectionChange(date, undefined); } - // If selectionStart exists and selectionEnd is undefined, we're in the middle of a selection - // Don't reset the start date - let mouseUp handle completing it }, [selectionStart, selectionEnd, onSelectionChange] ); @@ -145,13 +153,11 @@ const Calendar = ({ // Complete the selection with an end date if (selectionStart && !selectionEnd) { // Incomplete selection exists (range picker mid-selection) - // Only complete if date is different from start (prevent same-date on single click) - if (!moment(selectionStart).isSame(date, 'day')) { + if (!isSameDay(selectionStart, date)) { onSelectionChange(selectionStart, date); } } else { - // No selection, or complete selection exists (single date picker) - // Replace/set with new date (keyboard selection or standalone) + // Complete selection exists or a single date was chosen onSelectionChange(date, date); } }, @@ -159,29 +165,14 @@ const Calendar = ({ ); const handleDaysHighlighted = useCallback( - (days: DateSet) => { - // When both selectionStart and selectionEnd are defined (selection complete), - // highlight all dates between them + (date: Date) => { if (selectionStart && selectionEnd) { - setHighlightedDates( - DateSet.fromRange(selectionStart, selectionEnd) - ); - return; - } - // When selectionStart is defined but selectionEnd is not, - // extend the highlight to include the range from selectionStart to the hovered date - if (selectionStart && !selectionEnd && days.size > 0) { - // Get the last date from the DateSet (the hovered date) - const hoveredDate = days.max(); - if (hoveredDate) { - setHighlightedDates( - DateSet.fromRange(selectionStart, hoveredDate) - ); - return; - } + setHighlightedDates([selectionStart, selectionEnd]); + } else if (selectionStart && !selectionEnd) { + setHighlightedDates([selectionStart, date]); + } else { + setHighlightedDates([date, date]); } - // Otherwise, just use the days as-is (for single date hover) - setHighlightedDates(days); }, [selectionStart, selectionEnd] ); @@ -192,7 +183,7 @@ const Calendar = ({ // When navigating with keyboard during range selection, // highlight the range from start to focused date if (selectionStart && !selectionEnd) { - setHighlightedDates(DateSet.fromRange(selectionStart, date)); + setHighlightedDates([selectionStart, date]); } }, [selectionStart, selectionEnd] @@ -209,6 +200,25 @@ const Calendar = ({ [activeYear, monthFormat, minDateAllowed, maxDateAllowed] ); + const changeMonthBy = useCallback( + (months: number) => { + const currentDate = moment([activeYear, activeMonth, 1]); + + // In RTL mode, directions are reversed + const actualMonths = + direction === CalendarDirection.RightToLeft ? -months : months; + + const newDate = currentDate.clone().add(actualMonths, 'month'); + const newMonthStart = newDate.toDate(); + + if (isDateInRange(newMonthStart, minDateAllowed, maxDateAllowed)) { + setActiveYear(newDate.year()); + setActiveMonth(newDate.month()); + } + }, + [activeYear, activeMonth, minDateAllowed, maxDateAllowed, direction] + ); + const handleWheel = useCallback( (e: WheelEvent) => { e.preventDefault(); @@ -220,29 +230,12 @@ const Calendar = ({ scrollAccumulatorRef.current += e.deltaY; if (Math.abs(scrollAccumulatorRef.current) >= threshold) { - const currentDate = moment([activeYear, activeMonth, 1]); - const newDate = - scrollAccumulatorRef.current > 0 - ? currentDate.clone().add(1, 'month') - : currentDate.clone().subtract(1, 'month'); - - const newMonth = newDate.toDate(); - const isWithinRange = - (!minDateAllowed || - newMonth >= - moment(minDateAllowed).startOf('month').toDate()) && - (!maxDateAllowed || - newMonth <= - moment(maxDateAllowed).startOf('month').toDate()); - - if (isWithinRange) { - setActiveYear(newDate.year()); - setActiveMonth(newDate.month()); - } + const offset = scrollAccumulatorRef.current > 0 ? 1 : -1; + changeMonthBy(offset); scrollAccumulatorRef.current = 0; // Reset accumulator after month change } }, - [activeYear, activeMonth, minDateAllowed, maxDateAllowed] + [changeMonthBy] ); useEffect(() => { @@ -259,61 +252,18 @@ const Calendar = ({ }; }, [handleWheel]); - const handlePreviousMonth = useCallback(() => { - const currentDate = moment([activeYear, activeMonth, 1]); - // In RTL mode, "previous" button actually goes to next month - const newDate = - direction === CalendarDirection.RightToLeft - ? currentDate.clone().add(1, 'month') - : currentDate.clone().subtract(1, 'month'); - const newMonth = newDate.toDate(); - - const isWithinRange = - !minDateAllowed || - newMonth >= moment(minDateAllowed).startOf('month').toDate(); - - if (isWithinRange) { - setActiveYear(newDate.year()); - setActiveMonth(newDate.month()); - } - }, [activeYear, activeMonth, minDateAllowed, direction]); - - const handleNextMonth = useCallback(() => { - const currentDate = moment([activeYear, activeMonth, 1]); - // In RTL mode, "next" button actually goes to previous month - const newDate = - direction === CalendarDirection.RightToLeft - ? currentDate.clone().subtract(1, 'month') - : currentDate.clone().add(1, 'month'); - const newMonth = newDate.toDate(); - - const isWithinRange = - !maxDateAllowed || - newMonth <= moment(maxDateAllowed).startOf('month').toDate(); + const canChangeMonthBy = useCallback( + (months: number) => { + const currentDate = moment([activeYear, activeMonth, 1]); + const targetMonth = currentDate + .clone() + .add(months, 'month') + .toDate(); - if (isWithinRange) { - setActiveYear(newDate.year()); - setActiveMonth(newDate.month()); - } - }, [activeYear, activeMonth, maxDateAllowed, direction]); - - const isPreviousMonthDisabled = useMemo(() => { - if (!minDateAllowed) { - return false; - } - const currentDate = moment([activeYear, activeMonth, 1]); - const prevMonth = currentDate.clone().subtract(1, 'month').toDate(); - return prevMonth < moment(minDateAllowed).startOf('month').toDate(); - }, [activeYear, activeMonth, minDateAllowed]); - - const isNextMonthDisabled = useMemo(() => { - if (!maxDateAllowed) { - return false; - } - const currentDate = moment([activeYear, activeMonth, 1]); - const nextMonth = currentDate.clone().add(1, 'month').toDate(); - return nextMonth > moment(maxDateAllowed).startOf('month').toDate(); - }, [activeYear, activeMonth, maxDateAllowed]); + return isDateInRange(targetMonth, minDateAllowed, maxDateAllowed); + }, + [activeYear, activeMonth, minDateAllowed, maxDateAllowed] + ); const isVertical = calendarOrientation === 'vertical'; const PreviousMonthIcon = isVertical ? ArrowUpIcon : ArrowLeftIcon; @@ -327,8 +277,8 @@ const Calendar = ({