diff --git a/.prettierrc b/.prettierrc index 1fa20e43c1..937572dab6 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/prettierrc", "tabWidth": 2, "useTabs": false, + "singleQuote": false, "plugins": [ "prettier-plugin-jsdoc" ] diff --git a/9.code-workspace b/9.code-workspace index b3d1993f50..82532166f5 100644 --- a/9.code-workspace +++ b/9.code-workspace @@ -22,11 +22,15 @@ { "path": "docs", }, + { + "path": "next", + }, ], "settings": { "files.exclude": { "website": true, "docs": true, + "next": true, "typedoc": true, }, "cSpell.enabled": true, diff --git a/next/jest.config.ts b/next/jest.config.ts new file mode 100644 index 0000000000..4335fddb8c --- /dev/null +++ b/next/jest.config.ts @@ -0,0 +1,23 @@ +import { Config } from "@jest/types"; + +const config: Config.InitialOptions = { + preset: "ts-jest", + moduleNameMapper: { + "\\.css$": "identity-obj-proxy", + "^react-day-picker$": + process.env.TEST_ENV === "build" + ? "/dist/cjs/index.js" + : "/src/index.ts", + "^@/test/(.*)$": "/test/$1", + "^react-day-picker/style.module.css$": "react-day-picker/style.css", + }, + roots: ["./src", "./website/examples"], + testEnvironment: "jsdom", + coverageReporters: ["lcov", "text", "clover"], + setupFilesAfterEnv: ["./test/setup.ts"], + fakeTimers: { + enableGlobally: true, + }, +}; + +export default config; diff --git a/next/package.json b/next/package.json new file mode 100644 index 0000000000..975b7c863b --- /dev/null +++ b/next/package.json @@ -0,0 +1,101 @@ +{ + "name": "react-day-picker-next", + "version": "9.0.0-alpha.1", + "description": "Customizable Date Picker component for React", + "author": "Giampaolo Bellavite ", + "homepage": "https://react-day-picker.dev", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/gpbl/react-day-picker" + }, + "bugs": { + "url": "https://github.com/gpbl/react-day-picker/issues" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "main": "./dist/cjs/index.js", + "types": "./dist/cjs/index.d.ts", + "module": "./dist/esm/index.js", + "style": "./src/styles.css", + "exports": { + ".": { + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + }, + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./style.css": { + "default": "./src/style.css" + }, + "./style.module.css": { + "default": "./src/style.css", + "types": "./src/style.d.ts" + }, + "./package.json": { + "import": "./package.json", + "require": "./package.json", + "default": "./package.json" + } + }, + "scripts": { + "prepublish": "pnpm build", + "build-website": "pnpm run build && pnpm --filter website run build && rm -rf website/build/cache", + "build:cjs": "tsc --project tsconfig-cjs.json", + "build-typedoc-theme": "rm -rf ./typedoc-theme/dist && tsc -p ./typedoc-theme/tsconfig.json && cp -R ./typedoc-theme/src/resources ./typedoc-theme/dist", + "build:esm": "tsc --project tsconfig-esm.json", + "build": "pnpm build:cjs && pnpm build:esm", + "lint": "eslint src --ext .js,.jsx,.ts,.tsx", + "test": "jest", + "test-watch": "jest --watch", + "test-build": "TEST_ENV=build pnpm run test ./examples" + }, + "files": [ + "docs", + "dist/**", + "src/**" + ], + "peerDependencies": { + "date-fns": "^3.1.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "devDependencies": { + "@jest/types": "^29.6.3", + "@testing-library/dom": "^9.3.4", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", + "@testing-library/user-event": "^14.5.2", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.20", + "@types/react": "^18.2.59", + "@types/react-dom": "^18.2.19", + "@typescript-eslint/eslint-plugin": "^7.1.0", + "@typescript-eslint/parser": "^7.1.0", + "date-fns": "^3.3.1", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jest": "^27.9.0", + "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-testing-library": "^6.2.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "prettier": "^3.2.5", + "prettier-plugin-jsdoc": "^1.3.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "ts-jest": "^29.1.2", + "ts-node": "^10.9.2", + "tslib": "^2.6.2", + "typescript": "^5.3.3" + } +} diff --git a/next/src/.eslintignore b/next/src/.eslintignore new file mode 100644 index 0000000000..68b90b363f --- /dev/null +++ b/next/src/.eslintignore @@ -0,0 +1 @@ +style.css.d.ts diff --git a/src/DayPicker.test.tsx b/next/src/DayPicker.test.tsx similarity index 100% rename from src/DayPicker.test.tsx rename to next/src/DayPicker.test.tsx diff --git a/next/src/DayPicker.tsx b/next/src/DayPicker.tsx new file mode 100644 index 0000000000..72bdfcb34e --- /dev/null +++ b/next/src/DayPicker.tsx @@ -0,0 +1,43 @@ +import { Calendar } from "./components/Calendar"; +import { ContextProviders } from "./contexts/ContextProviders"; +import type { + Mode, + PropsBase, + PropsMulti, + PropsRange, + PropsSingle, +} from "./types/props"; + +/** Map of the props supported by selection modes. */ +export interface ModePropsMap { + single: PropsSingle; + multi: PropsMulti; + range: PropsRange; + none: object; +} + +export interface ModeProp { + mode?: T | undefined; +} + +/** + * Defines the props accepted by the DayPicker component. + * + * @see https://react-day-picker.dev/api/daypickerprops + */ +export type DayPickerProps = PropsBase & + ModeProp & + ModePropsMap[T]; + +/** + * Render the date picker component. + * + * @see https://react-day-picker.js.org + */ +export function DayPicker(props: DayPickerProps) { + return ( + + + + ); +} diff --git a/src/classes/CalendarDay.test.ts b/next/src/classes/CalendarDay.test.ts similarity index 100% rename from src/classes/CalendarDay.test.ts rename to next/src/classes/CalendarDay.test.ts diff --git a/src/classes/CalendarDay.ts b/next/src/classes/CalendarDay.ts similarity index 100% rename from src/classes/CalendarDay.ts rename to next/src/classes/CalendarDay.ts diff --git a/src/classes/CalendarMonth.test.ts b/next/src/classes/CalendarMonth.test.ts similarity index 100% rename from src/classes/CalendarMonth.test.ts rename to next/src/classes/CalendarMonth.test.ts diff --git a/src/classes/CalendarMonth.ts b/next/src/classes/CalendarMonth.ts similarity index 100% rename from src/classes/CalendarMonth.ts rename to next/src/classes/CalendarMonth.ts diff --git a/src/classes/CalendarWeek.test.ts b/next/src/classes/CalendarWeek.test.ts similarity index 100% rename from src/classes/CalendarWeek.test.ts rename to next/src/classes/CalendarWeek.test.ts diff --git a/src/classes/CalendarWeek.ts b/next/src/classes/CalendarWeek.ts similarity index 100% rename from src/classes/CalendarWeek.ts rename to next/src/classes/CalendarWeek.ts diff --git a/src/classes/index.ts b/next/src/classes/index.ts similarity index 100% rename from src/classes/index.ts rename to next/src/classes/index.ts diff --git a/src/components/Button.tsx b/next/src/components/Button.tsx similarity index 100% rename from src/components/Button.tsx rename to next/src/components/Button.tsx diff --git a/src/components/Calendar.test.tsx b/next/src/components/Calendar.test.tsx similarity index 100% rename from src/components/Calendar.test.tsx rename to next/src/components/Calendar.test.tsx diff --git a/src/components/Calendar.tsx b/next/src/components/Calendar.tsx similarity index 100% rename from src/components/Calendar.tsx rename to next/src/components/Calendar.tsx diff --git a/src/components/Chevron.tsx b/next/src/components/Chevron.tsx similarity index 100% rename from src/components/Chevron.tsx rename to next/src/components/Chevron.tsx diff --git a/src/components/DayGridCell.tsx b/next/src/components/DayGridCell.tsx similarity index 100% rename from src/components/DayGridCell.tsx rename to next/src/components/DayGridCell.tsx diff --git a/src/components/DayGridCellWrapper.tsx b/next/src/components/DayGridCellWrapper.tsx similarity index 100% rename from src/components/DayGridCellWrapper.tsx rename to next/src/components/DayGridCellWrapper.tsx diff --git a/src/components/Dropdown.tsx b/next/src/components/Dropdown.tsx similarity index 100% rename from src/components/Dropdown.tsx rename to next/src/components/Dropdown.tsx diff --git a/src/components/DropdownNav.tsx b/next/src/components/DropdownNav.tsx similarity index 100% rename from src/components/DropdownNav.tsx rename to next/src/components/DropdownNav.tsx diff --git a/src/components/Footer.tsx b/next/src/components/Footer.tsx similarity index 100% rename from src/components/Footer.tsx rename to next/src/components/Footer.tsx diff --git a/src/components/MonthCaption.tsx b/next/src/components/MonthCaption.tsx similarity index 100% rename from src/components/MonthCaption.tsx rename to next/src/components/MonthCaption.tsx diff --git a/src/components/MonthGrid.tsx b/next/src/components/MonthGrid.tsx similarity index 100% rename from src/components/MonthGrid.tsx rename to next/src/components/MonthGrid.tsx diff --git a/src/components/Months.tsx b/next/src/components/Months.tsx similarity index 100% rename from src/components/Months.tsx rename to next/src/components/Months.tsx diff --git a/src/components/MonthsDropdown.tsx b/next/src/components/MonthsDropdown.tsx similarity index 100% rename from src/components/MonthsDropdown.tsx rename to next/src/components/MonthsDropdown.tsx diff --git a/src/components/Nav.tsx b/next/src/components/Nav.tsx similarity index 100% rename from src/components/Nav.tsx rename to next/src/components/Nav.tsx diff --git a/src/components/Option.tsx b/next/src/components/Option.tsx similarity index 100% rename from src/components/Option.tsx rename to next/src/components/Option.tsx diff --git a/src/components/Select.tsx b/next/src/components/Select.tsx similarity index 100% rename from src/components/Select.tsx rename to next/src/components/Select.tsx diff --git a/src/components/WeekNumberRowHeader.tsx b/next/src/components/WeekNumberRowHeader.tsx similarity index 100% rename from src/components/WeekNumberRowHeader.tsx rename to next/src/components/WeekNumberRowHeader.tsx diff --git a/src/components/WeekRow.tsx b/next/src/components/WeekRow.tsx similarity index 100% rename from src/components/WeekRow.tsx rename to next/src/components/WeekRow.tsx diff --git a/src/components/WeekdayColumnHeader.tsx b/next/src/components/WeekdayColumnHeader.tsx similarity index 100% rename from src/components/WeekdayColumnHeader.tsx rename to next/src/components/WeekdayColumnHeader.tsx diff --git a/src/components/WeekdaysRow.tsx b/next/src/components/WeekdaysRow.tsx similarity index 100% rename from src/components/WeekdaysRow.tsx rename to next/src/components/WeekdaysRow.tsx diff --git a/src/components/YearsDropdown.tsx b/next/src/components/YearsDropdown.tsx similarity index 100% rename from src/components/YearsDropdown.tsx rename to next/src/components/YearsDropdown.tsx diff --git a/src/components/custom-components.ts b/next/src/components/custom-components.ts similarity index 100% rename from src/components/custom-components.ts rename to next/src/components/custom-components.ts diff --git a/src/components/utils/getClassNamesForModifiers.ts b/next/src/components/utils/getClassNamesForModifiers.ts similarity index 100% rename from src/components/utils/getClassNamesForModifiers.ts rename to next/src/components/utils/getClassNamesForModifiers.ts diff --git a/src/components/utils/getStyleForModifiers.ts b/next/src/components/utils/getStyleForModifiers.ts similarity index 100% rename from src/components/utils/getStyleForModifiers.ts rename to next/src/components/utils/getStyleForModifiers.ts diff --git a/src/components/utils/getWeekdays.test.ts b/next/src/components/utils/getWeekdays.test.ts similarity index 100% rename from src/components/utils/getWeekdays.test.ts rename to next/src/components/utils/getWeekdays.test.ts diff --git a/src/components/utils/getWeekdays.ts b/next/src/components/utils/getWeekdays.ts similarity index 100% rename from src/components/utils/getWeekdays.ts rename to next/src/components/utils/getWeekdays.ts diff --git a/src/contexts/CalendarContext/CalendarContext.test.tsx b/next/src/contexts/CalendarContext/CalendarContext.test.tsx similarity index 100% rename from src/contexts/CalendarContext/CalendarContext.test.tsx rename to next/src/contexts/CalendarContext/CalendarContext.test.tsx diff --git a/src/contexts/CalendarContext/CalendarContext.tsx b/next/src/contexts/CalendarContext/CalendarContext.tsx similarity index 100% rename from src/contexts/CalendarContext/CalendarContext.tsx rename to next/src/contexts/CalendarContext/CalendarContext.tsx diff --git a/src/contexts/CalendarContext/index.ts b/next/src/contexts/CalendarContext/index.ts similarity index 100% rename from src/contexts/CalendarContext/index.ts rename to next/src/contexts/CalendarContext/index.ts diff --git a/src/contexts/CalendarContext/utils/getDates.test.ts b/next/src/contexts/CalendarContext/utils/getDates.test.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getDates.test.ts rename to next/src/contexts/CalendarContext/utils/getDates.test.ts diff --git a/src/contexts/CalendarContext/utils/getDates.ts b/next/src/contexts/CalendarContext/utils/getDates.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getDates.ts rename to next/src/contexts/CalendarContext/utils/getDates.ts diff --git a/src/contexts/CalendarContext/utils/getDays.test.ts b/next/src/contexts/CalendarContext/utils/getDays.test.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getDays.test.ts rename to next/src/contexts/CalendarContext/utils/getDays.test.ts diff --git a/src/contexts/CalendarContext/utils/getDays.ts b/next/src/contexts/CalendarContext/utils/getDays.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getDays.ts rename to next/src/contexts/CalendarContext/utils/getDays.ts diff --git a/src/contexts/CalendarContext/utils/getDisplayMonths.test.ts b/next/src/contexts/CalendarContext/utils/getDisplayMonths.test.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getDisplayMonths.test.ts rename to next/src/contexts/CalendarContext/utils/getDisplayMonths.test.ts diff --git a/src/contexts/CalendarContext/utils/getDisplayMonths.ts b/next/src/contexts/CalendarContext/utils/getDisplayMonths.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getDisplayMonths.ts rename to next/src/contexts/CalendarContext/utils/getDisplayMonths.ts diff --git a/src/contexts/CalendarContext/utils/getDropdownMonths.test.ts b/next/src/contexts/CalendarContext/utils/getDropdownMonths.test.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getDropdownMonths.test.ts rename to next/src/contexts/CalendarContext/utils/getDropdownMonths.test.ts diff --git a/src/contexts/CalendarContext/utils/getDropdownMonths.ts b/next/src/contexts/CalendarContext/utils/getDropdownMonths.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getDropdownMonths.ts rename to next/src/contexts/CalendarContext/utils/getDropdownMonths.ts diff --git a/src/contexts/CalendarContext/utils/getDropdownYears.test.ts b/next/src/contexts/CalendarContext/utils/getDropdownYears.test.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getDropdownYears.test.ts rename to next/src/contexts/CalendarContext/utils/getDropdownYears.test.ts diff --git a/src/contexts/CalendarContext/utils/getDropdownYears.ts b/next/src/contexts/CalendarContext/utils/getDropdownYears.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getDropdownYears.ts rename to next/src/contexts/CalendarContext/utils/getDropdownYears.ts diff --git a/src/contexts/CalendarContext/utils/getFirstLastMonths.test.ts b/next/src/contexts/CalendarContext/utils/getFirstLastMonths.test.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getFirstLastMonths.test.ts rename to next/src/contexts/CalendarContext/utils/getFirstLastMonths.test.ts diff --git a/src/contexts/CalendarContext/utils/getFirstLastMonths.ts b/next/src/contexts/CalendarContext/utils/getFirstLastMonths.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getFirstLastMonths.ts rename to next/src/contexts/CalendarContext/utils/getFirstLastMonths.ts diff --git a/src/contexts/CalendarContext/utils/getMonths.test.ts b/next/src/contexts/CalendarContext/utils/getMonths.test.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getMonths.test.ts rename to next/src/contexts/CalendarContext/utils/getMonths.test.ts diff --git a/src/contexts/CalendarContext/utils/getMonths.ts b/next/src/contexts/CalendarContext/utils/getMonths.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getMonths.ts rename to next/src/contexts/CalendarContext/utils/getMonths.ts diff --git a/src/contexts/CalendarContext/utils/getNextMonth.test.ts b/next/src/contexts/CalendarContext/utils/getNextMonth.test.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getNextMonth.test.ts rename to next/src/contexts/CalendarContext/utils/getNextMonth.test.ts diff --git a/src/contexts/CalendarContext/utils/getNextMonth.ts b/next/src/contexts/CalendarContext/utils/getNextMonth.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getNextMonth.ts rename to next/src/contexts/CalendarContext/utils/getNextMonth.ts diff --git a/src/contexts/CalendarContext/utils/getPreviousMonth.test.ts b/next/src/contexts/CalendarContext/utils/getPreviousMonth.test.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getPreviousMonth.test.ts rename to next/src/contexts/CalendarContext/utils/getPreviousMonth.test.ts diff --git a/src/contexts/CalendarContext/utils/getPreviousMonth.ts b/next/src/contexts/CalendarContext/utils/getPreviousMonth.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getPreviousMonth.ts rename to next/src/contexts/CalendarContext/utils/getPreviousMonth.ts diff --git a/src/contexts/CalendarContext/utils/getStartMonth.test.ts b/next/src/contexts/CalendarContext/utils/getStartMonth.test.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getStartMonth.test.ts rename to next/src/contexts/CalendarContext/utils/getStartMonth.test.ts diff --git a/src/contexts/CalendarContext/utils/getStartMonth.ts b/next/src/contexts/CalendarContext/utils/getStartMonth.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getStartMonth.ts rename to next/src/contexts/CalendarContext/utils/getStartMonth.ts diff --git a/src/contexts/CalendarContext/utils/getWeeks.test.ts b/next/src/contexts/CalendarContext/utils/getWeeks.test.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getWeeks.test.ts rename to next/src/contexts/CalendarContext/utils/getWeeks.test.ts diff --git a/src/contexts/CalendarContext/utils/getWeeks.ts b/next/src/contexts/CalendarContext/utils/getWeeks.ts similarity index 100% rename from src/contexts/CalendarContext/utils/getWeeks.ts rename to next/src/contexts/CalendarContext/utils/getWeeks.ts diff --git a/src/contexts/ContextProviders.tsx b/next/src/contexts/ContextProviders.tsx similarity index 100% rename from src/contexts/ContextProviders.tsx rename to next/src/contexts/ContextProviders.tsx diff --git a/src/contexts/DayPickerContext/DayPickerContext.tsx b/next/src/contexts/DayPickerContext/DayPickerContext.tsx similarity index 100% rename from src/contexts/DayPickerContext/DayPickerContext.tsx rename to next/src/contexts/DayPickerContext/DayPickerContext.tsx diff --git a/src/contexts/DayPickerContext/__snapshots__/defaultClassNames.test.ts.snap b/next/src/contexts/DayPickerContext/__snapshots__/defaultClassNames.test.ts.snap similarity index 100% rename from src/contexts/DayPickerContext/__snapshots__/defaultClassNames.test.ts.snap rename to next/src/contexts/DayPickerContext/__snapshots__/defaultClassNames.test.ts.snap diff --git a/src/contexts/DayPickerContext/defaultClassNames.test.ts b/next/src/contexts/DayPickerContext/defaultClassNames.test.ts similarity index 100% rename from src/contexts/DayPickerContext/defaultClassNames.test.ts rename to next/src/contexts/DayPickerContext/defaultClassNames.test.ts diff --git a/src/contexts/DayPickerContext/defaultClassNames.ts b/next/src/contexts/DayPickerContext/defaultClassNames.ts similarity index 100% rename from src/contexts/DayPickerContext/defaultClassNames.ts rename to next/src/contexts/DayPickerContext/defaultClassNames.ts diff --git a/src/contexts/DayPickerContext/index.ts b/next/src/contexts/DayPickerContext/index.ts similarity index 100% rename from src/contexts/DayPickerContext/index.ts rename to next/src/contexts/DayPickerContext/index.ts diff --git a/src/contexts/DayPickerContext/utils/getClassNames.tsx b/next/src/contexts/DayPickerContext/utils/getClassNames.tsx similarity index 100% rename from src/contexts/DayPickerContext/utils/getClassNames.tsx rename to next/src/contexts/DayPickerContext/utils/getClassNames.tsx diff --git a/src/contexts/DayPickerContext/utils/getDataAttributes.tsx b/next/src/contexts/DayPickerContext/utils/getDataAttributes.tsx similarity index 100% rename from src/contexts/DayPickerContext/utils/getDataAttributes.tsx rename to next/src/contexts/DayPickerContext/utils/getDataAttributes.tsx diff --git a/src/contexts/DayPickerContext/utils/getFormatters.tsx b/next/src/contexts/DayPickerContext/utils/getFormatters.tsx similarity index 100% rename from src/contexts/DayPickerContext/utils/getFormatters.tsx rename to next/src/contexts/DayPickerContext/utils/getFormatters.tsx diff --git a/src/contexts/DayPickerContext/utils/getFromToDate.test.ts b/next/src/contexts/DayPickerContext/utils/getFromToDate.test.ts similarity index 100% rename from src/contexts/DayPickerContext/utils/getFromToDate.test.ts rename to next/src/contexts/DayPickerContext/utils/getFromToDate.test.ts diff --git a/src/contexts/DayPickerContext/utils/getFromToDate.ts b/next/src/contexts/DayPickerContext/utils/getFromToDate.ts similarity index 100% rename from src/contexts/DayPickerContext/utils/getFromToDate.ts rename to next/src/contexts/DayPickerContext/utils/getFromToDate.ts diff --git a/src/contexts/DayPickerContext/utils/getLabels.tsx b/next/src/contexts/DayPickerContext/utils/getLabels.tsx similarity index 100% rename from src/contexts/DayPickerContext/utils/getLabels.tsx rename to next/src/contexts/DayPickerContext/utils/getLabels.tsx diff --git a/src/contexts/FocusContext/FocusContext.tsx b/next/src/contexts/FocusContext/FocusContext.tsx similarity index 100% rename from src/contexts/FocusContext/FocusContext.tsx rename to next/src/contexts/FocusContext/FocusContext.tsx diff --git a/src/contexts/FocusContext/index.ts b/next/src/contexts/FocusContext/index.ts similarity index 100% rename from src/contexts/FocusContext/index.ts rename to next/src/contexts/FocusContext/index.ts diff --git a/src/contexts/FocusContext/utils/getNextFocus.test.tsx b/next/src/contexts/FocusContext/utils/getNextFocus.test.tsx similarity index 100% rename from src/contexts/FocusContext/utils/getNextFocus.test.tsx rename to next/src/contexts/FocusContext/utils/getNextFocus.test.tsx diff --git a/src/contexts/FocusContext/utils/getNextFocus.tsx b/next/src/contexts/FocusContext/utils/getNextFocus.tsx similarity index 100% rename from src/contexts/FocusContext/utils/getNextFocus.tsx rename to next/src/contexts/FocusContext/utils/getNextFocus.tsx diff --git a/src/contexts/FocusContext/utils/getPossibleFocusDate.test.ts b/next/src/contexts/FocusContext/utils/getPossibleFocusDate.test.ts similarity index 100% rename from src/contexts/FocusContext/utils/getPossibleFocusDate.test.ts rename to next/src/contexts/FocusContext/utils/getPossibleFocusDate.test.ts diff --git a/src/contexts/FocusContext/utils/getPossibleFocusDate.ts b/next/src/contexts/FocusContext/utils/getPossibleFocusDate.ts similarity index 100% rename from src/contexts/FocusContext/utils/getPossibleFocusDate.ts rename to next/src/contexts/FocusContext/utils/getPossibleFocusDate.ts diff --git a/src/contexts/ModifiersContext/ModifiersContext.tsx b/next/src/contexts/ModifiersContext/ModifiersContext.tsx similarity index 100% rename from src/contexts/ModifiersContext/ModifiersContext.tsx rename to next/src/contexts/ModifiersContext/ModifiersContext.tsx diff --git a/src/contexts/ModifiersContext/index.ts b/next/src/contexts/ModifiersContext/index.ts similarity index 100% rename from src/contexts/ModifiersContext/index.ts rename to next/src/contexts/ModifiersContext/index.ts diff --git a/src/contexts/ModifiersContext/utils/dateMatchModifiers.test.ts b/next/src/contexts/ModifiersContext/utils/dateMatchModifiers.test.ts similarity index 100% rename from src/contexts/ModifiersContext/utils/dateMatchModifiers.test.ts rename to next/src/contexts/ModifiersContext/utils/dateMatchModifiers.test.ts diff --git a/src/contexts/ModifiersContext/utils/dateMatchModifiers.ts b/next/src/contexts/ModifiersContext/utils/dateMatchModifiers.ts similarity index 100% rename from src/contexts/ModifiersContext/utils/dateMatchModifiers.ts rename to next/src/contexts/ModifiersContext/utils/dateMatchModifiers.ts diff --git a/src/contexts/SelectionContext/SelectionContext.tsx b/next/src/contexts/SelectionContext/SelectionContext.tsx similarity index 100% rename from src/contexts/SelectionContext/SelectionContext.tsx rename to next/src/contexts/SelectionContext/SelectionContext.tsx diff --git a/src/contexts/SelectionContext/index.ts b/next/src/contexts/SelectionContext/index.ts similarity index 100% rename from src/contexts/SelectionContext/index.ts rename to next/src/contexts/SelectionContext/index.ts diff --git a/src/contexts/SelectionContext/utils/addToRange.test.ts b/next/src/contexts/SelectionContext/utils/addToRange.test.ts similarity index 100% rename from src/contexts/SelectionContext/utils/addToRange.test.ts rename to next/src/contexts/SelectionContext/utils/addToRange.test.ts diff --git a/src/contexts/SelectionContext/utils/addToRange.ts b/next/src/contexts/SelectionContext/utils/addToRange.ts similarity index 100% rename from src/contexts/SelectionContext/utils/addToRange.ts rename to next/src/contexts/SelectionContext/utils/addToRange.ts diff --git a/src/formatters/formatCaption.test.ts b/next/src/formatters/formatCaption.test.ts similarity index 100% rename from src/formatters/formatCaption.test.ts rename to next/src/formatters/formatCaption.test.ts diff --git a/src/formatters/formatCaption.ts b/next/src/formatters/formatCaption.ts similarity index 100% rename from src/formatters/formatCaption.ts rename to next/src/formatters/formatCaption.ts diff --git a/src/formatters/formatDay.test.ts b/next/src/formatters/formatDay.test.ts similarity index 100% rename from src/formatters/formatDay.test.ts rename to next/src/formatters/formatDay.test.ts diff --git a/src/formatters/formatDay.ts b/next/src/formatters/formatDay.ts similarity index 100% rename from src/formatters/formatDay.ts rename to next/src/formatters/formatDay.ts diff --git a/src/formatters/formatMonthDropdown.test.ts b/next/src/formatters/formatMonthDropdown.test.ts similarity index 100% rename from src/formatters/formatMonthDropdown.test.ts rename to next/src/formatters/formatMonthDropdown.test.ts diff --git a/src/formatters/formatMonthDropdown.ts b/next/src/formatters/formatMonthDropdown.ts similarity index 100% rename from src/formatters/formatMonthDropdown.ts rename to next/src/formatters/formatMonthDropdown.ts diff --git a/src/formatters/formatWeekNumber.test.ts b/next/src/formatters/formatWeekNumber.test.ts similarity index 100% rename from src/formatters/formatWeekNumber.test.ts rename to next/src/formatters/formatWeekNumber.test.ts diff --git a/src/formatters/formatWeekNumber.ts b/next/src/formatters/formatWeekNumber.ts similarity index 100% rename from src/formatters/formatWeekNumber.ts rename to next/src/formatters/formatWeekNumber.ts diff --git a/src/formatters/formatWeekdayName.test.ts b/next/src/formatters/formatWeekdayName.test.ts similarity index 100% rename from src/formatters/formatWeekdayName.test.ts rename to next/src/formatters/formatWeekdayName.test.ts diff --git a/src/formatters/formatWeekdayName.ts b/next/src/formatters/formatWeekdayName.ts similarity index 100% rename from src/formatters/formatWeekdayName.ts rename to next/src/formatters/formatWeekdayName.ts diff --git a/src/formatters/formatYearDropdown.test.ts b/next/src/formatters/formatYearDropdown.test.ts similarity index 100% rename from src/formatters/formatYearDropdown.test.ts rename to next/src/formatters/formatYearDropdown.test.ts diff --git a/src/formatters/formatYearDropdown.ts b/next/src/formatters/formatYearDropdown.ts similarity index 100% rename from src/formatters/formatYearDropdown.ts rename to next/src/formatters/formatYearDropdown.ts diff --git a/src/formatters/index.ts b/next/src/formatters/index.ts similarity index 100% rename from src/formatters/index.ts rename to next/src/formatters/index.ts diff --git a/next/src/index.ts b/next/src/index.ts new file mode 100644 index 0000000000..7fb84bf274 --- /dev/null +++ b/next/src/index.ts @@ -0,0 +1,11 @@ +export * from "./DayPicker"; +export * from "./contexts/CalendarContext"; +export * from "./contexts/SelectionContext"; +export * from "./contexts/DayPickerContext"; + +export * from "./classes"; +export * from "./components/custom-components"; +export * from "./formatters"; +export * from "./labels"; + +export * from "./types"; diff --git a/src/labels/index.ts b/next/src/labels/index.ts similarity index 100% rename from src/labels/index.ts rename to next/src/labels/index.ts diff --git a/src/labels/labelDay.test.ts b/next/src/labels/labelDay.test.ts similarity index 100% rename from src/labels/labelDay.test.ts rename to next/src/labels/labelDay.test.ts diff --git a/src/labels/labelDay.ts b/next/src/labels/labelDay.ts similarity index 100% rename from src/labels/labelDay.ts rename to next/src/labels/labelDay.ts diff --git a/src/labels/labelGrid.ts b/next/src/labels/labelGrid.ts similarity index 100% rename from src/labels/labelGrid.ts rename to next/src/labels/labelGrid.ts diff --git a/src/labels/labelMonthDropdown.test.ts b/next/src/labels/labelMonthDropdown.test.ts similarity index 100% rename from src/labels/labelMonthDropdown.test.ts rename to next/src/labels/labelMonthDropdown.test.ts diff --git a/src/labels/labelMonthDropdown.ts b/next/src/labels/labelMonthDropdown.ts similarity index 100% rename from src/labels/labelMonthDropdown.ts rename to next/src/labels/labelMonthDropdown.ts diff --git a/src/labels/labelNext.test.ts b/next/src/labels/labelNext.test.ts similarity index 100% rename from src/labels/labelNext.test.ts rename to next/src/labels/labelNext.test.ts diff --git a/src/labels/labelNext.ts b/next/src/labels/labelNext.ts similarity index 100% rename from src/labels/labelNext.ts rename to next/src/labels/labelNext.ts diff --git a/src/labels/labelPrevious.test.ts b/next/src/labels/labelPrevious.test.ts similarity index 100% rename from src/labels/labelPrevious.test.ts rename to next/src/labels/labelPrevious.test.ts diff --git a/src/labels/labelPrevious.ts b/next/src/labels/labelPrevious.ts similarity index 100% rename from src/labels/labelPrevious.ts rename to next/src/labels/labelPrevious.ts diff --git a/src/labels/labelWeekNumber.test.ts b/next/src/labels/labelWeekNumber.test.ts similarity index 100% rename from src/labels/labelWeekNumber.test.ts rename to next/src/labels/labelWeekNumber.test.ts diff --git a/src/labels/labelWeekNumber.ts b/next/src/labels/labelWeekNumber.ts similarity index 100% rename from src/labels/labelWeekNumber.ts rename to next/src/labels/labelWeekNumber.ts diff --git a/src/labels/labelWeekNumberHeader.test.ts b/next/src/labels/labelWeekNumberHeader.test.ts similarity index 100% rename from src/labels/labelWeekNumberHeader.test.ts rename to next/src/labels/labelWeekNumberHeader.test.ts diff --git a/src/labels/labelWeekNumberHeader.ts b/next/src/labels/labelWeekNumberHeader.ts similarity index 100% rename from src/labels/labelWeekNumberHeader.ts rename to next/src/labels/labelWeekNumberHeader.ts diff --git a/src/labels/labelWeekday.test.ts b/next/src/labels/labelWeekday.test.ts similarity index 100% rename from src/labels/labelWeekday.test.ts rename to next/src/labels/labelWeekday.test.ts diff --git a/src/labels/labelWeekday.ts b/next/src/labels/labelWeekday.ts similarity index 100% rename from src/labels/labelWeekday.ts rename to next/src/labels/labelWeekday.ts diff --git a/src/labels/labelYearDropdown.test.ts b/next/src/labels/labelYearDropdown.test.ts similarity index 100% rename from src/labels/labelYearDropdown.test.ts rename to next/src/labels/labelYearDropdown.test.ts diff --git a/src/labels/labelYearDropdown.ts b/next/src/labels/labelYearDropdown.ts similarity index 100% rename from src/labels/labelYearDropdown.ts rename to next/src/labels/labelYearDropdown.ts diff --git a/next/src/style.css b/next/src/style.css new file mode 100644 index 0000000000..f29c646dba --- /dev/null +++ b/next/src/style.css @@ -0,0 +1,348 @@ +.rdp { + --rdp-months-gap: 2rem; + --rdp-cell-size: 2.75rem; + --rdp-font-small: 0.8125rem; + --rdp-font-medium: 1rem; + --rdp-font-large: 1.25rem; + --rdp-opacity: 0.5; + + --rdp-accent: #007aff; + --rdp-background: #e0efff; + + --rdp-accent-dark: #0a84ff; + --rdp-background-dark: #02203d; + + --rdp-outline: 2px solid var(--rdp-accent); /* Outline border for focused elements */ + --rdp-outline-selected: 3px solid var(--rdp-accent); /* Outline border for focused _and_ selected elements */ + + display: inline-block; + position: relative; + box-sizing: border-box; +} + +.rdp * { + box-sizing: border-box; +} + +.months_wrapper { + display: flex; + flex-wrap: wrap; + gap: var(--rdp-months-gap); + justify-content: center; +} + +.caption { + display: flex; + align-items: center; +} + +.multiple_months .caption { + position: relative; + display: block; + text-align: center; +} + +.caption_dropdowns, +.dropdown_nav { + position: relative; + display: inline-flex; +} + +.caption_dropdowns::after, +.dropdown_root::after { + position: absolute; + top: 50%; + right: 5px; /* Position the arrow on the right side */ + transform: translateY(-50%); /* Center the arrow vertically */ + pointer-events: none; /* Make it non-clickable */ +} + +.caption_label { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + margin: 0; + padding: 0.125em 0.25em; + white-space: nowrap; + color: currentColor; + border: 0; + border: 2px solid transparent; +} + +.caption_label svg { + margin: 0 0 0 5px; +} + +.nav { + inset-block-start: 0; + position: absolute; + inset-inline-end: 0; + white-space: nowrap; + padding: 0.25em; +} + +.multiple_months .caption_start .nav { + position: absolute; + inset-block-start: 50%; + inset-inline-start: 0; + transform: translateY(-50%); +} + +.multiple_months .caption_end .nav { + position: absolute; + inset-block-start: 50%; + inset-inline-end: 0; + transform: translateY(-50%); +} + +.button_next, +.button_previous { + -moz-appearance: none; + -webkit-appearance: none; + align-items: center; + appearance: none; + background: none; + border: 0; + border-radius: 100%; + color: inherit; + cursor: pointer; + display: inline-flex; + font: inherit; + justify-content: center; + margin: 0; + padding: 0; + padding: 0.25em; + position: relative; +} + +.button_next > svg, +.button_previous > svg, +.caption_label > svg { + fill: var(--rdp-accent); +} + +.button_next:disabled, +.button_next[aria-disabled="true"], +.button_previous:disabled, +.button_previous[aria-disabled="true"] { + opacity: var(--rdp-opacity); + cursor: default; +} + +.rdp[dir="rtl"] .button_next { + transform: rotate(180deg); +} + +.rdp[dir="rtl"] .button_previous { + transform: rotate(180deg); + transform-origin: 50%; +} + +/* ---------- */ +/* Dropdowns */ +/* ---------- */ + +.dropdown_year, /* Remove in v10 as .dropdown_root is added anyway */ +.dropdown_month, /* Remove in v10 as .dropdown_root is added anyway */ +.dropdown_root { + position: relative; + display: inline-flex; + align-items: center; +} + +.dropdown { + appearance: none; + position: absolute; + z-index: 2; + inset-block-start: 0; + bottom: 0; + inset-inline-start: 0; + width: 100%; + margin: 0; + padding: 0; + cursor: inherit; + opacity: 0; + border: none; + background-color: transparent; + line-height: inherit; + + font: revert; + font-size: revert; + font-size: 1rem; +} + +.dropdown[disabled], +.dropdown[aria-disabled="true"] { + opacity: unset; + color: unset; +} +/* +.dropdown:focus-visible:not([disabled]):not([aria-disabled='true']) + + .caption_label, +.dropdown:focus-visible:not([disabled]):not([aria-disabled='true']) + + .caption_label { + background-color: var(--rdp-background); + border: var(--rdp-outline); + border-radius: 6px; +} */ + +.dropdown_icon { + margin: 0 0 0 5px; +} + +.head { +} + +.head_row, +.row { + display: flex; + padding: 0 10px; +} + +.head_cell { + text-transform: uppercase; + width: var(--rdp-cell-size); + height: var(--rdp-cell-size); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.footer { + margin: 0.5em 0; +} + +.cell { + width: var(--rdp-cell-size); + height: var(--rdp-cell-size); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.day { + cursor: pointer; + justify-content: center; + align-items: center; + display: flex; + width: var(--rdp-cell-size); + height: var(--rdp-cell-size); + border: 2px solid transparent; + border-radius: 100%; + font-size: var(--rdp-font-medium); +} + +.day:focus { + outline: 2px solid #5b9dd9; + outline-offset: -2px; + outline: -webkit-focus-ring-color auto 1px; + outline: -moz-mac-focusring auto 1px; +} + +.day_today:not(.day_outside) { + color: var(--rdp-accent); +} + +.day_selected, +.day_selected.day_outside { + opacity: 1; + /* outline: none; */ + /* color: var(--rdp-accent); */ + background-color: var(--rdp-background); + font-size: var(--rdp-font-large); + font-weight: 600; + border-color: var(--rdp-accent); +} + +.day_selected:focus-visible { + outline-offset: -1px; +} + +.day_outside { + opacity: var(--rdp-opacity); +} + +.day_disabled { + opacity: var(--rdp-opacity); + cursor: default; +} + +.day_excluded:not(.day_selected) { + opacity: var(--rdp-opacity); + cursor: default; +} + +.day_hidden { + visibility: hidden; +} + +.day_range_start:not(.day_range_end) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.day_range_end:not(.day_range_start) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.rdp[dir="rtl"] .day_range_start:not(.day_range_end) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.rdp[dir="rtl"] .day_range_end:not(.day_range_start) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.day_range_end.day_range_start { + border-radius: 100%; +} + +.day_range_middle { + border-radius: 0; +} + +.weeknumber_rowheader { + justify-content: center; + align-items: center; + display: flex; + height: var(--rdp-cell-size); + border: 2px solid transparent; + border-radius: 100%; + font-size: var(--rdp-font-small); + font-weight: 500; + opacity: var(--rdp-opacity); +} + +.weekday_columnheader { + text-align: center; + opacity: var(--rdp-opacity); + text-transform: uppercase; + font-size: var(--rdp-font-small); + font-weight: 500; + padding: 0.25rem 0 0.5rem; +} + +.hide_weekdays .weekday_columnheader { + padding: 0; +} + +.weekdays_row, +.week_row { + display: grid; + grid-template-columns: repeat(7, 1fr); +} + +.with_weeknumber .weekdays_row, +.with_weeknumber .week_row { + grid-template-columns: repeat(8, 1fr); +} + +.month_caption { + margin-bottom: 0.5em; + font-size: var(--rdp-font-large); + font-weight: 600; +} diff --git a/src/style.d.ts b/next/src/style.d.ts similarity index 100% rename from src/style.d.ts rename to next/src/style.d.ts diff --git a/src/types.test.tsx b/next/src/types.test.tsx similarity index 100% rename from src/types.test.tsx rename to next/src/types.test.tsx diff --git a/src/types/events.ts b/next/src/types/events.ts similarity index 100% rename from src/types/events.ts rename to next/src/types/events.ts diff --git a/next/src/types/formatters.ts b/next/src/types/formatters.ts new file mode 100644 index 0000000000..4a405ef757 --- /dev/null +++ b/next/src/types/formatters.ts @@ -0,0 +1,29 @@ +import { formatCaption, formatMonthCaption } from "../formatters/formatCaption"; +import { formatDay } from "../formatters/formatDay"; +import { formatMonthDropdown } from "../formatters/formatMonthDropdown"; +import { formatWeekdayName } from "../formatters/formatWeekdayName"; +import { formatWeekNumber } from "../formatters/formatWeekNumber"; +import { + formatYearCaption, + formatYearDropdown, +} from "../formatters/formatYearDropdown"; + +/** Represent a map of formatters used to render localized content. */ +export type Formatters = { + /** Format the caption of a month grid. */ + formatCaption: typeof formatCaption; + /** @deprecated Use {@link Formatters.formatCaption} instead. */ + formatMonthCaption: typeof formatMonthCaption; + /** Format the label in the month dropdown. */ + formatMonthDropdown: typeof formatMonthDropdown; + /** @deprecated Use {@link Formatters.formatYearDropdown} instead. */ + formatYearCaption: typeof formatYearCaption; + /** Format the label in the year dropdown. */ + formatYearDropdown: typeof formatYearDropdown; + /** Format the day in the day cell. */ + formatDay: typeof formatDay; + /** Format the week number. */ + formatWeekNumber: typeof formatWeekNumber; + /** Format the week day name in the header */ + formatWeekdayName: typeof formatWeekdayName; +}; diff --git a/src/types/index.ts b/next/src/types/index.ts similarity index 100% rename from src/types/index.ts rename to next/src/types/index.ts diff --git a/next/src/types/labels.ts b/next/src/types/labels.ts new file mode 100644 index 0000000000..e66a6f89d8 --- /dev/null +++ b/next/src/types/labels.ts @@ -0,0 +1,28 @@ +import { labelDay } from "../labels/labelDay"; +import { labelMonthDropdown } from "../labels/labelMonthDropdown"; +import { labelNext } from "../labels/labelNext"; +import { labelPrevious } from "../labels/labelPrevious"; +import { labelWeekday } from "../labels/labelWeekday"; +import { labelWeekNumber } from "../labels/labelWeekNumber"; +import { labelWeekNumberHeader } from "../labels/labelWeekNumberHeader"; +import { labelYearDropdown } from "../labels/labelYearDropdown"; + +/** Map of functions returning ARIA labels for the relative elements. */ +export type Labels = { + /** Return the label for the month dropdown. */ + labelMonthDropdown: typeof labelMonthDropdown; + /** Return the label for the year dropdown. */ + labelYearDropdown: typeof labelYearDropdown; + /** Return the label for the next month button. */ + labelNext: typeof labelNext; + /** Return the label for the previous month button. */ + labelPrevious: typeof labelPrevious; + /** Return the label for the day cell. */ + labelDay: typeof labelDay; + /** Return the label for the weekday. */ + labelWeekday: typeof labelWeekday; + /** Return the label for the week number. */ + labelWeekNumber: typeof labelWeekNumber; + /** Return the label for the column of the week number. */ + labelWeekNumberHeader: typeof labelWeekNumberHeader; +}; diff --git a/next/src/types/matchers.ts b/next/src/types/matchers.ts new file mode 100644 index 0000000000..8b9eddb02e --- /dev/null +++ b/next/src/types/matchers.ts @@ -0,0 +1,56 @@ +/** + * A value or a function that matches a specific day. + * + + */ +export type Matcher = + | boolean + | ((date: Date) => boolean) + | Date + | Date[] + | DateRange + | DateBefore + | DateAfter + | DateInterval + | DayOfWeek; + +/** + * A matcher to match a day falling after the specified date, with the date not + * included. + * + + */ +export type DateAfter = { after: Date }; + +/** + * A matcher to match a day falling before the specified date, with the date not + * included. + *```tsx + * test + * ``` + */ +export type DateBefore = { before: Date }; + +/** + * A matcher to match a day falling before and/or after two dates, where the + * dates are not included. + * + + */ +export type DateInterval = { before: Date; after: Date }; + +/** + * A matcher to match a range of dates. The range can be open. Differently from + * `DateInterval`, the dates here are included. + * + + */ +export type DateRange = { from: Date | undefined; to?: Date | undefined }; + +/** + * A matcher to match a date being one of the specified days of the week (`0-7`, + * where `0` is Sunday). + * + + */ +export type DayOfWeek = { dayOfWeek: number[] }; diff --git a/next/src/types/modifiers.ts b/next/src/types/modifiers.ts new file mode 100644 index 0000000000..262a3d6002 --- /dev/null +++ b/next/src/types/modifiers.ts @@ -0,0 +1,36 @@ +import type { CSSProperties } from "react"; + +import type { CalendarDay } from "../classes/CalendarDay"; + +/** + * The name of the modifiers that are used internally by DayPicker. + * + * @deprecated Test deprecation message. + */ +export type InternalModifier = + | "disabled" + | "excluded" + | "focusable" + | "hidden" + | "outside" + | "range_end" + | "range_middle" + | "range_start" + | "selected" + | "today"; + +/** A map of modifiers with the days. */ +export type ModifiersMap = Record & + Record; + +/** The modifiers that are matching a day in the calendar. */ +export type Modifiers = Record & + Record; + +/** The style to apply to each day element matching a modifier. */ +export type ModifiersStyles = Record & + Partial>; + +/** The classnames to assign to each day element matching a modifier. */ +export type ModifiersClassNames = Record & + Partial>; diff --git a/src/types/props.ts b/next/src/types/props.ts similarity index 100% rename from src/types/props.ts rename to next/src/types/props.ts diff --git a/src/types/ui.ts b/next/src/types/ui.ts similarity index 100% rename from src/types/ui.ts rename to next/src/types/ui.ts diff --git a/src/utils/debounce.tsx b/next/src/utils/debounce.tsx similarity index 100% rename from src/utils/debounce.tsx rename to next/src/utils/debounce.tsx diff --git a/src/utils/isDateInRange.test.ts b/next/src/utils/isDateInRange.test.ts similarity index 100% rename from src/utils/isDateInRange.test.ts rename to next/src/utils/isDateInRange.test.ts diff --git a/src/utils/isDateInRange.ts b/next/src/utils/isDateInRange.ts similarity index 100% rename from src/utils/isDateInRange.ts rename to next/src/utils/isDateInRange.ts diff --git a/src/utils/typeguards.ts b/next/src/utils/typeguards.ts similarity index 100% rename from src/utils/typeguards.ts rename to next/src/utils/typeguards.ts diff --git a/src/utils/useControlledValue/index.ts b/next/src/utils/useControlledValue/index.ts similarity index 100% rename from src/utils/useControlledValue/index.ts rename to next/src/utils/useControlledValue/index.ts diff --git a/src/utils/useControlledValue/useControlledValue.test.ts b/next/src/utils/useControlledValue/useControlledValue.test.ts similarity index 100% rename from src/utils/useControlledValue/useControlledValue.test.ts rename to next/src/utils/useControlledValue/useControlledValue.test.ts diff --git a/src/utils/useControlledValue/useControlledValue.ts b/next/src/utils/useControlledValue/useControlledValue.ts similarity index 100% rename from src/utils/useControlledValue/useControlledValue.ts rename to next/src/utils/useControlledValue/useControlledValue.ts diff --git a/test/elements.ts b/next/test/elements.ts similarity index 100% rename from test/elements.ts rename to next/test/elements.ts diff --git a/test/render.tsx b/next/test/render.tsx similarity index 100% rename from test/render.tsx rename to next/test/render.tsx diff --git a/test/renderApp.tsx b/next/test/renderApp.tsx similarity index 100% rename from test/renderApp.tsx rename to next/test/renderApp.tsx diff --git a/test/renderHook.tsx b/next/test/renderHook.tsx similarity index 100% rename from test/renderHook.tsx rename to next/test/renderHook.tsx diff --git a/next/test/setup.ts b/next/test/setup.ts new file mode 100644 index 0000000000..7b0828bfa8 --- /dev/null +++ b/next/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/next/test/user.ts b/next/test/user.ts new file mode 100644 index 0000000000..1371a8e6f1 --- /dev/null +++ b/next/test/user.ts @@ -0,0 +1,6 @@ +import { userEvent } from '@testing-library/user-event'; + +/** Create a user that will advance timers. */ +export const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime +}); diff --git a/tsconfig-v8.json b/next/tsconfig-base.json similarity index 61% rename from tsconfig-v8.json rename to next/tsconfig-base.json index 1a80c8ddd2..fad921790a 100644 --- a/tsconfig-v8.json +++ b/next/tsconfig-base.json @@ -3,12 +3,10 @@ "target": "ES2018", "lib": ["DOM", "ESNext", "DOM.Iterable"], "jsx": "react-jsx", - "module": "ES2020", + "module": "ESNext", "moduleResolution": "Node", "sourceMap": true, "declaration": true, - "baseUrl": "./website/node_modules/react-day-picker-v8/src", - "rootDir": "./website/node_modules/react-day-picker-v8/src", "outDir": "./dist", "importHelpers": true, "esModuleInterop": true, @@ -16,7 +14,5 @@ "strict": true, "alwaysStrict": true, "skipLibCheck": true - }, - "include": ["./website/node_modules/react-day-picker-v8/src"], - "exclude": ["**/*.test.*"] + } } diff --git a/next/tsconfig-cjs.json b/next/tsconfig-cjs.json new file mode 100644 index 0000000000..d14cb3f89c --- /dev/null +++ b/next/tsconfig-cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig-base.json", + "compilerOptions": { + "outDir": "./dist/cjs", + "module": "CommonJS" + }, + "include": ["src"], + "exclude": ["**/*.test.*"] +} diff --git a/next/tsconfig-esm.json b/next/tsconfig-esm.json new file mode 100644 index 0000000000..9b169ecd00 --- /dev/null +++ b/next/tsconfig-esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig-base.json", + "compilerOptions": { + "outDir": "./dist/esm", + "module": "ES2020" + }, + "include": ["src"], + "exclude": ["**/*.test.*"] +} diff --git a/next/tsconfig.json b/next/tsconfig.json new file mode 100644 index 0000000000..cd3b0c8ac3 --- /dev/null +++ b/next/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig-base.json", + "compilerOptions": { + "paths": { + "@/test/*": ["./test/*"], + }, + "noEmit": true, + "types": ["node", "jest", "@testing-library/jest-dom"], + "lib": ["dom", "dom.iterable", "esnext"], + }, + "include": ["src", "test", "**/*.test.*"], +} diff --git a/package.json b/package.json index ba15d752a8..23ce61abb8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-day-picker", - "version": "9.0.0-alpha.1", + "version": "8.10.0", "description": "Customizable Date Picker for React", "author": "Giampaolo Bellavite ", "homepage": "https://react-day-picker.dev", @@ -89,6 +89,7 @@ "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "mockdate": "^3.0.5", "prettier": "^3.2.5", "prettier-plugin-jsdoc": "^1.3.0", "react": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29570332bd..1753f20ec0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,105 @@ settings: importers: .: + devDependencies: + '@jest/types': + specifier: ^29.6.3 + version: 29.6.3 + '@testing-library/dom': + specifier: ^9.3.4 + version: 9.3.4 + '@testing-library/jest-dom': + specifier: ^6.4.2 + version: 6.4.2(@types/jest@29.5.12)(jest@29.7.0) + '@testing-library/react': + specifier: ^14.2.1 + version: 14.2.1(react-dom@18.2.0)(react@18.2.0) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.5.2(@testing-library/dom@9.3.4) + '@types/jest': + specifier: ^29.5.12 + version: 29.5.12 + '@types/node': + specifier: ^20.11.20 + version: 20.11.24 + '@types/react': + specifier: ^18.2.59 + version: 18.2.61 + '@types/react-dom': + specifier: ^18.2.19 + version: 18.2.19 + '@typescript-eslint/eslint-plugin': + specifier: ^7.1.0 + version: 7.1.0(@typescript-eslint/parser@7.1.0)(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/parser': + specifier: ^7.1.0 + version: 7.1.0(eslint@8.57.0)(typescript@5.3.3) + date-fns: + specifier: ^3.3.1 + version: 3.3.1 + eslint: + specifier: ^8.57.0 + version: 8.57.0 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.0) + eslint-import-resolver-typescript: + specifier: ^3.6.1 + version: 3.6.1(@typescript-eslint/parser@7.1.0)(eslint-plugin-import@2.29.1)(eslint@8.57.0) + eslint-plugin-import: + specifier: ^2.29.1 + version: 2.29.1(@typescript-eslint/parser@7.1.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-plugin-jest: + specifier: ^27.9.0 + version: 27.9.0(@typescript-eslint/eslint-plugin@7.1.0)(eslint@8.57.0)(jest@29.7.0)(typescript@5.3.3) + eslint-plugin-prettier: + specifier: ^5.1.3 + version: 5.1.3(eslint-config-prettier@9.1.0)(eslint@8.57.0)(prettier@3.2.5) + eslint-plugin-react-hooks: + specifier: ^4.6.0 + version: 4.6.0(eslint@8.57.0) + eslint-plugin-testing-library: + specifier: ^6.2.0 + version: 6.2.0(eslint@8.57.0)(typescript@5.3.3) + identity-obj-proxy: + specifier: ^3.0.0 + version: 3.0.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.11.24)(ts-node@10.9.2) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0 + mockdate: + specifier: ^3.0.5 + version: 3.0.5 + prettier: + specifier: ^3.2.5 + version: 3.2.5 + prettier-plugin-jsdoc: + specifier: ^1.3.0 + version: 1.3.0(prettier@3.2.5) + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + ts-jest: + specifier: ^29.1.2 + version: 29.1.2(@babel/core@7.24.0)(@jest/types@29.6.3)(jest@29.7.0)(typescript@5.3.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.11.24)(typescript@5.3.3) + tslib: + specifier: ^2.6.2 + version: 2.6.2 + typescript: + specifier: ^5.3.3 + version: 5.3.3 + + next: devDependencies: '@jest/types': specifier: ^29.6.3 @@ -109,7 +208,7 @@ importers: version: link:.. react-day-picker-v8: specifier: npm:react-day-picker@8 - version: /react-day-picker@8.10.0(date-fns@3.3.1)(react@18.2.0) + version: link:.. typedoc: specifier: ^0.25.9 version: 0.25.9(typescript@5.3.3) @@ -3647,6 +3746,7 @@ packages: /date-fns@3.3.1: resolution: {integrity: sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==} + dev: true /debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} @@ -6676,6 +6776,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} dev: false + /mockdate@3.0.5: + resolution: {integrity: sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==} + dev: true + /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} @@ -7251,16 +7355,6 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - /react-day-picker@8.10.0(date-fns@3.3.1)(react@18.2.0): - resolution: {integrity: sha512-mz+qeyrOM7++1NCb1ARXmkjMkzWVh2GL9YiPbRjKe0zHccvekk4HE+0MPOZOrosn8r8zTHIIeOUXTmXRqmkRmg==} - peerDependencies: - date-fns: ^2.28.0 || ^3.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - dependencies: - date-fns: 3.3.1 - react: 18.2.0 - dev: false - /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4196ec84c1..a9c73e3f91 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - "." - "typedoc" - "website" + - "next" diff --git a/src/.prettierrc b/src/.prettierrc new file mode 100644 index 0000000000..b29bf45e6b --- /dev/null +++ b/src/.prettierrc @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "plugins": ["prettier-plugin-jsdoc"] +} diff --git a/src/DayPicker.tsx b/src/DayPicker.tsx index 72bdfcb34e..55c39c27fc 100644 --- a/src/DayPicker.tsx +++ b/src/DayPicker.tsx @@ -1,43 +1,112 @@ -import { Calendar } from "./components/Calendar"; -import { ContextProviders } from "./contexts/ContextProviders"; -import type { - Mode, - PropsBase, - PropsMulti, - PropsRange, - PropsSingle, -} from "./types/props"; +import { DayPickerDefaultProps } from './types/DayPickerDefault'; +import { DayPickerMultipleProps } from './types/DayPickerMultiple'; +import { DayPickerRangeProps } from './types/DayPickerRange'; +import { DayPickerSingleProps } from './types/DayPickerSingle'; -/** Map of the props supported by selection modes. */ -export interface ModePropsMap { - single: PropsSingle; - multi: PropsMulti; - range: PropsRange; - none: object; -} +import { Root } from './components/Root'; +import { RootProvider } from './contexts/RootProvider'; -export interface ModeProp { - mode?: T | undefined; -} +export type DayPickerProps = + | DayPickerDefaultProps + | DayPickerSingleProps + | DayPickerMultipleProps + | DayPickerRangeProps; /** - * Defines the props accepted by the DayPicker component. + * DayPicker render a date picker component to let users pick dates from a + * calendar. See http://react-day-picker.js.org for updated documentation and + * examples. * - * @see https://react-day-picker.dev/api/daypickerprops - */ -export type DayPickerProps = PropsBase & - ModeProp & - ModePropsMap[T]; - -/** - * Render the date picker component. + * ### Customization + * + * DayPicker offers different customization props. For example, + * + * - Show multiple months using `numberOfMonths` + * - Display a dropdown to navigate the months via `captionLayout` + * - Display the week numbers with `showWeekNumbers` + * - Disable or hide days with `disabled` or `hidden` + * + * ### Controlling the months + * + * Change the initially displayed month using the `defaultMonth` prop. The + * displayed months are controlled by DayPicker and stored in its internal + * state. To control the months yourself, use `month` instead of `defaultMonth` + * and use the `onMonthChange` event to set it. + * + * To limit the months the user can navigate to, use + * `fromDate`/`fromMonth`/`fromYear` or `toDate`/`toMonth`/`toYear`. + * + * ### Selection modes + * + * DayPicker supports different selection mode that can be toggled using the + * `mode` prop: + * + * - `mode="single"`: only one day can be selected. Use `required` to make the + * selection required. Use the `onSelect` event handler to get the selected + * days. + * - `mode="multiple"`: users can select one or more days. Limit the amount of + * days that can be selected with the `min` or the `max` props. + * - `mode="range"`: users can select a range of days. Limit the amount of days in + * the range with the `min` or the `max` props. + * - `mode="default"` (default): the built-in selections are disabled. Implement + * your own selection mode with `onDayClick`. + * + * The selection modes should cover the most common use cases. In case you need + * a more refined way of selecting days, use `mode="default"`. Use the + * `selected` props and add the day event handlers to add/remove days from the + * selection. + * + * ### Modifiers + * + * A _modifier_ represents different styles or states for the days displayed in + * the calendar (like "selected" or "disabled"). Define custom modifiers using + * the `modifiers` prop. + * + * ### Formatters and custom component + * + * You can customize how the content is displayed in the date picker by using + * either the formatters or replacing the internal components. + * + * For the most common cases you want to use the `formatters` prop to change how + * the content is formatted in the calendar. Use the `components` prop to + * replace the internal components, like the navigation icons. + * + * ### Styling + * + * DayPicker comes with a default, basic style in `react-day-picker/style` – use + * it as template for your own style. + * + * If you are using CSS modules, pass the imported styles object the + * `classNames` props. + * + * You can also style the elements via inline styles using the `styles` prop. + * + * ### Form fields + * + * If you need to bind the date picker to a form field, you can use the + * `useInput` hooks for a basic behavior. See the `useInput` source as an + * example to bind the date picker with form fields. + * + * ### Localization + * + * To localize DayPicker, import the locale from `date-fns` package and use the + * `locale` prop. + * + * For example, to use Spanish locale: * - * @see https://react-day-picker.js.org + * import { es } from 'date-fns/locale'; + * ; */ -export function DayPicker(props: DayPickerProps) { +export function DayPicker( + props: + | DayPickerDefaultProps + | DayPickerSingleProps + | DayPickerMultipleProps + | DayPickerRangeProps, +): JSX.Element { return ( - - - + + + ); } diff --git a/src/components/Button/Button.test.tsx b/src/components/Button/Button.test.tsx new file mode 100644 index 0000000000..34287990ef --- /dev/null +++ b/src/components/Button/Button.test.tsx @@ -0,0 +1,45 @@ +import { screen } from '@testing-library/react'; + +import { customRender } from '../../../test/render'; + +import { Button } from './Button'; + +let button: HTMLButtonElement; + +describe('when rendered without props', () => { + beforeEach(() => { + customRender( + ); +} diff --git a/src/components/WeekNumber/__snapshots__/WeekNumber.test.tsx.snap b/src/components/WeekNumber/__snapshots__/WeekNumber.test.tsx.snap new file mode 100644 index 0000000000..9fa11dbed9 --- /dev/null +++ b/src/components/WeekNumber/__snapshots__/WeekNumber.test.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`with "onWeekNumberClick" prop it should return a button element 1`] = ` + +`; + +exports[`without "onWeekNumberClick" prop it should return a span element 1`] = ` + + 10 + +`; diff --git a/src/components/WeekNumber/index.ts b/src/components/WeekNumber/index.ts new file mode 100644 index 0000000000..c78baa3fd5 --- /dev/null +++ b/src/components/WeekNumber/index.ts @@ -0,0 +1 @@ +export * from './WeekNumber'; diff --git a/src/components/YearsDropdown/YearsDropdown.test.tsx b/src/components/YearsDropdown/YearsDropdown.test.tsx new file mode 100644 index 0000000000..9c0134cb6f --- /dev/null +++ b/src/components/YearsDropdown/YearsDropdown.test.tsx @@ -0,0 +1,106 @@ +import { screen } from '@testing-library/react'; +import { addMonths, differenceInYears } from 'date-fns'; +import { DayPickerProps } from '../../DayPicker'; + +import { customRender } from '../../../test/render'; +import { user } from '../../../test/user'; +import { freezeBeforeAll } from '../../../test/utils'; + +import { YearsDropdown, YearsDropdownProps } from './YearsDropdown'; + +const today = new Date(2020, 12, 22); + +freezeBeforeAll(today); + +let root: HTMLDivElement; +let options: HTMLCollectionOf | undefined; +let select: HTMLSelectElement | null; + +function setup(props: YearsDropdownProps, dayPickerProps?: DayPickerProps) { + const view = customRender(, dayPickerProps); + root = view.container.firstChild as HTMLDivElement; + select = screen.queryByRole('combobox', { name: 'Year:' }); + options = select?.getElementsByTagName('option'); +} + +const props: YearsDropdownProps = { + displayMonth: today, + onChange: jest.fn(), +}; + +describe('when fromDate and toDate are passed in', () => { + beforeEach(() => { + setup(props, { fromDate: new Date(), toDate: addMonths(new Date(), 1) }); + }); + test('should render the dropdown element', () => { + expect(root).toMatchSnapshot(); + expect(select).toHaveAttribute('name', 'years'); + }); +}); + +describe('when "fromDate" is not set', () => { + beforeEach(() => { + setup(props, { fromDate: undefined }); + }); + test('should return nothing', () => { + expect(root).toBeNull(); + }); +}); + +describe('when "toDate" is not set', () => { + beforeEach(() => { + setup(props, { toDate: undefined }); + }); + test('should return nothing', () => { + expect(root).toBeNull(); + }); +}); + +describe('when "fromDate" and "toDate" are in the same year', () => { + const fromDate = new Date(2012, 0, 22); + const toDate = new Date(2012, 10, 22); + beforeEach(() => { + setup(props, { fromDate, toDate }); + }); + test('should display the months included between the two dates', () => { + expect(select).toBeInTheDocument(); + expect(options).toHaveLength(differenceInYears(toDate, fromDate) + 1); + }); + test('the month should be the same month', () => { + expect(options?.[0]).toHaveValue(`${fromDate.getFullYear()}`); + }); +}); + +describe('when "fromDate" and "toDate" are not in the same year', () => { + const fromDate = new Date(2012, 0, 22); + const toDate = new Date(2015, 10, 22); + const displayMonth = new Date(2013, 7, 0); + beforeEach(() => { + setup({ ...props, displayMonth }, { fromDate, toDate }); + }); + test('should display the full years', () => { + expect(options).toHaveLength(differenceInYears(toDate, fromDate) + 1); + }); + test('the first option should be fromDates year', () => { + expect(options?.[0]).toHaveValue(`${fromDate.getFullYear()}`); + }); + test('the last option should be "toDate"s year', () => { + expect(options?.[options.length - 1]).toHaveValue( + `${toDate.getFullYear()}`, + ); + }); + test('should select the displayed year', () => { + expect(select).toHaveValue(`${displayMonth.getFullYear()}`); + }); + + describe('when the dropdown changes', () => { + const newYear = fromDate.getFullYear(); + beforeEach(async () => { + if (select) await user.selectOptions(select, `${newYear}`); + }); + test('should fire the "onChange" event handler', () => { + const expectedYear = new Date(newYear, displayMonth.getMonth(), 1); + expect(props.onChange).toHaveBeenCalledWith(expectedYear); + }); + }); +}); diff --git a/src/components/YearsDropdown/YearsDropdown.tsx b/src/components/YearsDropdown/YearsDropdown.tsx new file mode 100644 index 0000000000..b1976b851f --- /dev/null +++ b/src/components/YearsDropdown/YearsDropdown.tsx @@ -0,0 +1,73 @@ +import { ChangeEventHandler } from 'react'; + +import { setYear, startOfMonth, startOfYear } from 'date-fns'; + +import { Dropdown } from '../Dropdown'; +import { useDayPicker } from '../../contexts/DayPicker'; +import { MonthChangeEventHandler } from '../../types/EventHandlers'; + +/** The props for the {@link YearsDropdown} component. */ +export interface YearsDropdownProps { + /** The month where the drop-down is displayed. */ + displayMonth: Date; + /** Callback to handle the `change` event. */ + onChange: MonthChangeEventHandler; +} + +/** + * Render a dropdown to change the year. Take in account the `nav.fromDate` and + * `toDate` from context. + */ +export function YearsDropdown(props: YearsDropdownProps): JSX.Element { + const { displayMonth } = props; + const { + fromDate, + toDate, + locale, + styles, + classNames, + components, + formatters: { formatYearCaption }, + labels: { labelYearDropdown }, + } = useDayPicker(); + + const years: Date[] = []; + + // Dropdown should appear only when both from/toDate is set + if (!fromDate) return <>; + if (!toDate) return <>; + + const fromYear = fromDate.getFullYear(); + const toYear = toDate.getFullYear(); + for (let year = fromYear; year <= toYear; year++) { + years.push(setYear(startOfYear(new Date()), year)); + } + + const handleChange: ChangeEventHandler = (e) => { + const newMonth = setYear( + startOfMonth(displayMonth), + Number(e.target.value), + ); + props.onChange(newMonth); + }; + + const DropdownComponent = components?.Dropdown ?? Dropdown; + + return ( + + {years.map((year) => ( + + ))} + + ); +} diff --git a/src/components/YearsDropdown/__snapshots__/YearsDropdown.test.tsx.snap b/src/components/YearsDropdown/__snapshots__/YearsDropdown.test.tsx.snap new file mode 100644 index 0000000000..091828a26e --- /dev/null +++ b/src/components/YearsDropdown/__snapshots__/YearsDropdown.test.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`when fromDate and toDate are passed in should render the dropdown element 1`] = ` +
+ + Year: + + + +
+`; diff --git a/src/components/YearsDropdown/index.ts b/src/components/YearsDropdown/index.ts new file mode 100644 index 0000000000..c4e8928db5 --- /dev/null +++ b/src/components/YearsDropdown/index.ts @@ -0,0 +1 @@ +export * from './YearsDropdown'; diff --git a/src/contexts/DayPicker/DayPickerContext.test.ts b/src/contexts/DayPicker/DayPickerContext.test.ts new file mode 100644 index 0000000000..2206724cc7 --- /dev/null +++ b/src/contexts/DayPicker/DayPickerContext.test.ts @@ -0,0 +1,210 @@ +/* eslint-disable testing-library/render-result-naming-convention */ + +import { es } from 'date-fns/locale'; +import { DayPickerProps } from '../../DayPicker'; + +import { renderDayPickerHook } from '../../../test/render'; +import { freezeBeforeAll } from '../../../test/utils'; + +import { CaptionLayout } from '../../components/Caption'; +import { DayPickerContextValue, useDayPicker } from '../../contexts/DayPicker'; +import { + DefaultContextProps, + getDefaultContextValues, +} from '../../contexts/DayPicker/defaultContextValues'; +import { DaySelectionMode } from '../../types/DayPickerBase'; +import { Formatters } from '../../types/Formatters'; +import { Labels } from '../../types/Labels'; +import { DayModifiers, ModifiersClassNames } from '../../types/Modifiers'; +import { ClassNames, Styles } from '../../types/Styles'; + +const today = new Date(2022, 5, 13); +const defaults = getDefaultContextValues(); + +freezeBeforeAll(today); + +function renderHook(props?: DayPickerProps) { + return renderDayPickerHook(useDayPicker, props); +} + +describe('when rendered without props', () => { + const testPropNames = Object.keys(defaults).filter( + (key) => key !== 'today', + ) as DefaultContextProps[]; + test.each(testPropNames)('should use the %s default value', (propName) => { + const result = renderHook(); + expect(result.current[propName]).toEqual(defaults[propName]); + }); +}); +describe('when passing "locale" from props', () => { + const locale = es; + test('should return the custom locale', () => { + const result = renderHook({ locale }); + expect(result.current.locale).toBe(locale); + }); +}); + +describe('when passing "numberOfMonths" from props', () => { + const numberOfMonths = 4; + test('should return the custom numberOfMonths', () => { + const result = renderHook({ numberOfMonths }); + expect(result.current.numberOfMonths).toBe(4); + }); +}); + +describe('when passing "today" from props', () => { + const today = new Date(2010, 9, 11); + test('should return the custom "today"', () => { + const result = renderHook({ today }); + expect(result.current.today).toBe(today); + }); +}); + +describe('when passing "captionLayout" from props', () => { + const captionLayout: CaptionLayout = 'dropdown'; + const fromYear = 2000; + const toYear = 2010; + const dayPickerProps: DayPickerProps = { captionLayout, fromYear, toYear }; + test('should return the custom "captionLayout"', () => { + const result = renderHook(dayPickerProps); + expect(result.current.captionLayout).toBe(captionLayout); + }); +}); + +describe('when "fromDate" and "toDate" are undefined', () => { + const fromDate = undefined; + const toDate = undefined; + describe('when using "dropdown" as "captionLayout"', () => { + const captionLayout: CaptionLayout = 'dropdown'; + test('should return "buttons" as "captionLayout"', () => { + const result = renderHook({ + fromDate, + toDate, + captionLayout, + }); + expect(result.current.captionLayout).toBe('buttons'); + }); + }); +}); + +describe('when "fromDate" is undefined, but not "toDate"', () => { + const fromDate = undefined; + const toDate = new Date(); + + describe('when using "dropdown" as "captionLayout"', () => { + const captionLayout: CaptionLayout = 'dropdown'; + test('should return "buttons" as "captionLayout"', () => { + const result = renderHook({ + fromDate, + toDate, + captionLayout, + }); + expect(result.current.captionLayout).toBe('buttons'); + }); + }); +}); + +describe('when "toDate" is undefined, but not "fromDate"', () => { + const fromDate = new Date(); + const toDate = undefined; + + describe('when using "dropdown" as "captionLayout"', () => { + const captionLayout: CaptionLayout = 'dropdown'; + test('should return "buttons" as "captionLayout"', () => { + const result = renderHook({ + fromDate, + toDate, + captionLayout, + }); + expect(result.current.captionLayout).toBe('buttons'); + }); + }); +}); + +describe('when using "dropdown" as "captionLayout"', () => { + const captionLayout: CaptionLayout = 'dropdown'; + const fromYear = 2000; + const toYear = 2010; + test('should return the custom "captionLayout"', () => { + const result = renderHook({ captionLayout, fromYear, toYear }); + expect(result.current.captionLayout).toBe(captionLayout); + }); +}); + +describe('when passing "modifiers" from props', () => { + const modifiers: DayModifiers = { foo: new Date() }; + test('should return the custom "modifiers"', () => { + const result = renderHook({ modifiers }); + expect(result.current.modifiers).toStrictEqual(modifiers); + }); +}); + +describe('when passing "modifiersClassNames" from props', () => { + const modifiersClassNames: ModifiersClassNames = { foo: 'bar' }; + test('should return the custom "modifiersClassNames"', () => { + const result = renderHook({ modifiersClassNames }); + expect(result.current.modifiersClassNames).toStrictEqual( + modifiersClassNames, + ); + }); +}); + +describe('when passing "styles" from props', () => { + const styles: Styles = { caption: { color: 'red ' } }; + test('should include the custom "styles"', () => { + const result = renderHook({ styles }); + expect(result.current.styles).toStrictEqual({ + ...defaults.styles, + ...styles, + }); + }); +}); + +describe('when passing "classNames" from props', () => { + const classNames: ClassNames = { caption: 'foo' }; + test('should include the custom "classNames"', () => { + const result = renderHook({ classNames }); + expect(result.current.classNames).toStrictEqual({ + ...defaults.classNames, + ...classNames, + }); + }); +}); + +describe('when passing "formatters" from props', () => { + const formatters: Partial = { formatCaption: jest.fn() }; + test('should include the custom "formatters"', () => { + const result = renderHook({ formatters }); + expect(result.current.formatters).toStrictEqual({ + ...defaults.formatters, + ...formatters, + }); + }); +}); + +describe('when passing "labels" from props', () => { + const labels: Partial = { labelDay: jest.fn() }; + test('should include the custom "labels"', () => { + const result = renderHook({ labels }); + expect(result.current.labels).toStrictEqual({ + ...defaults.labels, + ...labels, + }); + }); +}); + +describe('when passing an "id" from props', () => { + test('should return the id', () => { + const result = renderHook({ id: 'foo' }); + expect(result.current.id).toBe('foo'); + }); +}); + +describe('when in selection mode', () => { + const mode: DaySelectionMode = 'multiple'; + const onSelect = jest.fn(); + test('should return the "onSelect" event handler', () => { + const result = renderHook({ mode, onSelect }); + expect(result.current.onSelect).toBe(onSelect); + }); +}); diff --git a/src/contexts/DayPicker/DayPickerContext.tsx b/src/contexts/DayPicker/DayPickerContext.tsx new file mode 100644 index 0000000000..07b7851b26 --- /dev/null +++ b/src/contexts/DayPicker/DayPickerContext.tsx @@ -0,0 +1,156 @@ +import { createContext, ReactNode, useContext } from 'react'; + +import { Locale } from 'date-fns'; +import { DayPickerProps } from '../../DayPicker'; + +import { CaptionLayout } from '../../components/Caption'; +import { DayPickerBase, DaySelectionMode } from '../../types/DayPickerBase'; +import { + DayPickerMultipleProps, + isDayPickerMultiple, +} from '../../types/DayPickerMultiple'; +import { + DayPickerRangeProps, + isDayPickerRange, +} from '../../types/DayPickerRange'; +import { + DayPickerSingleProps, + isDayPickerSingle, +} from '../../types/DayPickerSingle'; +import { Formatters } from '../../types/Formatters'; +import { Labels } from '../../types/Labels'; +import { Matcher } from '../../types/Matchers'; +import { DayModifiers, ModifiersClassNames } from '../../types/Modifiers'; +import { ClassNames, Styles } from '../../types/Styles'; + +import { getDefaultContextValues } from './defaultContextValues'; +import { parseFromToProps } from './utils'; + +/** + * The value of the {@link DayPickerContext} extends the props from DayPicker + * with default and cleaned up values. + */ +export interface DayPickerContextValue extends DayPickerBase { + mode: DaySelectionMode; + onSelect?: + | DayPickerSingleProps['onSelect'] + | DayPickerMultipleProps['onSelect'] + | DayPickerRangeProps['onSelect']; + required?: boolean; + min?: number; + max?: number; + selected?: Matcher | Matcher[]; + + captionLayout: CaptionLayout; + classNames: Required; + formatters: Formatters; + labels: Labels; + locale: Locale; + modifiersClassNames: ModifiersClassNames; + modifiers: DayModifiers; + numberOfMonths: number; + styles: Styles; + today: Date; +} + +/** + * The DayPicker context shares the props passed to DayPicker within internal + * and custom components. It is used to set the default values and perform + * one-time calculations required to render the days. + * + * Access to this context from the {@link useDayPicker} hook. + */ +export const DayPickerContext = createContext< + DayPickerContextValue | undefined +>(undefined); + +/** The props for the {@link DayPickerProvider}. */ +export interface DayPickerProviderProps { + /** The initial props from the DayPicker component. */ + initialProps: DayPickerProps; + children?: ReactNode; +} +/** + * The provider for the {@link DayPickerContext}, assigning the defaults from the + * initial DayPicker props. + */ +export function DayPickerProvider(props: DayPickerProviderProps): JSX.Element { + const { initialProps } = props; + + const defaultContextValues = getDefaultContextValues(); + + const { fromDate, toDate } = parseFromToProps(initialProps); + + let captionLayout = + initialProps.captionLayout ?? defaultContextValues.captionLayout; + if (captionLayout !== 'buttons' && (!fromDate || !toDate)) { + // When no from/to dates are set, the caption is always buttons + captionLayout = 'buttons'; + } + + let onSelect; + if ( + isDayPickerSingle(initialProps) || + isDayPickerMultiple(initialProps) || + isDayPickerRange(initialProps) + ) { + onSelect = initialProps.onSelect; + } + + const value: DayPickerContextValue = { + ...defaultContextValues, + ...initialProps, + captionLayout, + classNames: { + ...defaultContextValues.classNames, + ...initialProps.classNames, + }, + components: { + ...initialProps.components, + }, + formatters: { + ...defaultContextValues.formatters, + ...initialProps.formatters, + }, + fromDate, + labels: { + ...defaultContextValues.labels, + ...initialProps.labels, + }, + mode: initialProps.mode || defaultContextValues.mode, + modifiers: { + ...defaultContextValues.modifiers, + ...initialProps.modifiers, + }, + modifiersClassNames: { + ...defaultContextValues.modifiersClassNames, + ...initialProps.modifiersClassNames, + }, + onSelect, + styles: { + ...defaultContextValues.styles, + ...initialProps.styles, + }, + toDate, + }; + + return ( + + {props.children} + + ); +} + +/** + * Hook to access the {@link DayPickerContextValue}. + * + * Use the DayPicker context to access to the props passed to DayPicker inside + * internal or custom components. + */ +export function useDayPicker(): DayPickerContextValue { + const context = useContext(DayPickerContext); + if (!context) { + throw new Error(`useDayPicker must be used within a DayPickerProvider.`); + } + return context; +} diff --git a/src/contexts/DayPicker/defaultClassNames.ts b/src/contexts/DayPicker/defaultClassNames.ts new file mode 100644 index 0000000000..80789237a0 --- /dev/null +++ b/src/contexts/DayPicker/defaultClassNames.ts @@ -0,0 +1,56 @@ +import { ClassNames } from '../../types/Styles'; + +/** The name of the default CSS classes. */ +export const defaultClassNames: Required = { + root: 'rdp', + multiple_months: 'rdp-multiple_months', + with_weeknumber: 'rdp-with_weeknumber', + vhidden: 'rdp-vhidden', + button_reset: 'rdp-button_reset', + button: 'rdp-button', + + caption: 'rdp-caption', + + caption_start: 'rdp-caption_start', + caption_end: 'rdp-caption_end', + caption_between: 'rdp-caption_between', + caption_label: 'rdp-caption_label', + + caption_dropdowns: 'rdp-caption_dropdowns', + + dropdown: 'rdp-dropdown', + dropdown_month: 'rdp-dropdown_month', + dropdown_year: 'rdp-dropdown_year', + dropdown_icon: 'rdp-dropdown_icon', + + months: 'rdp-months', + month: 'rdp-month', + table: 'rdp-table', + tbody: 'rdp-tbody', + tfoot: 'rdp-tfoot', + + head: 'rdp-head', + head_row: 'rdp-head_row', + head_cell: 'rdp-head_cell', + + nav: 'rdp-nav', + nav_button: 'rdp-nav_button', + nav_button_previous: 'rdp-nav_button_previous', + nav_button_next: 'rdp-nav_button_next', + + nav_icon: 'rdp-nav_icon', + + row: 'rdp-row', + weeknumber: 'rdp-weeknumber', + cell: 'rdp-cell', + + day: 'rdp-day', + day_today: 'rdp-day_today', + day_outside: 'rdp-day_outside', + day_selected: 'rdp-day_selected', + day_disabled: 'rdp-day_disabled', + day_hidden: 'rdp-day_hidden', + day_range_start: 'rdp-day_range_start', + day_range_end: 'rdp-day_range_end', + day_range_middle: 'rdp-day_range_middle', +}; diff --git a/src/contexts/DayPicker/defaultContextValues.ts b/src/contexts/DayPicker/defaultContextValues.ts new file mode 100644 index 0000000000..1575b94a12 --- /dev/null +++ b/src/contexts/DayPicker/defaultContextValues.ts @@ -0,0 +1,54 @@ +import { enUS } from 'date-fns/locale'; + +import { CaptionLayout } from '../../components/Caption'; +import { DayPickerContextValue } from '../../contexts/DayPicker'; + +import { defaultClassNames } from './defaultClassNames'; +import * as formatters from './formatters'; +import * as labels from './labels'; + +export type DefaultContextProps = + | 'captionLayout' + | 'classNames' + | 'formatters' + | 'locale' + | 'labels' + | 'modifiersClassNames' + | 'modifiers' + | 'numberOfMonths' + | 'styles' + | 'today' + | 'mode'; + +export type DefaultContextValues = Pick< + DayPickerContextValue, + DefaultContextProps +>; +/** + * Returns the default values to use in the DayPickerContext, in case they are + * not passed down with the DayPicker initial props. + */ +export function getDefaultContextValues(): DefaultContextValues { + const captionLayout: CaptionLayout = 'buttons'; + const classNames = defaultClassNames; + const locale = enUS; + const modifiersClassNames = {}; + const modifiers = {}; + const numberOfMonths = 1; + const styles = {}; + const today = new Date(); + + return { + captionLayout, + classNames, + formatters, + labels, + locale, + modifiersClassNames, + modifiers, + numberOfMonths, + styles, + today, + mode: 'default', + }; +} diff --git a/src/contexts/DayPicker/formatters/formatCaption.test.ts b/src/contexts/DayPicker/formatters/formatCaption.test.ts new file mode 100644 index 0000000000..0866e874f0 --- /dev/null +++ b/src/contexts/DayPicker/formatters/formatCaption.test.ts @@ -0,0 +1,15 @@ +import { es } from 'date-fns/locale'; + +import { formatCaption } from './formatCaption'; + +const date = new Date(2022, 10, 21); + +test('should return the formatted caption', () => { + expect(formatCaption(date)).toEqual('November 2022'); +}); + +describe('when a locale is passed in', () => { + test('should format using the locale', () => { + expect(formatCaption(date, { locale: es })).toEqual('noviembre 2022'); + }); +}); diff --git a/src/contexts/DayPicker/formatters/formatCaption.ts b/src/contexts/DayPicker/formatters/formatCaption.ts new file mode 100644 index 0000000000..9490460835 --- /dev/null +++ b/src/contexts/DayPicker/formatters/formatCaption.ts @@ -0,0 +1,9 @@ +import { format, Locale } from 'date-fns'; + +/** The default formatter for the caption. */ +export function formatCaption( + month: Date, + options?: { locale?: Locale }, +): string { + return format(month, 'LLLL y', options); +} diff --git a/src/contexts/DayPicker/formatters/formatDay.test.ts b/src/contexts/DayPicker/formatters/formatDay.test.ts new file mode 100644 index 0000000000..4e445e6ab3 --- /dev/null +++ b/src/contexts/DayPicker/formatters/formatDay.test.ts @@ -0,0 +1,7 @@ +import { formatDay } from './formatDay'; + +const date = new Date(2022, 10, 21); + +test('should return the formatted day', () => { + expect(formatDay(date)).toEqual('21'); +}); diff --git a/src/contexts/DayPicker/formatters/formatDay.ts b/src/contexts/DayPicker/formatters/formatDay.ts new file mode 100644 index 0000000000..98f8adcf1c --- /dev/null +++ b/src/contexts/DayPicker/formatters/formatDay.ts @@ -0,0 +1,6 @@ +import { format, Locale } from 'date-fns'; + +/** The default formatter for the Day button. */ +export function formatDay(day: Date, options?: { locale?: Locale }): string { + return format(day, 'd', options); +} diff --git a/src/contexts/DayPicker/formatters/formatMonthCaption.test.ts b/src/contexts/DayPicker/formatters/formatMonthCaption.test.ts new file mode 100644 index 0000000000..5d8082d1ae --- /dev/null +++ b/src/contexts/DayPicker/formatters/formatMonthCaption.test.ts @@ -0,0 +1,15 @@ +import { es } from 'date-fns/locale'; + +import { formatMonthCaption } from './formatMonthCaption'; + +const date = new Date(2022, 10, 21); + +test('should return the formatted month caption', () => { + expect(formatMonthCaption(date)).toEqual('November'); +}); + +describe('when a locale is passed in', () => { + test('should format using the locale', () => { + expect(formatMonthCaption(date, { locale: es })).toEqual('noviembre'); + }); +}); diff --git a/src/contexts/DayPicker/formatters/formatMonthCaption.ts b/src/contexts/DayPicker/formatters/formatMonthCaption.ts new file mode 100644 index 0000000000..27d9fd5869 --- /dev/null +++ b/src/contexts/DayPicker/formatters/formatMonthCaption.ts @@ -0,0 +1,9 @@ +import { format, Locale } from 'date-fns'; + +/** The default formatter for the Month caption. */ +export function formatMonthCaption( + month: Date, + options?: { locale?: Locale }, +): string { + return format(month, 'LLLL', options); +} diff --git a/src/contexts/DayPicker/formatters/formatWeekNumber.test.ts b/src/contexts/DayPicker/formatters/formatWeekNumber.test.ts new file mode 100644 index 0000000000..b9bc998e25 --- /dev/null +++ b/src/contexts/DayPicker/formatters/formatWeekNumber.test.ts @@ -0,0 +1,5 @@ +import { formatWeekNumber } from './formatWeekNumber'; + +test('should return the formatted week number', () => { + expect(formatWeekNumber(10)).toEqual('10'); +}); diff --git a/src/contexts/DayPicker/formatters/formatWeekNumber.ts b/src/contexts/DayPicker/formatters/formatWeekNumber.ts new file mode 100644 index 0000000000..77050f58d2 --- /dev/null +++ b/src/contexts/DayPicker/formatters/formatWeekNumber.ts @@ -0,0 +1,4 @@ +/** The default formatter for the week number. */ +export function formatWeekNumber(weekNumber: number): string { + return `${weekNumber}`; +} diff --git a/src/contexts/DayPicker/formatters/formatWeekdayName.test.ts b/src/contexts/DayPicker/formatters/formatWeekdayName.test.ts new file mode 100644 index 0000000000..b30c009c3a --- /dev/null +++ b/src/contexts/DayPicker/formatters/formatWeekdayName.test.ts @@ -0,0 +1,15 @@ +import { es } from 'date-fns/locale'; + +import { formatWeekdayName } from './formatWeekdayName'; + +const date = new Date(2022, 10, 21); + +test('should return the formatted weekday name', () => { + expect(formatWeekdayName(date)).toEqual('Mo'); +}); + +describe('when a locale is passed in', () => { + test('should format using the locale', () => { + expect(formatWeekdayName(date, { locale: es })).toEqual('lu'); + }); +}); diff --git a/src/contexts/DayPicker/formatters/formatWeekdayName.ts b/src/contexts/DayPicker/formatters/formatWeekdayName.ts new file mode 100644 index 0000000000..a204165779 --- /dev/null +++ b/src/contexts/DayPicker/formatters/formatWeekdayName.ts @@ -0,0 +1,9 @@ +import { format, Locale } from 'date-fns'; + +/** The default formatter for the name of the weekday. */ +export function formatWeekdayName( + weekday: Date, + options?: { locale?: Locale }, +): string { + return format(weekday, 'cccccc', options); +} diff --git a/src/contexts/DayPicker/formatters/formatYearCaption.test.ts b/src/contexts/DayPicker/formatters/formatYearCaption.test.ts new file mode 100644 index 0000000000..3d30db41ec --- /dev/null +++ b/src/contexts/DayPicker/formatters/formatYearCaption.test.ts @@ -0,0 +1,7 @@ +import { formatYearCaption } from './formatYearCaption'; + +const date = new Date(2022, 10, 21); + +test('should return the formatted weekday name', () => { + expect(formatYearCaption(date)).toEqual('2022'); +}); diff --git a/src/contexts/DayPicker/formatters/formatYearCaption.ts b/src/contexts/DayPicker/formatters/formatYearCaption.ts new file mode 100644 index 0000000000..d50c523d1b --- /dev/null +++ b/src/contexts/DayPicker/formatters/formatYearCaption.ts @@ -0,0 +1,11 @@ +import { format, Locale } from 'date-fns'; + +/** The default formatter for the Year caption. */ +export function formatYearCaption( + year: Date, + options?: { + locale?: Locale; + }, +): string { + return format(year, 'yyyy', options); +} diff --git a/src/contexts/DayPicker/formatters/index.ts b/src/contexts/DayPicker/formatters/index.ts new file mode 100644 index 0000000000..0f6d722d09 --- /dev/null +++ b/src/contexts/DayPicker/formatters/index.ts @@ -0,0 +1,6 @@ +export * from './formatCaption'; +export * from './formatDay'; +export * from './formatMonthCaption'; +export * from './formatWeekNumber'; +export * from './formatWeekdayName'; +export * from './formatYearCaption'; diff --git a/src/contexts/DayPicker/index.ts b/src/contexts/DayPicker/index.ts new file mode 100644 index 0000000000..de33e8bac8 --- /dev/null +++ b/src/contexts/DayPicker/index.ts @@ -0,0 +1 @@ +export * from './DayPickerContext'; diff --git a/src/contexts/DayPicker/labels/index.ts b/src/contexts/DayPicker/labels/index.ts new file mode 100644 index 0000000000..26c644143c --- /dev/null +++ b/src/contexts/DayPicker/labels/index.ts @@ -0,0 +1,7 @@ +export * from './labelDay'; +export * from './labelMonthDropdown'; +export * from './labelNext'; +export * from './labelPrevious'; +export * from './labelWeekday'; +export * from './labelWeekNumber'; +export * from './labelYearDropdown'; diff --git a/src/contexts/DayPicker/labels/labelDay.test.ts b/src/contexts/DayPicker/labels/labelDay.test.ts new file mode 100644 index 0000000000..12fc153152 --- /dev/null +++ b/src/contexts/DayPicker/labels/labelDay.test.ts @@ -0,0 +1,7 @@ +import { labelDay } from './labelDay'; + +const day = new Date(2022, 10, 21); + +test('should return the day label', () => { + expect(labelDay(day, {})).toEqual('21st November (Monday)'); +}); diff --git a/src/contexts/DayPicker/labels/labelDay.ts b/src/contexts/DayPicker/labels/labelDay.ts new file mode 100644 index 0000000000..f21a6f4c2a --- /dev/null +++ b/src/contexts/DayPicker/labels/labelDay.ts @@ -0,0 +1,8 @@ +import { format } from 'date-fns'; + +import { DayLabel } from '../../../types/Labels'; + +/** The default ARIA label for the day button. */ +export const labelDay: DayLabel = (day, activeModifiers, options): string => { + return format(day, 'do MMMM (EEEE)', options); +}; diff --git a/src/contexts/DayPicker/labels/labelMonthDropdown.test.ts b/src/contexts/DayPicker/labels/labelMonthDropdown.test.ts new file mode 100644 index 0000000000..0c54c18359 --- /dev/null +++ b/src/contexts/DayPicker/labels/labelMonthDropdown.test.ts @@ -0,0 +1,5 @@ +import { labelMonthDropdown } from './labelMonthDropdown'; + +test('should return the label', () => { + expect(labelMonthDropdown()).toEqual('Month: '); +}); diff --git a/src/contexts/DayPicker/labels/labelMonthDropdown.ts b/src/contexts/DayPicker/labels/labelMonthDropdown.ts new file mode 100644 index 0000000000..272e0a7383 --- /dev/null +++ b/src/contexts/DayPicker/labels/labelMonthDropdown.ts @@ -0,0 +1,4 @@ +/** The default ARIA label for the WeekNumber element. */ +export const labelMonthDropdown = (): string => { + return 'Month: '; +}; diff --git a/src/contexts/DayPicker/labels/labelNext.test.ts b/src/contexts/DayPicker/labels/labelNext.test.ts new file mode 100644 index 0000000000..96bf9f0a6d --- /dev/null +++ b/src/contexts/DayPicker/labels/labelNext.test.ts @@ -0,0 +1,5 @@ +import { labelNext } from './labelNext'; + +test('should return the label', () => { + expect(labelNext()).toEqual('Go to next month'); +}); diff --git a/src/contexts/DayPicker/labels/labelNext.ts b/src/contexts/DayPicker/labels/labelNext.ts new file mode 100644 index 0000000000..495a3abde0 --- /dev/null +++ b/src/contexts/DayPicker/labels/labelNext.ts @@ -0,0 +1,6 @@ +import { NavButtonLabel } from '../../../types/Labels'; + +/** The default ARIA label for next month button in navigation */ +export const labelNext: NavButtonLabel = (): string => { + return 'Go to next month'; +}; diff --git a/src/contexts/DayPicker/labels/labelPrevious.test.ts b/src/contexts/DayPicker/labels/labelPrevious.test.ts new file mode 100644 index 0000000000..cd5ff864f4 --- /dev/null +++ b/src/contexts/DayPicker/labels/labelPrevious.test.ts @@ -0,0 +1,5 @@ +import { labelPrevious } from './labelPrevious'; + +test('should return the label', () => { + expect(labelPrevious()).toEqual('Go to previous month'); +}); diff --git a/src/contexts/DayPicker/labels/labelPrevious.ts b/src/contexts/DayPicker/labels/labelPrevious.ts new file mode 100644 index 0000000000..22b399da56 --- /dev/null +++ b/src/contexts/DayPicker/labels/labelPrevious.ts @@ -0,0 +1,6 @@ +import { NavButtonLabel } from '../../../types/Labels'; + +/** The default ARIA label for previous month button in navigation */ +export const labelPrevious: NavButtonLabel = (): string => { + return 'Go to previous month'; +}; diff --git a/src/contexts/DayPicker/labels/labelWeekNumber.test.ts b/src/contexts/DayPicker/labels/labelWeekNumber.test.ts new file mode 100644 index 0000000000..eb4cef6500 --- /dev/null +++ b/src/contexts/DayPicker/labels/labelWeekNumber.test.ts @@ -0,0 +1,5 @@ +import { labelWeekNumber } from './labelWeekNumber'; + +test('should return the label', () => { + expect(labelWeekNumber(2)).toEqual('Week n. 2'); +}); diff --git a/src/contexts/DayPicker/labels/labelWeekNumber.ts b/src/contexts/DayPicker/labels/labelWeekNumber.ts new file mode 100644 index 0000000000..ccf86c58da --- /dev/null +++ b/src/contexts/DayPicker/labels/labelWeekNumber.ts @@ -0,0 +1,6 @@ +import { WeekNumberLabel } from '../../../types/Labels'; + +/** The default ARIA label for the WeekNumber element. */ +export const labelWeekNumber: WeekNumberLabel = (n): string => { + return `Week n. ${n}`; +}; diff --git a/src/contexts/DayPicker/labels/labelWeekday.test.ts b/src/contexts/DayPicker/labels/labelWeekday.test.ts new file mode 100644 index 0000000000..6c73c43a8c --- /dev/null +++ b/src/contexts/DayPicker/labels/labelWeekday.test.ts @@ -0,0 +1,15 @@ +import { es } from 'date-fns/locale'; + +import { labelWeekday } from './labelWeekday'; + +const weekDay = new Date(2022, 10, 21); + +test('should return the formatted weekday name', () => { + expect(labelWeekday(weekDay)).toEqual('Monday'); +}); + +describe('when a locale is passed in', () => { + test('should format using the locale', () => { + expect(labelWeekday(weekDay, { locale: es })).toEqual('lunes'); + }); +}); diff --git a/src/contexts/DayPicker/labels/labelWeekday.ts b/src/contexts/DayPicker/labels/labelWeekday.ts new file mode 100644 index 0000000000..11a5ff4de6 --- /dev/null +++ b/src/contexts/DayPicker/labels/labelWeekday.ts @@ -0,0 +1,8 @@ +import { format } from 'date-fns'; + +import { WeekdayLabel } from '../../../types/Labels'; + +/** The default ARIA label for the Weekday element. */ +export const labelWeekday: WeekdayLabel = (day, options): string => { + return format(day, 'cccc', options); +}; diff --git a/src/contexts/DayPicker/labels/labelYearDropdown.test.ts b/src/contexts/DayPicker/labels/labelYearDropdown.test.ts new file mode 100644 index 0000000000..c50ba4525a --- /dev/null +++ b/src/contexts/DayPicker/labels/labelYearDropdown.test.ts @@ -0,0 +1,5 @@ +import { labelYearDropdown } from './labelYearDropdown'; + +test('should return the label', () => { + expect(labelYearDropdown()).toEqual('Year: '); +}); diff --git a/src/contexts/DayPicker/labels/labelYearDropdown.ts b/src/contexts/DayPicker/labels/labelYearDropdown.ts new file mode 100644 index 0000000000..dc58cdd174 --- /dev/null +++ b/src/contexts/DayPicker/labels/labelYearDropdown.ts @@ -0,0 +1,4 @@ +/** The default ARIA label for the WeekNumber element. */ +export const labelYearDropdown = (): string => { + return 'Year: '; +}; diff --git a/src/contexts/DayPicker/utils/index.ts b/src/contexts/DayPicker/utils/index.ts new file mode 100644 index 0000000000..f9b0cee9da --- /dev/null +++ b/src/contexts/DayPicker/utils/index.ts @@ -0,0 +1 @@ +export * from './parseFromToProps'; diff --git a/src/contexts/DayPicker/utils/parseFromToProps.test.ts b/src/contexts/DayPicker/utils/parseFromToProps.test.ts new file mode 100644 index 0000000000..ccd1aecdf0 --- /dev/null +++ b/src/contexts/DayPicker/utils/parseFromToProps.test.ts @@ -0,0 +1,47 @@ +import { parseFromToProps } from '../../../contexts/DayPicker/utils'; + +describe('when "fromMonth" is passed in', () => { + const fromMonth = new Date(2021, 4, 3); + const expectedFromDate = new Date(2021, 4, 1); + const { fromDate } = parseFromToProps({ fromMonth }); + test('"fromDate" should be the start of that month', () => { + expect(fromDate).toEqual(expectedFromDate); + }); + describe('when "fromYear" is passed in', () => { + test('"fromDate" should be the start of that month', () => { + expect(fromDate).toEqual(expectedFromDate); + }); + }); +}); + +describe('when "fromYear" is passed in', () => { + const fromYear = 2021; + const expectedFromDate = new Date(2021, 0, 1); + const { fromDate } = parseFromToProps({ fromYear }); + test('"fromDate" should be the start of that year', () => { + expect(fromDate).toEqual(expectedFromDate); + }); +}); + +describe('when "toMonth" is passed in', () => { + const toMonth = new Date(2021, 4, 3); + const expectedToDate = new Date(2021, 4, 31); + const { toDate } = parseFromToProps({ toMonth }); + test('"toDate" should be the end of that month', () => { + expect(toDate).toEqual(expectedToDate); + }); + describe('when "fromYear" is passed in', () => { + test('"toDate" should be the end of that month', () => { + expect(toDate).toEqual(expectedToDate); + }); + }); +}); + +describe('when "toYear" is passed in', () => { + const toYear = 2021; + const expectedToDate = new Date(2021, 11, 31); + const { toDate } = parseFromToProps({ toYear }); + test('"toDate" should be the end of that year', () => { + expect(toDate).toEqual(expectedToDate); + }); +}); diff --git a/src/contexts/DayPicker/utils/parseFromToProps.ts b/src/contexts/DayPicker/utils/parseFromToProps.ts new file mode 100644 index 0000000000..9cd6010642 --- /dev/null +++ b/src/contexts/DayPicker/utils/parseFromToProps.ts @@ -0,0 +1,33 @@ +import { endOfMonth, startOfDay, startOfMonth } from 'date-fns'; + +import { DayPickerBase } from '../../../types/DayPickerBase'; + +/** + * Return the `fromDate` and `toDate` prop values values parsing the DayPicker + * props. + */ +export function parseFromToProps( + props: Pick< + DayPickerBase, + 'fromYear' | 'toYear' | 'fromDate' | 'toDate' | 'fromMonth' | 'toMonth' + >, +): { fromDate: Date | undefined; toDate: Date | undefined } { + const { fromYear, toYear, fromMonth, toMonth } = props; + let { fromDate, toDate } = props; + + if (fromMonth) { + fromDate = startOfMonth(fromMonth); + } else if (fromYear) { + fromDate = new Date(fromYear, 0, 1); + } + if (toMonth) { + toDate = endOfMonth(toMonth); + } else if (toYear) { + toDate = new Date(toYear, 11, 31); + } + + return { + fromDate: fromDate ? startOfDay(fromDate) : undefined, + toDate: toDate ? startOfDay(toDate) : undefined, + }; +} diff --git a/src/contexts/Focus/FocusContext.test.ts b/src/contexts/Focus/FocusContext.test.ts new file mode 100644 index 0000000000..6a56545842 --- /dev/null +++ b/src/contexts/Focus/FocusContext.test.ts @@ -0,0 +1,156 @@ +import { act } from '@testing-library/react'; +import { + addDays, + addMonths, + addWeeks, + addYears, + endOfWeek, + startOfWeek, +} from 'date-fns'; + +import { renderDayPickerHook, RenderHookResult } from '../../../test/render'; +import { freezeBeforeAll } from '../../../test/utils'; + +import { FocusContextValue, useFocusContext } from '../../contexts/Focus'; + +const today = new Date(2021, 11, 8); // make sure is in the middle of the week for the complete test +freezeBeforeAll(today); + +function renderHook() { + return renderDayPickerHook(useFocusContext); +} + +type HookFunction = + | 'focusDayAfter' + | 'focusDayBefore' + | 'focusWeekAfter' + | 'focusWeekBefore' + | 'focusMonthBefore' + | 'focusMonthAfter' + | 'focusYearBefore' + | 'focusYearAfter' + | 'focusStartOfWeek' + | 'focusEndOfWeek'; + +test('`focusedDay` should be undefined', () => { + const result = renderHook(); + expect(result.current.focusedDay).toBeUndefined(); +}); + +const tests: Array = [ + 'focusDayAfter', + 'focusDayBefore', + 'focusWeekAfter', + 'focusWeekBefore', + 'focusMonthBefore', + 'focusMonthAfter', + 'focusYearBefore', + 'focusYearAfter', + 'focusStartOfWeek', + 'focusEndOfWeek', +]; +describe.each(tests)('when calling %s', (fn: HookFunction) => { + test('`focusedDay` should be undefined', () => { + const result = renderHook(); + result.current[fn]; + expect(result.current.focusedDay).toBeUndefined(); + }); +}); + +describe('when a day is focused', () => { + const day = today; + let result: RenderHookResult; + beforeEach(() => { + result = renderHook(); + act(() => result.current.focus(day)); + }); + test('should set the focused day', () => { + expect(result.current.focusedDay).toEqual(day); + }); + describe('when "focusDayBefore" is called', () => { + const dayBefore = addDays(day, -1); + beforeEach(() => act(() => result.current.focusDayBefore())); + test('should focus the day before', () => { + expect(result.current.focusedDay).toEqual(dayBefore); + }); + test.todo('should call the navigation goToDate'); + }); + describe('when "focusDayAfter" is called', () => { + beforeEach(() => act(() => result.current.focusDayAfter())); + test('should focus the day after', () => { + const dayAfter = addDays(day, 1); + expect(result.current.focusedDay).toEqual(dayAfter); + }); + test.todo('should call the navigation goToDate'); + }); + describe('when "focusWeekBefore" is called', () => { + beforeEach(() => act(() => result.current.focusWeekBefore())); + test('should focus the day in the previous week', () => { + const prevWeek = addWeeks(day, -1); + expect(result.current.focusedDay).toEqual(prevWeek); + }); + test.todo('should call the navigation goToDate'); + }); + describe('when "focusWeekAfter" is called', () => { + beforeEach(() => act(() => result.current.focusWeekAfter())); + test('should focus the day in the next week', () => { + const nextWeek = addWeeks(day, 1); + expect(result.current.focusedDay).toEqual(nextWeek); + }); + test.todo('should call the navigation goToDate'); + }); + describe('when "focusStartOfWeek" is called', () => { + beforeEach(() => act(() => result.current.focusStartOfWeek())); + test('should focus the first day of the week', () => { + const firstDayOfWeek = startOfWeek(day); + expect(result.current.focusedDay).toEqual(firstDayOfWeek); + }); + test.todo('should call the navigation goToDate'); + }); + describe('when "focusEndOfWeek" is called', () => { + beforeEach(() => act(() => result.current.focusEndOfWeek())); + test('should focus the last day of the week', () => { + const lastDayOfWeek = endOfWeek(day); + expect(result.current.focusedDay).toEqual(lastDayOfWeek); + }); + test.todo('should call the navigation goToDate'); + }); + describe('when "focusMonthBefore" is called', () => { + beforeEach(() => act(() => result.current.focusMonthBefore())); + test('should focus the day in the month before', () => { + const monthBefore = addMonths(day, -1); + expect(result.current.focusedDay).toEqual(monthBefore); + }); + test.todo('should call the navigation goToDate'); + }); + describe('when "focusMonthAfter" is called', () => { + beforeEach(() => act(() => result.current.focusMonthAfter())); + test('should focus the day in the month after', () => { + const monthAfter = addMonths(day, 1); + expect(result.current.focusedDay).toEqual(monthAfter); + }); + test.todo('should call the navigation goToDate'); + }); + describe('when "focusYearBefore" is called', () => { + beforeEach(() => act(() => result.current.focusYearBefore())); + test('should focus the day in the year before', () => { + const prevYear = addYears(day, -1); + expect(result.current.focusedDay).toEqual(prevYear); + }); + test.todo('should call the navigation goToDate'); + }); + describe('when "focusYearAfter" is called', () => { + beforeEach(() => act(() => result.current.focusYearAfter())); + test('should focus the day in the year after', () => { + const nextYear = addYears(day, 1); + expect(result.current.focusedDay).toEqual(nextYear); + }); + test.todo('should call the navigation goToDate'); + }); + describe('when blur is called', () => { + beforeEach(() => act(() => result.current.blur())); + test('`focusedDay` should be undefined', () => { + expect(result.current.focusedDay).toBeUndefined(); + }); + }); +}); diff --git a/src/contexts/Focus/FocusContext.tsx b/src/contexts/Focus/FocusContext.tsx new file mode 100644 index 0000000000..bb88e13490 --- /dev/null +++ b/src/contexts/Focus/FocusContext.tsx @@ -0,0 +1,137 @@ +import { createContext, ReactNode, useContext, useState } from 'react'; + +import { isSameDay } from 'date-fns'; + +import { useDayPicker } from '../../contexts/DayPicker'; + +import { useModifiers } from '../Modifiers'; +import { useNavigation } from '../Navigation'; +import { getInitialFocusTarget } from './utils/getInitialFocusTarget'; +import { + getNextFocus, + MoveFocusBy, + MoveFocusDirection, +} from './utils/getNextFocus'; + +/** Represents the value of the {@link FocusContext}. */ +export type FocusContextValue = { + /** The day currently focused. */ + focusedDay: Date | undefined; + /** Day that will be focused. */ + focusTarget: Date | undefined; + /** Focus a day. */ + focus: (day: Date) => void; + /** Blur the focused day. */ + blur: () => void; + /** Focus the day after the focused day. */ + focusDayAfter: () => void; + /** Focus the day before the focused day. */ + focusDayBefore: () => void; + /** Focus the day in the week before the focused day. */ + focusWeekBefore: () => void; + /** Focus the day in the week after the focused day. */ + focusWeekAfter: () => void; + /* Focus the day in the month before the focused day. */ + focusMonthBefore: () => void; + /* Focus the day in the month after the focused day. */ + focusMonthAfter: () => void; + /* Focus the day in the year before the focused day. */ + focusYearBefore: () => void; + /* Focus the day in the year after the focused day. */ + focusYearAfter: () => void; + /* Focus the day at the start of the week of the focused day. */ + focusStartOfWeek: () => void; + /* Focus the day at the end of the week of focused day. */ + focusEndOfWeek: () => void; +}; + +/** + * The Focus context shares details about the focused day for the keyboard + * + * Access this context from the {@link useFocusContext} hook. + */ +export const FocusContext = createContext( + undefined, +); + +export type FocusProviderProps = { children: ReactNode }; + +/** The provider for the {@link FocusContext}. */ +export function FocusProvider(props: FocusProviderProps): JSX.Element { + const navigation = useNavigation(); + const modifiers = useModifiers(); + + const [focusedDay, setFocusedDay] = useState(); + const [lastFocused, setLastFocused] = useState(); + + const initialFocusTarget = getInitialFocusTarget( + navigation.displayMonths, + modifiers, + ); + + // TODO: cleanup and test obscure code below + const focusTarget = + focusedDay ?? (lastFocused && navigation.isDateDisplayed(lastFocused)) + ? lastFocused + : initialFocusTarget; + + const blur = () => { + setLastFocused(focusedDay); + setFocusedDay(undefined); + }; + const focus = (date: Date) => { + setFocusedDay(date); + }; + + const context = useDayPicker(); + + const moveFocus = (moveBy: MoveFocusBy, direction: MoveFocusDirection) => { + if (!focusedDay) return; + const nextFocused = getNextFocus(focusedDay, { + moveBy, + direction, + context, + modifiers, + }); + if (isSameDay(focusedDay, nextFocused)) return undefined; + navigation.goToDate(nextFocused, focusedDay); + focus(nextFocused); + }; + + const value: FocusContextValue = { + focusedDay, + focusTarget, + blur, + focus, + focusDayAfter: () => moveFocus('day', 'after'), + focusDayBefore: () => moveFocus('day', 'before'), + focusWeekAfter: () => moveFocus('week', 'after'), + focusWeekBefore: () => moveFocus('week', 'before'), + focusMonthBefore: () => moveFocus('month', 'before'), + focusMonthAfter: () => moveFocus('month', 'after'), + focusYearBefore: () => moveFocus('year', 'before'), + focusYearAfter: () => moveFocus('year', 'after'), + focusStartOfWeek: () => moveFocus('startOfWeek', 'before'), + focusEndOfWeek: () => moveFocus('endOfWeek', 'after'), + }; + + return ( + + {props.children} + + ); +} + +/** + * Hook to access the {@link FocusContextValue}. Use this hook to handle the + * focus state of the elements. + * + * This hook is meant to be used inside internal or custom components. + */ +export function useFocusContext(): FocusContextValue { + const context = useContext(FocusContext); + if (!context) { + throw new Error('useFocusContext must be used within a FocusProvider'); + } + return context; +} diff --git a/src/contexts/Focus/index.ts b/src/contexts/Focus/index.ts new file mode 100644 index 0000000000..412e19d1eb --- /dev/null +++ b/src/contexts/Focus/index.ts @@ -0,0 +1 @@ +export * from './FocusContext'; diff --git a/src/contexts/Focus/utils/getInitialFocusTarget.test.ts b/src/contexts/Focus/utils/getInitialFocusTarget.test.ts new file mode 100644 index 0000000000..905db43d48 --- /dev/null +++ b/src/contexts/Focus/utils/getInitialFocusTarget.test.ts @@ -0,0 +1,41 @@ +import { Modifiers } from '../../../types/Modifiers'; + +import { getInitialFocusTarget } from './getInitialFocusTarget'; + +describe('when no days are selected is selected', () => { + test('should return the first day of month', () => { + const displayMonth = new Date(2022, 7); + const modifiers: Modifiers = { + outside: [], + disabled: [], + selected: [], + hidden: [], + today: [], + range_start: [], + range_end: [], + range_middle: [], + }; + const initialFocusTarget = getInitialFocusTarget([displayMonth], modifiers); + expect(initialFocusTarget).toStrictEqual(displayMonth); + }); +}); + +describe('when a day is selected', () => { + test('should return the selected day', () => { + const displayMonths = [new Date(2022, 7)]; + const selectedDay1 = new Date(2022, 7, 17); + const selectedDay2 = new Date(2022, 7, 19); + const modifiers: Modifiers = { + outside: [], + disabled: [], + selected: [selectedDay1, selectedDay2], + hidden: [], + today: [], + range_start: [], + range_end: [], + range_middle: [], + }; + const initialFocusTarget = getInitialFocusTarget(displayMonths, modifiers); + expect(initialFocusTarget).toStrictEqual(selectedDay1); + }); +}); diff --git a/src/contexts/Focus/utils/getInitialFocusTarget.ts b/src/contexts/Focus/utils/getInitialFocusTarget.ts new file mode 100644 index 0000000000..92b29ac468 --- /dev/null +++ b/src/contexts/Focus/utils/getInitialFocusTarget.ts @@ -0,0 +1,48 @@ +import { addDays, endOfMonth, startOfMonth } from 'date-fns'; + +import { getActiveModifiers } from '../../../contexts/Modifiers'; +import { Modifiers } from '../../../types/Modifiers'; + +/** + * Returns the day that should be the target of the focus when DayPicker is + * rendered the first time. + * + * TODO: this function doesn't consider if the day is outside the month. We + * implemented this check in `useDayRender` but it should probably go here. See + * https://github.com/gpbl/react-day-picker/pull/1576 + */ +export function getInitialFocusTarget( + displayMonths: Date[], + modifiers: Modifiers, +) { + const firstDayInMonth = startOfMonth(displayMonths[0]); + const lastDayInMonth = endOfMonth(displayMonths[displayMonths.length - 1]); + + // TODO: cleanup code + let firstFocusableDay; + let today; + let date = firstDayInMonth; + while (date <= lastDayInMonth) { + const activeModifiers = getActiveModifiers(date, modifiers); + const isFocusable = !activeModifiers.disabled && !activeModifiers.hidden; + if (!isFocusable) { + date = addDays(date, 1); + continue; + } + if (activeModifiers.selected) { + return date; + } + if (activeModifiers.today && !today) { + today = date; + } + if (!firstFocusableDay) { + firstFocusableDay = date; + } + date = addDays(date, 1); + } + if (today) { + return today; + } else { + return firstFocusableDay; + } +} diff --git a/src/contexts/Focus/utils/getNextFocus.test.ts b/src/contexts/Focus/utils/getNextFocus.test.ts new file mode 100644 index 0000000000..45305df4b2 --- /dev/null +++ b/src/contexts/Focus/utils/getNextFocus.test.ts @@ -0,0 +1,264 @@ +/* eslint-disable jest/no-standalone-expect */ +import { addDays, format, parseISO } from 'date-fns'; + +import { + InternalModifier, + InternalModifiers, + Modifiers, +} from '../../../types/Modifiers'; + +import { + FocusDayPickerContext, + getNextFocus, + MoveFocusBy, + MoveFocusDirection, +} from './getNextFocus'; + +type test = { + focusedDay: string; + moveBy: MoveFocusBy; + direction: MoveFocusDirection; + context: FocusDayPickerContext; + expectedNextFocus: string; +}; + +const tests: test[] = [ + { + focusedDay: '2022-08-17', + moveBy: 'day', + direction: 'after', + context: {}, + expectedNextFocus: '2022-08-18', + }, + { + focusedDay: '2022-08-17', + moveBy: 'day', + direction: 'before', + context: {}, + expectedNextFocus: '2022-08-16', + }, + { + focusedDay: '2022-08-17', + moveBy: 'week', + direction: 'after', + context: {}, + expectedNextFocus: '2022-08-24', + }, + { + focusedDay: '2022-08-17', + moveBy: 'week', + direction: 'before', + context: {}, + expectedNextFocus: '2022-08-10', + }, + { + focusedDay: '2022-08-17', + moveBy: 'month', + direction: 'after', + context: {}, + expectedNextFocus: '2022-09-17', + }, + { + focusedDay: '2022-08-17', + moveBy: 'startOfWeek', + direction: 'before', + context: { + weekStartsOn: 1, + }, + expectedNextFocus: '2022-08-15', + }, + { + focusedDay: '2022-08-17', + moveBy: 'endOfWeek', + direction: 'before', + context: { + weekStartsOn: 1, + }, + expectedNextFocus: '2022-08-21', + }, + { + focusedDay: '2022-08-17', + moveBy: 'month', + direction: 'after', + context: {}, + expectedNextFocus: '2022-09-17', + }, + { + focusedDay: '2022-08-17', + moveBy: 'year', + direction: 'before', + context: {}, + expectedNextFocus: '2021-08-17', + }, + { + focusedDay: '2022-08-17', + moveBy: 'year', + direction: 'after', + context: {}, + expectedNextFocus: '2023-08-17', + }, +]; + +describe.each(tests)( + 'when focusing the $moveBy $direction $focusedDay', + ({ focusedDay, moveBy, direction, context, expectedNextFocus }) => { + test(`should return ${expectedNextFocus}`, () => { + const nextFocus = getNextFocus(parseISO(focusedDay), { + moveBy, + direction, + context, + }); + expect(format(nextFocus, 'yyyy-MM-dd')).toBe(expectedNextFocus); + }); + }, +); + +describe('when reaching the "fromDate"', () => { + const focusedDay = new Date(); + const fromDate = addDays(focusedDay, -1); + test('next focus should be "fromDate"', () => { + const nextFocus = getNextFocus(focusedDay, { + moveBy: 'day', + direction: 'before', + context: { fromDate }, + }); + expect(nextFocus).toStrictEqual(fromDate); + }); +}); + +describe('when reaching the "toDate"', () => { + const focusedDay = new Date(); + const toDate = addDays(focusedDay, 1); + test('next focus should be "toDate"', () => { + const nextFocus = getNextFocus(focusedDay, { + moveBy: 'day', + direction: 'after', + context: { toDate }, + }); + expect(nextFocus).toStrictEqual(toDate); + }); +}); + +const emptyModifiers: Modifiers = { + outside: [], + disabled: [], + selected: [], + hidden: [], + today: [], + range_start: [], + range_end: [], + range_middle: [], +}; + +type ModifiersTest = { + focusedDay: string; + skippedDay: string; + moveBy: MoveFocusBy; + direction: MoveFocusDirection; + modifierName: InternalModifier; + expectedNextFocus: string; + fromDate?: string; + toDate?: string; +}; + +const modifiersTest: ModifiersTest[] = [ + { + focusedDay: '2022-08-17', + skippedDay: '2022-08-18', + moveBy: 'day', + direction: 'after', + modifierName: InternalModifier.Hidden, + expectedNextFocus: '2022-08-19', + }, + { + focusedDay: '2022-08-17', + skippedDay: '2022-08-18', + moveBy: 'day', + direction: 'after', + modifierName: InternalModifier.Disabled, + expectedNextFocus: '2022-08-19', + }, + { + focusedDay: '2022-08-17', + skippedDay: '2022-08-16', + moveBy: 'day', + direction: 'before', + modifierName: InternalModifier.Hidden, + expectedNextFocus: '2022-08-15', + }, + { + focusedDay: '2022-08-17', + skippedDay: '2022-08-16', + moveBy: 'day', + direction: 'before', + modifierName: InternalModifier.Disabled, + expectedNextFocus: '2022-08-15', + }, + { + focusedDay: '2022-08-17', + skippedDay: '2022-08-16', + fromDate: '2022-08-01', + moveBy: 'month', + direction: 'before', + modifierName: InternalModifier.Disabled, + expectedNextFocus: '2022-08-01', + }, + { + focusedDay: '2022-08-17', + skippedDay: '2022-08-16', + toDate: '2022-08-31', + moveBy: 'month', + direction: 'after', + modifierName: InternalModifier.Disabled, + expectedNextFocus: '2022-08-31', + }, +]; +describe.each(modifiersTest)( + 'when focusing the $moveBy $direction $focusedDay with $modifierName modifier', + (modifierTest) => { + const modifiers: InternalModifiers = { + ...emptyModifiers, + [modifierTest.modifierName]: [parseISO(modifierTest.skippedDay)], + }; + const context = { + fromDate: modifierTest.fromDate + ? parseISO(modifierTest.fromDate) + : undefined, + toDate: modifierTest.toDate ? parseISO(modifierTest.toDate) : undefined, + }; + test(`should skip the ${modifierTest.modifierName} day`, () => { + const nextFocus = getNextFocus(parseISO(modifierTest.focusedDay), { + moveBy: modifierTest.moveBy, + direction: modifierTest.direction, + context, + modifiers, + }); + expect(format(nextFocus, 'yyyy-MM-dd')).toBe( + modifierTest.expectedNextFocus, + ); + }); + }, +); + +test('should avoid infinite recursion', () => { + const focusedDay = new Date(2022, 7, 17); + const modifiers: Modifiers = { + outside: [], + disabled: [{ after: focusedDay }], + selected: [], + hidden: [], + today: [], + range_start: [], + range_end: [], + range_middle: [], + }; + + const nextFocus = getNextFocus(focusedDay, { + moveBy: 'day', + direction: 'after', + modifiers, + context: {}, + }); + + expect(nextFocus).toStrictEqual(focusedDay); +}); diff --git a/src/contexts/Focus/utils/getNextFocus.ts b/src/contexts/Focus/utils/getNextFocus.ts new file mode 100644 index 0000000000..c70426ba09 --- /dev/null +++ b/src/contexts/Focus/utils/getNextFocus.ts @@ -0,0 +1,104 @@ +import { + addDays, + addMonths, + addWeeks, + addYears, + endOfISOWeek, + endOfWeek, + max, + min, + startOfISOWeek, + startOfWeek, +} from 'date-fns'; + +import { DayPickerContextValue } from '../../../contexts/DayPicker'; +import { getActiveModifiers } from '../../../contexts/Modifiers'; +import { Modifiers } from '../../../types/Modifiers'; + +export type MoveFocusBy = + | 'day' + | 'week' + | 'startOfWeek' + | 'endOfWeek' + | 'month' + | 'year'; + +export type MoveFocusDirection = 'after' | 'before'; + +export type FocusDayPickerContext = Partial< + Pick< + DayPickerContextValue, + 'ISOWeek' | 'weekStartsOn' | 'fromDate' | 'toDate' | 'locale' + > +>; + +export type FocusDayOptions = { + moveBy: MoveFocusBy; + direction: MoveFocusDirection; + context: FocusDayPickerContext; + modifiers?: Modifiers; + retry?: { count: number; lastFocused: Date }; +}; + +const MAX_RETRY = 365; + +/** Return the next date to be focused. */ +export function getNextFocus(focusedDay: Date, options: FocusDayOptions): Date { + const { + moveBy, + direction, + context, + modifiers, + retry = { count: 0, lastFocused: focusedDay }, + } = options; + const { weekStartsOn, fromDate, toDate, locale } = context; + + const moveFns = { + day: addDays, + week: addWeeks, + month: addMonths, + year: addYears, + startOfWeek: (date: Date) => + context.ISOWeek + ? startOfISOWeek(date) + : startOfWeek(date, { locale, weekStartsOn }), + endOfWeek: (date: Date) => + context.ISOWeek + ? endOfISOWeek(date) + : endOfWeek(date, { locale, weekStartsOn }), + }; + + let newFocusedDay = moveFns[moveBy]( + focusedDay, + direction === 'after' ? 1 : -1, + ); + + if (direction === 'before' && fromDate) { + newFocusedDay = max([fromDate, newFocusedDay]); + } else if (direction === 'after' && toDate) { + newFocusedDay = min([toDate, newFocusedDay]); + } + let isFocusable = true; + + if (modifiers) { + const activeModifiers = getActiveModifiers(newFocusedDay, modifiers); + isFocusable = !activeModifiers.disabled && !activeModifiers.hidden; + } + if (isFocusable) { + return newFocusedDay; + } else { + if (retry.count > MAX_RETRY) { + return retry.lastFocused; + } + return getNextFocus(newFocusedDay, { + moveBy, + direction, + context, + modifiers, + retry: { + ...retry, + count: retry.count + 1, + }, + }); + } +} diff --git a/src/contexts/Modifiers/ModifiersContext.test.ts b/src/contexts/Modifiers/ModifiersContext.test.ts new file mode 100644 index 0000000000..208cecfe23 --- /dev/null +++ b/src/contexts/Modifiers/ModifiersContext.test.ts @@ -0,0 +1,44 @@ +import { DayPickerProps } from '../../DayPicker'; + +import { renderDayPickerHook } from '../../../test/render'; + +import { useModifiers } from '../../contexts/Modifiers'; +import { + DayModifiers, + InternalModifier, + Modifiers, +} from '../../types/Modifiers'; + +const internalModifiers = Object.values(InternalModifier); + +function renderHook(dayPickerProps: Partial = {}) { + return renderDayPickerHook(useModifiers, dayPickerProps); +} + +describe('when rendered with custom modifiers', () => { + const modifier = new Date(2018, 11, 12); + const dayModifiers: DayModifiers = { + foo: modifier, + today: modifier, + outside: modifier, + disabled: modifier, + selected: modifier, + hidden: modifier, + range_start: modifier, + range_end: modifier, + range_middle: modifier, + }; + test('should return the custom modifiers', () => { + const result = renderHook({ modifiers: dayModifiers }); + expect(result.current.foo).toEqual([dayModifiers.foo]); + }); + test.each(internalModifiers)( + 'should override the %s internal modifier', + (internalModifier) => { + const result = renderHook({ modifiers: dayModifiers }); + expect(result.current[internalModifier]).toEqual([ + dayModifiers[internalModifier], + ]); + }, + ); +}); diff --git a/src/contexts/Modifiers/ModifiersContext.tsx b/src/contexts/Modifiers/ModifiersContext.tsx new file mode 100644 index 0000000000..da8b0d9093 --- /dev/null +++ b/src/contexts/Modifiers/ModifiersContext.tsx @@ -0,0 +1,63 @@ +import { createContext, useContext, ReactNode } from 'react'; + +import { useDayPicker } from '../../contexts/DayPicker'; +import { useSelectMultiple } from '../../contexts/SelectMultiple'; +import { useSelectRange } from '../../contexts/SelectRange'; +import { + CustomModifiers, + InternalModifiers, + Modifiers, +} from '../../types/Modifiers'; + +import { getCustomModifiers } from './utils/getCustomModifiers'; +import { getInternalModifiers } from './utils/getInternalModifiers'; + +/** + * The Modifiers context store the modifiers used in DayPicker. To access the + * value of this context, use {@link useModifiers}. + */ +export const ModifiersContext = createContext(undefined); + +export type ModifiersProviderProps = { children: ReactNode }; + +/** Provide the value for the {@link ModifiersContext}. */ +export function ModifiersProvider(props: ModifiersProviderProps): JSX.Element { + const dayPicker = useDayPicker(); + const selectMultiple = useSelectMultiple(); + const selectRange = useSelectRange(); + + const internalModifiers: InternalModifiers = getInternalModifiers( + dayPicker, + selectMultiple, + selectRange, + ); + + const customModifiers: CustomModifiers = getCustomModifiers( + dayPicker.modifiers, + ); + + const modifiers: Modifiers = { + ...internalModifiers, + ...customModifiers, + }; + + return ( + + {props.children} + + ); +} + +/** + * Return the modifiers used by DayPicker. + * + * This hook is meant to be used inside internal or custom components. Requires + * to be wrapped into {@link ModifiersProvider}. + */ +export function useModifiers(): Modifiers { + const context = useContext(ModifiersContext); + if (!context) { + throw new Error('useModifiers must be used within a ModifiersProvider'); + } + return context; +} diff --git a/src/contexts/Modifiers/index.ts b/src/contexts/Modifiers/index.ts new file mode 100644 index 0000000000..b02531bf57 --- /dev/null +++ b/src/contexts/Modifiers/index.ts @@ -0,0 +1,2 @@ +export * from './ModifiersContext'; +export * from './utils/getActiveModifiers'; diff --git a/src/contexts/Modifiers/utils/getActiveModifiers.test.ts b/src/contexts/Modifiers/utils/getActiveModifiers.test.ts new file mode 100644 index 0000000000..ca5a94c34d --- /dev/null +++ b/src/contexts/Modifiers/utils/getActiveModifiers.test.ts @@ -0,0 +1,53 @@ +import { addMonths } from 'date-fns'; + +import { + InternalModifier, + InternalModifiers, + Modifiers, +} from '../../../types/Modifiers'; + +import { getActiveModifiers } from './getActiveModifiers'; + +const day = new Date(); + +const internalModifiers: InternalModifiers = { + [InternalModifier.Outside]: [], + [InternalModifier.Disabled]: [], + [InternalModifier.Selected]: [], + [InternalModifier.Hidden]: [], + [InternalModifier.Today]: [], + [InternalModifier.RangeStart]: [], + [InternalModifier.RangeEnd]: [], + [InternalModifier.RangeMiddle]: [], +}; +describe('when the day matches a modifier', () => { + const modifiers: Modifiers = { + ...internalModifiers, + foo: [day], + }; + const result = getActiveModifiers(day, modifiers); + test('should return the modifier as active', () => { + expect(result.foo).toBe(true); + }); +}); +describe('when the day does not match a modifier', () => { + const modifiers: Modifiers = { + ...internalModifiers, + foo: [], + }; + const result = getActiveModifiers(day, modifiers); + test('should not return the modifier as active', () => { + expect(result.foo).toBeUndefined(); + }); +}); + +describe('when the day is not in the same display month', () => { + const modifiers: Modifiers = { + ...internalModifiers, + }; + const displayMonth = addMonths(day, 1); + const result = getActiveModifiers(day, modifiers, displayMonth); + test('should not return the modifier as active', () => { + expect(result.outside).toBe(true); + }); +}); diff --git a/src/contexts/Modifiers/utils/getActiveModifiers.ts b/src/contexts/Modifiers/utils/getActiveModifiers.ts new file mode 100644 index 0000000000..ff72778411 --- /dev/null +++ b/src/contexts/Modifiers/utils/getActiveModifiers.ts @@ -0,0 +1,33 @@ +import { isSameMonth } from 'date-fns'; + +import { ActiveModifiers, Modifiers } from '../../../types/Modifiers'; + +import { isMatch } from './isMatch'; + +/** Return the active modifiers for the given day. */ +export function getActiveModifiers( + day: Date, + /** The modifiers to match for the given date. */ + modifiers: Modifiers, + /** The month where the day is displayed, to add the "outside" modifiers. */ + displayMonth?: Date, +): ActiveModifiers { + const matchedModifiers = Object.keys(modifiers).reduce( + (result: string[], key: string): string[] => { + const modifier = modifiers[key]; + if (isMatch(day, modifier)) { + result.push(key); + } + return result; + }, + [], + ); + const activeModifiers: ActiveModifiers = {}; + matchedModifiers.forEach((modifier) => (activeModifiers[modifier] = true)); + + if (displayMonth && !isSameMonth(day, displayMonth)) { + activeModifiers.outside = true; + } + + return activeModifiers; +} diff --git a/src/contexts/Modifiers/utils/getCustomModifiers.test.ts b/src/contexts/Modifiers/utils/getCustomModifiers.test.ts new file mode 100644 index 0000000000..379bd6f2d6 --- /dev/null +++ b/src/contexts/Modifiers/utils/getCustomModifiers.test.ts @@ -0,0 +1,13 @@ +import { DayModifiers } from '../../../types/Modifiers'; +import { getCustomModifiers } from './getCustomModifiers'; + +describe('when some modifiers are not an array', () => { + const date = new Date(); + const dayModifiers: DayModifiers = { + foo: date, + }; + const result = getCustomModifiers(dayModifiers); + test('should return as array', () => { + expect(result.foo).toEqual([date]); + }); +}); diff --git a/src/contexts/Modifiers/utils/getCustomModifiers.ts b/src/contexts/Modifiers/utils/getCustomModifiers.ts new file mode 100644 index 0000000000..1189971354 --- /dev/null +++ b/src/contexts/Modifiers/utils/getCustomModifiers.ts @@ -0,0 +1,14 @@ +import { CustomModifiers, DayModifiers } from '../../../types/Modifiers'; + +import { matcherToArray } from './matcherToArray'; + +/** Create CustomModifiers from dayModifiers */ +export function getCustomModifiers( + dayModifiers: DayModifiers, +): CustomModifiers { + const customModifiers: CustomModifiers = {}; + Object.entries(dayModifiers).forEach(([modifier, matcher]) => { + customModifiers[modifier] = matcherToArray(matcher); + }); + return customModifiers; +} diff --git a/src/contexts/Modifiers/utils/getInternalModifiers.test.ts b/src/contexts/Modifiers/utils/getInternalModifiers.test.ts new file mode 100644 index 0000000000..026d9e244e --- /dev/null +++ b/src/contexts/Modifiers/utils/getInternalModifiers.test.ts @@ -0,0 +1,147 @@ +import { addDays } from 'date-fns'; + +import { DayPickerContextValue } from '../../../contexts/DayPicker'; +import { getDefaultContextValues } from '../../../contexts/DayPicker/defaultContextValues'; +import { SelectRangeContextValue } from '../../../contexts/SelectRange'; +import { InternalModifier, InternalModifiers } from '../../../types/Modifiers'; + +import { getInternalModifiers } from './getInternalModifiers'; + +const defaultDayPickerContext: DayPickerContextValue = + getDefaultContextValues(); +const defaultSelectMultipleContext = { + selected: undefined, + modifiers: { disabled: [] }, +}; +const defaultSelectRangeContext = { + selected: undefined, + modifiers: { + disabled: [], + range_start: [], + range_end: [], + range_middle: [], + }, +}; + +const { Selected, Disabled, Hidden, Today, RangeEnd, RangeMiddle, RangeStart } = + InternalModifier; + +const internalModifiers = [Selected, Disabled, Hidden, Today]; +test.each(internalModifiers)( + 'should transform to array the modifiers from the "%s" prop', + (propName) => { + const value = new Date(); + const modifiers = getInternalModifiers( + { ...defaultDayPickerContext, [propName]: value }, + defaultSelectMultipleContext, + defaultSelectRangeContext, + ); + expect(modifiers[propName]).toStrictEqual([value]); + }, +); + +describe('when navigation is limited by "fromDate"', () => { + const fromDate = new Date(); + const dayPickerContext: DayPickerContextValue = { + ...defaultDayPickerContext, + fromDate, + }; + test('should add a "before" matcher to the "disabled" modifiers', () => { + const modifiers = getInternalModifiers( + dayPickerContext, + defaultSelectMultipleContext, + defaultSelectRangeContext, + ); + expect(modifiers.disabled).toStrictEqual([{ before: fromDate }]); + }); +}); + +describe('when navigation is limited by "toDate"', () => { + const toDate = new Date(); + const dayPickerContext: DayPickerContextValue = { + ...defaultDayPickerContext, + toDate, + }; + test('should add an "after" matcher to the "disabled" modifiers', () => { + const modifiers = getInternalModifiers( + dayPickerContext, + defaultSelectMultipleContext, + defaultSelectRangeContext, + ); + expect(modifiers.disabled).toStrictEqual([{ after: toDate }]); + }); +}); + +describe('when in multiple select mode', () => { + const disabledDate = new Date(); + const dayPickerContext: DayPickerContextValue = { + ...defaultDayPickerContext, + mode: 'multiple', + }; + const selectMultipleContext = { + ...defaultSelectMultipleContext, + modifiers: { + [Disabled]: [disabledDate], + }, + }; + test('should add the disabled modifier from the select multiple context', () => { + const modifiers = getInternalModifiers( + dayPickerContext, + selectMultipleContext, + defaultSelectRangeContext, + ); + expect(modifiers.disabled).toStrictEqual([disabledDate]); + }); +}); + +describe('when in range select mode', () => { + const disabled = [new Date()]; + const rangeStart = new Date(); + const rangeMiddle = [addDays(rangeStart, 1), addDays(rangeStart, 2)]; + const rangeEnd = [addDays(rangeStart, 3)]; + const dayPickerContext: DayPickerContextValue = { + ...defaultDayPickerContext, + mode: 'range', + }; + const selectRangeContext: SelectRangeContextValue = { + ...defaultSelectRangeContext, + modifiers: { + [Disabled]: [disabled], + [RangeStart]: [rangeStart], + [RangeEnd]: rangeEnd, + [RangeMiddle]: rangeMiddle, + }, + }; + let internalModifiers: InternalModifiers; + beforeEach(() => { + internalModifiers = getInternalModifiers( + dayPickerContext, + defaultSelectMultipleContext, + selectRangeContext, + ); + }); + + test('should add the Disabled modifier from the SelectRange context', () => { + expect(internalModifiers[Disabled]).toStrictEqual( + selectRangeContext.modifiers[Disabled], + ); + }); + + test('should add the RangeStart modifier from the SelectRange context', () => { + expect(internalModifiers[RangeStart]).toStrictEqual( + selectRangeContext.modifiers[RangeStart], + ); + }); + + test('should add the RangeEnd modifier from the SelectRange context', () => { + expect(internalModifiers[RangeEnd]).toStrictEqual( + selectRangeContext.modifiers[RangeEnd], + ); + }); + + test('should add the RangeMiddle modifier from the SelectRange context', () => { + expect(internalModifiers[RangeMiddle]).toStrictEqual( + selectRangeContext.modifiers[RangeMiddle], + ); + }); +}); diff --git a/src/contexts/Modifiers/utils/getInternalModifiers.ts b/src/contexts/Modifiers/utils/getInternalModifiers.ts new file mode 100644 index 0000000000..c89e9b413c --- /dev/null +++ b/src/contexts/Modifiers/utils/getInternalModifiers.ts @@ -0,0 +1,58 @@ +import { DayPickerContextValue } from '../../../contexts/DayPicker'; +import { SelectMultipleContextValue } from '../../../contexts/SelectMultiple'; +import { SelectRangeContextValue } from '../../../contexts/SelectRange'; +import { isDayPickerMultiple } from '../../../types/DayPickerMultiple'; +import { isDayPickerRange } from '../../../types/DayPickerRange'; +import { InternalModifier, InternalModifiers } from '../../../types/Modifiers'; + +import { matcherToArray } from './matcherToArray'; + +const { + Selected, + Disabled, + Hidden, + Today, + RangeEnd, + RangeMiddle, + RangeStart, + Outside, +} = InternalModifier; + +/** Return the {@link InternalModifiers} from the DayPicker and select contexts. */ +export function getInternalModifiers( + dayPicker: DayPickerContextValue, + selectMultiple: SelectMultipleContextValue, + selectRange: SelectRangeContextValue, +) { + const internalModifiers: InternalModifiers = { + [Selected]: matcherToArray(dayPicker.selected), + [Disabled]: matcherToArray(dayPicker.disabled), + [Hidden]: matcherToArray(dayPicker.hidden), + [Today]: [dayPicker.today], + [RangeEnd]: [], + [RangeMiddle]: [], + [RangeStart]: [], + [Outside]: [], + }; + + if (dayPicker.fromDate) { + internalModifiers[Disabled].push({ before: dayPicker.fromDate }); + } + if (dayPicker.toDate) { + internalModifiers[Disabled].push({ after: dayPicker.toDate }); + } + + if (isDayPickerMultiple(dayPicker)) { + internalModifiers[Disabled] = internalModifiers[Disabled].concat( + selectMultiple.modifiers[Disabled], + ); + } else if (isDayPickerRange(dayPicker)) { + internalModifiers[Disabled] = internalModifiers[Disabled].concat( + selectRange.modifiers[Disabled], + ); + internalModifiers[RangeStart] = selectRange.modifiers[RangeStart]; + internalModifiers[RangeMiddle] = selectRange.modifiers[RangeMiddle]; + internalModifiers[RangeEnd] = selectRange.modifiers[RangeEnd]; + } + return internalModifiers; +} diff --git a/src/contexts/Modifiers/utils/isDateInRange.test.ts b/src/contexts/Modifiers/utils/isDateInRange.test.ts new file mode 100644 index 0000000000..5eb4d522cf --- /dev/null +++ b/src/contexts/Modifiers/utils/isDateInRange.test.ts @@ -0,0 +1,45 @@ +import { addDays } from 'date-fns'; + +import { isDateInRange } from './isDateInRange'; +import { DateRange } from '../../../types/Matchers'; + +const date = new Date(); + +describe('when range is missing the "from" date', () => { + const range: DateRange = { from: undefined }; + const result = isDateInRange(date, range); + test('should return false', () => { + expect(result).toBe(false); + }); +}); + +describe('when range is missing the "to" date', () => { + const result = isDateInRange(date, { from: date, to: undefined }); + test('should return true', () => { + expect(result).toBe(true); + }); +}); + +describe('when the range dates are the same as date', () => { + const range: DateRange = { from: date, to: date }; + const result = isDateInRange(date, range); + test('should return true', () => { + expect(result).toBe(true); + }); +}); + +describe('when the range dates are the same but not as date', () => { + const range: DateRange = { from: date, to: date }; + const result = isDateInRange(addDays(date, 1), range); + test('should return false', () => { + expect(result).toBe(false); + }); +}); + +describe('when the range is inverted', () => { + const range: DateRange = { from: addDays(date, 1), to: date }; + const result = isDateInRange(date, range); + test('should return true', () => { + expect(result).toBe(true); + }); +}); diff --git a/src/contexts/Modifiers/utils/isDateInRange.ts b/src/contexts/Modifiers/utils/isDateInRange.ts new file mode 100644 index 0000000000..fa8188b90a --- /dev/null +++ b/src/contexts/Modifiers/utils/isDateInRange.ts @@ -0,0 +1,25 @@ +import { differenceInCalendarDays, isSameDay } from 'date-fns'; + +import { DateRange } from '../../../types/Matchers'; + +/** Return `true` whether `date` is inside `range`. */ +export function isDateInRange(date: Date, range: DateRange): boolean { + let { from, to } = range; + if (from && to) { + const isRangeInverted = differenceInCalendarDays(to, from) < 0; + if (isRangeInverted) { + [from, to] = [to, from]; + } + const isInRange = + differenceInCalendarDays(date, from) >= 0 && + differenceInCalendarDays(to, date) >= 0; + return isInRange; + } + if (to) { + return isSameDay(to, date); + } + if (from) { + return isSameDay(from, date); + } + return false; +} diff --git a/src/contexts/Modifiers/utils/isMatch.test.ts b/src/contexts/Modifiers/utils/isMatch.test.ts new file mode 100644 index 0000000000..7ec8471ab4 --- /dev/null +++ b/src/contexts/Modifiers/utils/isMatch.test.ts @@ -0,0 +1,111 @@ +import { addDays, subDays } from 'date-fns'; + +import { + DateAfter, + DateBefore, + DateInterval, + DateRange, + DayOfWeek, +} from '../../../types/Matchers'; + +import { isMatch } from './isMatch'; + +const testDay = new Date(); + +describe('when the matcher is a boolean', () => { + const matcher = true; + const result = isMatch(testDay, [matcher]); + test('should return the boolean', () => { + expect(result).toBe(matcher); + }); +}); +describe('when matching the same day', () => { + const matcher = testDay; + const result = isMatch(testDay, [matcher]); + test('should return true', () => { + expect(result).toBe(true); + }); +}); + +describe('when matching an array of dates including the day', () => { + const matcher = [addDays(testDay, -1), testDay, addDays(testDay, 1)]; + const result = isMatch(testDay, [matcher]); + test('should return true', () => { + expect(result).toBe(true); + }); +}); + +describe('when matching date range', () => { + const matcher: DateRange = { + from: testDay, + to: addDays(testDay, 1), + }; + const result = isMatch(testDay, [matcher]); + test('should return true', () => { + expect(result).toBe(true); + }); +}); + +describe('when matching the day of week', () => { + const matcher: DayOfWeek = { + dayOfWeek: [testDay.getDay()], + }; + const result = isMatch(testDay, [matcher]); + test('should return true', () => { + expect(result).toBe(true); + }); +}); + +describe('when matching date interval (closed)', () => { + const matcher: DateInterval = { + before: addDays(testDay, 5), + after: subDays(testDay, 3), + }; + const result = isMatch(testDay, [matcher]); + test('should return true for the included day', () => { + expect(result).toBe(true); + }); +}); + +describe('when matching date interval (open)', () => { + const matcher: DateInterval = { + before: subDays(testDay, 4), + after: addDays(testDay, 5), + }; + test('should return false', () => { + const result = isMatch(testDay, [matcher]); + expect(result).toBe(false); + }); + test('should return true for the days before', () => { + const result = isMatch(subDays(testDay, 8), [matcher]); + expect(result).toBe(true); + }); + test('should return true for the days after', () => { + const result = isMatch(addDays(testDay, 8), [matcher]); + expect(result).toBe(true); + }); +}); + +describe('when matching the date after', () => { + const matcher: DateAfter = { after: addDays(testDay, -1) }; + const result = isMatch(testDay, [matcher]); + test('should return true', () => { + expect(result).toBe(true); + }); +}); + +describe('when matching the date before', () => { + const matcher: DateBefore = { before: addDays(testDay, +1) }; + const result = isMatch(testDay, [matcher]); + test('should return true', () => { + expect(result).toBe(true); + }); +}); + +describe('when the matcher is a function', () => { + const matcher = () => true; + const result = isMatch(testDay, [matcher]); + test('should return the result of the function', () => { + expect(result).toBe(true); + }); +}); diff --git a/src/contexts/Modifiers/utils/isMatch.ts b/src/contexts/Modifiers/utils/isMatch.ts new file mode 100644 index 0000000000..f41a724ff1 --- /dev/null +++ b/src/contexts/Modifiers/utils/isMatch.ts @@ -0,0 +1,79 @@ +import { differenceInCalendarDays, isAfter, isDate, isSameDay } from 'date-fns'; + +import { + isDateAfterType, + isDateBeforeType, + isDateInterval, + isDateRange, + isDayOfWeekType, + Matcher, +} from '../../../types/Matchers'; + +import { isDateInRange } from './isDateInRange'; + +/** Returns true if `value` is a Date type. */ +function isDateType(value: unknown): value is Date { + return isDate(value); +} + +/** Returns true if `value` is an array of valid dates. */ +function isArrayOfDates(value: unknown): value is Date[] { + return Array.isArray(value) && value.every(isDate); +} + +/** + * Returns whether a day matches against at least one of the given Matchers. + * + * const day = new Date(2022, 5, 19); + * const matcher1: DateRange = { + * from: new Date(2021, 12, 21), + * to: new Date(2021, 12, 30) + * } + * const matcher2: DateRange = { + * from: new Date(2022, 5, 1), + * to: new Date(2022, 5, 23) + * } + * + * const isMatch(day, [matcher1, matcher2]); // true, since day is in the matcher1 range. + */ +export function isMatch(day: Date, matchers: Matcher[]): boolean { + return matchers.some((matcher: Matcher) => { + if (typeof matcher === 'boolean') { + return matcher; + } + if (isDateType(matcher)) { + return isSameDay(day, matcher); + } + if (isArrayOfDates(matcher)) { + return matcher.includes(day); + } + if (isDateRange(matcher)) { + return isDateInRange(day, matcher); + } + if (isDayOfWeekType(matcher)) { + return matcher.dayOfWeek.includes(day.getDay()); + } + if (isDateInterval(matcher)) { + const diffBefore = differenceInCalendarDays(matcher.before, day); + const diffAfter = differenceInCalendarDays(matcher.after, day); + const isDayBefore = diffBefore > 0; + const isDayAfter = diffAfter < 0; + const isClosedInterval = isAfter(matcher.before, matcher.after); + if (isClosedInterval) { + return isDayAfter && isDayBefore; + } else { + return isDayBefore || isDayAfter; + } + } + if (isDateAfterType(matcher)) { + return differenceInCalendarDays(day, matcher.after) > 0; + } + if (isDateBeforeType(matcher)) { + return differenceInCalendarDays(matcher.before, day) > 0; + } + if (typeof matcher === 'function') { + return matcher(day); + } + return false; + }); +} diff --git a/src/contexts/Modifiers/utils/matcherToArray.test.ts b/src/contexts/Modifiers/utils/matcherToArray.test.ts new file mode 100644 index 0000000000..425f1ef9a9 --- /dev/null +++ b/src/contexts/Modifiers/utils/matcherToArray.test.ts @@ -0,0 +1,25 @@ +import { matcherToArray } from '../../../contexts/Modifiers/utils/matcherToArray'; +import { Matcher } from '../../../types/Matchers'; + +const matcher: Matcher = jest.fn(); + +describe('when a Matcher is passed in', () => { + test('should return an array with the Matcher', () => { + expect(matcherToArray(matcher)).toStrictEqual([matcher]); + }); +}); + +describe('when an array of Matchers is passed in', () => { + test('should return a copy of the array', () => { + const value = [matcher, matcher]; + const result = matcherToArray(value); + expect(result).toStrictEqual(value); + expect(result).not.toBe(value); + }); +}); + +describe('when undefined is passed in', () => { + test('should return an empty array', () => { + expect(matcherToArray(undefined)).toStrictEqual([]); + }); +}); diff --git a/src/contexts/Modifiers/utils/matcherToArray.ts b/src/contexts/Modifiers/utils/matcherToArray.ts new file mode 100644 index 0000000000..a515fdea66 --- /dev/null +++ b/src/contexts/Modifiers/utils/matcherToArray.ts @@ -0,0 +1,14 @@ +import { Matcher } from '../../../types/Matchers'; + +/** Normalize to array a matcher input. */ +export function matcherToArray( + matcher: Matcher | Matcher[] | undefined, +): Matcher[] { + if (Array.isArray(matcher)) { + return [...matcher]; + } else if (matcher !== undefined) { + return [matcher]; + } else { + return []; + } +} diff --git a/src/contexts/Navigation/NavigationContext.test.ts b/src/contexts/Navigation/NavigationContext.test.ts new file mode 100644 index 0000000000..3c10bc9450 --- /dev/null +++ b/src/contexts/Navigation/NavigationContext.test.ts @@ -0,0 +1,126 @@ +import { act } from '@testing-library/react'; +import { addMonths, startOfMonth, subMonths } from 'date-fns'; +import { DayPickerProps } from '../../DayPicker'; + +import { renderDayPickerHook, RenderHookResult } from '../../../test/render'; +import { freezeBeforeAll } from '../../../test/utils'; + +import { NavigationContextValue, useNavigation } from './NavigationContext'; + +const today = new Date(2021, 11, 8); +const todaysMonth = startOfMonth(today); +freezeBeforeAll(today); + +function renderHook(props: Partial = {}) { + return renderDayPickerHook(useNavigation, props); +} + +let result: RenderHookResult; +describe('when rendered', () => { + beforeEach(() => { + result = renderHook(); + }); + test('the current month should be the today`s month', () => { + expect(result.current.currentMonth).toEqual(todaysMonth); + }); + test('the display months should be the today`s month', () => { + expect(result.current.displayMonths).toEqual([todaysMonth]); + }); + test('the previous month should be the month before today`s month', () => { + expect(result.current.previousMonth).toEqual(subMonths(todaysMonth, 1)); + }); + test('the next month should be the month after today`s month', () => { + expect(result.current.nextMonth).toEqual(addMonths(todaysMonth, 1)); + }); + describe('when goToMonth is called', () => { + const newMonth = addMonths(todaysMonth, 10); + beforeEach(() => { + result = renderHook(); + act(() => result.current.goToMonth(newMonth)); + }); + test('should go to the specified month', () => { + expect(result.current.currentMonth).toEqual(newMonth); + }); + test('the display months should be the today`s month', () => { + expect(result.current.displayMonths).toEqual([newMonth]); + }); + test('the previous month should be the month before today`s month', () => { + expect(result.current.previousMonth).toEqual(subMonths(newMonth, 1)); + }); + test('the next month should be the month after today`s month', () => { + expect(result.current.nextMonth).toEqual(addMonths(newMonth, 1)); + }); + }); + describe('when goToDate is called with a date from another month', () => { + const newDate = addMonths(today, 10); + const onMonthChange = jest.fn(); + beforeEach(() => { + result = renderHook({ onMonthChange }); + act(() => result.current.goToDate(newDate)); + }); + test('should go to the specified month', () => { + const date = startOfMonth(newDate); + expect(result.current.currentMonth).toEqual(date); + expect(onMonthChange).toHaveBeenCalledWith(date); + }); + }); + describe('when isDateDisplayed is called', () => { + describe('with a date in the calendar', () => { + test('should return true', () => { + expect(result.current.isDateDisplayed(today)).toBe(true); + }); + }); + describe('with a date not in the calendar', () => { + test('should return false', () => { + expect(result.current.isDateDisplayed(addMonths(today, 1))).toBe(false); + }); + }); + }); +}); + +const numberOfMonths = 2; +describe('when the number of months is ${numberOfMonths}', () => { + beforeEach(() => { + result = renderHook({ numberOfMonths: 2 }); + }); + test('the current month should be the today`s month', () => { + expect(result.current.currentMonth).toEqual(todaysMonth); + }); + test('the display months should be the today`s and next month', () => { + expect(result.current.displayMonths).toEqual([ + todaysMonth, + addMonths(todaysMonth, 1), + ]); + }); + test('the previous month should be the month before today`s month', () => { + expect(result.current.previousMonth).toEqual(subMonths(todaysMonth, 1)); + }); + test('the next month should be the month after today`s month', () => { + expect(result.current.nextMonth).toEqual(addMonths(todaysMonth, 1)); + }); +}); + +describe(`when the number of months is ${numberOfMonths} and the navigation is paged`, () => { + beforeEach(() => { + result = renderHook({ numberOfMonths, pagedNavigation: true }); + }); + test('the current month should be the today`s month', () => { + expect(result.current.currentMonth).toEqual(todaysMonth); + }); + test('the display months should be the today`s and next month', () => { + expect(result.current.displayMonths).toEqual([ + todaysMonth, + addMonths(todaysMonth, 1), + ]); + }); + test(`the previous month should be the ${numberOfMonths} months before today's month`, () => { + expect(result.current.previousMonth).toEqual( + subMonths(todaysMonth, numberOfMonths), + ); + }); + test(`the next month should be ${numberOfMonths} months after today's month`, () => { + expect(result.current.nextMonth).toEqual( + addMonths(todaysMonth, numberOfMonths), + ); + }); +}); diff --git a/src/contexts/Navigation/NavigationContext.tsx b/src/contexts/Navigation/NavigationContext.tsx new file mode 100644 index 0000000000..2e896d8bbb --- /dev/null +++ b/src/contexts/Navigation/NavigationContext.tsx @@ -0,0 +1,101 @@ +import { createContext, ReactNode, useContext } from 'react'; + +import { addMonths, isBefore, isSameMonth } from 'date-fns'; + +import { useDayPicker } from '../DayPicker'; +import { useNavigationState } from './useNavigationState'; +import { getDisplayMonths } from './utils/getDisplayMonths'; +import { getNextMonth } from './utils/getNextMonth'; +import { getPreviousMonth } from './utils/getPreviousMonth'; + +/** Represents the value of the {@link NavigationContext}. */ +export interface NavigationContextValue { + /** + * The month to display in the calendar. When `numberOfMonths` is greater than + * one, is the first of the displayed months. + */ + currentMonth: Date; + /** + * The months rendered by DayPicker. DayPicker can render multiple months via + * `numberOfMonths`. + */ + displayMonths: Date[]; + /** Navigate to the specified month. */ + goToMonth: (month: Date) => void; + /** Navigate to the specified date. */ + goToDate: (date: Date, refDate?: Date) => void; + /** The next month to display. */ + nextMonth?: Date; + /** The previous month to display. */ + previousMonth?: Date; + /** Whether the given day is included in the displayed months. */ + isDateDisplayed: (day: Date) => boolean; +} + +/** + * The Navigation context shares details and methods to navigate the months in + * DayPicker. Access this context from the {@link useNavigation} hook. + */ +export const NavigationContext = createContext< + NavigationContextValue | undefined +>(undefined); + +/** Provides the values for the {@link NavigationContext}. */ +export function NavigationProvider(props: { + children?: ReactNode; +}): JSX.Element { + const dayPicker = useDayPicker(); + const [currentMonth, goToMonth] = useNavigationState(); + + const displayMonths = getDisplayMonths(currentMonth, dayPicker); + const nextMonth = getNextMonth(currentMonth, dayPicker); + const previousMonth = getPreviousMonth(currentMonth, dayPicker); + + const isDateDisplayed = (date: Date) => { + return displayMonths.some((displayMonth) => + isSameMonth(date, displayMonth), + ); + }; + + const goToDate = (date: Date, refDate?: Date) => { + if (isDateDisplayed(date)) { + return; + } + + if (refDate && isBefore(date, refDate)) { + goToMonth(addMonths(date, 1 + dayPicker.numberOfMonths * -1)); + } else { + goToMonth(date); + } + }; + + const value: NavigationContextValue = { + currentMonth, + displayMonths, + goToMonth, + goToDate, + previousMonth, + nextMonth, + isDateDisplayed, + }; + + return ( + + {props.children} + + ); +} + +/** + * Hook to access the {@link NavigationContextValue}. Use this hook to navigate + * between months or years in DayPicker. + * + * This hook is meant to be used inside internal or custom components. + */ +export function useNavigation(): NavigationContextValue { + const context = useContext(NavigationContext); + if (!context) { + throw new Error('useNavigation must be used within a NavigationProvider'); + } + return context; +} diff --git a/src/contexts/Navigation/index.ts b/src/contexts/Navigation/index.ts new file mode 100644 index 0000000000..f58d63d6d7 --- /dev/null +++ b/src/contexts/Navigation/index.ts @@ -0,0 +1 @@ +export * from './NavigationContext'; diff --git a/src/contexts/Navigation/useNavigationState.test.ts b/src/contexts/Navigation/useNavigationState.test.ts new file mode 100644 index 0000000000..0a8a8ac7a5 --- /dev/null +++ b/src/contexts/Navigation/useNavigationState.test.ts @@ -0,0 +1,36 @@ +import { act } from '@testing-library/react'; +import { addMonths, startOfMonth } from 'date-fns'; +import { DayPickerProps } from '../../DayPicker'; + +import { renderDayPickerHook } from '../../../test/render'; +import { freezeBeforeAll } from '../../../test/utils'; + +import { NavigationState, useNavigationState } from './useNavigationState'; + +const today = new Date(2021, 11, 8); +freezeBeforeAll(today); + +function renderHook(props: Partial = {}) { + return renderDayPickerHook(useNavigationState, props); +} + +describe('when goToMonth is called', () => { + test('should set the month in state', () => { + const onMonthChange = jest.fn(); + const result = renderHook({ onMonthChange }); + const month = addMonths(today, 2); + act(() => result.current[1](month)); + expect(result.current[0]).toEqual(startOfMonth(month)); + expect(onMonthChange).toHaveBeenCalledWith(startOfMonth(month)); + }); + describe('when navigation is disabled', () => { + test('should not set the month in state', () => { + const onMonthChange = jest.fn(); + const result = renderHook({ disableNavigation: true, onMonthChange }); + const month = addMonths(today, 2); + result.current[1](month); + expect(result.current[0]).toEqual(startOfMonth(today)); + expect(onMonthChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/contexts/Navigation/useNavigationState.ts b/src/contexts/Navigation/useNavigationState.ts new file mode 100644 index 0000000000..ea5f73085e --- /dev/null +++ b/src/contexts/Navigation/useNavigationState.ts @@ -0,0 +1,29 @@ +import { startOfMonth } from 'date-fns'; + +import { useDayPicker } from '../../contexts/DayPicker'; +import { useControlledValue } from '../../hooks/useControlledValue'; + +import { getInitialMonth } from './utils/getInitialMonth'; + +export type NavigationState = [ + /** The month DayPicker is navigating at */ + month: Date, + /** Go to the specified month. */ + goToMonth: (month: Date) => void, +]; + +/** Controls the navigation state. */ +export function useNavigationState(): NavigationState { + const context = useDayPicker(); + const initialMonth = getInitialMonth(context); + const [month, setMonth] = useControlledValue(initialMonth, context.month); + + const goToMonth = (date: Date) => { + if (context.disableNavigation) return; + const month = startOfMonth(date); + setMonth(month); + context.onMonthChange?.(month); + }; + + return [month, goToMonth]; +} diff --git a/src/contexts/Navigation/utils/getDisplayMonths.ts b/src/contexts/Navigation/utils/getDisplayMonths.ts new file mode 100644 index 0000000000..a868a40580 --- /dev/null +++ b/src/contexts/Navigation/utils/getDisplayMonths.ts @@ -0,0 +1,29 @@ +import { addMonths, differenceInCalendarMonths, startOfMonth } from 'date-fns'; + +/** + * Return the months to display in the component according to the number of + * months and the from/to date. + */ +export function getDisplayMonths( + month: Date, + { + reverseMonths, + numberOfMonths, + }: { + reverseMonths?: boolean; + numberOfMonths: number; + }, +): Date[] { + const start = startOfMonth(month); + const end = startOfMonth(addMonths(start, numberOfMonths)); + const monthsDiff = differenceInCalendarMonths(end, start); + let months = []; + + for (let i = 0; i < monthsDiff; i++) { + const nextMonth = addMonths(start, i); + months.push(nextMonth); + } + + if (reverseMonths) months = months.reverse(); + return months; +} diff --git a/src/contexts/Navigation/utils/getInitialMonth.test.ts b/src/contexts/Navigation/utils/getInitialMonth.test.ts new file mode 100644 index 0000000000..5e6466060b --- /dev/null +++ b/src/contexts/Navigation/utils/getInitialMonth.test.ts @@ -0,0 +1,56 @@ +import { addMonths, isSameMonth } from 'date-fns'; + +import { getInitialMonth } from './getInitialMonth'; + +describe('when no toDate is given', () => { + describe('when month is in context', () => { + const month = new Date(2010, 11, 12); + it('return that month', () => { + const initialMonth = getInitialMonth({ month }); + expect(isSameMonth(initialMonth, month)).toBe(true); + }); + }); + describe('when defaultMonth is in context', () => { + const defaultMonth = new Date(2010, 11, 12); + it('return that month', () => { + const initialMonth = getInitialMonth({ defaultMonth }); + expect(isSameMonth(initialMonth, defaultMonth)).toBe(true); + }); + }); + describe('when no month or defaultMonth are in context', () => { + const today = new Date(2010, 11, 12); + it('return the today month', () => { + const initialMonth = getInitialMonth({ today }); + expect(isSameMonth(initialMonth, today)).toBe(true); + }); + }); +}); +describe('when toDate is given', () => { + describe('when toDate is before the default initial date', () => { + const month = new Date(2010, 11, 12); + const toDate = addMonths(month, -2); + describe('when the number of month is 1', () => { + const numberOfMonths = 1; + it('return the toDate', () => { + const initialMonth = getInitialMonth({ + month, + toDate, + numberOfMonths, + }); + expect(isSameMonth(initialMonth, toDate)).toBe(true); + }); + }); + describe('when the number of month is 3', () => { + const numberOfMonths = 3; + it('return the toDate plus the number of months', () => { + const initialMonth = getInitialMonth({ + month, + toDate, + numberOfMonths, + }); + const expectedMonth = addMonths(toDate, -1 * (numberOfMonths - 1)); + expect(isSameMonth(initialMonth, expectedMonth)).toBe(true); + }); + }); + }); +}); diff --git a/src/contexts/Navigation/utils/getInitialMonth.ts b/src/contexts/Navigation/utils/getInitialMonth.ts new file mode 100644 index 0000000000..eaa4cea114 --- /dev/null +++ b/src/contexts/Navigation/utils/getInitialMonth.ts @@ -0,0 +1,22 @@ +import { addMonths, differenceInCalendarMonths, startOfMonth } from 'date-fns'; + +import { type DayPickerContextValue } from '../../DayPicker'; + +/** Return the initial month according to the given options. */ +export function getInitialMonth(context: Partial): Date { + const { month, defaultMonth, today } = context; + let initialMonth = month || defaultMonth || today || new Date(); + + const { toDate, fromDate, numberOfMonths = 1 } = context; + + // Fix the initialMonth if is after the to-date + if (toDate && differenceInCalendarMonths(toDate, initialMonth) < 0) { + const offset = -1 * (numberOfMonths - 1); + initialMonth = addMonths(toDate, offset); + } + // Fix the initialMonth if is before the from-date + if (fromDate && differenceInCalendarMonths(initialMonth, fromDate) < 0) { + initialMonth = fromDate; + } + return startOfMonth(initialMonth); +} diff --git a/src/contexts/Navigation/utils/getNextMonth.test.ts b/src/contexts/Navigation/utils/getNextMonth.test.ts new file mode 100644 index 0000000000..6bde1171e0 --- /dev/null +++ b/src/contexts/Navigation/utils/getNextMonth.test.ts @@ -0,0 +1,75 @@ +import { addMonths, isSameMonth } from 'date-fns'; + +import { getNextMonth } from './getNextMonth'; + +const startingMonth = new Date(2020, 4, 31); + +describe('when number of months is 1', () => { + describe('when the navigation is disabled', () => { + const disableNavigation = true; + it('the next month is undefined', () => { + const result = getNextMonth(startingMonth, { disableNavigation }); + expect(result).toBe(undefined); + }); + }); + describe('when in the navigable range', () => { + const toDate = addMonths(startingMonth, 3); + it('the next month is not undefined', () => { + const result = getNextMonth(startingMonth, { toDate }); + const expectedNextMonth = addMonths(startingMonth, 1); + expect(result && isSameMonth(result, expectedNextMonth)).toBeTruthy(); + }); + }); + describe('when not in the navigable range', () => { + const toDate = startingMonth; + it('the next month is undefined', () => { + const result = getNextMonth(startingMonth, { toDate }); + expect(result).toBe(undefined); + }); + }); +}); +describe('when displaying 3 months', () => { + const numberOfMonths = 3; + describe('when the navigation is paged', () => { + const pagedNavigation = true; + it('the next month is 3 months ahead', () => { + const result = getNextMonth(startingMonth, { + numberOfMonths, + pagedNavigation, + }); + const expectedNextMonth = addMonths(startingMonth, 3); + expect(result && isSameMonth(result, expectedNextMonth)).toBeTruthy(); + }); + describe('when the to-date is ahead less than 3 months', () => { + it('the next month is undefined', () => { + const result = getNextMonth(startingMonth, { + numberOfMonths, + pagedNavigation, + toDate: addMonths(startingMonth, 1), + }); + expect(result).toBe(undefined); + }); + }); + }); + describe('when the navigation is not paged', () => { + const pagedNavigation = false; + it('the next month is 1 months ahead', () => { + const result = getNextMonth(startingMonth, { + numberOfMonths, + pagedNavigation, + }); + const expectedNextMonth = addMonths(startingMonth, 1); + expect(result && isSameMonth(result, expectedNextMonth)).toBeTruthy(); + }); + describe('when the to-date is ahead less than 3 months', () => { + it('the next month is undefined', () => { + const result = getNextMonth(startingMonth, { + numberOfMonths, + pagedNavigation, + toDate: addMonths(startingMonth, 2), + }); + expect(result).toBe(undefined); + }); + }); + }); +}); diff --git a/src/contexts/Navigation/utils/getNextMonth.ts b/src/contexts/Navigation/utils/getNextMonth.ts new file mode 100644 index 0000000000..f80b8534ca --- /dev/null +++ b/src/contexts/Navigation/utils/getNextMonth.ts @@ -0,0 +1,42 @@ +import { addMonths, differenceInCalendarMonths, startOfMonth } from 'date-fns'; + +/** + * Returns the next month the user can navigate to according to the given + * options. + * + * Please note that the next month is not always the next calendar month: + * + * - If after the `toDate` range, is undefined; + * - If the navigation is paged, is the number of months displayed ahead. + */ +export function getNextMonth( + startingMonth: Date, + options: { + numberOfMonths?: number; + fromDate?: Date; + toDate?: Date; + pagedNavigation?: boolean; + today?: Date; + disableNavigation?: boolean; + }, +): Date | undefined { + if (options.disableNavigation) { + return undefined; + } + const { toDate, pagedNavigation, numberOfMonths = 1 } = options; + const offset = pagedNavigation ? numberOfMonths : 1; + const month = startOfMonth(startingMonth); + + if (!toDate) { + return addMonths(month, offset); + } + + const monthsDiff = differenceInCalendarMonths(toDate, startingMonth); + + if (monthsDiff < numberOfMonths) { + return undefined; + } + + // Jump forward as the number of months when paged navigation + return addMonths(month, offset); +} diff --git a/src/contexts/Navigation/utils/getPreviousMonth.test.ts b/src/contexts/Navigation/utils/getPreviousMonth.test.ts new file mode 100644 index 0000000000..3fbef98a0d --- /dev/null +++ b/src/contexts/Navigation/utils/getPreviousMonth.test.ts @@ -0,0 +1,55 @@ +import { addMonths, isSameMonth } from 'date-fns'; + +import { getPreviousMonth } from './getPreviousMonth'; + +const startingMonth = new Date(2020, 4, 31); + +describe('when number of months is 1', () => { + describe('when the navigation is disabled', () => { + const disableNavigation = true; + it('the previous month is undefined', () => { + const result = getPreviousMonth(startingMonth, { disableNavigation }); + expect(result).toBe(undefined); + }); + }); + describe('when in the navigable range', () => { + const fromDate = addMonths(startingMonth, -3); + it('the previous month is not undefined', () => { + const result = getPreviousMonth(startingMonth, { fromDate }); + const expectedPrevMonth = addMonths(startingMonth, -1); + expect(result && isSameMonth(result, expectedPrevMonth)).toBeTruthy(); + }); + }); + describe('when not in the navigable range', () => { + const fromDate = startingMonth; + it('the previous month is undefined', () => { + const result = getPreviousMonth(startingMonth, { fromDate }); + expect(result).toBe(undefined); + }); + }); +}); +describe('when displaying 3 months', () => { + const numberOfMonths = 3; + describe('when the navigation is paged', () => { + const pagedNavigation = true; + it('the previous month is 3 months back', () => { + const result = getPreviousMonth(startingMonth, { + numberOfMonths, + pagedNavigation, + }); + const expectedPrevMonth = addMonths(startingMonth, -numberOfMonths); + expect(result && isSameMonth(result, expectedPrevMonth)).toBeTruthy(); + }); + }); + describe('when the navigation is not paged', () => { + const pagedNavigation = false; + it('the previous month is 1 months back', () => { + const result = getPreviousMonth(startingMonth, { + numberOfMonths, + pagedNavigation, + }); + const expectedPrevMonth = addMonths(startingMonth, -1); + expect(result && isSameMonth(result, expectedPrevMonth)).toBeTruthy(); + }); + }); +}); diff --git a/src/contexts/Navigation/utils/getPreviousMonth.ts b/src/contexts/Navigation/utils/getPreviousMonth.ts new file mode 100644 index 0000000000..2adfe6f010 --- /dev/null +++ b/src/contexts/Navigation/utils/getPreviousMonth.ts @@ -0,0 +1,41 @@ +import { addMonths, differenceInCalendarMonths, startOfMonth } from 'date-fns'; + +/** + * Returns the next previous the user can navigate to, according to the given + * options. + * + * Please note that the previous month is not always the previous calendar + * month: + * + * - If before the `fromDate` date, is `undefined`; + * - If the navigation is paged, is the number of months displayed before. + */ +export function getPreviousMonth( + startingMonth: Date, + options: { + numberOfMonths?: number; + fromDate?: Date; + toDate?: Date; + pagedNavigation?: boolean; + today?: Date; + disableNavigation?: boolean; + }, +): Date | undefined { + if (options.disableNavigation) { + return undefined; + } + const { fromDate, pagedNavigation, numberOfMonths = 1 } = options; + const offset = pagedNavigation ? numberOfMonths : 1; + const month = startOfMonth(startingMonth); + if (!fromDate) { + return addMonths(month, -offset); + } + const monthsDiff = differenceInCalendarMonths(month, fromDate); + + if (monthsDiff <= 0) { + return undefined; + } + + // Jump back as the number of months when paged navigation + return addMonths(month, -offset); +} diff --git a/src/contexts/RootProvider.tsx b/src/contexts/RootProvider.tsx new file mode 100644 index 0000000000..70dc67b924 --- /dev/null +++ b/src/contexts/RootProvider.tsx @@ -0,0 +1,35 @@ +import { ReactNode } from 'react'; + +import { DayPickerProvider } from './DayPicker'; +import { FocusProvider } from './Focus'; +import { NavigationProvider } from './Navigation'; +import { SelectMultipleProvider } from './SelectMultiple'; +import { SelectRangeProvider } from './SelectRange'; +import { SelectSingleProvider } from './SelectSingle'; +import { ModifiersProvider } from './Modifiers'; + +/** The props of {@link RootProvider}. */ +export interface RootContext { + children?: ReactNode; +} + +/** Provide the value for all the context providers. */ +export function RootProvider(props: RootContext): JSX.Element { + const { children, ...initialProps } = props; + + return ( + + + + + + + {children} + + + + + + + ); +} diff --git a/src/contexts/SelectMultiple/SelectMultipleContext.test.ts b/src/contexts/SelectMultiple/SelectMultipleContext.test.ts new file mode 100644 index 0000000000..9c959ac83b --- /dev/null +++ b/src/contexts/SelectMultiple/SelectMultipleContext.test.ts @@ -0,0 +1,190 @@ +import { MouseEvent } from 'react'; + +import { addDays, addMonths } from 'date-fns'; +import { DayPickerProps } from '../../DayPicker'; + +import { renderDayPickerHook } from '../../../test/render'; +import { freezeBeforeAll } from '../../../test/utils'; + +import { isMatch } from '../../contexts/Modifiers/utils/isMatch'; +import { DayPickerMultipleProps } from '../../types/DayPickerMultiple'; +import { ActiveModifiers } from '../../types/Modifiers'; + +import { + SelectMultipleContextValue, + useSelectMultiple, +} from './SelectMultipleContext'; + +const today = new Date(2021, 11, 8); +freezeBeforeAll(today); + +function renderHook(props?: Partial) { + return renderDayPickerHook( + useSelectMultiple, + props, + ); +} + +describe('when is not a multiple select DayPicker', () => { + const result = renderHook(); + test('the selected day should be undefined', () => { + expect(result.current.selected).toBeUndefined(); + }); + test('the disabled modifiers should be empty', () => { + expect(result.current.selected).toBeUndefined(); + }); +}); + +const initialProps: DayPickerMultipleProps = { + mode: 'multiple', + onDayClick: jest.fn(), + onSelect: jest.fn(), +}; + +const selectedDay1 = today; +const selectedDay2 = addDays(today, 1); +const selectedDay3 = addDays(today, 4); + +describe('when days are selected', () => { + const selected = [selectedDay1, selectedDay2, selectedDay3]; + const dayPickerProps: DayPickerMultipleProps = { + ...initialProps, + selected, + }; + + test('it should return the days as selected', () => { + const result = renderHook(dayPickerProps); + expect(result.current.selected).toStrictEqual(selected); + }); + describe('when `onDayClick` is called with a not selected day', () => { + const clickedDay = addDays(selectedDay1, -1); + const activeModifiers = {}; + const event = {} as MouseEvent; + beforeAll(() => { + const result = renderHook(dayPickerProps); + result.current.onDayClick?.(clickedDay, activeModifiers, event); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + + test('should call the `onDayClick` from the DayPicker props', () => { + expect(dayPickerProps.onDayClick).toHaveBeenCalledWith( + clickedDay, + activeModifiers, + event, + ); + }); + test('should call `onSelect` with the clicked day selected', () => { + expect(dayPickerProps.onSelect).toHaveBeenCalledWith( + [...selected, clickedDay], + clickedDay, + activeModifiers, + event, + ); + }); + }); + describe('when `onDayClick` is called with a selected day', () => { + const clickedDay = selectedDay1; + const activeModifiers: ActiveModifiers = { selected: true }; + beforeAll(() => { + const result = renderHook(dayPickerProps); + result.current.onDayClick?.(clickedDay, activeModifiers, event); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + const event = {} as MouseEvent; + test('should call the `onDayClick` from the DayPicker props', () => { + expect(dayPickerProps.onDayClick).toHaveBeenCalledWith( + clickedDay, + activeModifiers, + event, + ); + }); + test('should call `onSelect` without the clicked day selected', () => { + const expectedSelected = selected.filter((day) => day !== clickedDay); + expect(dayPickerProps.onSelect).toHaveBeenCalledWith( + expectedSelected, + clickedDay, + activeModifiers, + event, + ); + }); + }); +}); + +describe('when the maximum number of days are selected', () => { + const selected = [selectedDay1, selectedDay2, selectedDay3]; + const dayPickerProps: DayPickerMultipleProps = { + ...initialProps, + selected, + max: selected.length, + }; + test('the selected days should not be disabled', () => { + const result = renderHook(dayPickerProps); + const { disabled } = result.current.modifiers; + expect(isMatch(selectedDay1, disabled)).toBe(false); + expect(isMatch(selectedDay2, disabled)).toBe(false); + expect(isMatch(selectedDay3, disabled)).toBe(false); + }); + test('the other days should be disabled', () => { + const result = renderHook(dayPickerProps); + const { disabled } = result.current.modifiers; + expect(isMatch(addMonths(selectedDay1, 1), disabled)).toBe(true); + expect(isMatch(addMonths(selectedDay2, 1), disabled)).toBe(true); + }); + describe('when `onDayClick` is called', () => { + const clickedDay = addMonths(selectedDay1, 1); + const activeModifiers: ActiveModifiers = {}; + beforeAll(() => { + const result = renderHook(dayPickerProps); + result.current.onDayClick?.(clickedDay, activeModifiers, event); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + const event = {} as MouseEvent; + test('should call the `onDayClick` from the DayPicker props', () => { + expect(dayPickerProps.onDayClick).toHaveBeenCalledWith( + clickedDay, + activeModifiers, + event, + ); + }); + test('should not call `onSelect`', () => { + expect(dayPickerProps.onSelect).not.toHaveBeenCalled(); + }); + }); +}); + +describe('when the minimum number of days are selected', () => { + const selected = [selectedDay1, selectedDay2, selectedDay3]; + const dayPickerProps: DayPickerMultipleProps = { + ...initialProps, + selected, + min: selected.length, + }; + describe('when `onDayClick` is called with one of the selected days', () => { + const clickedDay = selected[0]; + const activeModifiers: ActiveModifiers = { selected: true }; + beforeAll(() => { + const result = renderHook(dayPickerProps); + result.current.onDayClick?.(clickedDay, activeModifiers, event); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + const event = {} as MouseEvent; + test('should call the `onDayClick` from the DayPicker props', () => { + expect(dayPickerProps.onDayClick).toHaveBeenCalledWith( + clickedDay, + activeModifiers, + event, + ); + }); + test('should not call `onSelect`', () => { + expect(dayPickerProps.onSelect).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/contexts/SelectMultiple/SelectMultipleContext.tsx b/src/contexts/SelectMultiple/SelectMultipleContext.tsx new file mode 100644 index 0000000000..bfe61d48e8 --- /dev/null +++ b/src/contexts/SelectMultiple/SelectMultipleContext.tsx @@ -0,0 +1,151 @@ +import { createContext, ReactNode, useContext } from 'react'; + +import { isSameDay } from 'date-fns'; + +import { DayPickerBase } from '../../types/DayPickerBase'; +import { + DayPickerMultipleProps, + isDayPickerMultiple, +} from '../../types/DayPickerMultiple'; +import { DayClickEventHandler } from '../../types/EventHandlers'; +import { InternalModifier, Modifiers } from '../../types/Modifiers'; + +/** Represent the modifiers that are changed by the multiple selection. */ +export type SelectMultipleModifiers = Pick< + Modifiers, + InternalModifier.Disabled +>; + +/** Represents the value of a {@link SelectMultipleContext}. */ +export interface SelectMultipleContextValue { + /** The days that have been selected. */ + selected: Date[] | undefined; + /** The modifiers for the corresponding selection. */ + modifiers: SelectMultipleModifiers; + /** Event handler to attach to the day button to enable the multiple select. */ + onDayClick?: DayClickEventHandler; +} + +/** + * The SelectMultiple context shares details about the selected days when in + * multiple selection mode. + * + * Access this context from the {@link useSelectMultiple} hook. + */ +export const SelectMultipleContext = createContext< + SelectMultipleContextValue | undefined +>(undefined); + +export type SelectMultipleProviderProps = { + initialProps: DayPickerBase; + children?: ReactNode; +}; + +/** Provides the values for the {@link SelectMultipleContext}. */ +export function SelectMultipleProvider( + props: SelectMultipleProviderProps, +): JSX.Element { + if (!isDayPickerMultiple(props.initialProps)) { + const emptyContextValue: SelectMultipleContextValue = { + selected: undefined, + modifiers: { + disabled: [], + }, + }; + return ( + + {props.children} + + ); + } + return ( + + ); +} + +/** @private */ +export interface SelectMultipleProviderInternalProps { + initialProps: DayPickerMultipleProps; + children?: ReactNode; +} + +export function SelectMultipleProviderInternal({ + initialProps, + children, +}: SelectMultipleProviderInternalProps): JSX.Element { + const { selected, min, max } = initialProps; + + const onDayClick: DayClickEventHandler = (day, activeModifiers, e) => { + initialProps.onDayClick?.(day, activeModifiers, e); + + const isMinSelected = Boolean( + activeModifiers.selected && min && selected?.length === min, + ); + if (isMinSelected) { + return; + } + + const isMaxSelected = Boolean( + !activeModifiers.selected && max && selected?.length === max, + ); + if (isMaxSelected) { + return; + } + + const selectedDays = selected ? [...selected] : []; + + if (activeModifiers.selected) { + const index = selectedDays.findIndex((selectedDay) => + isSameDay(day, selectedDay), + ); + selectedDays.splice(index, 1); + } else { + selectedDays.push(day); + } + initialProps.onSelect?.(selectedDays, day, activeModifiers, e); + }; + + const modifiers: SelectMultipleModifiers = { + disabled: [], + }; + + if (selected) { + modifiers.disabled.push((day: Date) => { + const isMaxSelected = max && selected.length > max - 1; + const isSelected = selected.some((selectedDay) => + isSameDay(selectedDay, day), + ); + return Boolean(isMaxSelected && !isSelected); + }); + } + + const contextValue = { + selected, + onDayClick, + modifiers, + }; + + return ( + + {children} + + ); +} + +/** + * Hook to access the {@link SelectMultipleContextValue}. + * + * This hook is meant to be used inside internal or custom components. + */ +export function useSelectMultiple(): SelectMultipleContextValue { + const context = useContext(SelectMultipleContext); + if (!context) { + throw new Error( + 'useSelectMultiple must be used within a SelectMultipleProvider', + ); + } + return context; +} diff --git a/src/contexts/SelectMultiple/index.ts b/src/contexts/SelectMultiple/index.ts new file mode 100644 index 0000000000..87d485022f --- /dev/null +++ b/src/contexts/SelectMultiple/index.ts @@ -0,0 +1 @@ +export * from './SelectMultipleContext'; diff --git a/src/contexts/SelectRange/SelectRangeContext.test.ts b/src/contexts/SelectRange/SelectRangeContext.test.ts new file mode 100644 index 0000000000..9621c95f9d --- /dev/null +++ b/src/contexts/SelectRange/SelectRangeContext.test.ts @@ -0,0 +1,302 @@ +import { MouseEvent } from 'react'; + +import { + addDays, + addMonths, + differenceInCalendarDays, + subDays, +} from 'date-fns'; +import { DayPickerProps } from '../../DayPicker'; + +import { renderDayPickerHook } from '../../../test/render'; +import { freezeBeforeAll } from '../../../test/utils'; + +import { isMatch } from '../../contexts/Modifiers/utils/isMatch'; +import { DayPickerRangeProps } from '../../types/DayPickerRange'; +import { ActiveModifiers } from '../../types/Modifiers'; + +import { SelectRangeContextValue, useSelectRange } from './SelectRangeContext'; + +const today = new Date(2021, 11, 8); +freezeBeforeAll(today); + +function renderHook(props?: Partial) { + return renderDayPickerHook(useSelectRange, props); +} +describe('when is not a multiple select DayPicker', () => { + test('the selected day should be undefined', () => { + const result = renderHook(); + expect(result.current.selected).toBeUndefined(); + }); +}); + +const initialProps: DayPickerRangeProps = { + mode: 'range', + onDayClick: jest.fn(), + onSelect: jest.fn(), +}; + +const from = today; +const to = addDays(today, 6); +const stubEvent = {} as MouseEvent; + +describe('when no days are selected', () => { + test('the selected days should be undefined', () => { + const result = renderHook(); + expect(result.current.selected).toBeUndefined(); + }); + describe('when "onDayClick" is called', () => { + const day = from; + const activeModifiers = {}; + beforeAll(() => { + const result = renderHook(initialProps); + result.current.onDayClick?.(day, activeModifiers, stubEvent); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + test('should call the "onDayClick" from the DayPicker props', () => { + expect(initialProps.onDayClick).toHaveBeenCalledWith( + day, + activeModifiers, + stubEvent, + ); + }); + test('should call "onSelect" with the clicked day as the "from" prop', () => { + expect(initialProps.onSelect).toHaveBeenCalledWith( + { from: day, to: undefined }, + day, + activeModifiers, + stubEvent, + ); + }); + }); +}); + +describe('when only the "from" day is selected', () => { + const selected = { from, to: undefined }; + const dayPickerProps: DayPickerRangeProps = { + ...initialProps, + selected, + }; + test('should return the "range_start" modifiers with the "from" day', () => { + const result = renderHook(dayPickerProps); + expect(result.current.modifiers.range_start).toEqual([from]); + }); + test('should return the "range_end" modifiers with the "from" day', () => { + const result = renderHook(dayPickerProps); + expect(result.current.modifiers.range_end).toEqual([from]); + }); + test('should not return any "range_middle" modifiers', () => { + const result = renderHook(dayPickerProps); + expect(result.current.modifiers.range_middle).toEqual([]); + }); +}); + +describe('when only the "to" day is selected', () => { + const selected = { from: undefined, to }; + const dayPickerProps: DayPickerRangeProps = { + ...initialProps, + selected, + }; + test('should return the "range_start" modifiers with the "to" day', () => { + const result = renderHook(dayPickerProps); + expect(result.current.modifiers.range_start).toEqual([to]); + }); + test('should return the "range_end" modifiers with the "to" day', () => { + const result = renderHook(dayPickerProps); + expect(result.current.modifiers.range_end).toEqual([to]); + }); + test('should not return any "range_middle" modifiers', () => { + const result = renderHook(dayPickerProps); + expect(result.current.modifiers.range_middle).toEqual([]); + }); +}); + +describe('when a complete range of days is selected', () => { + const selected = { from, to }; + const dayPickerProps: DayPickerRangeProps = { + ...initialProps, + selected, + }; + test('should return the "range_start" modifiers with the "from" day', () => { + const result = renderHook(dayPickerProps); + expect(result.current.modifiers.range_start).toEqual([from]); + }); + test('should return the "range_end" modifiers with the "to" day', () => { + const result = renderHook(dayPickerProps); + expect(result.current.modifiers.range_end).toEqual([to]); + }); + test('should return the "range_middle" range modifiers', () => { + const result = renderHook(dayPickerProps); + expect(result.current.modifiers.range_middle).toEqual([ + { after: from, before: to }, + ]); + }); + describe('when "onDayClick" is called with the day before the from day', () => { + const day = addDays(from, -1); + const activeModifiers = {}; + + beforeAll(() => { + const result = renderHook(dayPickerProps); + result.current.onDayClick?.(day, activeModifiers, stubEvent); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + test('should call the "onDayClick" from the DayPicker props', () => { + expect(dayPickerProps.onDayClick).toHaveBeenCalledWith( + day, + activeModifiers, + stubEvent, + ); + }); + test('should call "onSelect" with the day selected', () => { + expect(dayPickerProps.onSelect).toHaveBeenCalledWith( + { from: day, to }, + day, + activeModifiers, + stubEvent, + ); + }); + }); +}); + +describe('when "from" and "to" are the same', () => { + const date = new Date(); + const selected = { from: date, to: date }; + const dayPickerProps: DayPickerRangeProps = { + ...initialProps, + selected, + }; + test('should return the "range_start" modifier with the date', () => { + const result = renderHook(dayPickerProps); + expect(result.current.modifiers.range_start).toEqual([date]); + }); + test('should return the "range_end" modifier with the date', () => { + const result = renderHook(dayPickerProps); + expect(result.current.modifiers.range_end).toEqual([date]); + }); + test('should return an empty "range_middle"', () => { + const result = renderHook(dayPickerProps); + expect(result.current.modifiers.range_middle).toEqual([]); + }); +}); + +describe('when the max number of the selected days is reached', () => { + const from = today; + const to = addDays(today, 6); + const selected = { from, to }; + const dayPickerProps: DayPickerRangeProps = { + ...initialProps, + selected, + max: 7, + }; + test('the days in the range should not be disabled', () => { + const result = renderHook(dayPickerProps); + const { disabled } = result.current.modifiers; + expect(isMatch(from, disabled)).toBe(false); + expect(isMatch(to, disabled)).toBe(false); + }); + test('the other days should be disabled', () => { + const result = renderHook(dayPickerProps); + const { disabled } = result.current.modifiers; + expect(isMatch(addMonths(from, 1), disabled)).toBe(true); + }); + describe('when "onDayClick" is called with a new day', () => { + const day = addMonths(from, 1); + const activeModifiers: ActiveModifiers = {}; + + beforeAll(() => { + const result = renderHook(dayPickerProps); + result.current.onDayClick?.(day, activeModifiers, stubEvent); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + test('should call the "onDayClick" from the DayPicker props', () => { + expect(dayPickerProps.onDayClick).toHaveBeenCalledWith( + day, + activeModifiers, + stubEvent, + ); + }); + }); +}); + +describe('when the minimum number of days are selected', () => { + const selected = { from, to }; + const dayPickerProps: DayPickerRangeProps = { + ...initialProps, + selected, + min: Math.abs(differenceInCalendarDays(to, from)), + }; + describe('when "onDayClick" is called with a day before "from"', () => { + const day = subDays(from, 1); + const activeModifiers: ActiveModifiers = { selected: true }; + + beforeAll(() => { + const result = renderHook(dayPickerProps); + result.current.onDayClick?.(day, activeModifiers, stubEvent); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + test('should call "onSelect" with the day included in the range', () => { + expect(dayPickerProps.onSelect).toHaveBeenCalledWith( + { from: day, to }, + day, + activeModifiers, + stubEvent, + ); + }); + }); + describe('when "onDayClick" is called with the "from" day', () => { + const day = from; + const activeModifiers: ActiveModifiers = { selected: true }; + beforeAll(() => { + const result = renderHook(dayPickerProps); + result.current.onDayClick?.(day, activeModifiers, stubEvent); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + + test('should call the "onDayClick" from the DayPicker props', () => { + expect(dayPickerProps.onDayClick).toHaveBeenCalledWith( + day, + activeModifiers, + stubEvent, + ); + }); + test('should call "onSelect" with an undefined range', () => { + expect(dayPickerProps.onSelect).toHaveBeenCalledWith( + undefined, + day, + activeModifiers, + stubEvent, + ); + }); + }); + + describe('when "onDayClick" is called with the "to" day', () => { + const day = to; + const activeModifiers: ActiveModifiers = { selected: true }; + beforeAll(() => { + const result = renderHook(dayPickerProps); + result.current.onDayClick?.(day, activeModifiers, stubEvent); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + + test('should call "onSelect" without the "to" in the range', () => { + expect(dayPickerProps.onSelect).toHaveBeenCalledWith( + { from: day, to: undefined }, + day, + activeModifiers, + stubEvent, + ); + }); + }); +}); diff --git a/src/contexts/SelectRange/SelectRangeContext.tsx b/src/contexts/SelectRange/SelectRangeContext.tsx new file mode 100644 index 0000000000..c7baf7924a --- /dev/null +++ b/src/contexts/SelectRange/SelectRangeContext.tsx @@ -0,0 +1,199 @@ +import { createContext, ReactNode, useContext } from 'react'; + +import { + addDays, + differenceInCalendarDays, + isSameDay, + subDays, +} from 'date-fns'; + +import { DayPickerBase } from '../../types/DayPickerBase'; +import { + DayPickerRangeProps, + isDayPickerRange, +} from '../../types/DayPickerRange'; +import { DayClickEventHandler } from '../../types/EventHandlers'; +import { DateRange } from '../../types/Matchers'; +import { InternalModifier, Modifiers } from '../../types/Modifiers'; + +import { addToRange } from './utils/addToRange'; + +/** Represent the modifiers that are changed by the range selection. */ +export type SelectRangeModifiers = Pick< + Modifiers, + | InternalModifier.Disabled + | InternalModifier.RangeEnd + | InternalModifier.RangeMiddle + | InternalModifier.RangeStart +>; + +/** Represents the value of a {@link SelectRangeContext}. */ +export interface SelectRangeContextValue { + /** The range of days that has been selected. */ + selected: DateRange | undefined; + /** The modifiers for the corresponding selection. */ + modifiers: SelectRangeModifiers; + /** Event handler to attach to the day button to enable the range select. */ + onDayClick?: DayClickEventHandler; +} + +/** + * The SelectRange context shares details about the selected days when in range + * selection mode. + * + * Access this context from the {@link useSelectRange} hook. + */ +export const SelectRangeContext = createContext< + SelectRangeContextValue | undefined +>(undefined); + +export interface SelectRangeProviderProps { + initialProps: DayPickerBase; + children?: ReactNode; +} + +/** Provides the values for the {@link SelectRangeProvider}. */ +export function SelectRangeProvider( + props: SelectRangeProviderProps, +): JSX.Element { + if (!isDayPickerRange(props.initialProps)) { + const emptyContextValue: SelectRangeContextValue = { + selected: undefined, + modifiers: { + range_start: [], + range_end: [], + range_middle: [], + disabled: [], + }, + }; + return ( + + {props.children} + + ); + } + return ( + + ); +} + +/** @private */ +export interface SelectRangeProviderInternalProps { + initialProps: DayPickerRangeProps; + children?: ReactNode; +} + +export function SelectRangeProviderInternal({ + initialProps, + children, +}: SelectRangeProviderInternalProps): JSX.Element { + const { selected } = initialProps; + const { from: selectedFrom, to: selectedTo } = selected || {}; + const min = initialProps.min; + const max = initialProps.max; + + const onDayClick: DayClickEventHandler = (day, activeModifiers, e) => { + initialProps.onDayClick?.(day, activeModifiers, e); + const newRange = addToRange(day, selected); + initialProps.onSelect?.(newRange, day, activeModifiers, e); + }; + + const modifiers: SelectRangeModifiers = { + range_start: [], + range_end: [], + range_middle: [], + disabled: [], + }; + + if (selectedFrom) { + modifiers.range_start = [selectedFrom]; + if (!selectedTo) { + modifiers.range_end = [selectedFrom]; + } else { + modifiers.range_end = [selectedTo]; + if (!isSameDay(selectedFrom, selectedTo)) { + modifiers.range_middle = [ + { + after: selectedFrom, + before: selectedTo, + }, + ]; + } + } + } else if (selectedTo) { + modifiers.range_start = [selectedTo]; + modifiers.range_end = [selectedTo]; + } + + if (min) { + if (selectedFrom && !selectedTo) { + modifiers.disabled.push({ + after: subDays(selectedFrom, min - 1), + before: addDays(selectedFrom, min - 1), + }); + } + if (selectedFrom && selectedTo) { + modifiers.disabled.push({ + after: selectedFrom, + before: addDays(selectedFrom, min - 1), + }); + } + if (!selectedFrom && selectedTo) { + modifiers.disabled.push({ + after: subDays(selectedTo, min - 1), + before: addDays(selectedTo, min - 1), + }); + } + } + if (max) { + if (selectedFrom && !selectedTo) { + modifiers.disabled.push({ + before: addDays(selectedFrom, -max + 1), + }); + modifiers.disabled.push({ + after: addDays(selectedFrom, max - 1), + }); + } + if (selectedFrom && selectedTo) { + const selectedCount = + differenceInCalendarDays(selectedTo, selectedFrom) + 1; + const offset = max - selectedCount; + modifiers.disabled.push({ + before: subDays(selectedFrom, offset), + }); + modifiers.disabled.push({ + after: addDays(selectedTo, offset), + }); + } + if (!selectedFrom && selectedTo) { + modifiers.disabled.push({ + before: addDays(selectedTo, -max + 1), + }); + modifiers.disabled.push({ + after: addDays(selectedTo, max - 1), + }); + } + } + + return ( + + {children} + + ); +} + +/** + * Hook to access the {@link SelectRangeContextValue}. + * + * This hook is meant to be used inside internal or custom components. + */ +export function useSelectRange(): SelectRangeContextValue { + const context = useContext(SelectRangeContext); + if (!context) { + throw new Error('useSelectRange must be used within a SelectRangeProvider'); + } + return context; +} diff --git a/src/contexts/SelectRange/index.ts b/src/contexts/SelectRange/index.ts new file mode 100644 index 0000000000..b7af47398d --- /dev/null +++ b/src/contexts/SelectRange/index.ts @@ -0,0 +1 @@ +export * from './SelectRangeContext'; diff --git a/src/contexts/SelectRange/utils/addToRange.test.ts b/src/contexts/SelectRange/utils/addToRange.test.ts new file mode 100644 index 0000000000..1589fa0daa --- /dev/null +++ b/src/contexts/SelectRange/utils/addToRange.test.ts @@ -0,0 +1,119 @@ +import { addDays, subDays } from 'date-fns'; + +import { DateRange } from '../../../types/Matchers'; + +import { addToRange } from './addToRange'; + +describe('when no "from" is the range', () => { + const range = { from: undefined }; + const day = new Date(); + let result: DateRange | undefined; + beforeAll(() => { + result = addToRange(day, range); + }); + test('should set "from" as the given day', () => { + expect(result).toEqual({ from: day, to: undefined }); + }); +}); + +describe('when no "to" is the range', () => { + const day = new Date(); + const range = { from: day, to: undefined }; + describe('and the day is the same as the "from" day', () => { + let result: DateRange | undefined; + beforeAll(() => { + result = addToRange(day, range); + }); + test('should return it in the range', () => { + expect(result).toEqual({ from: day, to: day }); + }); + }); + describe('and the day is before "from" day', () => { + const day = subDays(range.from, 1); + let result: DateRange | undefined; + beforeAll(() => { + result = addToRange(day, range); + }); + test('should set the day as the "from" range', () => { + expect(result).toEqual({ from: day, to: range.from }); + }); + }); + describe('and the day is after the "from" day', () => { + const day = addDays(range.from, 1); + let result: DateRange | undefined; + beforeAll(() => { + result = addToRange(day, range); + }); + test('should set the day as the "to" date', () => { + expect(result).toEqual({ from: range.from, to: day }); + }); + }); +}); + +describe('when "from", "to" and "day" are the same', () => { + const day = new Date(); + const range = { from: day, to: day }; + let result: DateRange | undefined; + beforeAll(() => { + result = addToRange(day, range); + }); + test('should return an undefined range (reset)', () => { + expect(result).toBeUndefined(); + }); +}); + +describe('when "to" and "day" are the same', () => { + const from = new Date(); + const to = addDays(from, 4); + const day = to; + const range = { from, to }; + let result: DateRange | undefined; + beforeAll(() => { + result = addToRange(day, range); + }); + test('should set "to" to undefined', () => { + expect(result).toEqual({ from: to, to: undefined }); + }); +}); + +describe('when "from" and "day" are the same', () => { + const from = new Date(); + const to = addDays(from, 4); + const day = from; + const range = { from, to }; + let result: DateRange | undefined; + beforeAll(() => { + result = addToRange(day, range); + }); + test('should return an undefined range (reset)', () => { + expect(result).toBeUndefined(); + }); +}); + +describe('when "from" is after "day"', () => { + const day = new Date(); + const from = addDays(day, 1); + const to = addDays(from, 4); + const range = { from, to }; + let result: DateRange | undefined; + beforeAll(() => { + result = addToRange(day, range); + }); + test('should set the day as "from"', () => { + expect(result).toEqual({ from: day, to: range.to }); + }); +}); + +describe('when "from" is before "day"', () => { + const day = new Date(); + const from = subDays(day, 1); + const to = addDays(from, 4); + const range = { from, to }; + let result: DateRange | undefined; + beforeAll(() => { + result = addToRange(day, range); + }); + test('should set the day as "to"', () => { + expect(result).toEqual({ from: range.from, to: day }); + }); +}); diff --git a/src/contexts/SelectRange/utils/addToRange.ts b/src/contexts/SelectRange/utils/addToRange.ts new file mode 100644 index 0000000000..f9dec3f0b4 --- /dev/null +++ b/src/contexts/SelectRange/utils/addToRange.ts @@ -0,0 +1,44 @@ +import { isAfter, isBefore, isSameDay } from 'date-fns'; + +import { DateRange } from '../../../types/Matchers'; + +/** + * Add a day to an existing range. + * + * The returned range takes in account the `undefined` values and if the added + * day is already present in the range. + */ +export function addToRange( + day: Date, + range?: DateRange, +): DateRange | undefined { + const { from, to } = range || {}; + if (from && to) { + if (isSameDay(to, day) && isSameDay(from, day)) { + return undefined; + } + if (isSameDay(to, day)) { + return { from: to, to: undefined }; + } + if (isSameDay(from, day)) { + return undefined; + } + if (isAfter(from, day)) { + return { from: day, to }; + } + return { from, to: day }; + } + if (to) { + if (isAfter(day, to)) { + return { from: to, to: day }; + } + return { from: day, to }; + } + if (from) { + if (isBefore(day, from)) { + return { from: day, to: from }; + } + return { from, to: day }; + } + return { from: day, to: undefined }; +} diff --git a/src/contexts/SelectSingle/SelectSingleContext.test.ts b/src/contexts/SelectSingle/SelectSingleContext.test.ts new file mode 100644 index 0000000000..6ab0e363c5 --- /dev/null +++ b/src/contexts/SelectSingle/SelectSingleContext.test.ts @@ -0,0 +1,84 @@ +import { MouseEvent } from 'react'; + +import { DayPickerProps } from '../../DayPicker'; + +import { renderDayPickerHook } from '../../../test/render'; +import { freezeBeforeAll } from '../../../test/utils'; + +import { DayPickerSingleProps } from '../../types/DayPickerSingle'; +import { ActiveModifiers } from '../../types/Modifiers'; + +import { + SelectSingleContextValue, + useSelectSingle, +} from './SelectSingleContext'; + +const today = new Date(2021, 11, 8); +freezeBeforeAll(today); + +function renderHook(props?: Partial) { + return renderDayPickerHook(useSelectSingle, props); +} +describe('when is not a single select DayPicker', () => { + test('the selected day should be undefined', () => { + const result = renderHook(); + expect(result.current.selected).toBeUndefined(); + }); +}); + +describe('when a day is selected from DayPicker props', () => { + test('the selected day should be today', () => { + const dayPickerProps: DayPickerSingleProps = { + mode: 'single', + selected: today, + }; + const result = renderHook(dayPickerProps); + expect(result.current.selected).toBe(today); + }); +}); +describe('when onDayClick is called', () => { + const dayPickerProps: DayPickerSingleProps = { + mode: 'single', + onSelect: jest.fn(), + onDayClick: jest.fn(), + }; + const result = renderHook(dayPickerProps); + const activeModifiers = {}; + const event = {} as MouseEvent; + test('should call the `onSelect` event handler', () => { + result.current.onDayClick?.(today, activeModifiers, event); + expect(dayPickerProps.onSelect).toHaveBeenCalledWith( + today, + today, + activeModifiers, + event, + ); + }); + test('should call the `onDayClick` event handler', () => { + result.current.onDayClick?.(today, activeModifiers, event); + expect(dayPickerProps.onDayClick).toHaveBeenCalledWith( + today, + activeModifiers, + event, + ); + }); +}); +describe('if a selected day is not required', () => { + const dayPickerProps: DayPickerSingleProps = { + mode: 'single', + onSelect: jest.fn(), + required: false, + }; + test('should call the `onSelect` event handler with an undefined day', () => { + const result = renderHook(dayPickerProps); + const activeModifiers: ActiveModifiers = { selected: true }; + const event = {} as MouseEvent; + result.current.onDayClick?.(today, activeModifiers, event); + expect(dayPickerProps.onSelect).toHaveBeenCalledWith( + undefined, + today, + activeModifiers, + event, + ); + }); +}); diff --git a/src/contexts/SelectSingle/SelectSingleContext.tsx b/src/contexts/SelectSingle/SelectSingleContext.tsx new file mode 100644 index 0000000000..08fe26d4ca --- /dev/null +++ b/src/contexts/SelectSingle/SelectSingleContext.tsx @@ -0,0 +1,99 @@ +import { createContext, ReactNode, useContext } from 'react'; + +import { DayPickerBase } from '../../types/DayPickerBase'; +import { + DayPickerSingleProps, + isDayPickerSingle, +} from '../../types/DayPickerSingle'; +import { DayClickEventHandler } from '../../types/EventHandlers'; + +/** Represents the value of a {@link SelectSingleContext}. */ +export interface SelectSingleContextValue { + /** The day that has been selected. */ + selected: Date | undefined; + /** Event handler to attach to the day button to enable the single select. */ + onDayClick?: DayClickEventHandler; +} + +/** + * The SelectSingle context shares details about the selected days when in + * single selection mode. + * + * Access this context from the {@link useSelectSingle} hook. + */ +export const SelectSingleContext = createContext< + SelectSingleContextValue | undefined +>(undefined); + +export interface SelectSingleProviderProps { + initialProps: DayPickerBase; + children?: ReactNode; +} + +/** Provides the values for the {@link SelectSingleProvider}. */ +export function SelectSingleProvider( + props: SelectSingleProviderProps, +): JSX.Element { + if (!isDayPickerSingle(props.initialProps)) { + const emptyContextValue: SelectSingleContextValue = { + selected: undefined, + }; + return ( + + {props.children} + + ); + } + return ( + + ); +} + +/** @private */ +export interface SelectSingleProviderInternal { + initialProps: DayPickerSingleProps; + children?: ReactNode; +} + +export function SelectSingleProviderInternal({ + initialProps, + children, +}: SelectSingleProviderInternal): JSX.Element { + const onDayClick: DayClickEventHandler = (day, activeModifiers, e) => { + initialProps.onDayClick?.(day, activeModifiers, e); + + if (activeModifiers.selected && !initialProps.required) { + initialProps.onSelect?.(undefined, day, activeModifiers, e); + return; + } + initialProps.onSelect?.(day, day, activeModifiers, e); + }; + + const contextValue: SelectSingleContextValue = { + selected: initialProps.selected, + onDayClick, + }; + return ( + + {children} + + ); +} + +/** + * Hook to access the {@link SelectSingleContextValue}. + * + * This hook is meant to be used inside internal or custom components. + */ +export function useSelectSingle(): SelectSingleContextValue { + const context = useContext(SelectSingleContext); + if (!context) { + throw new Error( + 'useSelectSingle must be used within a SelectSingleProvider', + ); + } + return context; +} diff --git a/src/contexts/SelectSingle/index.ts b/src/contexts/SelectSingle/index.ts new file mode 100644 index 0000000000..0b68843dc8 --- /dev/null +++ b/src/contexts/SelectSingle/index.ts @@ -0,0 +1 @@ +export * from './SelectSingleContext'; diff --git a/src/hooks/useActiveModifiers/index.ts b/src/hooks/useActiveModifiers/index.ts new file mode 100644 index 0000000000..aca3c4110d --- /dev/null +++ b/src/hooks/useActiveModifiers/index.ts @@ -0,0 +1 @@ +export * from './useActiveModifiers'; diff --git a/src/hooks/useActiveModifiers/useActiveModifiers.test.tsx b/src/hooks/useActiveModifiers/useActiveModifiers.test.tsx new file mode 100644 index 0000000000..e2d806782c --- /dev/null +++ b/src/hooks/useActiveModifiers/useActiveModifiers.test.tsx @@ -0,0 +1,29 @@ +import { addMonths } from 'date-fns'; + +import { renderDayPickerHook } from '../../../test/render'; + +import { ActiveModifiers } from '../../types/Modifiers'; + +import { useActiveModifiers } from './useActiveModifiers'; + +const date = new Date(2010, 5, 23); + +describe('when in the same month', () => { + const displayMonth = date; + test('should return the active modifiers', () => { + const result = renderDayPickerHook(() => + useActiveModifiers(date, displayMonth), + ); + expect(result).toBeDefined(); + }); +}); + +describe('when not in the same display month', () => { + const displayMonth = addMonths(date, 1); + test('should return the outside modifier', () => { + const result = renderDayPickerHook(() => + useActiveModifiers(date, displayMonth), + ); + expect(result.current.outside).toBe(true); + }); +}); diff --git a/src/hooks/useActiveModifiers/useActiveModifiers.tsx b/src/hooks/useActiveModifiers/useActiveModifiers.tsx new file mode 100644 index 0000000000..d71cb2d063 --- /dev/null +++ b/src/hooks/useActiveModifiers/useActiveModifiers.tsx @@ -0,0 +1,23 @@ +import { getActiveModifiers, useModifiers } from '../../contexts/Modifiers'; +import { ActiveModifiers } from '../../types/Modifiers'; + +/** + * Return the active modifiers for the specified day. + * + * This hook is meant to be used inside internal or custom components. + * + * @param day + * @param displayMonth + */ +export function useActiveModifiers( + day: Date, + /** + * The month where the date is displayed. If not the same as `date`, the day + * is an "outside day". + */ + displayMonth?: Date, +): ActiveModifiers { + const modifiers = useModifiers(); + const activeModifiers = getActiveModifiers(day, modifiers, displayMonth); + return activeModifiers; +} diff --git a/src/hooks/useControlledValue/index.ts b/src/hooks/useControlledValue/index.ts new file mode 100644 index 0000000000..5351928be0 --- /dev/null +++ b/src/hooks/useControlledValue/index.ts @@ -0,0 +1 @@ +export * from './useControlledValue'; diff --git a/src/hooks/useControlledValue/useControlledValue.test.ts b/src/hooks/useControlledValue/useControlledValue.test.ts new file mode 100644 index 0000000000..e033ffe697 --- /dev/null +++ b/src/hooks/useControlledValue/useControlledValue.test.ts @@ -0,0 +1,45 @@ +import { act } from 'react-dom/test-utils'; + +import { renderDayPickerHook } from '../../../test/render'; + +import { useControlledValue } from './useControlledValue'; + +function renderHook(defaultValue: string, controlledValue: string | undefined) { + return renderDayPickerHook(() => + useControlledValue(defaultValue, controlledValue), + ); +} + +describe('when the value is controlled', () => { + const defaultValue = 'foo'; // not controlled + const controlledValue = 'bar'; // now controlled + test('should return the controlled value', () => { + const result = renderHook(defaultValue, controlledValue); + expect(result.current[0]).toBe(controlledValue); + }); + describe('when setting a new value', () => { + const newValue = 'taz'; + test('should return the controlled value instead', () => { + const result = renderHook(defaultValue, controlledValue); + act(() => result.current[1](newValue)); + expect(result.current[0]).toBe(controlledValue); + }); + }); +}); + +describe('when the value is not controlled', () => { + const defaultValue = 'foo'; + const controlledValue = undefined; + test('should return the value', () => { + const result = renderHook(defaultValue, controlledValue); + expect(result.current[0]).toBe(defaultValue); + }); + describe('when setting a new value', () => { + const newValue = 'bar'; + test('should return the new value', async () => { + const result = renderHook(defaultValue, controlledValue); + await act(() => result.current[1](newValue)); + expect(result.current[0]).toBe(newValue); + }); + }); +}); diff --git a/src/hooks/useControlledValue/useControlledValue.ts b/src/hooks/useControlledValue/useControlledValue.ts new file mode 100644 index 0000000000..864bc742da --- /dev/null +++ b/src/hooks/useControlledValue/useControlledValue.ts @@ -0,0 +1,24 @@ +import { Dispatch, SetStateAction, useState } from 'react'; + +export type DispatchStateAction = Dispatch>; + +/** + * Helper hook for using controlled/uncontrolled values from a component props. + * + * When the value is not controlled, pass `undefined` as `controlledValue` and + * use the returned setter to update it. + * + * When the value is controlled, pass the controlled value as second argument, + * which will be always returned as `value`. + */ +export function useControlledValue( + defaultValue: T, + controlledValue: T | undefined, +): [T, DispatchStateAction] { + const [uncontrolledValue, setValue] = useState(defaultValue); + + const value = + controlledValue === undefined ? uncontrolledValue : controlledValue; + + return [value, setValue] as [T, DispatchStateAction]; +} diff --git a/src/hooks/useDayEventHandlers/index.ts b/src/hooks/useDayEventHandlers/index.ts new file mode 100644 index 0000000000..713a4ab1ee --- /dev/null +++ b/src/hooks/useDayEventHandlers/index.ts @@ -0,0 +1 @@ +export * from './useDayEventHandlers'; diff --git a/src/hooks/useDayEventHandlers/useDayEventHandlers.test.tsx b/src/hooks/useDayEventHandlers/useDayEventHandlers.test.tsx new file mode 100644 index 0000000000..ae7c612c2f --- /dev/null +++ b/src/hooks/useDayEventHandlers/useDayEventHandlers.test.tsx @@ -0,0 +1,170 @@ +import { DayPickerProps } from '../../DayPicker'; + +import { mockedContexts } from '../../../test/mockedContexts'; +import { renderDayPickerHook } from '../../../test/render'; + +import { FocusContextValue } from '../../contexts/Focus'; +import { + DayEventName, + EventName, + useDayEventHandlers, +} from '../useDayEventHandlers'; +import { ActiveModifiers } from '../../types/Modifiers'; + +const today = new Date(2010, 5, 23); + +function renderHook( + date: Date, + activeModifiers: ActiveModifiers, + dayPickerProps?: DayPickerProps, +) { + return renderDayPickerHook( + () => useDayEventHandlers(date, activeModifiers), + dayPickerProps, + mockedContexts, + ); +} + +const tests: [EventName, DayEventName][] = [ + ['onClick', 'onDayClick'], + ['onFocus', 'onDayFocus'], + ['onBlur', 'onDayBlur'], + ['onMouseEnter', 'onDayMouseEnter'], + ['onMouseLeave', 'onDayMouseLeave'], + ['onPointerEnter', 'onDayPointerEnter'], + ['onPointerLeave', 'onDayPointerLeave'], + ['onTouchEnd', 'onDayTouchEnd'], + ['onTouchCancel', 'onDayTouchCancel'], + ['onTouchMove', 'onDayTouchMove'], + ['onTouchStart', 'onDayTouchStart'], + ['onKeyUp', 'onDayKeyUp'], +]; + +describe.each(tests)('when calling "%s"', (eventName, dayEventName) => { + const activeModifiers: ActiveModifiers = {}; + const dayPickerProps = { + onDayClick: jest.fn(), + onDayFocus: jest.fn(), + onDayBlur: jest.fn(), + onDayMouseEnter: jest.fn(), + onDayMouseLeave: jest.fn(), + onDayPointerEnter: jest.fn(), + onDayPointerLeave: jest.fn(), + onDayTouchEnd: jest.fn(), + onDayTouchCancel: jest.fn(), + onDayTouchMove: jest.fn(), + onDayTouchStart: jest.fn(), + onDayKeyUp: jest.fn(), + onDayKeyDown: jest.fn(), + }; + const mouseEvent = {} as React.MouseEvent; + const date = today; + test(`${dayEventName} should have been called`, () => { + const result = renderHook(date, activeModifiers, dayPickerProps); + //@ts-expect-error TOFIX: How to mock mouse event here? + result.current[eventName]?.(mouseEvent); + expect(dayPickerProps[dayEventName]).toHaveBeenCalledWith( + date, + activeModifiers, + mouseEvent, + ); + }); +}); + +describe.each<'single' | 'multiple' | 'range'>(['single', 'multiple', 'range'])( + 'when calling "onClick" in "%s" selection mode', + (mode) => { + const activeModifiers: ActiveModifiers = {}; + const dayPickerProps = { + mode, + onDayClick: mockedContexts[mode].onDayClick, + }; + const mouseEvent = {} as React.MouseEvent; + const date = today; + test(`should have called "onDayClick" from the ${mode} context`, () => { + const result = renderHook(date, activeModifiers, dayPickerProps); + result.current.onClick?.(mouseEvent); + expect(dayPickerProps.onDayClick).toHaveBeenCalledTimes(1); + }); + }, +); + +describe('when calling "onFocus"', () => { + const date = today; + const activeModifiers: ActiveModifiers = {}; + const mouseEvent = {} as React.FocusEvent; + test('should focus the date in the context', () => { + const result = renderHook(date, activeModifiers); + result.current.onFocus?.(mouseEvent); + expect(mockedContexts.focus.focus).toHaveBeenCalledWith(date); + }); +}); + +describe('when calling "onBlur"', () => { + const date = today; + const activeModifiers: ActiveModifiers = {}; + const mouseEvent = {} as React.FocusEvent; + test('should blur the date in the context', () => { + const result = renderHook(date, activeModifiers); + result.current.onBlur?.(mouseEvent); + expect(mockedContexts.focus.blur).toHaveBeenCalled(); + }); +}); + +describe('when calling "onKeyDown"', () => { + const date = today; + const activeModifiers: ActiveModifiers = {}; + + const tests: [ + key: string, + dir: string, + shiftKey: boolean, + expectedMethod: keyof FocusContextValue, + ][] = [ + ['ArrowLeft', 'ltr', false, 'focusDayBefore'], + ['ArrowLeft', 'rtl', false, 'focusDayAfter'], + ['ArrowRight', 'ltr', false, 'focusDayAfter'], + ['ArrowRight', 'ltr', false, 'focusDayBefore'], + ['ArrowRight', 'ltr', false, 'focusDayAfter'], + ['ArrowDown', 'ltr', false, 'focusWeekAfter'], + ['ArrowUp', 'ltr', false, 'focusWeekBefore'], + ['PageUp', 'ltr', true, 'focusYearBefore'], + ['PageUp', 'ltr', false, 'focusMonthBefore'], + ['PageDown', 'ltr', true, 'focusYearAfter'], + ['PageDown', 'ltr', false, 'focusMonthAfter'], + ['Home', 'ltr', false, 'focusStartOfWeek'], + ['End', 'ltr', false, 'focusEndOfWeek'], + ]; + + describe.each(tests)( + 'when key is %s', + (key, dir, shiftKey, expectedMethod) => { + describe(`when text direction is "${dir.toUpperCase()}"`, () => { + describe(`when the shiftKey is ${ + shiftKey ? '' : 'not' + } pressed`, () => { + const keyboardEvent = { + key, + shiftKey, + } as React.KeyboardEvent; + keyboardEvent.preventDefault = jest.fn(); + keyboardEvent.stopPropagation = jest.fn(); + + beforeEach(() => { + const result = renderHook(date, activeModifiers, { dir }); + result.current.onKeyDown?.(keyboardEvent); + }); + test(`should call ${expectedMethod}`, () => { + expect(mockedContexts.focus[expectedMethod]).toHaveBeenCalledWith(); + }); + test(`should prevent the default event`, () => { + expect(keyboardEvent.preventDefault).toHaveBeenCalledWith(); + }); + test(`should stop the event propagation`, () => { + expect(keyboardEvent.preventDefault).toHaveBeenCalledWith(); + }); + }); + }); + }, + ); +}); diff --git a/src/hooks/useDayEventHandlers/useDayEventHandlers.tsx b/src/hooks/useDayEventHandlers/useDayEventHandlers.tsx new file mode 100644 index 0000000000..8f0c9d2929 --- /dev/null +++ b/src/hooks/useDayEventHandlers/useDayEventHandlers.tsx @@ -0,0 +1,207 @@ +import { + FocusEventHandler, + HTMLProps, + KeyboardEventHandler, + MouseEventHandler, + PointerEventHandler, + TouchEventHandler, +} from 'react'; + +import { useDayPicker } from '../../contexts/DayPicker'; +import { useFocusContext } from '../../contexts/Focus'; +import { useSelectMultiple } from '../../contexts/SelectMultiple'; +import { useSelectRange } from '../../contexts/SelectRange'; +import { useSelectSingle } from '../../contexts/SelectSingle'; +import { isDayPickerMultiple } from '../../types/DayPickerMultiple'; +import { isDayPickerRange } from '../../types/DayPickerRange'; +import { isDayPickerSingle } from '../../types/DayPickerSingle'; +import { ActiveModifiers } from '../../types/Modifiers'; + +export type EventName = + | 'onClick' + | 'onFocus' + | 'onBlur' + | 'onKeyDown' + | 'onKeyUp' + | 'onMouseEnter' + | 'onMouseLeave' + | 'onPointerEnter' + | 'onPointerLeave' + | 'onTouchCancel' + | 'onTouchEnd' + | 'onTouchMove' + | 'onTouchStart'; + +export type DayEventName = + | 'onDayClick' + | 'onDayFocus' + | 'onDayBlur' + | 'onDayKeyDown' + | 'onDayKeyUp' + | 'onDayMouseEnter' + | 'onDayMouseLeave' + | 'onDayPointerEnter' + | 'onDayPointerLeave' + | 'onDayTouchCancel' + | 'onDayTouchEnd' + | 'onDayTouchMove' + | 'onDayTouchStart'; + +export type DayEventHandlers = Pick, EventName>; + +/** + * This hook returns details about the content to render in the day cell. + * + * When a day cell is rendered in the table, DayPicker can either: + * + * - Render nothing: when the day is outside the month or has matched the "hidden" + * modifier. + * - Render a button when `onDayClick` or a selection mode is set. + * - Render a non-interactive element: when no selection mode is set, the day cell + * shouldn’t respond to any interaction. DayPicker should render a `div` or a + * `span`. + * + * ### Usage + * + * Use this hook to customize the behavior of the {@link Day} component. Create a + * new `Day` component using this hook and pass it to the `components` prop. The + * source of {@link Day} can be a good starting point. + */ +export function useDayEventHandlers( + date: Date, + activeModifiers: ActiveModifiers, +): DayEventHandlers { + const dayPicker = useDayPicker(); + const single = useSelectSingle(); + const multiple = useSelectMultiple(); + const range = useSelectRange(); + const { + focusDayAfter, + focusDayBefore, + focusWeekAfter, + focusWeekBefore, + blur, + focus, + focusMonthBefore, + focusMonthAfter, + focusYearBefore, + focusYearAfter, + focusStartOfWeek, + focusEndOfWeek, + } = useFocusContext(); + + const onClick: MouseEventHandler = (e) => { + if (isDayPickerSingle(dayPicker)) { + single.onDayClick?.(date, activeModifiers, e); + } else if (isDayPickerMultiple(dayPicker)) { + multiple.onDayClick?.(date, activeModifiers, e); + } else if (isDayPickerRange(dayPicker)) { + range.onDayClick?.(date, activeModifiers, e); + } else { + dayPicker.onDayClick?.(date, activeModifiers, e); + } + }; + + const onFocus: FocusEventHandler = (e) => { + focus(date); + dayPicker.onDayFocus?.(date, activeModifiers, e); + }; + + const onBlur: FocusEventHandler = (e) => { + blur(); + dayPicker.onDayBlur?.(date, activeModifiers, e); + }; + + const onMouseEnter: MouseEventHandler = (e) => { + dayPicker.onDayMouseEnter?.(date, activeModifiers, e); + }; + const onMouseLeave: MouseEventHandler = (e) => { + dayPicker.onDayMouseLeave?.(date, activeModifiers, e); + }; + const onPointerEnter: PointerEventHandler = (e) => { + dayPicker.onDayPointerEnter?.(date, activeModifiers, e); + }; + const onPointerLeave: PointerEventHandler = (e) => { + dayPicker.onDayPointerLeave?.(date, activeModifiers, e); + }; + const onTouchCancel: TouchEventHandler = (e) => { + dayPicker.onDayTouchCancel?.(date, activeModifiers, e); + }; + const onTouchEnd: TouchEventHandler = (e) => { + dayPicker.onDayTouchEnd?.(date, activeModifiers, e); + }; + const onTouchMove: TouchEventHandler = (e) => { + dayPicker.onDayTouchMove?.(date, activeModifiers, e); + }; + const onTouchStart: TouchEventHandler = (e) => { + dayPicker.onDayTouchStart?.(date, activeModifiers, e); + }; + + const onKeyUp: KeyboardEventHandler = (e) => { + dayPicker.onDayKeyUp?.(date, activeModifiers, e); + }; + + const onKeyDown: KeyboardEventHandler = (e) => { + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + e.stopPropagation(); + dayPicker.dir === 'rtl' ? focusDayAfter() : focusDayBefore(); + break; + case 'ArrowRight': + e.preventDefault(); + e.stopPropagation(); + dayPicker.dir === 'rtl' ? focusDayBefore() : focusDayAfter(); + break; + case 'ArrowDown': + e.preventDefault(); + e.stopPropagation(); + focusWeekAfter(); + break; + case 'ArrowUp': + e.preventDefault(); + e.stopPropagation(); + focusWeekBefore(); + break; + case 'PageUp': + e.preventDefault(); + e.stopPropagation(); + e.shiftKey ? focusYearBefore() : focusMonthBefore(); + break; + case 'PageDown': + e.preventDefault(); + e.stopPropagation(); + e.shiftKey ? focusYearAfter() : focusMonthAfter(); + break; + case 'Home': + e.preventDefault(); + e.stopPropagation(); + focusStartOfWeek(); + break; + case 'End': + e.preventDefault(); + e.stopPropagation(); + focusEndOfWeek(); + break; + } + dayPicker.onDayKeyDown?.(date, activeModifiers, e); + }; + + const eventHandlers: DayEventHandlers = { + onClick, + onFocus, + onBlur, + onKeyDown, + onKeyUp, + onMouseEnter, + onMouseLeave, + onPointerEnter, + onPointerLeave, + onTouchCancel, + onTouchEnd, + onTouchMove, + onTouchStart, + }; + + return eventHandlers; +} diff --git a/src/hooks/useDayRender/index.ts b/src/hooks/useDayRender/index.ts new file mode 100644 index 0000000000..ae8c9995bb --- /dev/null +++ b/src/hooks/useDayRender/index.ts @@ -0,0 +1 @@ +export * from './useDayRender'; diff --git a/src/hooks/useDayRender/useDayRender.test.tsx b/src/hooks/useDayRender/useDayRender.test.tsx new file mode 100644 index 0000000000..e66def6756 --- /dev/null +++ b/src/hooks/useDayRender/useDayRender.test.tsx @@ -0,0 +1,310 @@ +import { createRef } from 'react'; + +import { addDays, addMonths } from 'date-fns'; +import { DayPickerProps } from '../../DayPicker'; + +import { mockedContexts } from '../../../test/mockedContexts'; +import { renderDayPickerHook } from '../../../test/render'; +import { freezeBeforeAll } from '../../../test/utils'; + +import { defaultClassNames } from '../../contexts/DayPicker/defaultClassNames'; +import { FocusContextValue } from '../../contexts/Focus'; +import { SelectMultipleContextValue } from '../../contexts/SelectMultiple'; +import { SelectRangeContextValue } from '../../contexts/SelectRange'; +import { SelectSingleContextValue } from '../../contexts/SelectSingle'; +import { EventName } from '../useDayEventHandlers'; + +import { useDayRender } from './useDayRender'; + +const today = new Date(2022, 5, 13); + +freezeBeforeAll(today); + +function renderHook( + date: Date, + displayMonth: Date, + dayPickerProps?: DayPickerProps, + contexts?: { + single: SelectSingleContextValue; + multiple: SelectMultipleContextValue; + range: SelectRangeContextValue; + focus: FocusContextValue; + }, +) { + const buttonRef = createRef(); + return renderDayPickerHook( + () => useDayRender(date, displayMonth, buttonRef), + dayPickerProps, + contexts, + ); +} + +describe('when rendering the today’s date', () => { + const date = today; + const displayMonth = date; + test('the div should include the default class name', () => { + const result = renderHook(date, displayMonth); + expect(result.current.divProps.className?.split(' ')).toContain( + defaultClassNames.day, + ); + }); + test('the button should include the default class name', () => { + const result = renderHook(date, displayMonth); + expect(result.current.buttonProps.className?.split(' ')).toContain( + defaultClassNames.day, + ); + }); + test('the button should not have "aria-selected"', () => { + const result = renderHook(date, displayMonth); + expect(result.current.buttonProps['aria-selected']).toBeUndefined(); + }); + test('the button should have 0 as "tabIndex"', () => { + const result = renderHook(date, displayMonth); + expect(result.current.buttonProps.tabIndex).toBe(0); + }); + + const testEvents: EventName[] = [ + 'onClick', + 'onFocus', + 'onBlur', + 'onKeyDown', + 'onKeyUp', + 'onMouseEnter', + 'onMouseLeave', + 'onTouchCancel', + 'onTouchEnd', + 'onTouchMove', + 'onTouchStart', + ]; + test.each(testEvents)( + 'the button should have the "%s" event handler', + (eventName) => { + const result = renderHook(date, displayMonth); + expect(result.current.buttonProps[eventName]).toBeDefined(); + }, + ); + test('should return the day active modifiers', () => { + const result = renderHook(date, displayMonth); + expect(result.current.activeModifiers).toEqual({ today: true }); + }); +}); + +describe('when not in selection mode', () => { + const dayPickerProps = { mode: undefined }; + test('should not be a button', () => { + const result = renderHook(today, today, dayPickerProps); + expect(result.current.isButton).toBe(false); + }); +}); +describe('when "onDayClick" is not passed in', () => { + const dayPickerProps = { onDayClick: undefined }; + test('should not be a button', () => { + const result = renderHook(today, today, dayPickerProps); + expect(result.current.isButton).toBe(false); + }); +}); +describe('when in selection mode', () => { + const dayPickerProps: DayPickerProps = { mode: 'single' }; + test('should be a button', () => { + const result = renderHook(today, today, dayPickerProps); + expect(result.current.isButton).toBe(true); + }); +}); + +describe('when "onDayClick" is passed in', () => { + const dayPickerProps: DayPickerProps = { onDayClick: jest.fn() }; + test('should be a button', () => { + const result = renderHook(today, today, dayPickerProps); + expect(result.current.isButton).toBe(true); + }); +}); + +describe('when showing the outside days', () => { + const dayPickerProps: DayPickerProps = { showOutsideDays: false }; + describe('when the day is outside', () => { + const day = today; + const displayMonth = addMonths(today, 1); + test('should be hidden', () => { + const result = renderHook(day, displayMonth, dayPickerProps); + expect(result.current.isHidden).toBe(true); + }); + }); +}); + +describe('when the day has the "hidden" modifier active', () => { + const date = today; + const dayPickerProps: DayPickerProps = { + modifiers: { hidden: date }, + }; + test('should have the hidden modifier active', () => { + const result = renderHook(date, date, dayPickerProps); + expect(result.current.activeModifiers.hidden).toBe(true); + }); + test('should be hidden', () => { + const result = renderHook(date, date, dayPickerProps); + expect(result.current.isHidden).toBe(true); + }); +}); + +describe('when "modifiersStyles" is passed in', () => { + const date = today; + const dayPickerProps = { + modifiers: { foo: date }, + modifiersStyles: { foo: { color: 'red' } }, + }; + test('the div props should include the modifiers style', () => { + const result = renderHook(date, date, dayPickerProps); + expect(result.current.divProps.style).toStrictEqual( + dayPickerProps.modifiersStyles.foo, + ); + }); + test('the button props should include the modifiers style', () => { + const result = renderHook(date, date, dayPickerProps); + expect(result.current.buttonProps.style).toStrictEqual( + dayPickerProps.modifiersStyles.foo, + ); + }); +}); +describe('when "styles.day" is passed in', () => { + const date = today; + const dayPickerProps = { + styles: { day: { color: 'red' } }, + }; + test('the div props should include the style', () => { + const result = renderHook(date, date, dayPickerProps); + expect(result.current.divProps.style).toStrictEqual( + dayPickerProps.styles.day, + ); + }); + test('the button props should include the style', () => { + const result = renderHook(date, date, dayPickerProps); + expect(result.current.buttonProps.style).toStrictEqual( + dayPickerProps.styles.day, + ); + }); +}); + +describe('when "modifiersClassNames" is passed in', () => { + const date = today; + const dayPickerProps = { + modifiers: { foo: date }, + modifiersClassNames: { foo: 'bar' }, + }; + const result = renderHook(date, date, dayPickerProps); + test('the div props should include the modifiers classNames', () => { + expect(result.current.divProps.className).toContain( + dayPickerProps.modifiersClassNames.foo, + ); + }); + test('the button props should include the modifiers classNames', () => { + expect(result.current.buttonProps.className).toContain( + dayPickerProps.modifiersClassNames.foo, + ); + }); +}); + +describe('when "classNames.day" is passed in', () => { + const date = today; + const dayPickerProps = { + classNames: { day: 'foo' }, + }; + const result = renderHook(date, date, dayPickerProps); + test('the div props should include the class name', () => { + expect(result.current.divProps.className).toContain( + dayPickerProps.classNames.day, + ); + }); + test('the button props should include the class name', () => { + expect(result.current.buttonProps.className).toContain( + dayPickerProps.classNames.day, + ); + }); +}); + +describe('when the day is not target of focus', () => { + const yesterday = addDays(today, -1); + const tomorrow = addDays(today, 1); + const focusContext: FocusContextValue = { + ...mockedContexts.focus, + focusTarget: yesterday, + }; + const result = renderHook( + tomorrow, + tomorrow, + {}, + { ...mockedContexts, focus: focusContext }, + ); + test('the button should have tabIndex -1', () => { + expect(result.current.buttonProps.tabIndex).toBe(-1); + }); +}); + +describe('when the day is target of focus', () => { + const date = today; + const focusContext: FocusContextValue = { + ...mockedContexts.focus, + focusTarget: date, + }; + const result = renderHook( + date, + date, + {}, + { ...mockedContexts, focus: focusContext }, + ); + test('the button should have tabIndex 0', () => { + expect(result.current.buttonProps.tabIndex).toBe(0); + }); +}); + +describe('when the day is target of focus but outside', () => { + const date = today; + const focusContext: FocusContextValue = { + ...mockedContexts.focus, + focusTarget: date, + }; + const result = renderHook( + date, + date, + { modifiers: { outside: date } }, + { ...mockedContexts, focus: focusContext }, + ); + test('the button should have tabIndex -1', () => { + expect(result.current.buttonProps.tabIndex).toBe(-1); + }); +}); + +describe('when the day is focused', () => { + const date = today; + const focusContext: FocusContextValue = { + ...mockedContexts.focus, + focusedDay: date, + }; + const result = renderHook( + date, + date, + {}, + { ...mockedContexts, focus: focusContext }, + ); + + test('the button should have tabIndex 0', () => { + expect(result.current.buttonProps.tabIndex).toBe(0); + }); +}); + +describe('when the day is disabled', () => { + const date = today; + const dayPickerProps = { disabled: date }; + const result = renderHook(date, date, dayPickerProps); + test('the button should be disabled', () => { + expect(result.current.buttonProps.disabled).toBe(true); + }); +}); + +describe('when the day is selected', () => { + const date = today; + const dayPickerProps = { selected: date }; + const result = renderHook(date, date, dayPickerProps); + test('the button should have "aria-pressed"', () => { + expect(result.current.buttonProps['aria-selected']).toBe(true); + }); +}); diff --git a/src/hooks/useDayRender/useDayRender.tsx b/src/hooks/useDayRender/useDayRender.tsx new file mode 100644 index 0000000000..df71808a6d --- /dev/null +++ b/src/hooks/useDayRender/useDayRender.tsx @@ -0,0 +1,129 @@ +import { RefObject, useEffect } from 'react'; + +import { isSameDay } from 'date-fns'; + +import { ButtonProps } from '../../components/Button'; +import { DayContent } from '../../components/DayContent'; +import { useDayPicker } from '../../contexts/DayPicker'; +import { useFocusContext } from '../../contexts/Focus'; +import { useActiveModifiers } from '../useActiveModifiers'; +import { DayEventHandlers, useDayEventHandlers } from '../useDayEventHandlers'; +import { SelectedDays, useSelectedDays } from '../useSelectedDays'; +import { ActiveModifiers } from '../../types/Modifiers'; +import { StyledComponent } from '../../types/Styles'; + +import { getDayClassNames } from './utils/getDayClassNames'; +import { getDayStyle } from './utils/getDayStyle'; + +export type DayRender = { + /** Whether the day should be rendered a `button` instead of a `div` */ + isButton: boolean; + /** Whether the day should be hidden. */ + isHidden: boolean; + /** The modifiers active for the given day. */ + activeModifiers: ActiveModifiers; + /** The props to apply to the button element (when `isButton` is true). */ + buttonProps: StyledComponent & + Pick & + DayEventHandlers; + /** The props to apply to the div element (when `isButton` is false). */ + divProps: StyledComponent; + selectedDays: SelectedDays; +}; + +/** + * Return props and data used to render the {@link Day} component. + * + * Use this hook when creating a component to replace the built-in `Day` + * component. + */ +export function useDayRender( + /** The date to render. */ + day: Date, + /** + * The month where the date is displayed (if not the same as `date`, it means + * it is an "outside" day). + */ + displayMonth: Date, + /** + * A ref to the button element that will be target of focus when rendered (if + * required). + */ + buttonRef: RefObject, +): DayRender { + const dayPicker = useDayPicker(); + const focusContext = useFocusContext(); + const activeModifiers = useActiveModifiers(day, displayMonth); + const eventHandlers = useDayEventHandlers(day, activeModifiers); + const selectedDays = useSelectedDays(); + const isButton = Boolean( + dayPicker.onDayClick || dayPicker.mode !== 'default', + ); + + // Focus the button if the day is focused according to the focus context + useEffect(() => { + if (activeModifiers.outside) return; + if (!focusContext.focusedDay) return; + if (!isButton) return; + if (isSameDay(focusContext.focusedDay, day)) { + buttonRef.current?.focus(); + } + }, [ + focusContext.focusedDay, + day, + buttonRef, + isButton, + activeModifiers.outside, + ]); + + const className = getDayClassNames(dayPicker, activeModifiers).join(' '); + const style = getDayStyle(dayPicker, activeModifiers); + const isHidden = Boolean( + (activeModifiers.outside && !dayPicker.showOutsideDays) || + activeModifiers.hidden, + ); + + const DayContentComponent = dayPicker.components?.DayContent ?? DayContent; + const children = ( + + ); + + const divProps = { + style, + className, + children, + role: 'gridcell', + }; + + const isFocusTarget = + focusContext.focusTarget && + isSameDay(focusContext.focusTarget, day) && + !activeModifiers.outside; + + const isFocused = + focusContext.focusedDay && isSameDay(focusContext.focusedDay, day); + + const buttonProps = { + ...divProps, + disabled: activeModifiers.disabled, + role: 'gridcell', + ['aria-selected']: activeModifiers.selected, + tabIndex: isFocused || isFocusTarget ? 0 : -1, + ...eventHandlers, + }; + + const dayRender: DayRender = { + isButton, + isHidden, + activeModifiers: activeModifiers, + selectedDays, + buttonProps, + divProps, + }; + + return dayRender; +} diff --git a/src/hooks/useDayRender/utils/getDayClassNames.test.ts b/src/hooks/useDayRender/utils/getDayClassNames.test.ts new file mode 100644 index 0000000000..38365a3370 --- /dev/null +++ b/src/hooks/useDayRender/utils/getDayClassNames.test.ts @@ -0,0 +1,63 @@ +import { DayPickerContextValue } from '../../../contexts/DayPicker'; +import { defaultClassNames } from '../../../contexts/DayPicker/defaultClassNames'; +import { ActiveModifiers, InternalModifier } from '../../../types/Modifiers'; + +import { getDayClassNames } from './getDayClassNames'; + +type DayPickerOptions = Pick< + DayPickerContextValue, + 'modifiersClassNames' | 'classNames' +>; + +const internalModifiers = Object.values(InternalModifier); + +test('should include the day class name', () => { + const dayPicker: DayPickerOptions = { + modifiersClassNames: {}, + classNames: defaultClassNames, + }; + const activeModifiers: ActiveModifiers = {}; + expect(getDayClassNames(dayPicker, activeModifiers)).toContain( + defaultClassNames.day, + ); +}); + +describe('when using "modifiersClassNames" for a custom modifier', () => { + const modifierClassName = `foo-class`; + const dayPicker: DayPickerOptions = { + modifiersClassNames: { + foo: modifierClassName, + }, + classNames: defaultClassNames, + }; + const activeModifiers: ActiveModifiers = { foo: true }; + test('should return the custom class name for the modifier', () => { + expect(getDayClassNames(dayPicker, activeModifiers)).toContain( + modifierClassName, + ); + }); +}); + +describe.each(internalModifiers)( + 'when using "modifiersClassNames" for the %s (internal) modifier', + (internalModifier) => { + const modifierClassName = `foo-${internalModifier}`; + const dayPicker: DayPickerOptions = { + modifiersClassNames: { + [internalModifier]: modifierClassName, + }, + classNames: defaultClassNames, + }; + const activeModifiers: ActiveModifiers = { [internalModifier]: true }; + test('should return the custom class name for the modifier', () => { + expect(getDayClassNames(dayPicker, activeModifiers)).toContain( + modifierClassName, + ); + }); + test('should not include the default class name for the modifier', () => { + expect(getDayClassNames(dayPicker, activeModifiers)).not.toContain( + defaultClassNames.day_selected, + ); + }); + }, +); diff --git a/src/hooks/useDayRender/utils/getDayClassNames.ts b/src/hooks/useDayRender/utils/getDayClassNames.ts new file mode 100644 index 0000000000..98c2623e9e --- /dev/null +++ b/src/hooks/useDayRender/utils/getDayClassNames.ts @@ -0,0 +1,32 @@ +import { DayPickerContextValue } from '../../../contexts/DayPicker'; +import { ActiveModifiers, InternalModifier } from '../../../types/Modifiers'; + +function isInternalModifier(modifier: string): modifier is InternalModifier { + return Object.values(InternalModifier).includes(modifier as InternalModifier); +} + +/** + * Return the class names for the Day element, according to the given active + * modifiers. + * + * Custom class names are set via `modifiersClassNames` or `classNames`, where + * the first have the precedence. + */ +export function getDayClassNames( + dayPicker: Pick, + activeModifiers: ActiveModifiers, +) { + const classNames: string[] = [dayPicker.classNames.day]; + Object.keys(activeModifiers).forEach((modifier) => { + const customClassName = dayPicker.modifiersClassNames[modifier]; + if (customClassName) { + classNames.push(customClassName); + } else if (isInternalModifier(modifier)) { + const internalClassName = dayPicker.classNames[`day_${modifier}`]; + if (internalClassName) { + classNames.push(internalClassName); + } + } + }); + return classNames; +} diff --git a/src/hooks/useDayRender/utils/getDayStyle.ts b/src/hooks/useDayRender/utils/getDayStyle.ts new file mode 100644 index 0000000000..c16ffd9795 --- /dev/null +++ b/src/hooks/useDayRender/utils/getDayStyle.ts @@ -0,0 +1,24 @@ +import { CSSProperties } from 'react'; + +import { DayPickerContextValue } from '../../../contexts/DayPicker'; +import { ActiveModifiers } from '../../../types/Modifiers'; + +/** + * Return the style for the Day element, according to the given active + * modifiers. + */ +export function getDayStyle( + dayPicker: Pick, + activeModifiers: ActiveModifiers, +): CSSProperties { + let style: CSSProperties = { + ...dayPicker.styles.day, + }; + Object.keys(activeModifiers).forEach((modifier) => { + style = { + ...style, + ...dayPicker.modifiersStyles?.[modifier], + }; + }); + return style; +} diff --git a/src/hooks/useId/index.ts b/src/hooks/useId/index.ts new file mode 100644 index 0000000000..e14d58813f --- /dev/null +++ b/src/hooks/useId/index.ts @@ -0,0 +1 @@ +export * from './useId'; diff --git a/src/hooks/useId/useId.ts b/src/hooks/useId/useId.ts new file mode 100644 index 0000000000..b2991fd664 --- /dev/null +++ b/src/hooks/useId/useId.ts @@ -0,0 +1,166 @@ +/* +The MIT License (MIT) + +Copyright (c) 2018-present, React Training LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +/* eslint-disable prefer-const */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* + * Welcome to @reach/auto-id! + * Let's see if we can make sense of why this hook exists and its + * implementation. + * + * Some background: + * 1. Accessibility APIs rely heavily on element IDs + * 2. Requiring developers to put IDs on every element in Reach UI is both + * cumbersome and error-prone + * 3. With a component model, we can generate IDs for them! + * + * Solution 1: Generate random IDs. + * + * This works great as long as you don't server render your app. When React (in + * the client) tries to reuse the markup from the server, the IDs won't match + * and React will then recreate the entire DOM tree. + * + * Solution 2: Increment an integer + * + * This sounds great. Since we're rendering the exact same tree on the server + * and client, we can increment a counter and get a deterministic result between + * client and server. Also, JS integers can go up to nine-quadrillion. I'm + * pretty sure the tab will be closed before an app never needs + * 10 quadrillion IDs! + * + * Problem solved, right? + * + * Ah, but there's a catch! React's concurrent rendering makes this approach + * non-deterministic. While the client and server will end up with the same + * elements in the end, depending on suspense boundaries (and possibly some user + * input during the initial render) the incrementing integers won't always match + * up. + * + * Solution 3: Don't use IDs at all on the server; patch after first render. + * + * What we've done here is solution 2 with some tricks. With this approach, the + * ID returned is an empty string on the first render. This way the server and + * client have the same markup no matter how wild the concurrent rendering may + * have gotten. + * + * After the render, we patch up the components with an incremented ID. This + * causes a double render on any components with `useId`. Shouldn't be a problem + * since the components using this hook should be small, and we're only updating + * the ID attribute on the DOM, nothing big is happening. + * + * It doesn't have to be an incremented number, though--we could do generate + * random strings instead, but incrementing a number is probably the cheapest + * thing we can do. + * + * Additionally, we only do this patchup on the very first client render ever. + * Any calls to `useId` that happen dynamically in the client will be + * populated immediately with a value. So, we only get the double render after + * server hydration and never again, SO BACK OFF ALRIGHT? + */ + +import { useEffect, useLayoutEffect, useState } from 'react'; + +function canUseDOM() { + return !!( + typeof window !== 'undefined' && + window.document && + window.document.createElement + ); +} +/** + * React currently throws a warning when using useLayoutEffect on the server. To + * get around it, we can conditionally useEffect on the server (no-op) and + * useLayoutEffect in the browser. We occasionally need useLayoutEffect to + * ensure we don't get a render flash for certain operations, but we may also + * need affected components to render on the server. One example is when setting + * a component's descendants to retrieve their index values. + * + * Important to note that using this hook as an escape hatch will break the + * eslint dependency warnings unless you rename the import to `useLayoutEffect`. + * Use sparingly only when the effect won't effect the rendered HTML to avoid + * any server/client mismatch. + * + * If a useLayoutEffect is needed and the result would create a mismatch, it's + * likely that the component in question shouldn't be rendered on the server at + * all, so a better approach would be to lazily render those in a parent + * component after client-side hydration. + * + * https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85 + * https://github.com/reduxjs/react-redux/blob/master/src/utils/useIsomorphicLayoutEffect.js + * + * @param effect + * @param deps + */ +const useIsomorphicLayoutEffect = canUseDOM() ? useLayoutEffect : useEffect; + +let serverHandoffComplete = false; +let id = 0; +function genId() { + return `react-day-picker-${++id}`; +} + +/* eslint-disable react-hooks/rules-of-hooks */ + +/** + * UseId + * + * Autogenerate IDs to facilitate WAI-ARIA and server rendering. + * + * Note: The returned ID will initially be `null` and will update after a + * component mounts. Users may need to supply their own ID if they need + * consistent values for SSR. + * + * @see Docs https://reach.tech/auto-id + */ +function useId(idFromProps: string): string; +function useId(idFromProps: number): number; +function useId(idFromProps: string | number): string | number; +function useId(idFromProps: string | undefined | null): string | undefined; +function useId(idFromProps: number | undefined | null): number | undefined; +function useId( + idFromProps: string | number | undefined | null, +): string | number | undefined; +function useId(): string | undefined; + +function useId(providedId?: number | string | undefined | null) { + // TODO: Remove error flag when updating internal deps to React 18. None of + // our tricks will play well with concurrent rendering anyway. + + // If this instance isn't part of the initial render, we don't have to do the + // double render/patch-up dance. We can just generate the ID and return it. + let initialId = providedId ?? (serverHandoffComplete ? genId() : null); + let [id, setId] = useState(initialId); + + useIsomorphicLayoutEffect(() => { + if (id === null) { + // Patch the ID after render. We do this in `useLayoutEffect` to avoid any + // rendering flicker, though it'll make the first render slower (unlikely + // to matter, but you're welcome to measure your app and let us know if + // it's a problem). + setId(genId()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (serverHandoffComplete === false) { + // Flag all future uses of `useId` to skip the update dance. This is in + // `useEffect` because it goes after `useLayoutEffect`, ensuring we don't + // accidentally bail out of the patch-up dance prematurely. + serverHandoffComplete = true; + } + }, []); + + return providedId ?? id ?? undefined; +} + +export { useId, canUseDOM }; diff --git a/src/hooks/useId/useIsomorphicLayoutEffect.ts b/src/hooks/useId/useIsomorphicLayoutEffect.ts new file mode 100644 index 0000000000..e47c3620b1 --- /dev/null +++ b/src/hooks/useId/useIsomorphicLayoutEffect.ts @@ -0,0 +1,31 @@ +import { useEffect, useLayoutEffect } from 'react'; + +import { canUseDOM } from './useId'; + +/** + * React currently throws a warning when using useLayoutEffect on the server. To + * get around it, we can conditionally useEffect on the server (no-op) and + * useLayoutEffect in the browser. We occasionally need useLayoutEffect to + * ensure we don't get a render flash for certain operations, but we may also + * need affected components to render on the server. One example is when setting + * a component's descendants to retrieve their index values. + * + * Important to note that using this hook as an escape hatch will break the + * eslint dependency warnings unless you rename the import to `useLayoutEffect`. + * Use sparingly only when the effect won't effect the rendered HTML to avoid + * any server/client mismatch. + * + * If a useLayoutEffect is needed and the result would create a mismatch, it's + * likely that the component in question shouldn't be rendered on the server at + * all, so a better approach would be to lazily render those in a parent + * component after client-side hydration. + * + * https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85 + * https://github.com/reduxjs/react-redux/blob/master/src/utils/useIsomorphicLayoutEffect.js + * + * @param effect + * @param deps + */ +export const useIsomorphicLayoutEffect = canUseDOM() + ? useLayoutEffect + : useEffect; diff --git a/src/hooks/useInput/index.ts b/src/hooks/useInput/index.ts new file mode 100644 index 0000000000..955c3722df --- /dev/null +++ b/src/hooks/useInput/index.ts @@ -0,0 +1 @@ +export * from './useInput'; diff --git a/src/hooks/useInput/useInput.ts b/src/hooks/useInput/useInput.ts new file mode 100644 index 0000000000..e4230a2779 --- /dev/null +++ b/src/hooks/useInput/useInput.ts @@ -0,0 +1,183 @@ +import { + ChangeEventHandler, + FocusEventHandler, + InputHTMLAttributes, + useState, +} from 'react'; + +import { differenceInCalendarDays, format as _format, parse } from 'date-fns'; +import { enUS } from 'date-fns/locale'; + +import { parseFromToProps } from '../../contexts/DayPicker/utils'; +import { DayPickerBase } from '../../types/DayPickerBase'; +import { DayPickerSingleProps } from '../../types/DayPickerSingle'; +import { + DayClickEventHandler, + MonthChangeEventHandler, +} from '../../types/EventHandlers'; + +import { isValidDate } from './utils/isValidDate'; + +/** The props to attach to the input field when using {@link useInput}. */ +export type InputProps = Pick< + InputHTMLAttributes, + 'onBlur' | 'onChange' | 'onFocus' | 'value' | 'placeholder' +>; + +/** The props to attach to the DayPicker component when using {@link useInput}. */ +export type InputDayPickerProps = Pick< + DayPickerSingleProps, + | 'fromDate' + | 'toDate' + | 'locale' + | 'month' + | 'onDayClick' + | 'onMonthChange' + | 'selected' + | 'today' +>; + +export interface UseInputOptions + extends Pick< + DayPickerBase, + | 'locale' + | 'fromDate' + | 'toDate' + | 'fromMonth' + | 'toMonth' + | 'fromYear' + | 'toYear' + | 'today' + > { + /** The initially selected date */ + defaultSelected?: Date; + /** + * The format string for formatting the input field. See + * https://date-fns.org/docs/format for a list of format strings. + * + * @defaultValue PP + */ + format?: string; + /** Make the selection required. */ + required?: boolean; +} + +/** Represent the value returned by {@link useInput}. */ +export interface UseInputValue { + /** The props to pass to a DayPicker component. */ + dayPickerProps: InputDayPickerProps; + /** The props to pass to an input field. */ + inputProps: InputProps; + /** A function to reset to the initial state. */ + reset: () => void; + /** A function to set the selected day. */ + setSelected: (day: Date | undefined) => void; +} + +/** Return props and setters for binding an input field to DayPicker. */ +export function useInput(options: UseInputOptions = {}): UseInputValue { + const { + locale = enUS, + required, + format = 'PP', + defaultSelected, + today = new Date(), + } = options; + const { fromDate, toDate } = parseFromToProps(options); + + // Shortcut to the DateFns functions + const parseValue = (value: string) => parse(value, format, today, { locale }); + + // Initialize states + const [month, setMonth] = useState(defaultSelected ?? today); + const [selectedDay, setSelectedDay] = useState(defaultSelected); + const defaultInputValue = defaultSelected + ? _format(defaultSelected, format, { locale }) + : ''; + const [inputValue, setInputValue] = useState(defaultInputValue); + + const reset = () => { + setSelectedDay(defaultSelected); + setMonth(defaultSelected ?? today); + setInputValue(defaultInputValue ?? ''); + }; + + const setSelected = (date: Date | undefined) => { + setSelectedDay(date); + setMonth(date ?? today); + setInputValue(date ? _format(date, format, { locale }) : ''); + }; + + const handleDayClick: DayClickEventHandler = (day, { selected }) => { + if (!required && selected) { + setSelectedDay(undefined); + setInputValue(''); + return; + } + setSelectedDay(day); + setInputValue(day ? _format(day, format, { locale }) : ''); + }; + + const handleMonthChange: MonthChangeEventHandler = (month) => { + setMonth(month); + }; + + // When changing the input field, save its value in state and check if the + // string is a valid date. If it is a valid day, set it as selected and update + // the calendar’s month. + const handleChange: ChangeEventHandler = (e) => { + setInputValue(e.target.value); + const day = parseValue(e.target.value); + const isBefore = fromDate && differenceInCalendarDays(fromDate, day) > 0; + const isAfter = toDate && differenceInCalendarDays(day, toDate) > 0; + if (!isValidDate(day) || isBefore || isAfter) { + setSelectedDay(undefined); + return; + } + setSelectedDay(day); + setMonth(day); + }; + + // Special case for _required_ fields: on blur, if the value of the input is not + // a valid date, reset the calendar and the input value. + const handleBlur: FocusEventHandler = (e) => { + const day = parseValue(e.target.value); + if (!isValidDate(day)) { + reset(); + } + }; + + // When focusing, make sure DayPicker visualizes the month of the date in the + // input field. + const handleFocus: FocusEventHandler = (e) => { + if (!e.target.value) { + reset(); + return; + } + const day = parseValue(e.target.value); + if (isValidDate(day)) { + setMonth(day); + } + }; + + const dayPickerProps: InputDayPickerProps = { + month: month, + onDayClick: handleDayClick, + onMonthChange: handleMonthChange, + selected: selectedDay, + locale, + fromDate, + toDate, + today, + }; + + const inputProps: InputProps = { + onBlur: handleBlur, + onChange: handleChange, + onFocus: handleFocus, + value: inputValue, + placeholder: _format(new Date(), format, { locale }), + }; + + return { dayPickerProps, inputProps, reset, setSelected }; +} diff --git a/src/hooks/useInput/utils/isValidDate.tsx b/src/hooks/useInput/utils/isValidDate.tsx new file mode 100644 index 0000000000..c861c71c3a --- /dev/null +++ b/src/hooks/useInput/utils/isValidDate.tsx @@ -0,0 +1,4 @@ +/** @private */ +export function isValidDate(day: Date): boolean { + return !isNaN(day.getTime()); +} diff --git a/src/hooks/useSelectedDays/index.ts b/src/hooks/useSelectedDays/index.ts new file mode 100644 index 0000000000..fb45a7a6fb --- /dev/null +++ b/src/hooks/useSelectedDays/index.ts @@ -0,0 +1 @@ +export * from './useSelectedDays'; diff --git a/src/hooks/useSelectedDays/useSelectedDays.test.ts b/src/hooks/useSelectedDays/useSelectedDays.test.ts new file mode 100644 index 0000000000..b8c7270c59 --- /dev/null +++ b/src/hooks/useSelectedDays/useSelectedDays.test.ts @@ -0,0 +1,42 @@ +import { DayPickerProps } from '../../DayPicker'; + +import { mockedContexts } from '../../../test/mockedContexts'; +import { renderDayPickerHook } from '../../../test/render'; +import { freezeBeforeAll } from '../../../test/utils'; + +import { useSelectedDays } from './useSelectedDays'; + +const today = new Date(2021, 11, 8); +freezeBeforeAll(today); + +function renderHook(dayPickerProps: DayPickerProps) { + return renderDayPickerHook( + () => useSelectedDays(), + dayPickerProps, + mockedContexts, + ); +} + +describe('when in single selection mode', () => { + const mode = 'single'; + test('should return the selection from the single context', () => { + const result = renderHook({ mode, selected: today }); + expect(result.current).toBe(mockedContexts.single.selected); + }); +}); + +describe('when in multiple selection mode', () => { + const mode = 'multiple'; + test('should return the selection from the multiple context', () => { + const result = renderHook({ mode }); + expect(result.current).toBe(mockedContexts.multiple.selected); + }); +}); + +describe('when in range selection mode', () => { + const mode = 'range'; + test('should return the selection from the range context', () => { + const result = renderHook({ mode }); + expect(result.current).toBe(mockedContexts.range.selected); + }); +}); diff --git a/src/hooks/useSelectedDays/useSelectedDays.ts b/src/hooks/useSelectedDays/useSelectedDays.ts new file mode 100644 index 0000000000..3d9218397f --- /dev/null +++ b/src/hooks/useSelectedDays/useSelectedDays.ts @@ -0,0 +1,33 @@ +import { useDayPicker } from '../../contexts/DayPicker'; +import { useSelectMultiple } from '../../contexts/SelectMultiple'; +import { useSelectRange } from '../../contexts/SelectRange'; +import { useSelectSingle } from '../../contexts/SelectSingle'; +import { isDayPickerMultiple } from '../../types/DayPickerMultiple'; +import { isDayPickerRange } from '../../types/DayPickerRange'; +import { isDayPickerSingle } from '../../types/DayPickerSingle'; +import { DateRange } from '../../types/Matchers'; + +export type SelectedDays = Date | Date[] | DateRange | undefined; + +/** + * Return the current selected days when DayPicker is in selection mode. Days + * selected by the custom selection mode are not returned. + * + * This hook is meant to be used inside internal or custom components. + */ +export function useSelectedDays(): SelectedDays { + const dayPicker = useDayPicker(); + const single = useSelectSingle(); + const multiple = useSelectMultiple(); + const range = useSelectRange(); + + const selectedDays = isDayPickerSingle(dayPicker) + ? single.selected + : isDayPickerMultiple(dayPicker) + ? multiple.selected + : isDayPickerRange(dayPicker) + ? range.selected + : undefined; + + return selectedDays; +} diff --git a/src/index.ts b/src/index.ts index 7fb84bf274..b53d652d2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,46 @@ -export * from "./DayPicker"; -export * from "./contexts/CalendarContext"; -export * from "./contexts/SelectionContext"; -export * from "./contexts/DayPickerContext"; +export * from './DayPicker'; -export * from "./classes"; -export * from "./components/custom-components"; -export * from "./formatters"; -export * from "./labels"; +export * from './components/Button'; +export * from './components/Caption'; +export * from './components/CaptionDropdowns'; +export * from './components/CaptionLabel'; +export * from './components/CaptionNavigation'; +export * from './components/Day'; +export * from './components/DayContent'; +export * from './components/Dropdown'; +export * from './components/Footer'; +export * from './components/Head'; +export * from './components/HeadRow'; +export * from './components/IconDropdown'; +export * from './components/IconRight'; +export * from './components/IconLeft'; +export * from './components/Months'; +export * from './components/Row'; +export * from './components/WeekNumber'; -export * from "./types"; +export * from './hooks/useInput'; +export * from './hooks/useDayRender'; +export * from './hooks/useActiveModifiers'; + +export * from './contexts/DayPicker'; +export * from './contexts/Focus'; +export * from './contexts/Navigation'; +export * from './contexts/RootProvider'; +export * from './contexts/SelectMultiple'; +export * from './contexts/SelectRange'; +export * from './contexts/SelectSingle'; + +export * from './types/DayPickerBase'; +export * from './types/DayPickerDefault'; +export * from './types/DayPickerMultiple'; +export * from './types/DayPickerRange'; +export * from './types/DayPickerSingle'; +export * from './types/EventHandlers'; +export * from './types/Formatters'; +export * from './types/Labels'; +export * from './types/Matchers'; +export * from './types/Modifiers'; +export * from './types/Styles'; + +export * from './contexts/Modifiers/utils/isMatch'; +export * from './contexts/SelectRange/utils/addToRange'; diff --git a/src/style.css b/src/style.css index f29c646dba..704560b661 100644 --- a/src/style.css +++ b/src/style.css @@ -1,163 +1,187 @@ .rdp { - --rdp-months-gap: 2rem; - --rdp-cell-size: 2.75rem; - --rdp-font-small: 0.8125rem; - --rdp-font-medium: 1rem; - --rdp-font-large: 1.25rem; - --rdp-opacity: 0.5; + --rdp-cell-size: 40px; /* Size of the day cells. */ + --rdp-caption-font-size: 18px; /* Font size for the caption labels. */ + --rdp-accent-color: #0000ff; /* Accent color for the background of selected days. */ + --rdp-background-color: #e7edff; /* Background color for the hovered/focused elements. */ + --rdp-accent-color-dark: #3003e1; /* Accent color for the background of selected days (to use in dark-mode). */ + --rdp-background-color-dark: #180270; /* Background color for the hovered/focused elements (to use in dark-mode). */ + --rdp-outline: 2px solid var(--rdp-accent-color); /* Outline border for focused elements */ + --rdp-outline-selected: 3px solid var(--rdp-accent-color); /* Outline border for focused _and_ selected elements */ + --rdp-selected-color: #fff; /* Color of selected day text */ - --rdp-accent: #007aff; - --rdp-background: #e0efff; + margin: 1em; +} - --rdp-accent-dark: #0a84ff; - --rdp-background-dark: #02203d; +/* Hide elements for devices that are not screen readers */ +.rdp-vhidden { + box-sizing: border-box; + padding: 0; + margin: 0; + background: transparent; + border: 0; + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + position: absolute !important; + top: 0; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + overflow: hidden !important; + clip: rect(1px, 1px, 1px, 1px) !important; + border: 0 !important; +} + +/* Buttons */ +.rdp-button_reset { + appearance: none; + position: relative; + margin: 0; + padding: 0; + cursor: default; + color: inherit; + background: none; + font: inherit; - --rdp-outline: 2px solid var(--rdp-accent); /* Outline border for focused elements */ - --rdp-outline-selected: 3px solid var(--rdp-accent); /* Outline border for focused _and_ selected elements */ + -moz-appearance: none; + -webkit-appearance: none; +} - display: inline-block; - position: relative; - box-sizing: border-box; +.rdp-button_reset:focus-visible { + /* Make sure to reset outline only when :focus-visible is supported */ + outline: none; } -.rdp * { - box-sizing: border-box; +.rdp-button { + border: 2px solid transparent; +} + +.rdp-button[disabled]:not(.rdp-day_selected) { + opacity: 0.25; +} + +.rdp-button:not([disabled]) { + cursor: pointer; } -.months_wrapper { +.rdp-button:focus-visible:not([disabled]) { + color: inherit; + background-color: var(--rdp-background-color); + border: var(--rdp-outline); +} + +.rdp-button:hover:not([disabled]):not(.rdp-day_selected) { + background-color: var(--rdp-background-color); +} + +.rdp-months { display: flex; - flex-wrap: wrap; - gap: var(--rdp-months-gap); - justify-content: center; } -.caption { +.rdp-month { + margin: 0 1em; +} + +.rdp-month:first-child { + margin-left: 0; +} + +.rdp-month:last-child { + margin-right: 0; +} + +.rdp-table { + margin: 0; + max-width: calc(var(--rdp-cell-size) * 7); + border-collapse: collapse; +} + +.rdp-with_weeknumber .rdp-table { + max-width: calc(var(--rdp-cell-size) * 8); + border-collapse: collapse; +} + +.rdp-caption { display: flex; align-items: center; + justify-content: space-between; + padding: 0; + text-align: left; } -.multiple_months .caption { +.rdp-multiple_months .rdp-caption { position: relative; display: block; text-align: center; } -.caption_dropdowns, -.dropdown_nav { +.rdp-caption_dropdowns { position: relative; display: inline-flex; } -.caption_dropdowns::after, -.dropdown_root::after { - position: absolute; - top: 50%; - right: 5px; /* Position the arrow on the right side */ - transform: translateY(-50%); /* Center the arrow vertically */ - pointer-events: none; /* Make it non-clickable */ -} - -.caption_label { +.rdp-caption_label { position: relative; z-index: 1; display: inline-flex; align-items: center; margin: 0; - padding: 0.125em 0.25em; + padding: 0 0.25em; white-space: nowrap; color: currentColor; border: 0; border: 2px solid transparent; + font-family: inherit; + font-size: var(--rdp-caption-font-size); + font-weight: bold; } -.caption_label svg { - margin: 0 0 0 5px; -} - -.nav { - inset-block-start: 0; - position: absolute; - inset-inline-end: 0; +.rdp-nav { white-space: nowrap; - padding: 0.25em; } -.multiple_months .caption_start .nav { +.rdp-multiple_months .rdp-caption_start .rdp-nav { position: absolute; - inset-block-start: 50%; - inset-inline-start: 0; + top: 50%; + left: 0; transform: translateY(-50%); } -.multiple_months .caption_end .nav { +.rdp-multiple_months .rdp-caption_end .rdp-nav { position: absolute; - inset-block-start: 50%; - inset-inline-end: 0; + top: 50%; + right: 0; transform: translateY(-50%); } -.button_next, -.button_previous { - -moz-appearance: none; - -webkit-appearance: none; - align-items: center; - appearance: none; - background: none; - border: 0; - border-radius: 100%; - color: inherit; - cursor: pointer; +.rdp-nav_button { display: inline-flex; - font: inherit; + align-items: center; justify-content: center; - margin: 0; - padding: 0; + width: var(--rdp-cell-size); + height: var(--rdp-cell-size); padding: 0.25em; - position: relative; -} - -.button_next > svg, -.button_previous > svg, -.caption_label > svg { - fill: var(--rdp-accent); -} - -.button_next:disabled, -.button_next[aria-disabled="true"], -.button_previous:disabled, -.button_previous[aria-disabled="true"] { - opacity: var(--rdp-opacity); - cursor: default; -} - -.rdp[dir="rtl"] .button_next { - transform: rotate(180deg); -} - -.rdp[dir="rtl"] .button_previous { - transform: rotate(180deg); - transform-origin: 50%; + border-radius: 100%; } /* ---------- */ /* Dropdowns */ /* ---------- */ -.dropdown_year, /* Remove in v10 as .dropdown_root is added anyway */ -.dropdown_month, /* Remove in v10 as .dropdown_root is added anyway */ -.dropdown_root { +.rdp-dropdown_year, +.rdp-dropdown_month { position: relative; display: inline-flex; align-items: center; } -.dropdown { +.rdp-dropdown { appearance: none; position: absolute; z-index: 2; - inset-block-start: 0; + top: 0; bottom: 0; - inset-inline-start: 0; + left: 0; width: 100%; margin: 0; padding: 0; @@ -165,184 +189,128 @@ opacity: 0; border: none; background-color: transparent; + font-family: inherit; + font-size: inherit; line-height: inherit; - - font: revert; - font-size: revert; - font-size: 1rem; } -.dropdown[disabled], -.dropdown[aria-disabled="true"] { +.rdp-dropdown[disabled] { opacity: unset; color: unset; } -/* -.dropdown:focus-visible:not([disabled]):not([aria-disabled='true']) - + .caption_label, -.dropdown:focus-visible:not([disabled]):not([aria-disabled='true']) - + .caption_label { - background-color: var(--rdp-background); + +.rdp-dropdown:focus-visible:not([disabled]) + .rdp-caption_label { + background-color: var(--rdp-background-color); border: var(--rdp-outline); border-radius: 6px; -} */ +} -.dropdown_icon { +.rdp-dropdown_icon { margin: 0 0 0 5px; } -.head { +.rdp-head { + border: 0; } -.head_row, -.row { - display: flex; - padding: 0 10px; +.rdp-head_row, +.rdp-row { + height: 100%; } -.head_cell { - text-transform: uppercase; - width: var(--rdp-cell-size); +.rdp-head_cell { + vertical-align: middle; + font-size: 0.75em; + font-weight: 700; + text-align: center; + height: 100%; height: var(--rdp-cell-size); - display: inline-flex; - align-items: center; - justify-content: center; + padding: 0; + text-transform: uppercase; } -.footer { - margin: 0.5em 0; +.rdp-tbody { + border: 0; +} + +.rdp-tfoot { + margin: 0.5em; } -.cell { +.rdp-cell { width: var(--rdp-cell-size); + height: 100%; height: var(--rdp-cell-size); - display: inline-flex; - align-items: center; - justify-content: center; + padding: 0; + text-align: center; } -.day { - cursor: pointer; - justify-content: center; - align-items: center; +.rdp-weeknumber { + font-size: 0.75em; +} + +.rdp-weeknumber, +.rdp-day { display: flex; + overflow: hidden; + align-items: center; + justify-content: center; + box-sizing: border-box; width: var(--rdp-cell-size); + max-width: var(--rdp-cell-size); height: var(--rdp-cell-size); + margin: 0; border: 2px solid transparent; border-radius: 100%; - font-size: var(--rdp-font-medium); } -.day:focus { - outline: 2px solid #5b9dd9; - outline-offset: -2px; - outline: -webkit-focus-ring-color auto 1px; - outline: -moz-mac-focusring auto 1px; +.rdp-day_today:not(.rdp-day_outside) { + font-weight: bold; } -.day_today:not(.day_outside) { - color: var(--rdp-accent); -} - -.day_selected, -.day_selected.day_outside { +.rdp-day_selected, +.rdp-day_selected:focus-visible, +.rdp-day_selected:hover { + color: var(--rdp-selected-color); opacity: 1; - /* outline: none; */ - /* color: var(--rdp-accent); */ - background-color: var(--rdp-background); - font-size: var(--rdp-font-large); - font-weight: 600; - border-color: var(--rdp-accent); -} - -.day_selected:focus-visible { - outline-offset: -1px; -} - -.day_outside { - opacity: var(--rdp-opacity); + background-color: var(--rdp-accent-color); } -.day_disabled { - opacity: var(--rdp-opacity); - cursor: default; -} - -.day_excluded:not(.day_selected) { - opacity: var(--rdp-opacity); - cursor: default; +.rdp-day_outside { + opacity: 0.5; } -.day_hidden { - visibility: hidden; +.rdp-day_selected:focus-visible { + /* Since the background is the same use again the outline */ + outline: var(--rdp-outline); + outline-offset: 2px; + z-index: 1; } -.day_range_start:not(.day_range_end) { +.rdp:not([dir='rtl']) .rdp-day_range_start:not(.rdp-day_range_end) { border-top-right-radius: 0; border-bottom-right-radius: 0; } -.day_range_end:not(.day_range_start) { +.rdp:not([dir='rtl']) .rdp-day_range_end:not(.rdp-day_range_start) { border-top-left-radius: 0; border-bottom-left-radius: 0; } -.rdp[dir="rtl"] .day_range_start:not(.day_range_end) { +.rdp[dir='rtl'] .rdp-day_range_start:not(.rdp-day_range_end) { border-top-left-radius: 0; border-bottom-left-radius: 0; } -.rdp[dir="rtl"] .day_range_end:not(.day_range_start) { +.rdp[dir='rtl'] .rdp-day_range_end:not(.rdp-day_range_start) { border-top-right-radius: 0; border-bottom-right-radius: 0; } -.day_range_end.day_range_start { +.rdp-day_range_end.rdp-day_range_start { border-radius: 100%; } -.day_range_middle { +.rdp-day_range_middle { border-radius: 0; } - -.weeknumber_rowheader { - justify-content: center; - align-items: center; - display: flex; - height: var(--rdp-cell-size); - border: 2px solid transparent; - border-radius: 100%; - font-size: var(--rdp-font-small); - font-weight: 500; - opacity: var(--rdp-opacity); -} - -.weekday_columnheader { - text-align: center; - opacity: var(--rdp-opacity); - text-transform: uppercase; - font-size: var(--rdp-font-small); - font-weight: 500; - padding: 0.25rem 0 0.5rem; -} - -.hide_weekdays .weekday_columnheader { - padding: 0; -} - -.weekdays_row, -.week_row { - display: grid; - grid-template-columns: repeat(7, 1fr); -} - -.with_weeknumber .weekdays_row, -.with_weeknumber .week_row { - grid-template-columns: repeat(8, 1fr); -} - -.month_caption { - margin-bottom: 0.5em; - font-size: var(--rdp-font-large); - font-weight: 600; -} diff --git a/src/style.css.d.ts b/src/style.css.d.ts new file mode 100644 index 0000000000..bc1e148e43 --- /dev/null +++ b/src/style.css.d.ts @@ -0,0 +1,39 @@ +declare const styles: { + rdp: string; + 'rdp-vhidden': string; + 'rdp-button_reset': string; + 'rdp-button': string; + 'rdp-day_selected': string; + 'rdp-months': string; + 'rdp-month': string; + 'rdp-table': string; + 'rdp-with_weeknumber': string; + 'rdp-caption': string; + 'rdp-multiple_months': string; + 'rdp-caption_dropdowns': string; + 'rdp-caption_label': string; + 'rdp-nav': string; + 'rdp-caption_start': string; + 'rdp-caption_end': string; + 'rdp-nav_button': string; + 'rdp-dropdown_year': string; + 'rdp-dropdown_month': string; + 'rdp-dropdown': string; + 'rdp-dropdown_icon': string; + 'rdp-head': string; + 'rdp-head_row': string; + 'rdp-row': string; + 'rdp-head_cell': string; + 'rdp-tbody': string; + 'rdp-tfoot': string; + 'rdp-cell': string; + 'rdp-weeknumber': string; + 'rdp-day': string; + 'rdp-day_today': string; + 'rdp-day_outside': string; + 'rdp-day_range_start': string; + 'rdp-day_range_end': string; + 'rdp-day_range_middle': string; +}; + +export default styles; diff --git a/src/types/DayPickerBase.ts b/src/types/DayPickerBase.ts new file mode 100644 index 0000000000..117aa4692c --- /dev/null +++ b/src/types/DayPickerBase.ts @@ -0,0 +1,361 @@ +import { CSSProperties, ReactNode } from 'react'; + +import { Locale } from 'date-fns'; + +import { CaptionLayout, CaptionProps } from '../components/Caption'; +import { CaptionLabelProps } from '../components/CaptionLabel'; +import { DayProps } from '../components/Day'; +import { DayContentProps } from '../components/DayContent'; +import { DropdownProps } from '../components/Dropdown'; +import { FooterProps } from '../components/Footer'; +import { MonthsProps } from '../components/Months'; +import { RowProps } from '../components/Row'; +import { WeekNumberProps } from '../components/WeekNumber'; + +import { + DayClickEventHandler, + DayFocusEventHandler, + DayKeyboardEventHandler, + DayMouseEventHandler, + DayPointerEventHandler, + DayTouchEventHandler, + MonthChangeEventHandler, + WeekNumberClickEventHandler, +} from './EventHandlers'; +import { Formatters } from './Formatters'; +import { Labels } from './Labels'; +import { Matcher } from './Matchers'; +import { + DayModifiers, + ModifiersClassNames, + ModifiersStyles, +} from './Modifiers'; +import { ClassNames, StyledComponent, Styles } from './Styles'; + +/** + * Selection modes supported by DayPicker. + * + * - `single`: use DayPicker to select single days. + * - `multiple`: allow selecting multiple days. + * - `range`: use DayPicker to select a range of days + * - `default`: disable the built-in selection behavior. Customize what is + * selected by using {@link DayPickerBase.onDayClick}. + */ +export type DaySelectionMode = 'single' | 'multiple' | 'range' | 'default'; + +/** + * The base props for the {@link DayPicker} component and the + * {@link DayPickerContext}. + */ +export interface DayPickerBase { + /** + * The CSS class to add to the container element. To change the name of the + * class instead, use `classNames.root`. + */ + className?: string; + /** + * Change the class names of the HTML elements. + * + * Use this prop when you need to change the default class names — for example + * when using CSS modules. + */ + classNames?: ClassNames; + /** Change the class name for the day matching the {@link modifiers}. */ + modifiersClassNames?: ModifiersClassNames; + + /** Style to apply to the container element. */ + style?: CSSProperties; + /** Change the inline styles of the HTML elements. */ + styles?: Styles; + /** Change the inline style for the day matching the {@link modifiers}. */ + modifiersStyles?: ModifiersStyles; + + /** + * A unique id to replace the random generated id – used by DayPicker for + * accessibility. + */ + id?: string; + + /** + * The initial month to show in the calendar. Use this prop to let DayPicker + * control the current month. If you need to set the month programmatically, + * use {@link month]] and [[onMonthChange}. + * + * @defaultValue The current month + */ + defaultMonth?: Date; + /** + * The month displayed in the calendar. + * + * As opposed to {@link DayPickerBase.defaultMonth}, use this prop with + * {@link DayPickerBase.onMonthChange} to change the month programmatically. + */ + month?: Date; + /** Event fired when the user navigates between months. */ + onMonthChange?: MonthChangeEventHandler; + /** + * The number of displayed months. + * + * @defaultValue 1 + */ + numberOfMonths?: number; + /** The earliest day to start the month navigation. */ + fromDate?: Date; + /** The latest day to end the month navigation. */ + toDate?: Date; + /** The earliest month to start the month navigation. */ + fromMonth?: Date; + /** The latest month to end the month navigation. */ + toMonth?: Date; + /** The earliest year to start the month navigation. */ + fromYear?: number; + /** The latest year to end the month navigation. */ + toYear?: number; + /** + * Disable the navigation between months. + * + * @defaultValue false + */ + disableNavigation?: boolean; + /** + * Paginate the month navigation displaying the {@link numberOfMonths} at time. + * + * @defaultValue false + */ + pagedNavigation?: boolean; + /** + * Render the months in reversed order (when {@link numberOfMonths} is greater + * than `1`) to display the most recent month first. + * + * @defaultValue false + */ + reverseMonths?: boolean; + + /** + * Change the layout of the caption: + * + * - `buttons`: display prev/right buttons + * - `dropdown`: display drop-downs to change the month and the year + * + * **Note:** the `dropdown` layout is available only when `fromDate`, + * `fromMonth` or`fromYear` and `toDate`, `toMonth` or `toYear` are set. + * + * @defaultValue buttons + */ + captionLayout?: CaptionLayout; + /** + * Display six weeks per months, regardless the month’s number of weeks. To + * use this prop, {@link showOutsideDays} must be set. + * + * @defaultValue false + */ + fixedWeeks?: boolean; + /** + * Hide the month’s head displaying the weekday names. + * + * @defaultValue false + */ + hideHead?: boolean; + /** + * Show the outside days. An outside day is a day falling in the next or the + * previous month. + * + * @defaultValue false + */ + showOutsideDays?: boolean; + /** + * Show the week numbers column. Weeks are numbered according to the local + * week index. + * + * - To use ISO week numbering, use the {@link ISOWeek} prop. + * - To change how the week numbers are displayed, use the {@link Formatters} + * prop. + * + * @defaultValue false + * @see {@link ISOWeek} , {@link weekStartsOn} and {@link firstWeekContainsDate}. + */ + showWeekNumber?: boolean; + /** + * The index of the first day of the week (0 - Sunday). Overrides the locale's + * one. + * + * @see {@link ISOWeek} . + */ + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + /** + * The day of January, which is always in the first week of the year. Can be + * either Monday (`1`) or Thursday (`4`). + * + * @see https://date-fns.org/docs/getWeek + * @see https://en.wikipedia.org/wiki/Week#Numbering + * @see {@link ISOWeek} . + */ + firstWeekContainsDate?: 1 | 4; + /** + * Use ISO week dates instead of the locale setting. Setting this prop will + * ignore {@link weekStartsOn} and {@link firstWeekContainsDate}. + * + * @see https://en.wikipedia.org/wiki/ISO_week_date + */ + ISOWeek?: boolean; + + /** + * Map of components used to create the layout. Look at the [components + * source](https://github.com/gpbl/react-day-picker/tree/main/src/components) + * to understand how internal components are built and provide your custom + * components. + */ + components?: CustomComponents; + + /** Content to add to the table footer element. */ + footer?: ReactNode; + + /** + * When a selection mode is set, DayPicker will focus the first selected day + * (if set) or the today's date (if not disabled). + * + * Use this prop when you need to focus DayPicker after a user actions, for + * improved accessibility. + */ + initialFocus?: boolean; + + /** Apply the `disabled` modifier to the matching days. */ + disabled?: Matcher | Matcher[] | undefined; + /** + * Apply the `hidden` modifier to the matching days. Will hide them from the + * calendar. + */ + hidden?: Matcher | Matcher[] | undefined; + + /** Apply the `selected` modifier to the matching days. */ + selected?: Matcher | Matcher[] | undefined; + + /** + * The today’s date. Default is the current date. This Date will get the + * `today` modifier to style the day. + */ + today?: Date; + /** Add modifiers to the matching days. */ + modifiers?: DayModifiers; + + /** + * The date-fns locale object used to localize dates. + * + * @defaultValue en-US + */ + locale?: Locale; + + /** + * Labels creators to override the defaults. Use this prop to customize the + * ARIA labels attributes. + */ + labels?: Partial; + + /** + * A map of formatters. Use the formatters to override the default formatting + * functions. + */ + formatters?: Partial; + + /** + * The text direction of the calendar. Use `ltr` for left-to-right (default) + * or `rtl` for right-to-left. + */ + dir?: HTMLDivElement['dir']; + + /** + * A cryptographic nonce ("number used once") which can be used by Content + * Security Policy for the inline `style` attributes. + */ + nonce?: HTMLDivElement['nonce']; + + /** Add a `title` attribute to the container element. */ + title?: HTMLDivElement['title']; + + /** Add the language tag to the container element. */ + lang?: HTMLDivElement['lang']; + + /** Event callback fired when the next month button is clicked. */ + onNextClick?: MonthChangeEventHandler; + /** Event callback fired when the previous month button is clicked. */ + onPrevClick?: MonthChangeEventHandler; + /** + * Event callback fired when the week number is clicked. Requires + * `showWeekNumbers` set. + */ + onWeekNumberClick?: WeekNumberClickEventHandler; + + /** Event callback fired when the user clicks on a day. */ + onDayClick?: DayClickEventHandler; + /** Event callback fired when the user focuses on a day. */ + onDayFocus?: DayFocusEventHandler; + /** Event callback fired when the user blurs from a day. */ + onDayBlur?: DayFocusEventHandler; + /** Event callback fired when the user hovers on a day. */ + onDayMouseEnter?: DayMouseEventHandler; + /** Event callback fired when the user hovers away from a day. */ + onDayMouseLeave?: DayMouseEventHandler; + /** Event callback fired when the user presses a key on a day. */ + onDayKeyDown?: DayKeyboardEventHandler; + /** Event callback fired when the user presses a key on a day. */ + onDayKeyUp?: DayKeyboardEventHandler; + /** Event callback fired when the user presses a key on a day. */ + onDayKeyPress?: DayKeyboardEventHandler; + /** Event callback fired when the pointer enters a day. */ + onDayPointerEnter?: DayPointerEventHandler; + /** Event callback fired when the pointer leaves a day. */ + onDayPointerLeave?: DayPointerEventHandler; + /** Event callback when a day touch event is canceled. */ + onDayTouchCancel?: DayTouchEventHandler; + /** Event callback when a day touch event ends. */ + onDayTouchEnd?: DayTouchEventHandler; + /** Event callback when a day touch event moves. */ + onDayTouchMove?: DayTouchEventHandler; + /** Event callback when a day touch event starts. */ + onDayTouchStart?: DayTouchEventHandler; +} + +/** + * Map of the components that can be changed using the `components` prop. + * + * @see https://github.com/gpbl/react-day-picker/tree/main/src/components + */ +export interface CustomComponents { + /** The component for the caption element. */ + Caption?: (props: CaptionProps) => JSX.Element | null; + /** The component for the caption element. */ + CaptionLabel?: (props: CaptionLabelProps) => JSX.Element | null; + /** + * The component for the day element. + * + * Each `Day` in DayPicker should render one of the following, according to + * the return value of {@link useDayRender}. + * + * - An empty `Fragment`, to render if `isHidden` is true + * - A `button` element, when the day is interactive, e.g. is selectable + * - A `div` or a `span` element, when the day is not interactive + */ + Day?: (props: DayProps) => JSX.Element | null; + /** The component for the content of the day element. */ + DayContent?: (props: DayContentProps) => JSX.Element | null; + /** The component for the drop-down elements. */ + Dropdown?: (props: DropdownProps) => JSX.Element | null; + /** The component for the table footer. */ + Footer?: (props: FooterProps) => JSX.Element | null; + /** The component for the table’s head. */ + Head?: () => JSX.Element | null; + /** The component for the table’s head row. */ + HeadRow?: () => JSX.Element | null; + /** The component for the small icon in the drop-downs. */ + IconDropdown?: (props: StyledComponent) => JSX.Element | null; + /** The arrow right icon (used for the Navigation buttons). */ + IconRight?: (props: StyledComponent) => JSX.Element | null; + /** The arrow left icon (used for the Navigation buttons). */ + IconLeft?: (props: StyledComponent) => JSX.Element | null; + /** The component wrapping the month grids. */ + Months?: (props: MonthsProps) => JSX.Element | null; + /** The component for the table rows. */ + Row?: (props: RowProps) => JSX.Element | null; + /** The component for the week number in the table rows. */ + WeekNumber?: (props: WeekNumberProps) => JSX.Element | null; +} diff --git a/src/types/DayPickerDefault.ts b/src/types/DayPickerDefault.ts new file mode 100644 index 0000000000..e184d3da29 --- /dev/null +++ b/src/types/DayPickerDefault.ts @@ -0,0 +1,18 @@ +import { DayPickerProps } from '../DayPicker'; + +import { DayPickerBase } from './DayPickerBase'; + +/** + * The props for the {@link DayPicker} component when using `mode="default"` or + * `undefined`. + */ +export interface DayPickerDefaultProps extends DayPickerBase { + mode?: undefined | 'default'; +} + +/** Returns true when the props are of type {@link DayPickerDefaultProps}. */ +export function isDayPickerDefault( + props: DayPickerProps, +): props is DayPickerDefaultProps { + return props.mode === undefined || props.mode === 'default'; +} diff --git a/src/types/DayPickerMultiple.ts b/src/types/DayPickerMultiple.ts new file mode 100644 index 0000000000..35ff9aac2e --- /dev/null +++ b/src/types/DayPickerMultiple.ts @@ -0,0 +1,25 @@ +import { type DayPickerProps } from '../DayPicker'; +import { DayPickerContextValue } from '../contexts/DayPicker'; + +import { DayPickerBase } from './DayPickerBase'; +import { SelectMultipleEventHandler } from './EventHandlers'; + +/** The props for the {@link DayPicker} component when using `mode="multiple"`. */ +export interface DayPickerMultipleProps extends DayPickerBase { + mode: 'multiple'; + /** The selected days. */ + selected?: Date[] | undefined; + /** Event fired when a days added or removed to the selection. */ + onSelect?: SelectMultipleEventHandler; + /** The minimum amount of days that can be selected. */ + min?: number; + /** The maximum amount of days that can be selected. */ + max?: number; +} + +/** Returns true when the props are of type {@link DayPickerMultipleProps}. */ +export function isDayPickerMultiple( + props: DayPickerProps | DayPickerContextValue, +): props is DayPickerMultipleProps { + return props.mode === 'multiple'; +} diff --git a/src/types/DayPickerRange.ts b/src/types/DayPickerRange.ts new file mode 100644 index 0000000000..0f267c5437 --- /dev/null +++ b/src/types/DayPickerRange.ts @@ -0,0 +1,27 @@ +import { DayPickerProps } from '../DayPicker'; + +import { DayPickerContextValue } from '../contexts/DayPicker'; + +import { DayPickerBase } from './DayPickerBase'; +import { SelectRangeEventHandler } from './EventHandlers'; +import { DateRange } from './Matchers'; + +/** The props for the {@link DayPicker} component when using `mode="range"`. */ +export interface DayPickerRangeProps extends DayPickerBase { + mode: 'range'; + /** The selected range of days. */ + selected?: DateRange | undefined; + /** Event fired when a range (or a part of the range) is selected. */ + onSelect?: SelectRangeEventHandler; + /** The minimum amount of days that can be selected. */ + min?: number; + /** The maximum amount of days that can be selected. */ + max?: number; +} + +/** Returns true when the props are of type {@link DayPickerRangeProps}. */ +export function isDayPickerRange( + props: DayPickerProps | DayPickerContextValue, +): props is DayPickerRangeProps { + return props.mode === 'range'; +} diff --git a/src/types/DayPickerSingle.ts b/src/types/DayPickerSingle.ts new file mode 100644 index 0000000000..3d8c58eeaf --- /dev/null +++ b/src/types/DayPickerSingle.ts @@ -0,0 +1,24 @@ +import { DayPickerProps } from '../DayPicker'; + +import { DayPickerContextValue } from '../contexts/DayPicker'; + +import { DayPickerBase } from './DayPickerBase'; +import { SelectSingleEventHandler } from './EventHandlers'; + +/** The props for the {@link DayPicker} component when using `mode="single"`. */ +export interface DayPickerSingleProps extends DayPickerBase { + mode: 'single'; + /** The selected day. */ + selected?: Date | undefined; + /** Event fired when a day is selected. */ + onSelect?: SelectSingleEventHandler; + /** Make the selection required. */ + required?: boolean; +} + +/** Returns true when the props are of type {@link DayPickerSingleProps}. */ +export function isDayPickerSingle( + props: DayPickerProps | DayPickerContextValue, +): props is DayPickerSingleProps { + return props.mode === 'single'; +} diff --git a/src/types/EventHandlers.ts b/src/types/EventHandlers.ts new file mode 100644 index 0000000000..c517ffe92c --- /dev/null +++ b/src/types/EventHandlers.ts @@ -0,0 +1,103 @@ +import { + FocusEvent, + KeyboardEvent, + MouseEvent, + PointerEvent, + TouchEvent, +} from 'react'; + +import { DateRange } from './Matchers'; + +import { ActiveModifiers } from './Modifiers'; + +/** The event handler when a day is clicked. */ +export type DayClickEventHandler = ( + day: Date, + activeModifiers: ActiveModifiers, + e: MouseEvent, +) => void; + +/** The event handler when a day is focused. */ +export type DayFocusEventHandler = ( + day: Date, + activeModifiers: ActiveModifiers, + e: FocusEvent | KeyboardEvent, +) => void; + +/** The event handler when a day gets a keyboard event. */ +export type DayKeyboardEventHandler = ( + day: Date, + activeModifiers: ActiveModifiers, + e: KeyboardEvent, +) => void; + +/** The event handler when a day gets a mouse event. */ +export type DayMouseEventHandler = ( + day: Date, + activeModifiers: ActiveModifiers, + e: MouseEvent, +) => void; + +/** The event handler when a day gets a pointer event. */ +export type DayPointerEventHandler = ( + day: Date, + activeModifiers: ActiveModifiers, + e: PointerEvent, +) => void; + +/** The event handler when a month is changed in the calendar. */ +export type MonthChangeEventHandler = (month: Date) => void; + +/** The event handler when selecting multiple days. */ +export type SelectMultipleEventHandler = ( + /** The selected days */ + days: Date[] | undefined, + /** The day that was clicked triggering the event. */ + selectedDay: Date, + /** The day that was clicked */ + activeModifiers: ActiveModifiers, + /** The mouse event that triggered this event. */ + e: MouseEvent, +) => void; + +/** The event handler when selecting a range of days. */ +export type SelectRangeEventHandler = ( + /** The current range of the selected days. */ + range: DateRange | undefined, + /** The day that was selected (or clicked) triggering the event. */ + selectedDay: Date, + /** The modifiers of the selected day. */ + activeModifiers: ActiveModifiers, + e: MouseEvent, +) => void; + +/** The event handler when selecting a single day. */ +export type SelectSingleEventHandler = ( + /** + * The selected day, `undefined` when `required={false}` (default) and the day + * is clicked again. + */ + day: Date | undefined, + /** The day that was selected (or clicked) triggering the event. */ + selectedDay: Date, + /** The modifiers of the selected day. */ + activeModifiers: ActiveModifiers, + e: MouseEvent, +) => void; + +/** The event handler when the week number is clicked. */ +export type WeekNumberClickEventHandler = ( + /** The week number that has been clicked. */ + weekNumber: number, + /** The dates in the clicked week. */ + dates: Date[], + /** The mouse event that triggered this event. */ + e: MouseEvent, +) => void; + +/** The event handler when a day gets a touch event. */ +export type DayTouchEventHandler = ( + day: Date, + activeModifiers: ActiveModifiers, + e: TouchEvent, +) => void; diff --git a/src/types/Styles.ts b/src/types/Styles.ts new file mode 100644 index 0000000000..eaea2164d8 --- /dev/null +++ b/src/types/Styles.ts @@ -0,0 +1,131 @@ +import { CSSProperties, ReactNode } from 'react'; + +/** The style (either via class names or via in-line styles) of an element. */ +export type StyledElement = { + /** The root element. */ + readonly root: T; + /** The root element when `numberOfMonths > 1`. */ + readonly multiple_months: T; + /** The root element when `showWeekNumber={true}`. */ + readonly with_weeknumber: T; + /** The style of an element visually hidden. */ + readonly vhidden: T; + /** The style for resetting the buttons. */ + readonly button_reset: T; + /** The buttons. */ + readonly button: T; + + /** The caption (showing the calendar heading and the navigation) */ + readonly caption: T; + /** The caption when at the start of a series of months. */ + readonly caption_start: T; + /** The caption when at the end of a series of months. */ + readonly caption_end: T; + /** The caption when between two months (when `multipleMonths > 2`). */ + readonly caption_between: T; + /** The caption label. */ + readonly caption_label: T; + /** The drop-downs container. */ + readonly caption_dropdowns: T; + + /** The drop-down (select) element. */ + readonly dropdown: T; + /** The drop-down to change the month. */ + readonly dropdown_month: T; + /** The drop-down to change the year. */ + readonly dropdown_year: T; + /** The drop-down icon. */ + readonly dropdown_icon: T; + + /** The months wrapper. */ + readonly months: T; + /** The table wrapper. */ + readonly month: T; + /** Table containing the monthly calendar. */ + readonly table: T; + /** The table body. */ + readonly tbody: T; + /** The table footer. */ + readonly tfoot: T; + + /** The table’s head. */ + readonly head: T; + /** The row in the head. */ + readonly head_row: T; + /** The head cell. */ + readonly head_cell: T; + + /** The navigation container. */ + readonly nav: T; + + /** The navigation button. */ + readonly nav_button: T; + /** The "previous month" navigation button. */ + readonly nav_button_previous: T; + /** The "next month" navigation button. */ + readonly nav_button_next: T; + /** The icon for the navigation button. */ + readonly nav_icon: T; + + /** The table’s row. */ + readonly row: T; + /** The weeknumber displayed in the column. */ + readonly weeknumber: T; + /** The table cell containing the day element. */ + readonly cell: T; + + /** The day element: it is a `span` when not interactive, a `button` otherwise. */ + readonly day: T; + /** The day when outside the month. */ + readonly day_outside: T; + /** The day when selected. */ + readonly day_selected: T; + /** The day when disabled. */ + readonly day_disabled: T; + /** The day when hidden. */ + readonly day_hidden: T; + /** The day when at the start of a selected range. */ + readonly day_range_start: T; + /** The day when at the end of a selected range. */ + readonly day_range_end: T; + /** + * The day in the middle of a selected range: it does not include the "from" + * and the "to" days. + */ + readonly day_range_middle: T; + /** The day when today. */ + readonly day_today: T; +}; + +/** + * These elements must not be in the `styles` or `classNames` records as they + * are styled via the `modifiersStyles` or `modifiersClassNames` pop + */ +export type InternalModifiersElement = + | 'day_outside' + | 'day_selected' + | 'day_disabled' + | 'day_hidden' + | 'day_range_start' + | 'day_range_end' + | 'day_range_middle' + | 'day_today'; + +/** The class names of each element. */ +export type ClassNames = Partial>; + +/** + * The inline-styles of each styled element, to use with the `styles` prop. Day + * modifiers, such as `today` or `hidden`, should be styled using the + * `modifiersStyles` prop. + */ +export type Styles = Partial< + Omit, InternalModifiersElement> +>; + +/** Props of a component that can be styled via classNames or inline-styles. */ +export type StyledComponent = { + className?: string; + style?: CSSProperties; + children?: ReactNode; +}; diff --git a/src/types/formatters.ts b/src/types/formatters.ts index 4a405ef757..47a8e8978b 100644 --- a/src/types/formatters.ts +++ b/src/types/formatters.ts @@ -1,29 +1,35 @@ -import { formatCaption, formatMonthCaption } from "../formatters/formatCaption"; -import { formatDay } from "../formatters/formatDay"; -import { formatMonthDropdown } from "../formatters/formatMonthDropdown"; -import { formatWeekdayName } from "../formatters/formatWeekdayName"; -import { formatWeekNumber } from "../formatters/formatWeekNumber"; -import { - formatYearCaption, - formatYearDropdown, -} from "../formatters/formatYearDropdown"; +import { ReactNode } from 'react'; + +import { Locale } from 'date-fns'; + +/** Represents a function to format a date. */ +export type DateFormatter = ( + date: Date, + options?: { + locale?: Locale; + }, +) => ReactNode; /** Represent a map of formatters used to render localized content. */ export type Formatters = { - /** Format the caption of a month grid. */ - formatCaption: typeof formatCaption; - /** @deprecated Use {@link Formatters.formatCaption} instead. */ - formatMonthCaption: typeof formatMonthCaption; - /** Format the label in the month dropdown. */ - formatMonthDropdown: typeof formatMonthDropdown; - /** @deprecated Use {@link Formatters.formatYearDropdown} instead. */ - formatYearCaption: typeof formatYearCaption; - /** Format the label in the year dropdown. */ - formatYearDropdown: typeof formatYearDropdown; + /** Format the month in the caption when `captionLayout` is `buttons`. */ + formatCaption: DateFormatter; + /** Format the month in the navigation dropdown. */ + formatMonthCaption: DateFormatter; + /** Format the year in the navigation dropdown. */ + formatYearCaption: DateFormatter; /** Format the day in the day cell. */ - formatDay: typeof formatDay; + formatDay: DateFormatter; /** Format the week number. */ - formatWeekNumber: typeof formatWeekNumber; + formatWeekNumber: WeekNumberFormatter; /** Format the week day name in the header */ - formatWeekdayName: typeof formatWeekdayName; + formatWeekdayName: DateFormatter; }; + +/** Represent a function to format the week number. */ +export type WeekNumberFormatter = ( + weekNumber: number, + options?: { + locale?: Locale; + }, +) => ReactNode; diff --git a/src/types/labels.ts b/src/types/labels.ts index e66a6f89d8..c99d8d2f92 100644 --- a/src/types/labels.ts +++ b/src/types/labels.ts @@ -1,28 +1,54 @@ -import { labelDay } from "../labels/labelDay"; -import { labelMonthDropdown } from "../labels/labelMonthDropdown"; -import { labelNext } from "../labels/labelNext"; -import { labelPrevious } from "../labels/labelPrevious"; -import { labelWeekday } from "../labels/labelWeekday"; -import { labelWeekNumber } from "../labels/labelWeekNumber"; -import { labelWeekNumberHeader } from "../labels/labelWeekNumberHeader"; -import { labelYearDropdown } from "../labels/labelYearDropdown"; +import { Locale } from 'date-fns'; -/** Map of functions returning ARIA labels for the relative elements. */ +import { ActiveModifiers } from './Modifiers'; + +/** Map of functions to translate ARIA labels for the relative elements. */ export type Labels = { - /** Return the label for the month dropdown. */ - labelMonthDropdown: typeof labelMonthDropdown; - /** Return the label for the year dropdown. */ - labelYearDropdown: typeof labelYearDropdown; - /** Return the label for the next month button. */ - labelNext: typeof labelNext; - /** Return the label for the previous month button. */ - labelPrevious: typeof labelPrevious; - /** Return the label for the day cell. */ - labelDay: typeof labelDay; - /** Return the label for the weekday. */ - labelWeekday: typeof labelWeekday; - /** Return the label for the week number. */ - labelWeekNumber: typeof labelWeekNumber; - /** Return the label for the column of the week number. */ - labelWeekNumberHeader: typeof labelWeekNumberHeader; + labelMonthDropdown: () => string; + labelYearDropdown: () => string; + labelNext: NavButtonLabel; + labelPrevious: NavButtonLabel; + /** + * @deprecated This label is not used anymore and this function will be + * removed in the future. + */ + labelDay: DayLabel; + labelWeekday: WeekdayLabel; + labelWeekNumber: WeekNumberLabel; }; + +/** Return the ARIA label for the {@link Day} component. */ +export type DayLabel = ( + day: Date, + activeModifiers: ActiveModifiers, + options?: { + locale?: Locale; + }, +) => string; + +/** + * Return the ARIA label for the "next month" / "prev month" buttons in the + * navigation. + */ +export type NavButtonLabel = ( + month?: Date, + options?: { + locale?: Locale; + }, +) => string; + +/** Return the ARIA label for the Head component. */ +export type WeekdayLabel = ( + day: Date, + options?: { + locale?: Locale; + }, +) => string; + +/** Return the ARIA label of the week number. */ +export type WeekNumberLabel = ( + n: number, + options?: { + locale?: Locale; + }, +) => string; diff --git a/src/types/matchers.ts b/src/types/matchers.ts index 8b9eddb02e..0cd53276ac 100644 --- a/src/types/matchers.ts +++ b/src/types/matchers.ts @@ -1,7 +1,52 @@ /** * A value or a function that matches a specific day. * - + * Matchers are passed to DayPicker via {@link DayPickerBase.disabled}, + * {@link DayPickerBase.hidden]] or [[DayPickerProps.selected} and are used to + * determine if a day should get a {@link Modifier}. + * + * Matchers can be of different types: + * + * // will always match the day + * const booleanMatcher: Matcher = true; + * + * // will match the today's date + * const dateMatcher: Matcher = new Date(); + * + * // will match the days in the array + * const arrayMatcher: Matcher = [ + * new Date(2019, 1, 2), + * new Date(2019, 1, 4), + * ]; + * + * // will match days after the 2nd of February 2019 + * const afterMatcher: DateAfter = { after: new Date(2019, 1, 2) }; + * // will match days before the 2nd of February 2019 } + * const beforeMatcher: DateBefore = { before: new Date(2019, 1, 2) }; + * + * // will match Sundays + * const dayOfWeekMatcher: DayOfWeek = { + * dayOfWeek: 0, + * }; + * + * // will match the included days, except the two dates + * const intervalMatcher: DateInterval = { + * after: new Date(2019, 1, 2), + * before: new Date(2019, 1, 5), + * }; + * + * // will match the included days, including the two dates + * const rangeMatcher: DateRange = { + * from: new Date(2019, 1, 2), + * to: new Date(2019, 1, 5), + * }; + * + * // will match when the function return true + * const functionMatcher: Matcher = (day: Date) => { + * return day.getMonth() === 2; // match when month is March + * }; + * + * @see {@link isMatch} */ export type Matcher = | boolean @@ -17,40 +62,59 @@ export type Matcher = /** * A matcher to match a day falling after the specified date, with the date not * included. - * - */ export type DateAfter = { after: Date }; /** * A matcher to match a day falling before the specified date, with the date not * included. - *```tsx - * test - * ``` */ export type DateBefore = { before: Date }; /** * A matcher to match a day falling before and/or after two dates, where the * dates are not included. - * - */ export type DateInterval = { before: Date; after: Date }; /** * A matcher to match a range of dates. The range can be open. Differently from - * `DateInterval`, the dates here are included. - * - + * {@link DateInterval}, the dates here are included. */ export type DateRange = { from: Date | undefined; to?: Date | undefined }; /** - * A matcher to match a date being one of the specified days of the week (`0-7`, + * A matcher to match a date being one of the specified days of the week (`0-6`, * where `0` is Sunday). - * - */ export type DayOfWeek = { dayOfWeek: number[] }; + +/** Returns true if `matcher` is of type {@link DateInterval}. */ +export function isDateInterval(matcher: unknown): matcher is DateInterval { + return Boolean( + matcher && + typeof matcher === 'object' && + 'before' in matcher && + 'after' in matcher, + ); +} + +/** Returns true if `value` is a {@link DateRange} type. */ +export function isDateRange(value: unknown): value is DateRange { + return Boolean(value && typeof value === 'object' && 'from' in value); +} + +/** Returns true if `value` is of type {@link DateAfter}. */ +export function isDateAfterType(value: unknown): value is DateAfter { + return Boolean(value && typeof value === 'object' && 'after' in value); +} + +/** Returns true if `value` is of type {@link DateBefore}. */ +export function isDateBeforeType(value: unknown): value is DateBefore { + return Boolean(value && typeof value === 'object' && 'before' in value); +} + +/** Returns true if `value` is a {@link DayOfWeek} type. */ +export function isDayOfWeekType(value: unknown): value is DayOfWeek { + return Boolean(value && typeof value === 'object' && 'dayOfWeek' in value); +} diff --git a/src/types/modifiers.ts b/src/types/modifiers.ts index 262a3d6002..26e3320d0d 100644 --- a/src/types/modifiers.ts +++ b/src/types/modifiers.ts @@ -1,36 +1,78 @@ -import type { CSSProperties } from "react"; +import { CSSProperties } from 'react'; -import type { CalendarDay } from "../classes/CalendarDay"; +import { Matcher } from './Matchers'; /** - * The name of the modifiers that are used internally by DayPicker. + * A _modifier_ represents different styles or states of a day displayed in the + * calendar. + */ +export type Modifier = string; + +/** The modifiers used by DayPicker. */ +export type Modifiers = CustomModifiers & InternalModifiers; + +/** The name of the modifiers that are used internally by DayPicker. */ +export enum InternalModifier { + Outside = 'outside', + /** + * Name of the modifier applied to the disabled days, using the `disabled` + * prop. + */ + Disabled = 'disabled', + /** + * Name of the modifier applied to the selected days using the `selected` + * prop). + */ + Selected = 'selected', + /** Name of the modifier applied to the hidden days using the `hidden` prop). */ + Hidden = 'hidden', + /** Name of the modifier applied to the day specified using the `today` prop). */ + Today = 'today', + /** + * The modifier applied to the day starting a selected range, when in range + * selection mode. + */ + RangeStart = 'range_start', + /** + * The modifier applied to the day ending a selected range, when in range + * selection mode. + */ + RangeEnd = 'range_end', + /** + * The modifier applied to the days between the start and the end of a + * selected range, when in range selection mode. + */ + RangeMiddle = 'range_middle', +} + +/** Map of matchers used for the internal modifiers. */ +export type InternalModifiers = Record; + +/** + * The modifiers that are matching a day in the calendar. Use the + * {@link useActiveModifiers} hook to get the modifiers for a day. * - * @deprecated Test deprecation message. + * const activeModifiers: ActiveModifiers = { + * selected: true, + * customModifier: true, + * }; */ -export type InternalModifier = - | "disabled" - | "excluded" - | "focusable" - | "hidden" - | "outside" - | "range_end" - | "range_middle" - | "range_start" - | "selected" - | "today"; - -/** A map of modifiers with the days. */ -export type ModifiersMap = Record & - Record; - -/** The modifiers that are matching a day in the calendar. */ -export type Modifiers = Record & - Record; +export type ActiveModifiers = Record & + Partial>; /** The style to apply to each day element matching a modifier. */ -export type ModifiersStyles = Record & +export type ModifiersStyles = Record & Partial>; /** The classnames to assign to each day element matching a modifier. */ -export type ModifiersClassNames = Record & +export type ModifiersClassNames = Record & Partial>; + +/** The custom modifiers passed to the {@link DayPickerBase.modifiers}. */ +export type DayModifiers = Record; + +/** + * A map of matchers used as custom modifiers by DayPicker component. This is + * the same as {@link DayModifiers]], but it accepts only array of [[Matcher}s. + */ +export type CustomModifiers = Record; diff --git a/test/.prettierrc b/test/.prettierrc new file mode 100644 index 0000000000..b29bf45e6b --- /dev/null +++ b/test/.prettierrc @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "plugins": ["prettier-plugin-jsdoc"] +} diff --git a/test/mockedContexts.ts b/test/mockedContexts.ts new file mode 100644 index 0000000000..2f9a60b63e --- /dev/null +++ b/test/mockedContexts.ts @@ -0,0 +1,52 @@ +import { + FocusContextValue, + SelectMultipleContextValue, + SelectRangeContextValue, + SelectSingleContextValue, +} from '../src/'; + +const singleContext: SelectSingleContextValue = { + selected: new Date(), + onDayClick: jest.fn(), +}; + +const multipleContext: SelectMultipleContextValue = { + selected: [new Date()], + modifiers: { disabled: [] }, + onDayClick: jest.fn(), +}; + +const rangeContext: SelectRangeContextValue = { + selected: undefined, + modifiers: { + disabled: [], + range_start: [], + range_end: [], + range_middle: [], + }, + onDayClick: jest.fn(), +}; + +const focusContext: FocusContextValue = { + focus: jest.fn(), + focusedDay: undefined, + focusTarget: undefined, + blur: jest.fn(), + focusDayAfter: jest.fn(), + focusDayBefore: jest.fn(), + focusWeekBefore: jest.fn(), + focusWeekAfter: jest.fn(), + focusMonthBefore: jest.fn(), + focusMonthAfter: jest.fn(), + focusYearBefore: jest.fn(), + focusYearAfter: jest.fn(), + focusStartOfWeek: jest.fn(), + focusEndOfWeek: jest.fn(), +}; + +export const mockedContexts = { + single: singleContext, + multiple: multipleContext, + range: rangeContext, + focus: focusContext, +}; diff --git a/test/render/customRender.tsx b/test/render/customRender.tsx new file mode 100644 index 0000000000..c4891e3a43 --- /dev/null +++ b/test/render/customRender.tsx @@ -0,0 +1,15 @@ +import { ReactElement } from 'react'; + +import { render } from '@testing-library/react'; +import { DayPickerProps } from '../../src/DayPicker'; +import { RootProvider } from '../../src/contexts/RootProvider'; + +/** Render a React Element wrapped with the Root Provider. */ +export function customRender( + /** The element to render. */ + element: ReactElement, + /** The initial DayPicker props to pass to the Root Provider. */ + dayPickerProps: DayPickerProps = {}, +) { + return render({element}); +} diff --git a/test/render/index.ts b/test/render/index.ts new file mode 100644 index 0000000000..a35a288110 --- /dev/null +++ b/test/render/index.ts @@ -0,0 +1,2 @@ +export * from './customRender'; +export * from './renderDayPickerHook'; diff --git a/test/render/renderDayPickerHook.tsx b/test/render/renderDayPickerHook.tsx new file mode 100644 index 0000000000..7bf940581e --- /dev/null +++ b/test/render/renderDayPickerHook.tsx @@ -0,0 +1,58 @@ +import { render } from '@testing-library/react'; +import { type DayPickerProps } from '../../src/DayPicker'; + +import { FocusContext, type FocusContextValue } from '../../src/contexts/Focus'; +import { RootProvider } from '../../src/contexts/RootProvider'; +import { + SelectMultipleContext, + type SelectMultipleContextValue, +} from '../../src/contexts/SelectMultiple'; +import { + SelectRangeContext, + type SelectRangeContextValue, +} from '../../src/contexts/SelectRange'; +import { + SelectSingleContext, + type SelectSingleContextValue, +} from '../../src/contexts/SelectSingle'; + +/** Render a DayPicker hook inside the {@link RootProvider}. */ +export type RenderHookResult = { + current: TResult; +}; +export function renderDayPickerHook( + hook: () => TResult, + dayPickerProps?: DayPickerProps, + /** Pass the mocked contexts. */ + contexts?: { + single: SelectSingleContextValue; + multiple: SelectMultipleContextValue; + range: SelectRangeContextValue; + focus: FocusContextValue; + }, +): RenderHookResult { + const returnVal = { current: undefined as TResult }; + function Test(): JSX.Element { + const hookResult: TResult = hook(); + returnVal.current = hookResult; + return <>; + } + render( + + {contexts ? ( + + + + + + + + + + ) : ( + + )} + , + ); + return returnVal; +} diff --git a/test/selectors.ts b/test/selectors.ts new file mode 100644 index 0000000000..e102b7bb15 --- /dev/null +++ b/test/selectors.ts @@ -0,0 +1,108 @@ +import { screen } from '@testing-library/react'; +import { format } from 'date-fns'; + +export function getDayButton(day: Date, index = 0) { + return screen.getAllByRole('gridcell', { + name: day.getDate().toString(), + })[index]; +} + +export function getAllSelectedDays() { + const buttons = screen + .getByRole('grid') + .getElementsByTagName('tbody')[0] + .getElementsByTagName('button'); + + return Array.from(buttons).filter( + (button) => button.getAttribute('aria-selected') === 'true', + ); +} + +export function getAllEnabledDays() { + const buttons = screen + .getByRole('grid') + .getElementsByTagName('tbody')[0] + .getElementsByTagName('button'); + + return Array.from(buttons).filter((button) => !button.disabled); +} + +export function getDayButtons(day: Date) { + return screen.getByRole('button', { + name: format(day, 'do MMMM (EEEE)'), + }); +} + +export function queryDayButton(day: Date) { + return screen.queryByRole('button', { + name: format(day, 'do MMMM (EEEE)'), + }); +} + +export function getDayCell(day: Date) { + return getDayButton(day); +} +export function getWeekButton(week: number) { + return screen.getByRole('button', { + name: `Week n. ${week}`, + }); +} + +export function getTableFooter() { + return screen.getByRole('grid').querySelector('tfoot'); +} + +export function queryTableFooter() { + return screen.queryByRole('grid')?.querySelector('tfoot'); +} + +export function getPrevButton() { + return screen.getByRole('button', { name: 'Go to previous month' }); +} + +export function queryPrevButton() { + return screen.queryByRole('button', { name: 'Go to previous month' }); +} + +export function getNextButton() { + return screen.getByRole('button', { name: 'Go to next month' }); +} + +export function queryNextButton() { + return screen.queryByRole('button', { name: 'Go to next month' }); +} + +export function getMonthCaption(displayIndex = 0) { + return screen.getAllByRole('presentation')[displayIndex]; +} + +export function getMonthGrid(index = 0) { + return screen.getAllByRole('grid')[index]; +} + +export function queryMonthGrids() { + return screen.queryAllByRole('grid'); +} + +export function getYearDropdown() { + return screen.getByRole('combobox', { name: 'Year:' }); +} + +export function queryYearDropdown() { + return screen.queryByRole('combobox', { name: 'Year:' }); +} + +export function getMonthDropdown() { + return screen.getByRole('combobox', { name: 'Month:' }); +} + +export function queryMonthDropdown() { + return screen.queryByRole('combobox', { name: 'Month:' }); +} + +export function getFocusedElement() { + if (!document.activeElement) { + throw new Error('Could not find any focused element'); + } + return document.activeElement; +} diff --git a/test/user.ts b/test/user.ts index 1371a8e6f1..e43b8e8851 100644 --- a/test/user.ts +++ b/test/user.ts @@ -1,6 +1,3 @@ import { userEvent } from '@testing-library/user-event'; -/** Create a user that will advance timers. */ -export const user = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime -}); +export const user = userEvent.setup(); diff --git a/test/utils/focusDaysGrid.ts b/test/utils/focusDaysGrid.ts new file mode 100644 index 0000000000..1d9f54b604 --- /dev/null +++ b/test/utils/focusDaysGrid.ts @@ -0,0 +1,15 @@ +import { fireEvent } from '@testing-library/dom'; +import { act } from '@testing-library/react'; + +import { user } from '../user'; + +import { getFocusedElement } from '../selectors'; + +export async function focusDaysGrid() { + // Make sure nothing is focused + await act(() => fireEvent.blur(getFocusedElement())); + // By pressing tab 3 times + await act(() => user.tab()); + await act(() => user.tab()); + await act(() => user.tab()); +} diff --git a/test/utils/freezeBeforeAll.ts b/test/utils/freezeBeforeAll.ts new file mode 100644 index 0000000000..ef75c112ff --- /dev/null +++ b/test/utils/freezeBeforeAll.ts @@ -0,0 +1,3 @@ +export function freezeBeforeAll(date: Date) { + jest.useFakeTimers().setSystemTime(date); +} diff --git a/test/utils/index.ts b/test/utils/index.ts new file mode 100644 index 0000000000..fb1605fbd5 --- /dev/null +++ b/test/utils/index.ts @@ -0,0 +1,2 @@ +export * from './freezeBeforeAll'; +export * from './focusDaysGrid'; diff --git a/tsconfig.json b/tsconfig.json index cd3b0c8ac3..af63a495ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,11 +2,12 @@ "extends": "./tsconfig-base.json", "compilerOptions": { "paths": { - "@/test/*": ["./test/*"], + "@/test/*": ["./test/*"] }, "noEmit": true, "types": ["node", "jest", "@testing-library/jest-dom"], - "lib": ["dom", "dom.iterable", "esnext"], + "lib": ["dom", "dom.iterable", "esnext"] }, "include": ["src", "test", "**/*.test.*"], + "exclude": ["website", "docs", "next", "typedoc"] } diff --git a/website/app/theme-provider.tsx b/website/app/theme-provider.tsx index 3d87ae2629..6f02676d5e 100644 --- a/website/app/theme-provider.tsx +++ b/website/app/theme-provider.tsx @@ -1,5 +1,4 @@ "use client"; -import * as React from "react"; import { ThemeProvider as NextThemesProvider } from "next-themes"; import type { ThemeProviderProps } from "next-themes/dist/types"; diff --git a/website/components/SidebarLink.tsx b/website/components/SidebarLink.tsx index e3e038ca7f..7322e8b43b 100644 --- a/website/components/SidebarLink.tsx +++ b/website/components/SidebarLink.tsx @@ -1,8 +1,8 @@ import React from "react"; -import styles from "./SidebarLink.module.css"; import { clx } from "@/lib/clx"; import Link from "next/link"; +import styles from "./SidebarLink.module.css"; export interface SidebarLinkProps { children: React.ReactNode; diff --git a/website/components/Steps.tsx b/website/components/Steps.tsx index 2f3c41e4ea..9fa28a3494 100644 --- a/website/components/Steps.tsx +++ b/website/components/Steps.tsx @@ -1,7 +1,3 @@ -import { clx } from "@/lib/clx"; - -import styles from "./Steps.module.css"; - export function Steps({ ...props }) { return (
{ renderApp(); }); test('should have the "id" attribute', () => { - expect(app().firstChild).toHaveAttribute('id', 'testId'); + expect(app().firstChild).toHaveAttribute("id", "testId"); }); test('should have the "title" attribute', () => { - expect(app().firstChild).toHaveAttribute('title', 'foo_title'); + expect(app().firstChild).toHaveAttribute("title", "foo_title"); }); test('should have the "lang" attribute', () => { - expect(app().firstChild).toHaveAttribute('lang', 'it'); + expect(app().firstChild).toHaveAttribute("lang", "it"); }); -test('should have the data set attribute', () => { - expect(app().firstChild).toHaveAttribute('data-test', 'testData'); +test("should have the data set attribute", () => { + expect(app().firstChild).toHaveAttribute("data-test", "testData"); }); diff --git a/website/examples/ModifiersStyle.test.tsx b/website/examples/ModifiersStyle.test.tsx index 42873b1095..e31f13a9d6 100644 --- a/website/examples/ModifiersStyle.test.tsx +++ b/website/examples/ModifiersStyle.test.tsx @@ -1,7 +1,7 @@ -import { gridcell } from '@/test/elements'; -import { renderApp } from '@/test/renderApp'; +import { gridcell } from "@/test/elements"; +import { renderApp } from "@/test/renderApp"; -import { ModifiersStyle } from './ModifiersStyle'; +import { ModifiersStyle } from "./ModifiersStyle"; const today = new Date(2021, 10, 25); jest.useFakeTimers().setSystemTime(today); @@ -13,8 +13,8 @@ beforeEach(() => { const days = [new Date(2021, 5, 23), new Date(2021, 5, 24)]; const style = { fontWeight: 900, - color: 'lightgreen' + color: "lightgreen", }; -test.each(days)('The day %s should have the proper inline style', (day) => { +test.each(days)("The day %s should have the proper inline style", (day) => { expect(gridcell(day)).toHaveStyle(style); }); diff --git a/website/examples/Single.test.tsx b/website/examples/Single.test.tsx index 89154b2f9a..8ddb02a25c 100644 --- a/website/examples/Single.test.tsx +++ b/website/examples/Single.test.tsx @@ -1,8 +1,8 @@ -import { app, gridcell } from '@/test/elements'; -import { renderApp } from '@/test/renderApp'; -import { user } from '@/test/user'; +import { app, gridcell } from "@/test/elements"; +import { renderApp } from "@/test/renderApp"; +import { user } from "@/test/user"; -import { Single } from './Single'; +import { Single } from "./Single"; const today = new Date(2021, 10, 25); jest.useFakeTimers().setSystemTime(today); @@ -11,23 +11,23 @@ beforeEach(() => { renderApp(); }); -describe('when a day is clicked', () => { +describe("when a day is clicked", () => { const day = new Date(2021, 10, 1); beforeEach(async () => { await user.click(gridcell(day)); }); - test('should appear as selected', () => { - expect(gridcell(day)).toHaveAttribute('aria-selected', 'true'); + test("should appear as selected", () => { + expect(gridcell(day)).toHaveAttribute("aria-selected", "true"); }); - describe('when the day is clicked again', () => { + describe("when the day is clicked again", () => { beforeEach(async () => { await user.click(gridcell(day)); }); - test('should appear as not selected', () => { - expect(gridcell(day)).not.toHaveAttribute('aria-selected'); + test("should appear as not selected", () => { + expect(gridcell(day)).not.toHaveAttribute("aria-selected"); }); - test('should update the footer', () => { - expect(app()).not.toHaveTextContent('You selected November 1st, 2021'); + test("should update the footer", () => { + expect(app()).not.toHaveTextContent("You selected November 1st, 2021"); }); }); }); diff --git a/website/examples/SingleRequired.tsx b/website/examples/SingleRequired.tsx index d254b912fb..a474771400 100644 --- a/website/examples/SingleRequired.tsx +++ b/website/examples/SingleRequired.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react'; +import { useState } from "react"; -import { DayPicker } from 'react-day-picker'; +import { DayPicker } from "react-day-picker"; export function SingleRequired() { const [selectedDay, setSelectedDay] = useState(new Date()); diff --git a/website/examples/Weeknumber.test.tsx b/website/examples/Weeknumber.test.tsx index 7f6da3aa52..61f57809e2 100644 --- a/website/examples/Weeknumber.test.tsx +++ b/website/examples/Weeknumber.test.tsx @@ -1,8 +1,8 @@ -import { app, rowheader } from '@/test/elements'; -import { renderApp } from '@/test/renderApp'; -import { user } from '@/test/user'; +import { app, rowheader } from "@/test/elements"; +import { renderApp } from "@/test/renderApp"; +import { user } from "@/test/user"; -import { Weeknumber } from './Weeknumber'; +import { Weeknumber } from "./Weeknumber"; const today = new Date(2021, 10, 25); jest.useFakeTimers().setSystemTime(today); @@ -11,16 +11,16 @@ beforeEach(() => { renderApp(); }); -describe('when displaying November 2021', () => { - test('should display the 45th week number', () => { - expect(rowheader('Week 45')).toBeInTheDocument(); +describe("when displaying November 2021", () => { + test("should display the 45th week number", () => { + expect(rowheader("Week 45")).toBeInTheDocument(); }); - describe('when the week button is clicked', () => { + describe("when the week button is clicked", () => { beforeEach(async () => { - await user.click(rowheader('Week 45')); + await user.click(rowheader("Week 45")); }); - test('should update the footer', () => { - expect(app()).toHaveTextContent('You clicked the week n. 45.'); + test("should update the footer", () => { + expect(app()).toHaveTextContent("You clicked the week n. 45."); }); }); }); diff --git a/website/examples/WeeknumberCustom.test.tsx b/website/examples/WeeknumberCustom.test.tsx index 72d556ade7..1039757b05 100644 --- a/website/examples/WeeknumberCustom.test.tsx +++ b/website/examples/WeeknumberCustom.test.tsx @@ -1,12 +1,12 @@ -import { rowheader } from '@/test/elements'; -import { renderApp } from '@/test/renderApp'; +import { rowheader } from "@/test/elements"; +import { renderApp } from "@/test/renderApp"; -import { WeeknumberCustom } from './WeeknumberCustom'; +import { WeeknumberCustom } from "./WeeknumberCustom"; beforeEach(() => { renderApp(); }); -test('should display the 1st week (even if December)', () => { - expect(rowheader('Week 1')).toBeInTheDocument(); +test("should display the 1st week (even if December)", () => { + expect(rowheader("Week 1")).toBeInTheDocument(); });