diff --git a/.babelrc b/.babelrc index 40a8fe2cba..cc5885df62 100644 --- a/.babelrc +++ b/.babelrc @@ -1,39 +1,66 @@ { "env": { "test": { - "presets": ["airbnb"], + "presets": [["airbnb", { looseClasses: true }]], "plugins": [ - "inline-react-svg", - ["transform-replace-object-assign", "object.assign"], - "istanbul" + ["import-path-replace", { + "rules": [ + { + "match": "../src/", + "replacement": "../lib/" + } + ] + }], + ["inline-react-svg", { + "svgo": false + }], + ["transform-replace-object-assign", { "moduleSpecifier": "object.assign" }], + "./scripts/pure-component-fallback.js", + "istanbul", ] }, + "development": { - "presets": ["airbnb"], + "presets": [["airbnb", { looseClasses: true }]], "plugins": [ - "inline-react-svg", - ["transform-replace-object-assign", "object.assign"], + ["inline-react-svg", { + "svgo": false + }], + ["transform-replace-object-assign", { "moduleSpecifier": "object.assign" }], + "./scripts/pure-component-fallback.js", ], }, + "production": { - "presets": ["airbnb"], + "presets": [["airbnb", { looseClasses: true, removePropTypes: true }]], "plugins": [ - "inline-react-svg", - ["transform-replace-object-assign", "object.assign"], + ["inline-react-svg", { + "svgo": false + }], + ["transform-replace-object-assign", { "moduleSpecifier": "object.assign" }], + "./scripts/pure-component-fallback.js", ], }, + "cjs": { - "presets": ["airbnb"], + "presets": [["airbnb", { looseClasses: true, removePropTypes: true }]], "plugins": [ - "inline-react-svg", - ["transform-replace-object-assign", "object.assign"], + ["inline-react-svg", { + "svgo": false + }], + ["transform-replace-object-assign", { "moduleSpecifier": "object.assign" }], + "./scripts/pure-component-fallback.js", ], }, + "esm": { - "presets": [["airbnb", { modules: false }]], + "presets": [["airbnb", { looseClasses: true, modules: false, removePropTypes: true }]], "plugins": [ - "inline-react-svg", - ["transform-replace-object-assign", "object.assign"], + ["inline-react-svg", { + "svgo": false + }], + ["transform-replace-object-assign", { "moduleSpecifier": "object.assign" }], + "./scripts/pure-component-fallback.js", ], }, }, diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index edfe81afba..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -lib/ -.storybook/ -test/_helpers/ -webpack.config.js diff --git a/.eslintrc b/.eslintrc index c6a4fe8e9e..d5780077d5 100644 --- a/.eslintrc +++ b/.eslintrc @@ -3,29 +3,64 @@ "extends": [ "airbnb", - "plugin:react-with-styles/recommended" + "plugin:react-with-styles/recommended", ], "plugins": [ - "react-with-styles" + "react-with-styles", ], "env": { "browser": true, - "node": true + "node": true, }, + "ignorePatterns": [ + "lib/", + ".storybook/", + "test/_helpers/", + "webpack.config.js", + ], + + "parser": "@babel/eslint-parser", + "rules": { - "no-mixed-operators": [2, { "allowSamePrecedence": true }], + "max-len": "off", + + "react/forbid-foreign-prop-types": 2, // For babel-plugin-transform-react-remove-prop-types "jsx-a11y/click-events-have-key-events": 1, // TODO: enable - "react/no-did-mount-set-state": 0, // necessary for server-rendering + "react-with-styles/no-unused-styles": 2, + + "no-restricted-imports": 0, // TODO: enable with full RTL support - "react-with-styles/no-unused-styles": 2 + "react/jsx-props-no-spreading": 0, // TODO: re-evaluate + + "react/no-deprecated": 1, // TODO: update to UNSAFE_componentWillReceiveProps }, + "overrides": [ + { + "files": "test/**/*", + "env": { + "mocha": true, + }, + "extends": "airbnb", + "rules": { + "react/jsx-props-no-spreading": 0, + //"import/no-extraneous-dependencies": [2, { + //"devDependencies": true + //}], + "indent": [2, 2, { + "MemberExpression": "off", + }], + "react/function-component-definition": "off", + }, + }, + ], + "settings": { - "propWrapperFunctions": ["forbidExtraProps", "exact", "Object.freeze"] - } + "propWrapperFunctions": ["forbidExtraProps", "exact", "Object.freeze"], + }, } diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..e3d5b963d0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,49 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**react-dates version** +e.g. react-dates@18.3.1 + +**Describe the bug** +A clear and concise description of what the bug is. + +**Source code (including props configuration)** +Steps to reproduce the behavior: +``` + this.setState({ startDate, endDate })} + focusedInput={this.state.focusedInput} + onFocusChange={focusedInput => this.setState({ focusedInput })} +/> +``` +If you have custom methods that you are passing into a `react-dates` component, e.g. `onDatesChange`, `onFocusChange`, `renderMonth`, `isDayBlocked`, etc., please include the source for those as well. + +**Screenshots/Gifs** +If applicable, add screenshots or gifs to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Is the issue reproducible in Storybook?** +Please link to the relevant storybook example + +**Additional context** +Add any other context about the problem here. diff --git a/.github/workflows/node-pretest.yml b/.github/workflows/node-pretest.yml new file mode 100644 index 0000000000..981f03f4c2 --- /dev/null +++ b/.github/workflows/node-pretest.yml @@ -0,0 +1,37 @@ +name: 'Tests: pretest/posttest/build' + +on: [pull_request, push] + +jobs: + pretest: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: ljharb/actions/node/install@main + name: 'nvm install lts/* && npm install' + with: + node-version: 'lts/*' + - run: npm run pretest + + # posttest: + # runs-on: ubuntu-latest + + # steps: + # - uses: actions/checkout@v2 + # - uses: ljharb/actions/node/install@main + # name: 'nvm install lts/* && npm install' + # with: + # node-version: 'lts/*' + # - run: npm run posttest + + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: ljharb/actions/node/install@main + name: 'nvm install lts/* && npm install' + with: + node-version: 'lts/*' + - run: npm run build diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml new file mode 100644 index 0000000000..1f5467ef3e --- /dev/null +++ b/.github/workflows/node.yml @@ -0,0 +1,87 @@ +name: 'Tests: node.js' + +on: [pull_request, push] + +jobs: + matrix: + runs-on: ubuntu-latest + outputs: + latest: ${{ steps.set-matrix.outputs.requireds }} + minors: ${{ steps.set-matrix.outputs.optionals }} + steps: + - uses: ljharb/actions/node/matrix@main + id: set-matrix + with: + type: 'majors' + versionsAsRoot: true + preset: '>=4' + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ljharb/actions/node/install@main + with: + node-version: 'lts/*' + - uses: actions/cache@v2 + id: cache + with: + path: | + lib + esm + test-build + key: ${{ runner.os }}-${{ hashFiles('package.json', 'src/**', 'test/**', 'scripts/buildCSS.js') }} + - run: npm run build + if: steps.cache.outputs.cache-hit != 'true' + - run: npm run build:test + if: steps.cache.outputs.cache-hit != 'true' + + + majors: + needs: [build, matrix] + name: 'latest minors' + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + node-version: ${{ fromJson(needs.matrix.outputs.latest) }} + react: + - '16' + - '16.9' + - '16.3' + - '16.0' + - '15' + - '15.5' + - '15.0' + - '0.14' + + steps: + - uses: actions/checkout@v2 + - uses: ljharb/actions/node/install@main + name: 'nvm install ${{ matrix.node-version }} && npm install' + with: + node-version: ${{ matrix.node-version }} + skip-ls-check: ${{ !startsWith(matrix.node-version, '5.') && !startsWith(matrix.node-version, '4.') && 'true' || 'false' }} + - uses: actions/cache@v2 + id: cache + with: + path: | + lib + esm + test-build + key: ${{ runner.os }}-${{ hashFiles('package.json', 'src/**', 'test/**', 'scripts/buildCSS.js') }} + - run: npm run react + env: + REACT: ${{ matrix.react }} + - run: npm run tests-only + env: + REACT: ${{ matrix.react }} + - uses: codecov/codecov-action@v2 + + node: + name: 'node.js' + needs: [majors] + runs-on: ubuntu-latest + steps: + - run: 'echo tests completed' diff --git a/.github/workflows/rebase.yml b/.github/workflows/rebase.yml new file mode 100644 index 0000000000..027aed0797 --- /dev/null +++ b/.github/workflows/rebase.yml @@ -0,0 +1,15 @@ +name: Automatic Rebase + +on: [pull_request_target] + +jobs: + _: + name: "Automatic Rebase" + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: ljharb/rebase@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/require-allow-edits.yml b/.github/workflows/require-allow-edits.yml new file mode 100644 index 0000000000..549d7b4823 --- /dev/null +++ b/.github/workflows/require-allow-edits.yml @@ -0,0 +1,12 @@ +name: Require “Allow Edits” + +on: [pull_request_target] + +jobs: + _: + name: "Require “Allow Edits”" + + runs-on: ubuntu-latest + + steps: + - uses: ljharb/require-allow-edits@main diff --git a/.gitignore b/.gitignore index 6a6c6fb3b6..3d7cb404f7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ node_modules # build folder lib esm +test-build .idea # gh-pages @@ -48,3 +49,5 @@ package-lock.json css/styles.css +# Cache +.cache/ diff --git a/.npmignore b/.npmignore index bb95d36efb..f27011d803 100644 --- a/.npmignore +++ b/.npmignore @@ -32,13 +32,18 @@ node_modules # Optional REPL history .node_repl_history +.cache +.github +.lgtm .storybook .storybook-css +_gh-pages examples +MAINTAINERS public react-dates-demo.gif stories test +test-build css !lib/css - diff --git a/.nycrc b/.nycrc index be4697c2d1..33fbfd6d2a 100644 --- a/.nycrc +++ b/.nycrc @@ -7,18 +7,13 @@ "src" ], "require": [ - "babel-register" ], "reporter": [ "text", + "html", "lcov" ], "all": true, - "check-coverage": true, - "statements": 83, - "branches": 79, - "lines": 83, - "functions": 70, "sourceMap": false, "instrument": false } diff --git a/.storybook-css/config.js b/.storybook-css/config.js index f3b12328c7..da06e751a8 100644 --- a/.storybook-css/config.js +++ b/.storybook-css/config.js @@ -21,8 +21,8 @@ function getLink(href, text) { return `${text}`; } -const README = getLink('https://github.com/airbnb/react-dates/blob/master/README.md', 'README'); -const wrapperSource = getLink('https://github.com/airbnb/react-dates/tree/master/examples', 'wrapper source'); +const README = getLink('https://github.com/react-dates/react-dates/blob/HEAD/README.md', 'README'); +const wrapperSource = getLink('https://github.com/react-dates/react-dates/tree/HEAD/examples', 'wrapper source'); const helperText = `All examples are built using a wrapper component that is not exported by react-dates. Please see the ${README} for more information about minimal setup or explore @@ -52,7 +52,7 @@ addDecorator(story => ( setOptions({ name: 'REACT-DATES', - url: 'https://github.com/airbnb/react-dates', + url: 'https://github.com/react-dates/react-dates', }); function loadStories() { @@ -67,6 +67,7 @@ function loadStories() { require('../stories/DayPickerRangeController'); require('../stories/DayPickerSingleDateController'); require('../stories/DayPicker'); + require('../stories/PresetDateRangePicker'); } setAddon(infoAddon); diff --git a/.storybook-css/webpack.config.js b/.storybook-css/webpack.config.js index 88fcdd2cf2..f4a9a10970 100644 --- a/.storybook-css/webpack.config.js +++ b/.storybook-css/webpack.config.js @@ -8,6 +8,7 @@ module.exports = { use: ['style-loader', 'raw-loader', 'sass-loader'], include: [ path.resolve(__dirname, '../css/'), + /@storybook\/addon-info/, ], }, { diff --git a/.storybook/config.js b/.storybook/config.js index f0eca636d6..a14004d170 100644 --- a/.storybook/config.js +++ b/.storybook/config.js @@ -1,4 +1,10 @@ import React from 'react'; + +if (process.env.NODE_ENV !== 'production') { + const whyDidYouRender = require('@welldone-software/why-did-you-render'); + whyDidYouRender(React); +} + import moment from 'moment'; import aphroditeInterface from 'react-with-styles-interface-aphrodite'; @@ -21,8 +27,8 @@ function getLink(href, text) { return `${text}`; } -const README = getLink('https://github.com/airbnb/react-dates/blob/master/README.md', 'README'); -const wrapperSource = getLink('https://github.com/airbnb/react-dates/tree/master/examples', 'wrapper source'); +const README = getLink('https://github.com/react-dates/react-dates/blob/HEAD/README.md', 'README'); +const wrapperSource = getLink('https://github.com/react-dates/react-dates/tree/HEAD/examples', 'wrapper source'); const helperText = `All examples are built using a wrapper component that is not exported by react-dates. Please see the ${README} for more information about minimal setup or explore @@ -52,7 +58,7 @@ addDecorator(story => ( setOptions({ name: 'REACT-DATES', - url: 'https://github.com/airbnb/react-dates', + url: 'https://github.com/react-dates/react-dates', }); function loadStories() { @@ -67,6 +73,7 @@ function loadStories() { require('../stories/DayPickerRangeController'); require('../stories/DayPickerSingleDateController'); require('../stories/DayPicker'); + require('../stories/PresetDateRangePicker'); } setAddon(infoAddon); diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js index 88fcdd2cf2..0e6b53922b 100644 --- a/.storybook/webpack.config.js +++ b/.storybook/webpack.config.js @@ -1,40 +1,35 @@ const path = require('path'); -module.exports = { - module: { - rules: [ - { - test: /\.s?css$/, - use: ['style-loader', 'raw-loader', 'sass-loader'], - include: [ - path.resolve(__dirname, '../css/'), - ], - }, - { - test: /\.svg$/, - use: [ - { - loader: 'babel-loader', - query: { - presets: ['airbnb'], - }, +module.exports = ({ config }) => { + config.module.rules.push( + { + test: /\.s?css$/, + use: ['style-loader', 'raw-loader', 'sass-loader'], + include: [path.resolve(__dirname, '../css/')], + }, + { + test: /\.svg$/, + use: [ + { + loader: 'babel-loader', + query: { + presets: ['airbnb'], }, - ], - }, - { - test: /\.jsx$/, - use: [ - { - loader: 'babel-loader', - query: { - presets: ['airbnb'], - }, + }, + ], + }, + { + test: /\.jsx$/, + use: [ + { + loader: 'babel-loader', + query: { + presets: ['airbnb'], }, - ], - }, - ], - }, - resolve: { - extensions: ['.js', '.jsx'], - }, + }, + ], + }, + ); + config.resolve.extensions = ['.js', '.jsx']; + return config; }; diff --git a/.travis.yml b/.travis.yml index d0c8bbd383..ea471eba53 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,41 +1,13 @@ language: node_js -node_js: - - "9" - - "8" - - "7" - - "6" - - "4" - - "iojs" +node_js: lts/* before_install: - - 'nvm install-latest-npm' -before_script: - - 'if [ -n "${KARMA-}" ]; then export DISPLAY=:99.0; fi' - - 'if [ -n "${KARMA-}" ]; then sh -e /etc/init.d/xvfb start; fi' - - 'if [ -n "${KARMA-}" ]; then sleep 3; fi' - - 'if [ -n "${REACT-}" ] && [ "${TEST-}" = true ]; then sh install-relevant-react.sh && npm ls >/dev/null || echo "temporary bypass"; fi' + - nvm install-latest-npm +services: + - xvfb script: - - 'if [ -n "${LINT-}" ]; then npm run lint ; fi' - - 'if [ "${TEST-}" = true ]; then npm run tests-only ; fi' - - 'if [ -n "${KARMA-}" ]; then npm run tests-karma ; fi' - - 'if [ -n "${COVERAGE-}" ] && [ "${TRAVIS_BRANCH-}" = "master" ]; then npm run cover; cat ./coverage/lcov.info | ./node_modules/.bin/coveralls ; fi' + - npm run tests-karma env: global: - - TEST=true - matrix: - - REACT=0.14 - - REACT=15 + - REACT=16 + - DISPLAY=:99.0 sudo: false -matrix: - fast_finish: true - include: - - node_js: "lts/*" - env: COVERAGE=true TEST=false - - node_js: "lts/*" - env: KARMA=true TEST=false - - node_js: "lts/*" - env: LINT=true TEST=false - allow_failures: - - node_js: "9" - - node_js: "7" - - node_js: "iojs" - - env: KARMA=true TEST=false diff --git a/CHANGELOG.md b/CHANGELOG.md index b4e46807e6..ede78bcdbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,304 +1,649 @@ # Change Log + + +## 21.8.0 + +- [new] Add option to allow days violating min nights to be clicked ([#1913](https://github.com/react-dates/react-dates/pull/1913)) +- [fix] Prevent vertical scrollable prev nav button from overlapping weekday headers ([#1914](https://github.com/react-dates/react-dates/pull/1914)) + +## 21.7.2 +- [fix] Add tests for previous fix ([#1912](https://github.com/react-dates/react-dates/pull/1912)) + +## 21.7.1 +- [fix] Fix render conditional in DayPickerKeyboardShortcuts ([#1911](https://github.com/react-dates/react-dates/pull/1911)) + +## 21.7.0 +- [fix] Keep scroll position when prev months rendered on vertical scrollable calendar ([#1902](https://github.com/react-dates/react-dates/pull/1902)) +- [new] Add ability to not show prev or next navigation buttons ([#1900](https://github.com/react-dates/react-dates/pull/1900)) +- [fix] Fix logic for applying default navigation button styling ([#1898](https://github.com/react-dates/react-dates/pull/1898)) + +## 21.6.0 + +- [new] Add functionality to see previous months for vertical scrollable calendar ([#1894](https://github.com/react-dates/react-dates/pull/1894)) +- [new] Add support for noNavButtons in DayPickerSingleDateController (match DayPickerRangeController support) ([#1895](https://github.com/react-dates/react-dates/pull/1895)) + +## 21.5.1 + +- [fix] Remove redundant overflowY:scroll on CalendarMonthGrid ([#1881](https://github.com/react-dates/react-dates/pull/1881)) + +## 21.5.0 + +- [new] Add support for custom month navigation buttons ([#1859](https://github.com/react-dates/react-dates/pull/1859)) + +## 21.4.0 + +- [new] Expose 'after-hovered-start' modifier and add 'before-hovered-end', 'no-selected-start-before-selected-end', 'selected-start-in-hovered-span', 'selected-end-in-hovered-span', 'selected-start-no-selected-end', and 'selected-end-no-selected-start' modifiers [#1608](https://github.com/react-dates/react-dates/pull/1608) +- [fix] Loosen up CustomizeableCalendarDay restriction on children ([#1857](https://github.com/react-dates/react-dates/pull/1857)) + +## 21.3.2 + +- [fix] Revert "Call getStateForNewMonth when date/startDate/endDate is set to a date that is not visible" ([#1851](https://github.com/react-dates/react-dates/pull/1851)) + +## 21.3.1 + +- [fix] Update react-with-styles 4.1.0-alpha.1 -> 4.1.0 + +## 21.3.0 + +- [new] Update react-with-styles 4.0.1 -> 4.1.0 ([#1843](https://github.com/react-dates/react-dates/pull/1843)) +- [new] Add Input Font-Weight to Default Theme ([#1765](https://github.com/react-dates/react-dates/pull/#1765)) + +## 21.2.1 + +- [fix] Call getStateForNewMonth when date/startDate/endDate is set to a date that is not visible ([#1834](https://github.com/react-dates/react-dates/pull/1834)) + +## 21.2.0 + +- [fix] Revert "Merge pull request [#1758](https://github.com/react-dates/react-dates/pull/1758): Remove all direct imports of css in favor of injected prop" ([#1818](https://github.com/react-dates/react-dates/pull/1818)) +- [fix] Fix for getWeekHeaders(), prevents it from changing state.currentMonth ([#1796](https://github.com/react-dates/react-dates/pull/1796)) +- [new] Add support for positioning month navigation under the calendar ([#1784](https://github.com/react-dates/react-dates/pull/1784)) +- [new] Add minDate and maxDate props to DateRangePicker ([#1793](https://github.com/react-dates/react-dates/pull/1793), [#1794](https://github.com/react-dates/react-dates/pull/1794)) + +## 21.1.0 + +- [fix] `DayPicker`: week headers: use the passed-in moment object’s instance, to support localy ([#1577](https://github.com/react-dates/react-dates/pull/1577)) +- [fix] Combine labelled DayPicker container elements ([#1783](https://github.com/react-dates/react-dates/pull/1783)) +- [new] Add a render function for customizable week header text ([#1787](https://github.com/react-dates/react-dates/pull/1787)) + +## 21.0.1 + +- [fix] [deps] Update react-with-styles ^4.0.0 -> ^4.0.1 ([#1781](https://github.com/react-dates/react-dates/pull/1781)) + +## 21.0.0 + +- [breaking] [deps] Update react-with-styles and other deps ([#1761](https://github.com/react-dates/react-dates/pull/1761) +- [new] [deps] Update dependencies related to react-with-styles ([#1775](https://github.com/react-dates/react-dates/pull/1775)) +- [new] `DayPickerSingleDateController`: Add onMultiplyScrollableMonths ([#1770](https://github.com/react-dates/react-dates/pull/1770)) +- [dev] Fix moment date formats ([#1767](https://github.com/react-dates/react-dates/pull/1767)) +- [dev] Fix addons webpack loader, Fix tests ([#1764](https://github.com/react-dates/react-dates/pull/1764)) +- [dev] build: fix linting (refs eslint/eslint#12119) +- [new] [deps] update `@babel/runtime`, `airbnb-prop-types`, `object.values`, `prop-types`, `react-outside-click-handler`, `react-portal` +- [dev] [deps] update `@babel/*`, `@storybook/*`, `babel-plugin-inline-svg`, `babel-plugin-istanbul`, `babel-preset-airbnb`, `eslint-config-airbnb` to v18 (plus peer deps), `eslint` to v6; `eslint-plugin-react-with-styles`, `karma`, `karma-firefox-launcher`, `mocha`, `safe-publish-latest`, `sass-loader`, `sinon`, `sinon-sandbox`, `coveralls`, `enzyme-adapter-react-helper` +- [fix] Remove all direct imports of css in favor of injected prop ([#1758](https://github.com/react-dates/react-dates/pull/1758)) + +## 20.3.0 +- [fix] Optimize setState dayPickerContainerStyles in responsivizePickerPosition ([#1735](https://github.com/react-dates/react-dates/pull/1735)) +- [fix] Stop calendar blinking on DateRangePickerInput focus switch (fixes #1523) ([#1553](https://github.com/react-dates/react-dates/pull/1553)) +- [new] [a11y] Add `aria-roledescription` ([#1746](https://github.com/react-dates/react-dates/pull/1746)) + +## 20.2.5 +- [fix] Defer day focusing until next animation frame ([#1707](https://github.com/react-dates/react-dates/pull/1707)) +- [fix] Fix startDate style (@mmarkelov, [#1710](https://github.com/react-dates/react-dates/pull/1710)) +- [fix] Pass correct props to SingleDatePicker on close (@AlokTakshak, [#1678](https://github.com/react-dates/react-dates/pull/1678)) +- [dev] Update blocked navigation (min/maxDate) stories (@ianduvall, [#1598](https://github.com/react-dates/react-dates/pull/1598)) +- [dev] Add positioning to custom navigation in stories (@dougmacknz, [#1573](https://github.com/react-dates/react-dates/pull/1573)) +- [dev] Update karma 3 → 4, mocha 3 → 6, nyc 12 → 14, sinon 6 → 7, eslint 5 → 6 ([#1713](https://github.com/react-dates/react-dates/pull/1713), [#1712](https://github.com/react-dates/react-dates/pull/1713)) + +## 20.2.4 +- [fix] Replace react-addons-shallow-compare with enzyme-shallow-equal (bf7e3347702f) +- [fix] Optimize SVG assets ([#1690](https://github.com/react-dates/react-dates/pull/1690)) +- [fix] Update babel-preset-airbnb 3.2.1 -> 4.0.0 ([#1692](https://github.com/react-dates/react-dates/pull/1692)) + +## 20.2.3 +- [fix] Add guard for undefined objects in deleteModifier ([#1687](https://github.com/react-dates/react-dates/pull/1687)) +- [dev] Update Storybook from v4 to v5 ([@trotzig](https://github.com/trotzig) [#1673](https://github.com/react-dates/react-dates/pull/1673)) + +## 20.2.2 +- [fix] Add guard for undefined objects in addModifier ([#1667](https://github.com/react-dates/react-dates/pull/1667)) + +## 20.2.1 +- [fix] Compile classes in loose mode ([#1655](https://github.com/react-dates/react-dates/pull/1655)) +- [fix] Performance optimizations ([#1656](https://github.com/react-dates/react-dates/pull/1656), [#1657](https://github.com/react-dates/react-dates/pull/1657), [#1659](https://github.com/react-dates/react-dates/pull/1659), [#1661](https://github.com/react-dates/react-dates/pull/1661), [#1662](https://github.com/react-dates/react-dates/pull/1662), and [#1663](https://github.com/react-dates/react-dates/pull/1663)) +- [docs] Add `initialVisibleMonth` to `DayPickerRangeController` in readme ([@AlokTakshak](https://github.com/AlokTakshak) [1652](https://github.com/react-dates/react-dates/pull/1652)) + +## 20.2.0 +- [fix] Fix date selection in the SDP ([#1530](https://github.com/react-dates/react-dates/pull/1530)) +- [new] Add explicit aria label props ([#1594](https://github.com/react-dates/react-dates/pull/1594)) + +## 20.1.0 +- [new] Add `renderKeyboardShortcutsButton` prop ([#1576](https://github.com/react-dates/react-dates/pull/1576)) + +## 20.0.0 +- [breaking] Omit tabindex prop from calendar navigation buttons when custom buttons are supplied ([#1563](https://github.com/react-dates/react-dates/pull/1563)) +- [new] Add hovered-start-blocked-minimun-nights and hovered-start-first-possible-end modifiers ([#1547](https://github.com/react-dates/react-dates/pull/1547)) +- [fix] clearTimeout added for the setCalendarMonthGridHeight timeout ([#1468](https://github.com/react-dates/react-dates/pull/1468)) +- [fix] Remove default styles for the last-in-range modifier ([#1538](https://github.com/react-dates/react-dates/pull/1538)) + +## 19.0.4 +- [fix] Added `selected` aria label to dates in the selected range ([#1555](https://github.com/react-dates/react-dates/pull/1555)) + +## 19.0.3 +- [fix] Fix date selection in the SDP ([#1540](https://github.com/react-dates/react-dates/pull/1540)) + +## 19.0.2 +- no changes; extraneous publish + +## 19.0.1 +- [fix] Fix single date picker not responding to input ([#1533](https://github.com/react-dates/react-dates/pull/1533)) +- [fix] Fixes the focus out event in IE11 ([#1524](https://github.com/react-dates/react-dates/pull/1524)) + +## 19.0.0 +- [breaking] Call `onDatesChange` before `onFocusChange` in the DRP ([#1525](https://github.com/react-dates/react-dates/pull/1525)) + +## 18.5.0 +- [fix] Add `aria-disabled` attribute to the (Customizable)CalendarDay ([#1521](https://github.com/react-dates/react-dates/pull/1521)) +- [new] Add `startDateOffset` and `endDateOffset` props to the DRP ([#1252](https://github.com/react-dates/react-dates/pull/1252)) + +## 18.4.1 +- [fix] Make DRP and SDP calendars tabbable from the inputs ([#1499](https://github.com/react-dates/react-dates/pull/1499)) + +## 18.4.0 +- [new] Clarify VoiceOver text for dates selected as start-date and end-date ([#1501](https://github.com/react-dates/react-dates/pull/1501)) + +## 18.3.1 +- [fix][RTL] Fix the SDP and DRP noflip util function ([#1492](https://github.com/react-dates/react-dates/pull/1492)) + +## 18.3.0 +- [fix] Update the SDP and DRP to be compatible with `react-with-direction` ([#1482](https://github.com/react-dates/react-dates/pull/1482)) +- [new] Add `minDate` and `maxDate` props to block month navigation ([#1311](https://github.com/react-dates/react-dates/pull/1311)) +- [fix][a11y] Remove space/enter onKeyDown handling for open/close keyboard shortcuts panel ([#1464](https://github.com/react-dates/react-dates/pull/1464)) +- [fix][a11y] Fix lack of visible focus in Firefox and IE on "?" keyboard shortcuts button ([#1463](https://github.com/react-dates/react-dates/pull/1463)) + +## 18.2.2 +- [fix] Conditionally apply the `shouldComponentUpdate` method in the babel transform ([#1457](https://github.com/react-dates/react-dates/pull/1457)) +- [fix] Fix incorrect VO for selected check-in date ([#1451](https://github.com/react-dates/react-dates/pull/1451)) + +## 18.2.1 +- [fix] Add babel transform to handle PureComponent fallbacks ([#1452](https://github.com/react-dates/react-dates/pull/1452)) + +## 18.2.0 +- [new] Add `onTab`/`onShiftTab` callbacks to the DayPicker (and its controllers) ([#1427](https://github.com/react-dates/react-dates/pull/1427)) + +## 18.1.1 +- [fix] Prevent outside range dates from being selected by typing ([#1370](https://github.com/react-dates/react-dates/pull/1370)) + +## 18.1.0 +- [fix] Default the `calendarMonthPadding` theme variable to 0 when undefined ([#1355](https://github.com/react-dates/react-dates/pull/1355)) +- [new] Add `SingleDatePickerInputController` component ([#1360](https://github.com/react-dates/react-dates/pull/1360)) +- [new] Add `horizontalMonthPadding` as a prop to the DRP ([#1364](https://github.com/react-dates/react-dates/pull/1364)) +- [fix] Fix unnecessary rerender in `DayPickerNavigation` ([#1363](https://github.com/react-dates/react-dates/pull/1363)) + +## 18.0.4 +- [fix] revert 'revert 'Conditionally use `PureComponent` instead of `Component`'' ([4f8eb01](https://github.com/react-dates/react-dates/commit/4f8eb01168ef6c4ae7d74e95ad14acb28960e43e)) + +## 18.0.3 +- [fix] revert 'Conditionally use `PureComponent` instead of `Component`' ([50c382f](https://github.com/react-dates/react-dates/commit/50c382f7cf3e3ba60f4fdaa00eae53cf06d3c97b)) + +## 18.0.2 +- [fix] Remove svgo from "inline-react-svg" babel plugin options ([#1350](https://github.com/react-dates/react-dates/pull/1350)) + +## 18.0.1 +- [fix] Center vertical month navigation ([#1347](https://github.com/react-dates/react-dates/pull/1347)) + +## 18.0.0 +- [fix] Conditionally use `PureComponent` instead of `Component` ([#1335](https://github.com/react-dates/react-dates/pull/1335)) +- [breaking] Remove propTypes in production ([#1322](https://github.com/react-dates/react-dates/pull/1322)) +- [fix] Change border-styles to minimise overlap ([#1328](https://github.com/react-dates/react-dates/pull/1328)) +- [fix] Only blur the `activeElement` when available ([#1345](https://github.com/react-dates/react-dates/pull/1345)) + +## 17.2.0 +- [fix] Add modifiers for next months in the vertical scrollable datepickers ([#1293](https://github.com/react-dates/react-dates/pull/1293)) +- [fix] Fix cursor jumping to the end of the controlled input when typing ([#1287](https://github.com/react-dates/react-dates/pull/1287)) +- [new] Add `horizontalMonthPadding` prop and `dayPickerHorizontalPadding` and `noScrollBarOnVerticalScrollable` theme variables ([#1298](https://github.com/react-dates/react-dates/pull/1298)) +- [fix] Fix issue where custom month navigation was not clickable in FF ([#1305](https://github.com/react-dates/react-dates/pull/1305)) + +## 17.1.1 +- [fix] Set `DayPickerNavigation__horizontal` height to zero ([#1265](https://github.com/react-dates/react-dates/pull/1265)) + +## 17.1.0 +- [new] Add `ModifiersShape` and use throughout the codebase ([#1231](https://github.com/react-dates/react-dates/pull/1231)) +- [fix] Fix minimum nights `blocked` modifiers being applied incorrectly ([#1259](https://github.com/react-dates/react-dates/pull/1259)) +- [fix] Update conditions where `adjustDayPickerHeight` is called ([#1241](https://github.com/react-dates/react-dates/pull/1241)) +- [fix] Do not render `OutsideClickHandler` unnecessarily ([#1256](https://github.com/react-dates/react-dates/pull/1256)) + +## 17.0.0 +- [fix] Replace dumb quotes with smart quotes in default phrases ([#1168](https://github.com/react-dates/react-dates/pull/1168)) +- [fix] Fix outside day movement on hover ([#1178](https://github.com/react-dates/react-dates/pull/1178)) +- [fix] Add `focusable="false"` to SVGs ([#1190](https://github.com/react-dates/react-dates/pull/1190)) +- [fix] Use `react-outside-click-handler` instead of the internal component ([#1191](https://github.com/react-dates/react-dates/pull/1191)) +- [breaking] Change the way month heights are calculated and update the name of some `CalendarMonth`/`CalendarMonthGrid` props ([#1192](https://github.com/react-dates/react-dates/pull/1192)) +- [new] Pass nextMonth to `onPrevMonthClick`/`onNextMonthClick` ([#1207](https://github.com/react-dates/react-dates/pull/1207)) +- [new] Allow input border styles to be overridden in the theme ([#1201](https://github.com/react-dates/react-dates/pull/1201)) +- [new] Allow consolidated-events@2.0.0 ([#1218](https://github.com/react-dates/react-dates/pull/1218)) +- [fix] Remove input.blur() call to fix a focus trap in Safari/IE ([#1214](https://github.com/react-dates/react-dates/pull/1214)) +- [new] Add support for month/year transitions ([#1106](https://github.com/react-dates/react-dates/pull/1106)) +- [breaking] Rename renderMonth=>renderMonthText and renderCaption=>renderMonthElement ([#1220](https://github.com/react-dates/react-dates/pull/1220)) +- [breaking] Remove default styling *completely* from the `navNext`/`navPrev` props, including position ([#1204](https://github.com/react-dates/react-dates/pull/1204)) +- [fix] Fix propType warnings for `onMonthChange`/`onYearChange` ([#1222](https://github.com/react-dates/react-dates/pull/1222)) +- [breaking] Remove `OutsideClickHandler` export entirely ([#1225](https://github.com/react-dates/react-dates/pull/1225)) + +## 16.7.1 +- [fix] react-with-styles v3 requires react-with-direction as a peer dep; this provides it but forwards the peer dep requirement ([#1348](https://github.com/react-dates/react-dates/issues/1348)) + +## 16.7.0 +- [fix] Force border-radius to be 0 on the inputs ([#1157](https://github.com/react-dates/react-dates/pull/1157)) +- [fix] Clear previous min nights modifiers, not current ([#994](https://github.com/react-dates/react-dates/pull/994)) +- [fix] Tweak default input styling ([#1158](https://github.com/react-dates/react-dates/pull/1158)) +- [fix] Round transform3d values to fix font blur ([#1155](https://github.com/react-dates/react-dates/pull/1155)) +- [new] Add `noNavButtons` prop ([#1160](https://github.com/react-dates/react-dates/pull/1160)) + +## 16.6.1 +- [fix] Fix selective disabling of the `DateRangePicker` ([#1116](https://github.com/react-dates/react-dates/pull/1116)) +- [fix] Fix `onOutsideClick` refactor ([#1115](https://github.com/react-dates/react-dates/pull/1115)) + +## 16.6.0 +- [new] Add `appendToBody`/`disableScroll` props ([#1069](https://github.com/react-dates/react-dates/pull/1069)) +- [fix] Address unexpected blur call ([#1107](https://github.com/react-dates/react-dates/pull/1107)) +- [new] Add `verticalBorderSpacing` prop to `DayPickerSingleDateController`/`DayPickerRangeController` ([#1096](https://github.com/react-dates/react-dates/pull/1096)) +- [fix] Move focus to `DayPicker` when readOnly is true ([#961](https://github.com/react-dates/react-dates/pull/961)) + +## 16.5.0 +- [new] Export `CustomizeableCalendarDay` default styles ([#1095](https://github.com/react-dates/react-dates/pull/1095)) +- [new] Allow selectively disabling either input in the DRP ([#](https://github.com/react-dates/react-dates/pull/606)) +- [new] Add `dayAriaLabelFormat` prop to the SDP/DRP ([#](https://github.com/react-dates/react-dates/pull/984)) + +## 16.4.0 +- [new] Export `OutsideClickHandler` in index.js ([#1089](https://github.com/react-dates/react-dates/pull/1089)) +- [fix] Do not apply `verticalSpacing` when `withPortal` or `withFullScreenPortal` is true ([#980](https://github.com/react-dates/react-dates/pull/980)) +- [fix] Handle minimum nights when selecting `startDate` ([#1015](https://github.com/react-dates/react-dates/pull/1015)) +- [fix] Fix style of `CloseIcon` in the SDP ([#1058](https://github.com/react-dates/react-dates/pull/1058)) + +## 16.3.6 +- [fix] Address width issues for vertical DayPickers ([#1055](https://github.com/react-dates/react-dates/pull/1055)) + +## 16.3.5 (I promise this one is good) +- [fix] Includes all necessary CSS ([c965348](https://github.com/react-dates/react-dates/commit/c96534896d8fe5c28ddc1f1090ef43dfaeebb5d6)) + +## 16.3.4 +- [fix] Bumps all the RWS libraries again, now with less breakage! + +## 16.3.3 +- [revert] Reverts 'Bump react-with-style-interface-css dependency ([#1043](https://github.com/react-dates/react-dates/pull/1043))' + +## 16.3.2 +- [revert] Reverts 'Bump react-with-styles dependency ([#1041](https://github.com/react-dates/react-dates/pull/1041))' + +## 16.3.1 +- [deps] Bump react-with-styles dependency ([#1041](https://github.com/react-dates/react-dates/pull/1041)) +- [deps] Bump react-with-style-interface-css dependency ([#1043](https://github.com/react-dates/react-dates/pull/1043)) + +## 16.3.0 +- [new] customInfoPanel position prop ([#989](https://github.com/react-dates/react-dates/pull/989)) +- [fix] Fix CustomizableCalendarDay selected/selected-start/selected-end specificity issues ([#979](https://github.com/react-dates/react-dates/pull/979)) +- [fix] Add modifiers for `firstDayOfWeek` and `lastDayOfWeek` ([#988](https://github.com/react-dates/react-dates/pull/988)) +- [fix] Ensure callbacks only trigger after state has been updates ([#990](https://github.com/react-dates/react-dates/pull/990)) + +## 16.2.1 +- [fix] SDP `block` styling also makes the input full width ([#972](https://github.com/react-dates/react-dates/pull/972)) + +## 16.2.0 +- [new] Add `startDateOffset`/`endDateOffset` props to `DayPickerRangeController` ([#884](https://github.com/react-dates/react-dates/pull/884)) +- [fix] Make all styles inline in `CustomizableCalendarDay` ([#964](https://github.com/react-dates/react-dates/pull/964)) + +## 16.1.1 +- [fix] Address some small bugs with `CustomizableCalendarDay` ([#962](https://github.com/react-dates/react-dates/pull/962)) + +## 16.1.0 +- [fix] Allow for changing of the input value via highlight and replace ([#955](https://github.com/react-dates/react-dates/pull/955)) +- [fix] Fix OPEN_UP styling ([#925](https://github.com/react-dates/react-dates/pull/925)) +- [fix] Don't read invisible months to the screen reader ([#940](https://github.com/react-dates/react-dates/pull/940)) +- [new] Add phrase for aria-label for the selected day ([#905](https://github.com/react-dates/react-dates/pull/905)) + +## 16.0.2 +- [fix] Fix keyboard navigation issues ([#916](https://github.com/react-dates/react-dates/pull/916)) +- [fix] Fix React warnings when events are referenced later ([#682](https://github.com/react-dates/react-dates/pull/682)) + +## 16.0.1 +- [fix] Add back missing onKeyDown method to `CalendarDay` ([#901](https://github.com/react-dates/react-dates/pull/901)) + +## 16.0.0 +- [breaking] Simplify `CalendarDay` component ([#894](https://github.com/react-dates/react-dates/pull/894)) +- [breaking] rename `renderDay` prop to `renderDayContents` ([#894](https://github.com/react-dates/react-dates/pull/894)) +- [new] Add `renderCalendarDay` component to allow for easy one-off customization of `CalendarDay` ([#894](https://github.com/react-dates/react-dates/pull/894)) + +## 15.5.2 +- revert [#866](https://github.com/react-dates/react-dates/pull/866); it turned out to be semver-major + +## 15.5.1 +- [fix] Adjust `small` variant height to be 36px ([#892](https://github.com/react-dates/react-dates/pull/892)) + +## 15.5.0 +- [new] Add `small` variant ([#891](https://github.com/react-dates/react-dates/pull/891)) + +## 15.4.0 +- [fix] Set font sizes according to theme variable ([#885](https://github.com/react-dates/react-dates/pull/885)) +- [new] Add `verticalSpacing` prop ([#883](https://github.com/react-dates/react-dates/pull/883)) + +## 15.3.0 +- [new] Add `transitionDuration` prop ([#865](https://github.com/react-dates/react-dates/pull/865)) +- [fix] Remove default prop values for required startDateId and endDateId props ([#866](https://github.com/react-dates/react-dates/pull/866)) +- [new] Add `block` styling prop ([#871](https://github.com/react-dates/react-dates/pull/871)) +- [new] Add `noBorder` prop to `DayPicker` variations ([#869](https://github.com/react-dates/react-dates/pull/869)) +- [new] Add `noBorder` prop to inputs ([#870](https://github.com/react-dates/react-dates/pull/870)) +- [fix] Remove unused width style in `KeyboardShortcutsRow` ([#867](https://github.com/react-dates/react-dates/pull/867)) + +## 15.2.1 +- [fix] Republish `_datepicker.css` + +## 15.2.0 +- [new] Add back today modifier (and class) ([#861](https://github.com/react-dates/react-dates/pull/861)) +- [new] Add ariaLabelFormat prop to CalendarDay ([#842](https://github.com/react-dates/react-dates/pull/842), [#857](https://github.com/react-dates/react-dates/pull/857)) +- [fix] Reset `after-hover-start` in `componentWillReceiveProps` instead of only on click ([#843](https://github.com/react-dates/react-dates/pull/843)) +- [fix] Use `color.background` variable instead of hardcoded #fff for theming ([#852](https://github.com/react-dates/react-dates/pull/852)) +- [fix] Update CalendarMonthGrid months based on locale change ([#795](https://github.com/react-dates/react-dates/pull/795)) + ## 15.1.0 -- [fix] Add explicit border-radius on KeyboardShortcuts button ([#792](https://github.com/airbnb/react-dates/pull/792)) -- [fix] Pass onClose from SingleDatePicker to DayPickerSingleDateController ([#816](https://github.com/airbnb/react-dates/pull/816)) -- [new] Pass modifiers to `renderDay` as second arg ([#829](https://github.com/airbnb/react-dates/pull/829)) -- [fix] Fix KeyboardShortcutsPanel focus issues ([#825](https://github.com/airbnb/react-dates/pull/825)) +- [fix] Add explicit border-radius on KeyboardShortcuts button ([#792](https://github.com/react-dates/react-dates/pull/792)) +- [fix] Pass onClose from SingleDatePicker to DayPickerSingleDateController ([#816](https://github.com/react-dates/react-dates/pull/816)) +- [new] Pass modifiers to `renderDay` as second arg ([#829](https://github.com/react-dates/react-dates/pull/829)) +- [fix] Fix KeyboardShortcutsPanel focus issues ([#825](https://github.com/react-dates/react-dates/pull/825)) ## 15.0.0 -- [breaking] Rename SDP keydown callback props so that they match the DRP ([#800](https://github.com/airbnb/react-dates/pull/800)) -- [fix] Explicitly set the border-radius on the keyboard shortcuts button ([#792](https://github.com/airbnb/react-dates/pull/792)) +- [breaking] Rename SDP keydown callback props so that they match the DRP ([#800](https://github.com/react-dates/react-dates/pull/800)) +- [fix] Explicitly set the border-radius on the keyboard shortcuts button ([#792](https://github.com/react-dates/react-dates/pull/792)) ## 14.1.0 -- [new] Add esm build ([#791](https://github.com/airbnb/react-dates/pull/791)) -- [new] Add back `selected-start`/`selected-end` modifiers to `CalendarDay` ([#796](https://github.com/airbnb/react-dates/pull/796)) +- [new] Add esm build ([#791](https://github.com/react-dates/react-dates/pull/791)) +- [new] Add back `selected-start`/`selected-end` modifiers to `CalendarDay` ([#796](https://github.com/react-dates/react-dates/pull/796)) ## 14.0.0 -- [fix] Flip arrow navigation in RTL context ([#775](https://github.com/airbnb/react-dates/pull/775)) -- [new] Add `verticalHeight` prop to SDP, DRP and DayPicker ([#773](https://github.com/airbnb/react-dates/pull/773)) -- [breaking] Modify default `DateInput` styling, convert inputs to actual inputs, and remove caption ids ([#780](https://github.com/airbnb/react-dates/pull/780)) +- [fix] Flip arrow navigation in RTL context ([#775](https://github.com/react-dates/react-dates/pull/775)) +- [new] Add `verticalHeight` prop to SDP, DRP and DayPicker ([#773](https://github.com/react-dates/react-dates/pull/773)) +- [breaking] Modify default `DateInput` styling, convert inputs to actual inputs, and remove caption ids ([#780](https://github.com/react-dates/react-dates/pull/780)) ## 13.0.6 -- [fix] Update `react-with-styles-interface-css` dependency ([#777](https://github.com/airbnb/react-dates/pull/777)) +- [fix] Update `react-with-styles-interface-css` dependency ([#777](https://github.com/react-dates/react-dates/pull/777)) ## 13.0.5 - [fix] Add back missing built CSS file ## 13.0.4 -- [fix] Pass through `customCloseIcon` prop from the SDP to the SDPInput ([#767](https://github.com/airbnb/react-dates/pull/767)) -- [fix] Fix incorrect available/unavailable phrase being read on `CalendarDay` components ([#771](https://github.com/airbnb/react-dates/pull/771)) +- [fix] Pass through `customCloseIcon` prop from the SDP to the SDPInput ([#767](https://github.com/react-dates/react-dates/pull/767)) +- [fix] Fix incorrect available/unavailable phrase being read on `CalendarDay` components ([#771](https://github.com/react-dates/react-dates/pull/771)) ## 13.0.3 -- [fix] Change CSS style specificity to 0 for the default stylesheet ([#753](https://github.com/airbnb/react-dates/pull/753)) -- [fix] Remove unnecessary caption object from `CalendarMonth` styles ([#757](https://github.com/airbnb/react-dates/pull/757)) +- [fix] Change CSS style specificity to 0 for the default stylesheet ([#753](https://github.com/react-dates/react-dates/pull/753)) +- [fix] Remove unnecessary caption object from `CalendarMonth` styles ([#757](https://github.com/react-dates/react-dates/pull/757)) ## 13.0.2 - [fix] Use default export of `registerCSSInterfaceWithDefaultTheme` in `initialize` ## 13.0.1 -- [fix] Move caption div back outside of `CalendarMonth` table ([#748](https://github.com/airbnb/react-dates/pull/748)) +- [fix] Move caption div back outside of `CalendarMonth` table ([#748](https://github.com/react-dates/react-dates/pull/748)) ## 13.0.0 -- [breaking] Convert react-dates to rely on `react-with-styles` in place of CSS stylesheets ([#722](https://github.com/airbnb/react-dates/pull/722)) +- [breaking] Convert react-dates to rely on `react-with-styles` in place of CSS stylesheets ([#722](https://github.com/react-dates/react-dates/pull/722)) ## 12.7.1 -- [fix] set explicit border radius on shortcuts button ([#792](https://github.com/airbnb/react-dates/pull/792)) +- [fix] set explicit border radius on shortcuts button ([#792](https://github.com/react-dates/react-dates/pull/792)) ## 12.7.0 -- [new] Some accessibility improvements and patches ([#715](https://github.com/airbnb/react-dates/pull/715)) +- [new] Some accessibility improvements and patches ([#715](https://github.com/react-dates/react-dates/pull/715)) ## 12.6.0 -- [new] Add `weekDayFormat` prop to SDP/DRP ([#650](https://github.com/airbnb/react-dates/pull/650)) -- [new] Add `openDirection` prop to SDP/DRP ([#653](https://github.com/airbnb/react-dates/pull/653)) -- [fix] Reset visibleDays/currentMonth state when `enableOutsideDays` or `numberOfMonths` has changed ([#702](https://github.com/airbnb/react-dates/pull/702)) -- [new] Add $react-dates-color-primary-dark CSS variable ([#704](https://github.com/airbnb/react-dates/pull/704)) +- [new] Add `weekDayFormat` prop to SDP/DRP ([#650](https://github.com/react-dates/react-dates/pull/650)) +- [new] Add `openDirection` prop to SDP/DRP ([#653](https://github.com/react-dates/react-dates/pull/653)) +- [fix] Reset visibleDays/currentMonth state when `enableOutsideDays` or `numberOfMonths` has changed ([#702](https://github.com/react-dates/react-dates/pull/702)) +- [new] Add $react-dates-color-primary-dark CSS variable ([#704](https://github.com/react-dates/react-dates/pull/704)) ## 12.5.1 -- [fix] Ensure `this.childNode` exists in the `OutsideClickHandler` ([e330839](https://github.com/airbnb/react-dates/commit/e3308395212bef07d1f3c05f413cac3dd245ac98)) -- [fix] Remove `minimumNights` prop from the `DayPickerSingleDateController` ([#686](https://github.com/airbnb/react-dates/pull/686)) +- [fix] Ensure `this.childNode` exists in the `OutsideClickHandler` ([e330839](https://github.com/react-dates/react-dates/commit/e3308395212bef07d1f3c05f413cac3dd245ac98)) +- [fix] Remove `minimumNights` prop from the `DayPickerSingleDateController` ([#686](https://github.com/react-dates/react-dates/pull/686)) ## 12.5.0 -- [fix] Fix `onOutsideClick` prop functionality in the SDP ([#666](https://github.com/airbnb/react-dates/pull/666)) -- [new] Add `inputIconPosition` prop ([#627](https://github.com/airbnb/react-dates/pull/627)) -- [fix] Adjust DayPicker styles when portal status is set ([#659](https://github.com/airbnb/react-dates/pull/659)) +- [fix] Fix `onOutsideClick` prop functionality in the SDP ([#666](https://github.com/react-dates/react-dates/pull/666)) +- [new] Add `inputIconPosition` prop ([#627](https://github.com/react-dates/react-dates/pull/627)) +- [fix] Adjust DayPicker styles when portal status is set ([#659](https://github.com/react-dates/react-dates/pull/659)) ## 12.4.0 -- [fix] Pass `onPrevMonthClick`/`onNextMonthClick` props through the SDP ([#657](https://github.com/airbnb/react-dates/pull/657)) -- [fix] Recalculate modifiers when prop modifiers change ([#668](https://github.com/airbnb/react-dates/pull/668)) -- [new] Pass back month as argument to `onPrevMonthClick`/`onNextMonthClick` props ([#667](https://github.com/airbnb/react-dates/pull/667)) +- [fix] Pass `onPrevMonthClick`/`onNextMonthClick` props through the SDP ([#657](https://github.com/react-dates/react-dates/pull/657)) +- [fix] Recalculate modifiers when prop modifiers change ([#668](https://github.com/react-dates/react-dates/pull/668)) +- [new] Pass back month as argument to `onPrevMonthClick`/`onNextMonthClick` props ([#667](https://github.com/react-dates/react-dates/pull/667)) ## 12.3.0 -- [fix] Allows users to type in same-day start date and end date when `minimumNights` is 0 ([#555](https://github.com/airbnb/react-dates/pull/555)) -- [new] Add `firstDayOfWeek` prop ([#371](https://github.com/airbnb/react-dates/pull/371)) -- [fix] Add back `phrases` support for `SingleDatePicker` ([#623](https://github.com/airbnb/react-dates/pull/623)) +- [fix] Allows users to type in same-day start date and end date when `minimumNights` is 0 ([#555](https://github.com/react-dates/react-dates/pull/555)) +- [new] Add `firstDayOfWeek` prop ([#371](https://github.com/react-dates/react-dates/pull/371)) +- [fix] Add back `phrases` support for `SingleDatePicker` ([#623](https://github.com/react-dates/react-dates/pull/623)) ## 12.2.4 -- [fix] Fix `initialVisibleMonth` error in the `DayPickerRangeController` component ([#617](https://github.com/airbnb/react-dates/pull/617)) -- [fix] Pass through missing `keepOpenOnDateSelect` prop to the `DayPickerSingleDateController` component ([#620](https://github.com/airbnb/react-dates/pull/620)) +- [fix] Fix `initialVisibleMonth` error in the `DayPickerRangeController` component ([#617](https://github.com/react-dates/react-dates/pull/617)) +- [fix] Pass through missing `keepOpenOnDateSelect` prop to the `DayPickerSingleDateController` component ([#620](https://github.com/react-dates/react-dates/pull/620)) ## 12.2.3 -- [fix] Export `DayPickerSingleDateController` in index.js ([#609](https://github.com/airbnb/react-dates/pull/609)) +- [fix] Export `DayPickerSingleDateController` in index.js ([#609](https://github.com/react-dates/react-dates/pull/609)) ## 12.2.2 -- [fix] Reevaluate `--blocked` and `--blocked-outside-range` modifiers in the SDP componentWilLReceiveProps ([#550](https://github.com/airbnb/react-dates/pull/550)) +- [fix] Reevaluate `--blocked` and `--blocked-outside-range` modifiers in the SDP componentWilLReceiveProps ([#550](https://github.com/react-dates/react-dates/pull/550)) ## 12.2.1 -- [fix] Fix `isTouchDevice` warning in `DayPickerSingleDateController` ([77e2135](https://github.com/airbnb/react-dates/commit/77e2135d2009994fbf2c62e3ff68ce82e5786194)) +- [fix] Fix `isTouchDevice` warning in `DayPickerSingleDateController` ([77e2135](https://github.com/react-dates/react-dates/commit/77e2135d2009994fbf2c62e3ff68ce82e5786194)) ## v12.2.0 -- [fix] Deprecate `isTouchDevice` in favor of `is-touch-device` ([#576](https://github.com/airbnb/react-dates/pull/576)) -- [fix] Disable calendar icon when component is disabled ([#591](https://github.com/airbnb/react-dates/pull/591)) -- [fix] Fix issue where range does not clear on invisible months ([#575](https://github.com/airbnb/react-dates/pull/575)) -- [new] Add `DayPickerSingleDateController` component ([#573](https://github.com/airbnb/react-dates/pull/573)) +- [fix] Deprecate `isTouchDevice` in favor of `is-touch-device` ([#576](https://github.com/react-dates/react-dates/pull/576)) +- [fix] Disable calendar icon when component is disabled ([#591](https://github.com/react-dates/react-dates/pull/591)) +- [fix] Fix issue where range does not clear on invisible months ([#575](https://github.com/react-dates/react-dates/pull/575)) +- [new] Add `DayPickerSingleDateController` component ([#573](https://github.com/react-dates/react-dates/pull/573)) ## v12.1.2 -- [fix] Add null check for calendarMonthGrid ref ([#552](https://github.com/airbnb/react-dates/pull/552)) +- [fix] Add null check for calendarMonthGrid ref ([#552](https://github.com/react-dates/react-dates/pull/552)) ## v12.1.1 -- [fix] Remove `--hovered-span` modifier when selecting a new end date ([#523](https://github.com/airbnb/react-dates/pull/523)) -- [fix] Improve `isTouchDevice` detection logic ([#516](https://github.com/airbnb/react-dates/pull/516)) -- [fix] Recompute `--blocked` and `--blocked-outside-range` when `focusedInput` changes ([#522](https://github.com/airbnb/react-dates/pull/522)) +- [fix] Remove `--hovered-span` modifier when selecting a new end date ([#523](https://github.com/react-dates/react-dates/pull/523)) +- [fix] Improve `isTouchDevice` detection logic ([#516](https://github.com/react-dates/react-dates/pull/516)) +- [fix] Recompute `--blocked` and `--blocked-outside-range` when `focusedInput` changes ([#522](https://github.com/react-dates/react-dates/pull/522)) ## v12.1.0 -- [new] Add `showDefaultInputIcon` and `customInputIcon` props to SDP ([#513](https://github.com/airbnb/react-dates/pull/513)) +- [new] Add `showDefaultInputIcon` and `customInputIcon` props to SDP ([#513](https://github.com/react-dates/react-dates/pull/513)) ## v12.0.0 -- [breaking] Updates moment peer dependency to ^2.18.1 ([#505](https://github.com/airbnb/react-dates/pull/505)) +- [breaking] Updates moment peer dependency to ^2.18.1 ([#505](https://github.com/react-dates/react-dates/pull/505)) ## v11.1.0 -- [fix] Patch issues with vertical scrollable datepickers, after-hovered-start and month transitions ([#503](https://github.com/airbnb/react-dates/pull/503)) -- [new] Adds a `readOnly` prop on the DRP and SDP ([#501](https://github.com/airbnb/react-dates/pull/501)) -- [fix] Disable hover when `focusedInput` is falsey ([#483](https://github.com/airbnb/react-dates/pull/483)) +- [fix] Patch issues with vertical scrollable datepickers, after-hovered-start and month transitions ([#503](https://github.com/react-dates/react-dates/pull/503)) +- [new] Adds a `readOnly` prop on the DRP and SDP ([#501](https://github.com/react-dates/react-dates/pull/501)) +- [fix] Disable hover when `focusedInput` is falsy ([#483](https://github.com/react-dates/react-dates/pull/483)) ## v11.0.1 -- [fix] Fixes small modifier issues in the DRP after rearchitecture ([#489](https://github.com/airbnb/react-dates/pull/489)) +- [fix] Fixes small modifier issues in the DRP after rearchitecture ([#489](https://github.com/react-dates/react-dates/pull/489)) ## v11.0.0 -- [breaking] Dramatic rearchitecture of modifiers with the goal of improved performance ([#450](https://github.com/airbnb/react-dates/pull/450)) +- [breaking] Dramatic rearchitecture of modifiers with the goal of improved performance ([#450](https://github.com/react-dates/react-dates/pull/450)) ## v10.2.0 -- [new] Add RTL support to the DRP and the SDP with the `isRTL` prop ([#454](https://github.com/airbnb/react-dates/pull/454)) -- [new] Add `renderMonth` prop to DRP and SDP([#449](https://github.com/airbnb/react-dates/pull/449)) +- [new] Add RTL support to the DRP and the SDP with the `isRTL` prop ([#454](https://github.com/react-dates/react-dates/pull/454)) +- [new] Add `renderMonth` prop to DRP and SDP([#449](https://github.com/react-dates/react-dates/pull/449)) ## v10.1.3 - [Fix] OutsideClickHandler: ensure this.childNode exists (#437) ## v10.1.2 -- [fix] Remove unused scss variables ([#475](https://github.com/airbnb/react-dates/pull/475)) -- [fix] Address some issues introduced by the accessibility PR in v10.0.0 ([#477](https://github.com/airbnb/react-dates/pull/477)) -- [fix] Only update phrase object in the DRP when necessary ([#448](https://github.com/airbnb/react-dates/pull/448)) +- [fix] Remove unused scss variables ([#475](https://github.com/react-dates/react-dates/pull/475)) +- [fix] Address some issues introduced by the accessibility PR in v10.0.0 ([#477](https://github.com/react-dates/react-dates/pull/477)) +- [fix] Only update phrase object in the DRP when necessary ([#448](https://github.com/react-dates/react-dates/pull/448)) ## v10.1.1 - [fix] Remove unnecessary `onClose` instances on the `SDPInput` and `DateInput` components ## v10.1.0 -- [new] Add `onClose` callback ([#397](https://github.com/airbnb/react-dates/pull/397)) +- [new] Add `onClose` callback ([#397](https://github.com/react-dates/react-dates/pull/397)) ## v10.0.1 -- [fix] Fix a few nits as a result of the accessibility PR ([#429](https://github.com/airbnb/react-dates/pull/429)) +- [fix] Fix a few nits as a result of the accessibility PR ([#429](https://github.com/react-dates/react-dates/pull/429)) ## v10.0.0 -- [breaking] Add keyboard accessibility to react-dates ([#301](https://github.com/airbnb/react-dates/pull/301)) +- [breaking] Add keyboard accessibility to react-dates ([#301](https://github.com/react-dates/react-dates/pull/301)) ## v9.0.1 -- [fix] Fixes `withPortal` implementation in Firefox ([#421](https://github.com/airbnb/react-dates/pull/421)) +- [fix] Fixes `withPortal` implementation in Firefox ([#421](https://github.com/react-dates/react-dates/pull/421)) ## v9.0.0 -- [fix] Only send down relevant modifiers down the tree ([#412](https://github.com/airbnb/react-dates/pull/412)) -- [fix] Optimise `isSameDay` method ([#415](https://github.com/airbnb/react-dates/pull/415)) -- [fix] Blur input for portal implementations (and on touch devices) ([#410](https://github.com/airbnb/react-dates/pull/410)) -- [breaking] Add `daySize` prop to scale the pickers properly ([#406](https://github.com/airbnb/react-dates/pull/406)) +- [fix] Only send down relevant modifiers down the tree ([#412](https://github.com/react-dates/react-dates/pull/412)) +- [fix] Optimise `isSameDay` method ([#415](https://github.com/react-dates/react-dates/pull/415)) +- [fix] Blur input for portal implementations (and on touch devices) ([#410](https://github.com/react-dates/react-dates/pull/410)) +- [breaking] Add `daySize` prop to scale the pickers properly ([#406](https://github.com/react-dates/react-dates/pull/406)) ## v8.2.1 -- [fix] Add `needsclick` to inputs to disable fastclick ([#377](https://github.com/airbnb/react-dates/pull/377)) -- [deps] Update `style-loader`, `sinon`, `babel-loader`, `coveralls`, and `karma-webpack` ([#379](https://github.com/airbnb/react-dates/pull/379), [#372](https://github.com/airbnb/react-dates/pull/372), [#373](https://github.com/airbnb/react-dates/pull/373)) +- [fix] Add `needsclick` to inputs to disable fastclick ([#377](https://github.com/react-dates/react-dates/pull/377)) +- [deps] Update `style-loader`, `sinon`, `babel-loader`, `coveralls`, and `karma-webpack` ([#379](https://github.com/react-dates/react-dates/pull/379), [#372](https://github.com/react-dates/react-dates/pull/372), [#373](https://github.com/react-dates/react-dates/pull/373)) ## v8.2.0 -- [new] Add `renderCalendarInfo` prop to DRP and SDP ([#341](https://github.com/airbnb/react-dates/pull/341)) +- [new] Add `renderCalendarInfo` prop to DRP and SDP ([#341](https://github.com/react-dates/react-dates/pull/341)) ## v8.1.1 -- [fix] Add missing `customCloseIcon` propType declarations ([#367](https://github.com/airbnb/react-dates/pull/367)) +- [fix] Add missing `customCloseIcon` propType declarations ([#367](https://github.com/react-dates/react-dates/pull/367)) ## v8.1.0 -- [new] Add `customCloseIcon` prop ([#356](https://github.com/airbnb/react-dates/pull/356)) +- [new] Add `customCloseIcon` prop ([#356](https://github.com/react-dates/react-dates/pull/356)) ## v8.0.0 -- [fix] Remove `$react-dates-width-day-picker` variable from `CalendarMonthGrid.scss`, allowing overrides ([#352](https://github.com/airbnb/react-dates/pull/352)) -- [new] Create `defaultPhrases` file for i18n ([#351](https://github.com/airbnb/react-dates/pull/351)) -- [fix] Set `isTouchDevice` on `componentDidMount` ([#336](https://github.com/airbnb/react-dates/pull/336)) -- [fix] Change `CalendarMonthGrid` background to use `$react-dates-color-white` ([#342](https://github.com/airbnb/react-dates/pull/342)) -- [breaking] Make `onFocusChange` and `onDate(s)Change` props required and `forbidExtraProps` on all components ([#332](https://github.com/airbnb/react-dates/pull/332)) -- [fix] Fix caption alignment when using bootstrap ([#323](https://github.com/airbnb/react-dates/pull/323)) +- [fix] Remove `$react-dates-width-day-picker` variable from `CalendarMonthGrid.scss`, allowing overrides ([#352](https://github.com/react-dates/react-dates/pull/352)) +- [new] Create `defaultPhrases` file for i18n ([#351](https://github.com/react-dates/react-dates/pull/351)) +- [fix] Set `isTouchDevice` on `componentDidMount` ([#336](https://github.com/react-dates/react-dates/pull/336)) +- [fix] Change `CalendarMonthGrid` background to use `$react-dates-color-white` ([#342](https://github.com/react-dates/react-dates/pull/342)) +- [breaking] Make `onFocusChange` and `onDate(s)Change` props required and `forbidExtraProps` on all components ([#332](https://github.com/react-dates/react-dates/pull/332)) +- [fix] Fix caption alignment when using bootstrap ([#323](https://github.com/react-dates/react-dates/pull/323)) ## v7.0.1 -- [fix] Fix minimum nights issues for startDates/endDates with time ([#310](https://github.com/airbnb/react-dates/pull/310)) +- [fix] Fix minimum nights issues for startDates/endDates with time ([#310](https://github.com/react-dates/react-dates/pull/310)) ## v7.0.0 -- [breaking] Simplify `CalendarDay` DOM ([#291](https://github.com/airbnb/react-dates/pull/291)) +- [breaking] Simplify `CalendarDay` DOM ([#291](https://github.com/react-dates/react-dates/pull/291)) ## v6.1.0 -- [fix] Revert "Simplify `CalendarDay` DOM ([#291](https://github.com/airbnb/react-dates/pull/291))" -- [new] Add `renderDay` prop to customize the content of the `CalendarDay` component ([#307](https://github.com/airbnb/react-dates/pull/307)) +- [fix] Revert "Simplify `CalendarDay` DOM ([#291](https://github.com/react-dates/react-dates/pull/291))" +- [new] Add `renderDay` prop to customize the content of the `CalendarDay` component ([#307](https://github.com/react-dates/react-dates/pull/307)) ## v6.0.2 -- [fix] Fix `day` prop type warning to `CalendarDay` ([#305](https://github.com/airbnb/react-dates/pull/305)) -- [fix] Remove blinking cursor in iOS ([#304](https://github.com/airbnb/react-dates/pull/304)) -- [fix] Do not render `DayPicker` when not visible ([#286](https://github.com/airbnb/react-dates/pull/286)) -- [breaking] Simplify `CalendarDay` DOM ([#291](https://github.com/airbnb/react-dates/pull/291)) +- [fix] Fix `day` prop type warning to `CalendarDay` ([#305](https://github.com/react-dates/react-dates/pull/305)) +- [fix] Remove blinking cursor in iOS ([#304](https://github.com/react-dates/react-dates/pull/304)) +- [fix] Do not render `DayPicker` when not visible ([#286](https://github.com/react-dates/react-dates/pull/286)) +- [breaking] Simplify `CalendarDay` DOM ([#291](https://github.com/react-dates/react-dates/pull/291)) ## v6.0.1 - - [fix] Attached SDP closes on outside click again ([#288](https://github.com/airbnb/react-dates/pull/288)) - - [fix] SDP display value defaults to moment's `L` format again instead of ISO ([#285](https://github.com/airbnb/react-dates/pull/285)) + - [fix] Attached SDP closes on outside click again ([#288](https://github.com/react-dates/react-dates/pull/288)) + - [fix] SDP display value defaults to moment's `L` format again instead of ISO ([#285](https://github.com/react-dates/react-dates/pull/285)) ## v6.0.0 - - [breaking] Remove hidden `label` element in favor of an `aria-label` property ([#280](https://github.com/airbnb/react-dates/pull/280)) - - [new] Add `customArrowIcon` prop ([#277](https://github.com/airbnb/react-dates/pull/277)) - - [breaking] Remove mousedown/mouseup/touchstart/touchend/touchtap handlers in favor of click ([#275](https://github.com/airbnb/react-dates/pull/275)) - - [fix] Fix duplicate months created when increasing `numberOfMonths` and include year in `CalendarMonth` key ([#279](https://github.com/airbnb/react-dates/pull/279)) - - [new] Add `screenReaderInputMessage` to populate the `aria-describedby` attribute on the input ([#266](https://github.com/airbnb/react-dates/pull/266)) + - [breaking] Remove hidden `label` element in favor of an `aria-label` property ([#280](https://github.com/react-dates/react-dates/pull/280)) + - [new] Add `customArrowIcon` prop ([#277](https://github.com/react-dates/react-dates/pull/277)) + - [breaking] Remove mousedown/mouseup/touchstart/touchend/touchtap handlers in favor of click ([#275](https://github.com/react-dates/react-dates/pull/275)) + - [fix] Fix duplicate months created when increasing `numberOfMonths` and include year in `CalendarMonth` key ([#279](https://github.com/react-dates/react-dates/pull/279)) + - [new] Add `screenReaderInputMessage` to populate the `aria-describedby` attribute on the input ([#266](https://github.com/react-dates/react-dates/pull/266)) ## v5.2.0 - - [new] Add `VERTICAL_SCROLLABLE` orientation to the `DayPickerRangeController` and child components ([#250](https://github.com/airbnb/react-dates/pull/250)) + - [new] Add `VERTICAL_SCROLLABLE` orientation to the `DayPickerRangeController` and child components ([#250](https://github.com/react-dates/react-dates/pull/250)) ## v5.1.1 - - [fix] Fix regression where user was no longer able to type into input ([#269](https://github.com/airbnb/react-dates/pull/269)) + - [fix] Fix regression where user was no longer able to type into input ([#269](https://github.com/react-dates/react-dates/pull/269)) ## v5.1.0 - - [new] Add `showDefaultInputIcon` and `customInputIcon` prop to show an icon at the beginning of the input field ([#222](https://github.com/airbnb/react-dates/pull/222)) + - [new] Add `showDefaultInputIcon` and `customInputIcon` prop to show an icon at the beginning of the input field ([#222](https://github.com/react-dates/react-dates/pull/222)) ## v5.0.0 - - [breaking] Update input value to use ISO format instead of the display format ([#229](https://github.com/airbnb/react-dates/pull/229)) - - [breaking] Performance improvements, including the removal of the modifiers prop from `CalendarDay` ([#217](https://github.com/airbnb/react-dates/pull/217)) + - [breaking] Update input value to use ISO format instead of the display format ([#229](https://github.com/react-dates/react-dates/pull/229)) + - [breaking] Performance improvements, including the removal of the modifiers prop from `CalendarDay` ([#217](https://github.com/react-dates/react-dates/pull/217)) ## v4.3.3 - - [fix] Force DayPicker and CalendarMonthGrid alignment to the left ([#257](https://github.com/airbnb/react-dates/pull/257),[#258](https://github.com/airbnb/react-dates/pull/258)) + - [fix] Force DayPicker and CalendarMonthGrid alignment to the left ([#257](https://github.com/react-dates/react-dates/pull/257),[#258](https://github.com/react-dates/react-dates/pull/258)) ## v4.3.2 - - [fix] Finish refactor from 471bd602302f4dfe4f1e66b79d50b22f7511d8ba ([#233](https://github.com/airbnb/react-dates/pull/233)) + - [fix] Finish refactor from 471bd602302f4dfe4f1e66b79d50b22f7511d8ba ([#233](https://github.com/react-dates/react-dates/pull/233)) ## v4.3.1 (unpublished) - - [fix] Don’t create an unnecessary array from a NodeList, which avoids needing `Array.from` ([#233](https://github.com/airbnb/react-dates/pull/233)) + - [fix] Don’t create an unnecessary array from a NodeList, which avoids needing `Array.from` ([#233](https://github.com/react-dates/react-dates/pull/233)) ## v4.3.0 - - [new] Add today modifier to the `SingleDatePicker` component ([#218](https://github.com/airbnb/react-dates/pull/218)) - - [fix] Fix week header alignment when `numberOfMonths` is greater than 2 ([#221](https://github.com/airbnb/react-dates/pull/221)) - - [fix] Fix `transition`/`transform` prefixing on `.CalendarMonthGrid--animating` class ([#220](https://github.com/airbnb/react-dates/pull/220)) - - [fix] Do not allow `pointer-events` on invisible first month ([#227](https://github.com/airbnb/react-dates/pull/227)) - - [fix] Remove `maxLength` attribute from inputs ([#219](https://github.com/airbnb/react-dates/pull/219)) + - [new] Add today modifier to the `SingleDatePicker` component ([#218](https://github.com/react-dates/react-dates/pull/218)) + - [fix] Fix week header alignment when `numberOfMonths` is greater than 2 ([#221](https://github.com/react-dates/react-dates/pull/221)) + - [fix] Fix `transition`/`transform` prefixing on `.CalendarMonthGrid--animating` class ([#220](https://github.com/react-dates/react-dates/pull/220)) + - [fix] Do not allow `pointer-events` on invisible first month ([#227](https://github.com/react-dates/react-dates/pull/227)) + - [fix] Remove `maxLength` attribute from inputs ([#219](https://github.com/react-dates/react-dates/pull/219)) ## v4.2.0 - - [new] Add `isDayHighlighted` prop to the DRP/SDP which applies a `highlighted-calendar` to the relevant days ([#206](https://github.com/airbnb/react-dates/pull/206)) - - [new] Add `today` modifier to the `DayPickerRangeController` component ([#213](https://github.com/airbnb/react-dates/pull/213)) + - [new] Add `isDayHighlighted` prop to the DRP/SDP which applies a `highlighted-calendar` to the relevant days ([#206](https://github.com/react-dates/react-dates/pull/206)) + - [new] Add `today` modifier to the `DayPickerRangeController` component ([#213](https://github.com/react-dates/react-dates/pull/213)) ## v4.1.2 - - [fix] `DayPicker` now has initial width set, even before any other interaction ([#215](https://github.com/airbnb/react-dates/pull/215)) + - [fix] `DayPicker` now has initial width set, even before any other interaction ([#215](https://github.com/react-dates/react-dates/pull/215)) ## v4.1.1 - - [fix] Fix issue where the DayPicker height and width were not always being set initially ([#196](https://github.com/airbnb/react-dates/pull/196)) - - [fix] Fix closed DRP/SDP refocus issue on window blur and refocus ([#212](https://github.com/airbnb/react-dates/pull/212)) + - [fix] Fix issue where the DayPicker height and width were not always being set initially ([#196](https://github.com/react-dates/react-dates/pull/196)) + - [fix] Fix closed DRP/SDP refocus issue on window blur and refocus ([#212](https://github.com/react-dates/react-dates/pull/212)) ## v4.1.0 - - [new] Separate out date range input event handling logic into the `DateRangePickerInputController` component ([#180](https://github.com/airbnb/react-dates/pull/180)) - - [fix] Only responsivize the DRP and SDP when `withPortal` and `withFullScreenPortal` options are false ([#183](https://github.com/airbnb/react-dates/pull/183)) - - [new] Separate out date range calendar event handling logic and styles into the `DayPickerRangeController` component ([#167](https://github.com/airbnb/react-dates/pull/167)) + - [new] Separate out date range input event handling logic into the `DateRangePickerInputController` component ([#180](https://github.com/react-dates/react-dates/pull/180)) + - [fix] Only responsivize the DRP and SDP when `withPortal` and `withFullScreenPortal` options are false ([#183](https://github.com/react-dates/react-dates/pull/183)) + - [new] Separate out date range calendar event handling logic and styles into the `DayPickerRangeController` component ([#167](https://github.com/react-dates/react-dates/pull/167)) ## v4.0.2 - - [patch] Revert [#176](https://github.com/airbnb/react-dates/pull/176) ([#189](https://github.com/airbnb/react-dates/pull/189)) + - [patch] Revert [#176](https://github.com/react-dates/react-dates/pull/176) ([#189](https://github.com/react-dates/react-dates/pull/189)) ## v4.0.1 - - [patch] `initialVisibleMonth` prop will now be called every time the `DayPicker` is opened ([#176](https://github.com/airbnb/react-dates/pull/176)) - - [patch] Use the `readOnly` prop on inputs instead of the `disabled` prop on touch devices ([#174](https://github.com/airbnb/react-dates/pull/174)) + - [patch] `initialVisibleMonth` prop will now be called every time the `DayPicker` is opened ([#176](https://github.com/react-dates/react-dates/pull/176)) + - [patch] Use the `readOnly` prop on inputs instead of the `disabled` prop on touch devices ([#174](https://github.com/react-dates/react-dates/pull/174)) ## v4.0.0 - - [breaking] Cut the tether dependency from react-dates ([#163](https://github.com/airbnb/react-dates/pull/163)) + - [breaking] Cut the tether dependency from react-dates ([#163](https://github.com/react-dates/react-dates/pull/163)) ## v3.6.0 - - [new] Add `navPrev`/`navNext` props for custom month navigation ([#161](https://github.com/airbnb/react-dates/pull/161)) - - [fix] Add missing right border on caret ([#160](https://github.com/airbnb/react-dates/pull/160)) - - [fix] Adjust `DayPicker` height when `initialVisibleMonth` height is different from the current month's ([#159](https://github.com/airbnb/react-dates/pull/159)) - - [new] Add `keepOpenOnDateSelect` prop to the `DateRangePicker` and `SingleDatePicker` ([#157](https://github.com/airbnb/react-dates/pull/157)) + - [new] Add `navPrev`/`navNext` props for custom month navigation ([#161](https://github.com/react-dates/react-dates/pull/161)) + - [fix] Add missing right border on caret ([#160](https://github.com/react-dates/react-dates/pull/160)) + - [fix] Adjust `DayPicker` height when `initialVisibleMonth` height is different from the current month's ([#159](https://github.com/react-dates/react-dates/pull/159)) + - [new] Add `keepOpenOnDateSelect` prop to the `DateRangePicker` and `SingleDatePicker` ([#157](https://github.com/react-dates/react-dates/pull/157)) ## v3.5.0 - - [new] Add support for clear date button on the `SingleDatePicker` ([#155](https://github.com/airbnb/react-dates/pull/155)) - - [fix] Fix focus behavior for vertically attached datepickers ([#121](https://github.com/airbnb/react-dates/pull/121)) + - [new] Add support for clear date button on the `SingleDatePicker` ([#155](https://github.com/react-dates/react-dates/pull/155)) + - [fix] Fix focus behavior for vertically attached datepickers ([#121](https://github.com/react-dates/react-dates/pull/121)) ## v3.4.0 - - [new] Add support for `required` attribute on inputs ([#142](https://github.com/airbnb/react-dates/pull/142)) + - [new] Add support for `required` attribute on inputs ([#142](https://github.com/react-dates/react-dates/pull/142)) ## v3.3.4 - - [fix] Fix same tether overlay issue for the `SingleDatePicker` component ([#133](https://github.com/airbnb/react-dates/pull/133)) + - [fix] Fix same tether overlay issue for the `SingleDatePicker` component ([#133](https://github.com/react-dates/react-dates/pull/133)) ## v3.3.3 - - [fix] Allow for elements to be interacted with when rendered beneath the tether component ([#131](https://github.com/airbnb/react-dates/pull/131)) + - [fix] Allow for elements to be interacted with when rendered beneath the tether component ([#131](https://github.com/react-dates/react-dates/pull/131)) ## v3.3.2 - - [fix] Responsive the `DateRangePicker` and `SingleDatePicker` components ([#80](https://github.com/airbnb/react-dates/pull/83)) + - [fix] Responsive the `DateRangePicker` and `SingleDatePicker` components ([#80](https://github.com/react-dates/react-dates/pull/83)) ## v3.3.1 - - [fix] Update all days to use noon as their time stamp to fix a number of DST issues ([#114](https://github.com/airbnb/react-dates/pull/114)) + - [fix] Update all days to use noon as their time stamp to fix a number of DST issues ([#114](https://github.com/react-dates/react-dates/pull/114)) ## v3.3.0 - - [new] Add `anchorDirection` prop to the SingleDatePicker and DateRangePicker components ([#72](https://github.com/airbnb/react-dates/pull/72)) + - [new] Add `anchorDirection` prop to the SingleDatePicker and DateRangePicker components ([#72](https://github.com/react-dates/react-dates/pull/72)) ## v3.2.0 - - [new] Add `initialVisibleMonth` prop to the SingleDatePicker, DateRangePicker, and DayPicker components ([#70](https://github.com/airbnb/react-dates/pull/70)) + - [new] Add `initialVisibleMonth` prop to the SingleDatePicker, DateRangePicker, and DayPicker components ([#70](https://github.com/react-dates/react-dates/pull/70)) ## v3.1.1 - [fix] Fix moment dependencies to allow v2.10 - v2.14 ## v3.1.0 - - [new] Allow `displayFormat` prop to take a function as well as a string ([#98](https://github.com/airbnb/react-dates/pull/98)) - - [fix] Default value for `displayFormat` now actually returns moment's `L` format based on the locale ([#98](https://github.com/airbnb/react-dates/pull/98))) + - [new] Allow `displayFormat` prop to take a function as well as a string ([#98](https://github.com/react-dates/react-dates/pull/98)) + - [fix] Default value for `displayFormat` now actually returns moment's `L` format based on the locale ([#98](https://github.com/react-dates/react-dates/pull/98))) ## v3.0.0 - - [breaking] Move the constants file to the top-level ([#53](https://github.com/airbnb/react-dates/pull/53)) - - [breaking] Add `reopenPickerOnClearDates` prop so that the DateRangePicker no longer automatically reopens when clearing dates ([#75](https://github.com/airbnb/react-dates/pull/75)) + - [breaking] Move the constants file to the top-level ([#53](https://github.com/react-dates/react-dates/pull/53)) + - [breaking] Add `reopenPickerOnClearDates` prop so that the DateRangePicker no longer automatically reopens when clearing dates ([#75](https://github.com/react-dates/react-dates/pull/75)) ## v2.2.0 - - [fix] Fix height issue where an extra table row was being rendered for some months ([#57](https://github.com/airbnb/react-dates/pull/57)) - - [fix] Disables user-select on navigation ([#74](https://github.com/airbnb/react-dates/pull/74)) - - [new] Allows for a custom date display format ([#52](https://github.com/airbnb/react-dates/pull/52)) + - [fix] Fix height issue where an extra table row was being rendered for some months ([#57](https://github.com/react-dates/react-dates/pull/57)) + - [fix] Disables user-select on navigation ([#74](https://github.com/react-dates/react-dates/pull/74)) + - [new] Allows for a custom date display format ([#52](https://github.com/react-dates/react-dates/pull/52)) ## v2.1.1 - [fix] Fix initial day of month to utc to fix daylight savings time problem in Brazil and other locales diff --git a/INTHEWILD.md b/INTHEWILD.md index 636d59c2cc..741d8e9856 100644 --- a/INTHEWILD.md +++ b/INTHEWILD.md @@ -1,4 +1,4 @@ -Please use [pull requests](https://github.com/airbnb/react-dates/pull/new/master) to add your organization and/or project to this document! +Please use [pull requests](https://github.com/react-dates/react-dates/pull/new) to add your organization and/or project to this document! Organizations ---------- diff --git a/README.md b/README.md index 59727fbe0b..ffa3f4973d 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ > An easily internationalizable, accessible, mobile-friendly datepicker library for the web. -![react-dates in action](https://raw.githubusercontent.com/airbnb/react-dates/master/react-dates-demo.gif) +![react-dates in action](https://raw.githubusercontent.com/react-dates/react-dates/HEAD/react-dates-demo.gif) ## Live Playground -For examples of the datepicker in action, go to http://airbnb.io/react-dates. +For examples of the datepicker in action, go to [react-dates.github.io](https://react-dates.github.io/react-dates/). OR @@ -26,7 +26,7 @@ To run that demo on your own computer: ## Getting Started ### Install dependencies -Ensure packages are installed with correct version numbers by running: +Ensure packages are installed with correct version numbers by running (from your command line): ```sh ( export PKG=react-dates; @@ -37,9 +37,11 @@ Ensure packages are installed with correct version numbers by running: Which produces and runs a command like: ```sh - npm install --save react-dates moment@>=#.## react@>=#.## react-dom@>=#.## react-addons-shallow-compare@>=#.## + npm install --save react-dates moment@>=#.## react@>=#.## react-dom@>=#.## ``` + > If you are running Windows, that command will not work, but if you are running npm 5 or higher, you can run `npx install-peerdeps react-dates` on any platform + ### Initialize ```js import 'react-dates/initialize'; @@ -65,32 +67,53 @@ Create a CSS file with the contents of `require.resolve('react-dates/lib/css/_da To see this in action, you can check out https://github.com/majapw/react-dates-demo which adds `react-dates` on top of a simple `create-react-app` setup. +#### Overriding Base Class +By default `react-dates` will use `PureComponent` conditionally if it is available. However, it is possible to override this setting and use `Component` and `shouldComponentUpdate` instead. It is also possible to override the logic in `build/util/baseClass` if you know that you are using a React version with `PureComponent`. + ```javascript + import React from 'react'; + export default React.PureComponent; + export const pureComponentAvailable = true; + ``` + #### Overriding styles -Right now, the easiest way to tweak `react-dates` to your heart's contents is to create another stylesheet to override the default react-dates styles. For example, you could create a file named `react_dates_overrides.css` with the following contents: +Right now, the easiest way to tweak `react-dates` to your heart's content is to create another stylesheet to override the default react-dates styles. For example, you could create a file named `react_dates_overrides.css` with the following contents (Make sure when you import said file to your `app.js`, you import it after the `react-dates` styles): ```css -.CalendarDay__highlighted_calendar { - background: #82E0AA; - color: #186A3B; +// NOTE: the order of these styles DO matter + +// Will edit everything selected including everything between a range of dates +.CalendarDay__selected_span { + background: #82e0aa; //background + color: white; //text + border: 1px solid $light-red; //default styles include a border +} + +// Will edit selected date or the endpoints of a range of dates +.CalendarDay__selected { + background: $dark-red; + color: white; } -.CalendarDay__highlighted_calendar:hover { - background: #58D68D; - color: #186A3B; +// Will edit when hovered over. _span style also has this property +.CalendarDay__selected:hover { + background: orange; + color: white; } -.CalendarDay__highlighted_calendar:active { - background: #58D68D; - color: #186A3B; +// Will edit when the second date (end date) in a range of dates +// is not yet selected. Edits the dates between your mouse and said date +.CalendarDay__hovered_span:hover, +.CalendarDay__hovered_span { + background: brown; } ``` -This would override the background and text colors applied to highlighted calendar days. You can use this method with the default set-up to override any aspect of the calendar to have it better fit to your particular needs. +This would override the background and text colors applied to highlighted calendar days. You can use this method with the default set-up to override any aspect of the calendar to have it better fit to your particular needs. If there are any styles that you need that aren't listed here, you can always check the source css of each element. ### Make some awesome datepickers We provide a handful of components for your use. If you supply essential props to each component, you'll get a full featured interactive date picker. With additional optional props, you can customize the look and feel of the inputs, calendar, etc. You can see what each of the props do in the [live demo](http://airbnb.io/react-dates/) or explore -how to properly wrap the pickers in the [examples folder](https://github.com/airbnb/react-dates/tree/master/examples). +how to properly wrap the pickers in the [examples folder](https://github.com/react-dates/react-dates/tree/HEAD/examples). #### DateRangePicker The `DateRangePicker` is a fully controlled component that allows users to select a date range. You can control the selected @@ -103,21 +126,25 @@ Here is the minimum *REQUIRED* setup you need to get the `DateRangePicker` worki ```jsx this.setState({ startDate, endDate })} // PropTypes.func.isRequired, focusedInput={this.state.focusedInput} // PropTypes.oneOf([START_DATE, END_DATE]) or null, onFocusChange={focusedInput => this.setState({ focusedInput })} // PropTypes.func.isRequired, /> ``` -The following is a list of other *OPTIONAL* props you may provide to the `DateRangePicker` to customize appearance and behavior to your heart's desire. Again, please explore the [storybook](http://airbnb.io/react-dates/?selectedKind=DRP%20-%20Input%20Props&selectedStory=default&full=0&down=1&left=1&panelRight=0&downPanel=kadirahq%2Fstorybook-addon-actions%2Factions-panel) for more information on what each of these props do. +The following is a list of other *OPTIONAL* props you may provide to the `DateRangePicker` to customize appearance and behavior to your heart's desire. All constants (indicated by `ALL_CAPS`) are provided as named exports in `react-dates/constants`. Please explore the [storybook](http://airbnb.io/react-dates/?selectedKind=DRP%20-%20Input%20Props&selectedStory=default&full=0&down=1&left=1&panelRight=0&downPanel=kadirahq%2Fstorybook-addon-actions%2Factions-panel) for more information on what each of these props do. ```js // input related props -startDateId: PropTypes.string.isRequired, startDatePlaceholderText: PropTypes.string, -endDateId: PropTypes.string.isRequired, endDatePlaceholderText: PropTypes.string, -disabled: PropTypes.bool, +startDateAriaLabel: PropTypes.string, +endDateAriaLabel: PropTypes.string, +startDateTitleText: PropTypes.string, +endDateTitleText: PropTypes.string, +disabled: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf([START_DATE, END_DATE])]), required: PropTypes.bool, readOnly: PropTypes.bool, screenReaderInputMessage: PropTypes.string, @@ -126,14 +153,23 @@ showDefaultInputIcon: PropTypes.bool, customInputIcon: PropTypes.node, customArrowIcon: PropTypes.node, customCloseIcon: PropTypes.node, +inputIconPosition: PropTypes.oneOf([ICON_BEFORE_POSITION, ICON_AFTER_POSITION]), +noBorder: PropTypes.bool, +block: PropTypes.bool, +small: PropTypes.bool, +regular: PropTypes.bool, +autoComplete: PropTypes.string, // calendar presentation and interaction related props -renderMonth: PropTypes.func, -orientation: OrientationShape, -anchorDirection: anchorDirectionShape, +renderMonthText: mutuallyExclusiveProps(PropTypes.func, 'renderMonthText', 'renderMonthElement'), // (month) => PropTypes.string, +orientation: PropTypes.oneOf([HORIZONTAL_ORIENTATION, VERTICAL_ORIENTATION]), +anchorDirection: PropTypes.oneOf([ANCHOR_LEFT, ANCHOR_RIGHT]), +openDirection: PropTypes.oneOf([OPEN_DOWN, OPEN_UP]), horizontalMargin: PropTypes.number, withPortal: PropTypes.bool, withFullScreenPortal: PropTypes.bool, +appendToBody: PropTypes.bool, +disableScroll: PropTypes.bool, daySize: nonNegativeInteger, isRTL: PropTypes.bool, initialVisibleMonth: PropTypes.func, @@ -142,7 +178,9 @@ numberOfMonths: PropTypes.number, keepOpenOnDateSelect: PropTypes.bool, reopenPickerOnClearDates: PropTypes.bool, renderCalendarInfo: PropTypes.func, +renderMonthElement: mutuallyExclusiveProps(PropTypes.func, 'renderMonthText', 'renderMonthElement'), PropTypes.func, // ({ month, onMonthSelect, onYearSelect, isVisible }) => PropTypes.node, hideKeyboardShortcutsPanel: PropTypes.bool, +verticalSpacing: PropTypes.number, // navigation related props navPrev: PropTypes.node, @@ -150,10 +188,14 @@ navNext: PropTypes.node, onPrevMonthClick: PropTypes.func, onNextMonthClick: PropTypes.func, onClose: PropTypes.func, +transitionDuration: nonNegativeInteger, // milliseconds // day presentation and interaction related props -renderDay: PropTypes.func, +renderCalendarDay: PropTypes.func, +renderDayContents: PropTypes.func, minimumNights: PropTypes.number, +minDate: momentPropTypes.momentObj, +maxDate: momentPropTypes.momentObj, enableOutsideDays: PropTypes.bool, isDayBlocked: PropTypes.func, isOutsideRange: PropTypes.func, @@ -164,6 +206,7 @@ displayFormat: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), monthFormat: PropTypes.string, weekDayFormat: PropTypes.string, phrases: PropTypes.shape(getPhrasePropTypes(DateRangePickerPhrases)), +dayAriaLabelFormat: PropTypes.string, ``` #### SingleDatePicker @@ -180,14 +223,16 @@ Here is the minimum *REQUIRED* setup you need to get the `SingleDatePicker` work onDateChange={date => this.setState({ date })} // PropTypes.func.isRequired focused={this.state.focused} // PropTypes.bool onFocusChange={({ focused }) => this.setState({ focused })} // PropTypes.func.isRequired + id="your_unique_id" // PropTypes.string.isRequired, /> ``` -The following is a list of other *OPTIONAL* props you may provide to the `SingleDatePicker` to customize appearance and behavior to your heart's desire. Again, please explore the [storybook](http://airbnb.io/react-dates/?selectedKind=SDP%20-%20Input%20Props&selectedStory=default&full=0&down=1&left=1&panelRight=0&downPanel=kadirahq%2Fstorybook-addon-actions%2Factions-panel) for more information on what each of these props do. +The following is a list of other *OPTIONAL* props you may provide to the `SingleDatePicker` to customize appearance and behavior to your heart's desire. All constants (indicated by `ALL_CAPS`) are provided as named exports in `react-dates/constants`. Please explore the [storybook](http://airbnb.io/react-dates/?selectedKind=SDP%20-%20Input%20Props&selectedStory=default&full=0&down=1&left=1&panelRight=0&downPanel=kadirahq%2Fstorybook-addon-actions%2Factions-panel) for more information on what each of these props do. ```js // input related props -id: PropTypes.string.isRequired, placeholder: PropTypes.string, +ariaLabel: PropTypes.string, +titleText: PropTypes.string, disabled: PropTypes.bool, required: PropTypes.bool, readOnly: PropTypes.bool, @@ -196,23 +241,34 @@ showClearDate: PropTypes.bool, customCloseIcon: PropTypes.node, showDefaultInputIcon: PropTypes.bool, customInputIcon: PropTypes.node, +inputIconPosition: PropTypes.oneOf([ICON_BEFORE_POSITION, ICON_AFTER_POSITION]), +noBorder: PropTypes.bool, +block: PropTypes.bool, +small: PropTypes.bool, +regular: PropTypes.bool, +autoComplete: PropTypes.string, // calendar presentation and interaction related props -renderMonth: PropTypes.func, -orientation: OrientationShape, -anchorDirection: anchorDirectionShape, +renderMonthText: mutuallyExclusiveProps(PropTypes.func, 'renderMonthText', 'renderMonthElement'), // (month) => PropTypes.string, +orientation: PropTypes.oneOf([HORIZONTAL_ORIENTATION, VERTICAL_ORIENTATION]), +anchorDirection: PropTypes.oneOf([ANCHOR_LEFT, ANCHOR_RIGHT]), +openDirection: PropTypes.oneOf([OPEN_DOWN, OPEN_UP]), horizontalMargin: PropTypes.number, withPortal: PropTypes.bool, withFullScreenPortal: PropTypes.bool, +appendToBody: PropTypes.bool, +disableScroll: PropTypes.bool, initialVisibleMonth: PropTypes.func, firstDayOfWeek: PropTypes.oneOf([0, 1, 2, 3, 4, 5, 6]), numberOfMonths: PropTypes.number, keepOpenOnDateSelect: PropTypes.bool, reopenPickerOnClearDate: PropTypes.bool, renderCalendarInfo: PropTypes.func, +renderMonthElement: mutuallyExclusiveProps(PropTypes.func, 'renderMonthText', 'renderMonthElement'), // ({ month, onMonthSelect, onYearSelect, isVisible }) => PropTypes.node, hideKeyboardShortcutsPanel: PropTypes.bool, daySize: nonNegativeInteger, isRTL: PropTypes.bool, +verticalSpacing: PropTypes.number, // navigation related props navPrev: PropTypes.node, @@ -220,9 +276,11 @@ navNext: PropTypes.node, onPrevMonthClick: PropTypes.func, onNextMonthClick: PropTypes.func, onClose: PropTypes.func, +transitionDuration: nonNegativeInteger, // milliseconds // day presentation and interaction related props -renderDay: PropTypes.func, +renderCalendarDay: PropTypes.func, +renderDayContents: PropTypes.func, enableOutsideDays: PropTypes.bool, isDayBlocked: PropTypes.func, isOutsideRange: PropTypes.func, @@ -233,6 +291,7 @@ displayFormat: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), monthFormat: PropTypes.string, weekDayFormat: PropTypes.string, phrases: PropTypes.shape(getPhrasePropTypes(SingleDatePickerPhrases)), +dayAriaLabelFormat: PropTypes.string, ``` #### DayPickerRangeController @@ -248,6 +307,7 @@ Here is the minimum *REQUIRED* setup you need to get the `DayPickerRangeControll onDatesChange={({ startDate, endDate }) => this.setState({ startDate, endDate })} // PropTypes.func.isRequired, focusedInput={this.state.focusedInput} // PropTypes.oneOf([START_DATE, END_DATE]) or null, onFocusChange={focusedInput => this.setState({ focusedInput })} // PropTypes.func.isRequired, + initialVisibleMonth={() => moment().add(2, "M")} // PropTypes.func or null, /> ``` @@ -260,17 +320,21 @@ The following is a list of other *OPTIONAL* props you may provide to the `DayPic withPortal: PropTypes.bool, initialVisibleMonth: PropTypes.func, renderCalendarInfo: PropTypes.func, + renderMonthElement: mutuallyExclusiveProps(PropTypes.func, 'renderMonthText', 'renderMonthElement'), // ({ month, onMonthSelect, onYearSelect, isVisible }) => PropTypes.node, onOutsideClick: PropTypes.func, keepOpenOnDateSelect: PropTypes.bool, + noBorder: PropTypes.bool, // navigation related props navPrev: PropTypes.node, navNext: PropTypes.node, onPrevMonthClick: PropTypes.func, onNextMonthClick: PropTypes.func, + transitionDuration: nonNegativeInteger, // milliseconds // day presentation and interaction related props - renderDay: PropTypes.func, + renderCalendarDay: PropTypes.func, + renderDayContents: PropTypes.func, minimumNights: PropTypes.number, isOutsideRange: PropTypes.func, isDayBlocked: PropTypes.func, @@ -280,23 +344,27 @@ The following is a list of other *OPTIONAL* props you may provide to the `DayPic monthFormat: PropTypes.string, weekDayFormat: PropTypes.string, phrases: PropTypes.shape(getPhrasePropTypes(DayPickerPhrases)), + dayAriaLabelFormat: PropTypes.string, /> ``` ## Localization -[Moment.js](http://momentjs.com) is a peer dependency of `react-dates`, so `react-dates` will use a single instance of `moment` which is imported in the user's project. To load a locale it is enough to invoke `moment.locale` in the component where `moment` is imported, with the [locale key](http://momentjs.com/docs/#/i18n/) of choice, e.g.: -``` +[Moment.js](http://momentjs.com) is a peer dependency of `react-dates`. The latter then uses a single instance of `moment` which is imported in one’s project. Loading a locale is done by calling `moment.locale(..)` in the component where `moment` is imported, with the [locale key](http://momentjs.com/docs/#/i18n/) of choice. For instance: + +```js moment.locale('pl'); // Polish ``` +However, this only solves date localization. For complete internationalization of the components, `react-dates` defines a certain amount of [user interface strings](https://github.com/react-dates/react-dates/blob/HEAD/src/defaultPhrases.js) in English which can be changed through the `phrases` prop (explore the [storybook](http://airbnb.io/react-dates/?selectedKind=DateRangePicker%20%28DRP%29&selectedStory=non-english%20locale&full=0&addons=1&stories=1&panelRight=0&addonPanel=kadirahq%2Fstorybook-addon-actions%2Factions-panel) for examples). For accessibility and usability concerns, **all these UI elements should be translated**. + ## Advanced `react-dates` no longer relies strictly on CSS, but rather relies on `react-with-styles` as an abstraction layer between how styles are applied and how they are written. The instructions above will get the project working out of the box, but there's a lot more customization that can be done. ### Interfaces -The `react-dates/initialize` script actually relies on [react-with-styles-interface-css](https://github.com/airbnb/react-with-styles-interface-css) under the hood. If you are interested in a different solution for styling in your project, you can do your own initialization of a another [interface](https://github.com/airbnb/react-with-styles/blob/master/README.md#interfaces). At Airbnb, for instance, we rely on [Aphrodite](https://github.com/Khan/aphrodite) under the hood and therefore use the Aphrodite interface for `react-with-styles`. If you want to do the same, you would use the following pattern: +The `react-dates/initialize` script actually relies on [react-with-styles-interface-css](https://github.com/airbnb/react-with-styles-interface-css) under the hood. If you are interested in a different solution for styling in your project, you can do your own initialization of a another [interface](https://github.com/airbnb/react-with-styles/blob/HEAD/README.md#interfaces). At Airbnb, for instance, we rely on [Aphrodite](https://github.com/Khan/aphrodite) under the hood and therefore use the Aphrodite interface for `react-with-styles`. If you want to do the same, you would use the following pattern: ```js import ThemedStyleSheet from 'react-with-styles/lib/ThemedStyleSheet'; import aphroditeInterface from 'react-with-styles-interface-aphrodite'; @@ -309,7 +377,7 @@ ThemedStyleSheet.registerTheme(DefaultTheme); The above code has to be run before any `react-dates` component is imported. Otherwise, you will get an error. Also note that if you register any custom interface manually, you *must* also manually register a theme. ### Theming -`react-dates` also now supports a different way to theme. You can see the default theme values in [this file](https://github.com/airbnb/react-dates/blob/master/src/theme/DefaultTheme.js) and you would override them in the following manner: +`react-dates` also now supports a different way to theme. You can see the default theme values in [this file](https://github.com/react-dates/react-dates/blob/HEAD/src/theme/DefaultTheme.js) and you would override them in the following manner: ```js import ThemedStyleSheet from 'react-with-styles/lib/ThemedStyleSheet'; import aphroditeInterface from 'react-with-styles-interface-aphrodite'; @@ -337,16 +405,16 @@ ThemedStyleSheet.registerTheme({ The above code would use shades of green instead of shades of yellow for the highlight color on `CalendarDay` components. Note that you *must* register an interface if you manually register a theme. One will not work without the other. #### A note on using `react-with-styles-interface-css` -The default interface that `react-dates` ships with is the [CSS interface](https://github.com/airbnb/react-with-styles-interface-css). If you want to use this interface along with the theme registration method, you will need to rebuild the core `_datepicker.css` file. We do not currently expose a utility method to build this file, but you can follow along with the code in https://github.com/airbnb/react-dates/blob/master/scripts/buildCSS.js to build your own custom themed CSS file. +The default interface that `react-dates` ships with is the [CSS interface](https://github.com/airbnb/react-with-styles-interface-css). If you want to use this interface along with the theme registration method, you will need to rebuild the core `_datepicker.css` file. We do not currently expose a utility method to build this file, but you can follow along with the code in https://github.com/react-dates/react-dates/blob/HEAD/scripts/buildCSS.js to build your own custom themed CSS file. [package-url]: https://npmjs.org/package/react-dates -[npm-version-svg]: http://versionbadg.es/airbnb/react-dates.svg -[travis-svg]: https://travis-ci.org/airbnb/react-dates.svg -[travis-url]: https://travis-ci.org/airbnb/react-dates -[deps-svg]: https://david-dm.org/airbnb/react-dates.svg -[deps-url]: https://david-dm.org/airbnb/react-dates -[dev-deps-svg]: https://david-dm.org/airbnb/react-dates/dev-status.svg -[dev-deps-url]: https://david-dm.org/airbnb/react-dates#info=devDependencies +[npm-version-svg]: http://versionbadg.es/react-dates/react-dates.svg +[travis-svg]: https://travis-ci.org/react-dates/react-dates.svg +[travis-url]: https://travis-ci.org/react-dates/react-dates +[deps-svg]: https://david-dm.org/react-dates/react-dates.svg +[deps-url]: https://david-dm.org/react-dates/react-dates +[dev-deps-svg]: https://david-dm.org/react-dates/react-dates/dev-status.svg +[dev-deps-url]: https://david-dm.org/react-dates/react-dates#info=devDependencies [npm-badge-png]: https://nodei.co/npm/react-dates.png?downloads=true&stars=true [license-image]: http://img.shields.io/npm/l/react-dates.svg [license-url]: LICENSE diff --git a/css/storybook.scss b/css/storybook.scss index 2f94c9ee71..2e20e67c21 100644 --- a/css/storybook.scss +++ b/css/storybook.scss @@ -17,4 +17,8 @@ html { a { color: #008489; font-weight: bold; -} \ No newline at end of file +} + +.foo-bar { + background: red !important; +} diff --git a/examples/DateRangePickerWrapper.jsx b/examples/DateRangePickerWrapper.jsx index 97588980cc..a210448ff0 100644 --- a/examples/DateRangePickerWrapper.jsx +++ b/examples/DateRangePickerWrapper.jsx @@ -8,13 +8,21 @@ import DateRangePicker from '../src/components/DateRangePicker'; import { DateRangePickerPhrases } from '../src/defaultPhrases'; import DateRangePickerShape from '../src/shapes/DateRangePickerShape'; -import { START_DATE, END_DATE, HORIZONTAL_ORIENTATION, ANCHOR_LEFT } from '../src/constants'; +import { + START_DATE, + END_DATE, + HORIZONTAL_ORIENTATION, + ANCHOR_LEFT, + NAV_POSITION_TOP, + OPEN_DOWN, +} from '../src/constants'; import isInclusivelyAfterDay from '../src/utils/isInclusivelyAfterDay'; const propTypes = { // example props for the demo autoFocus: PropTypes.bool, autoFocusEndDate: PropTypes.bool, + stateDateWrapper: PropTypes.func, initialStartDate: momentPropTypes.momentObj, initialEndDate: momentPropTypes.momentObj, @@ -47,9 +55,13 @@ const defaultProps = { customInputIcon: null, customArrowIcon: null, customCloseIcon: null, + block: false, + small: false, + regular: false, + autoComplete: 'off', // calendar presentation and interaction related props - renderMonth: null, + renderMonthText: null, orientation: HORIZONTAL_ORIENTATION, anchorDirection: ANCHOR_LEFT, horizontalMargin: 0, @@ -60,8 +72,10 @@ const defaultProps = { keepOpenOnDateSelect: false, reopenPickerOnClearDates: false, isRTL: false, + openDirection: OPEN_DOWN, // navigation related props + navPosition: NAV_POSITION_TOP, navPrev: null, navNext: null, onPrevMonthClick() {}, @@ -69,7 +83,8 @@ const defaultProps = { onClose() {}, // day presentation and interaction related props - renderDay: null, + renderCalendarDay: undefined, + renderDayContents: null, minimumNights: 1, enableOutsideDays: false, isDayBlocked: () => false, @@ -80,6 +95,8 @@ const defaultProps = { displayFormat: () => moment.localeData().longDateFormat('L'), monthFormat: 'MMMM YYYY', phrases: DateRangePickerPhrases, + + stateDateWrapper: date => date, }; class DateRangePickerWrapper extends React.Component { @@ -104,7 +121,11 @@ class DateRangePickerWrapper extends React.Component { } onDatesChange({ startDate, endDate }) { - this.setState({ startDate, endDate }); + const { stateDateWrapper } = this.props; + this.setState({ + startDate: startDate && stateDateWrapper(startDate), + endDate: endDate && stateDateWrapper(endDate), + }); } onFocusChange(focusedInput) { @@ -122,6 +143,7 @@ class DateRangePickerWrapper extends React.Component { 'autoFocusEndDate', 'initialStartDate', 'initialEndDate', + 'stateDateWrapper', ]); return ( diff --git a/examples/DayPickerRangeControllerWrapper.jsx b/examples/DayPickerRangeControllerWrapper.jsx index 5554b920af..95ae47099a 100644 --- a/examples/DayPickerRangeControllerWrapper.jsx +++ b/examples/DayPickerRangeControllerWrapper.jsx @@ -18,28 +18,42 @@ const propTypes = forbidExtraProps({ autoFocusEndDate: PropTypes.bool, initialStartDate: momentPropTypes.momentObj, initialEndDate: momentPropTypes.momentObj, + startDateOffset: PropTypes.func, + endDateOffset: PropTypes.func, + showInputs: PropTypes.bool, + minDate: momentPropTypes.momentObj, + maxDate: momentPropTypes.momentObj, keepOpenOnDateSelect: PropTypes.bool, minimumNights: PropTypes.number, isOutsideRange: PropTypes.func, isDayBlocked: PropTypes.func, isDayHighlighted: PropTypes.func, + daysViolatingMinNightsCanBeClicked: PropTypes.bool, // DayPicker props enableOutsideDays: PropTypes.bool, numberOfMonths: PropTypes.number, orientation: ScrollableOrientationShape, + verticalHeight: PropTypes.number, withPortal: PropTypes.bool, initialVisibleMonth: PropTypes.func, renderCalendarInfo: PropTypes.func, + renderMonthElement: PropTypes.func, + renderMonthText: PropTypes.func, navPrev: PropTypes.node, navNext: PropTypes.node, + renderNavPrevButton: PropTypes.func, + renderNavNextButton: PropTypes.func, onPrevMonthClick: PropTypes.func, onNextMonthClick: PropTypes.func, onOutsideClick: PropTypes.func, - renderDay: PropTypes.func, + renderCalendarDay: PropTypes.func, + renderDayContents: PropTypes.func, + renderKeyboardShortcutsButton: PropTypes.func, + renderKeyboardShortcutsPanel: PropTypes.func, // i18n monthFormat: PropTypes.string, @@ -52,17 +66,25 @@ const defaultProps = { autoFocusEndDate: false, initialStartDate: null, initialEndDate: null, + startDateOffset: undefined, + endDateOffset: undefined, + showInputs: false, + minDate: null, + maxDate: null, // day presentation and interaction related props - renderDay: null, + renderCalendarDay: undefined, + renderDayContents: null, minimumNights: 1, isDayBlocked: () => false, isOutsideRange: day => !isInclusivelyAfterDay(day, moment()), isDayHighlighted: () => false, enableOutsideDays: false, + daysViolatingMinNightsCanBeClicked: false, // calendar presentation and interaction related props orientation: HORIZONTAL_ORIENTATION, + verticalHeight: undefined, withPortal: false, initialVisibleMonth: null, numberOfMonths: 2, @@ -70,10 +92,16 @@ const defaultProps = { keepOpenOnDateSelect: false, renderCalendarInfo: null, isRTL: false, + renderMonthText: null, + renderMonthElement: null, + renderKeyboardShortcutsButton: undefined, + renderKeyboardShortcutsPanel: undefined, // navigation related props navPrev: null, navNext: null, + renderNavPrevButton: null, + renderNavNextButton: null, onPrevMonthClick() {}, onNextMonthClick() {}, @@ -86,6 +114,7 @@ class DayPickerRangeControllerWrapper extends React.Component { super(props); this.state = { + errorMessage: null, focusedInput: props.autoFocusEndDate ? END_DATE : START_DATE, startDate: props.initialStartDate, endDate: props.initialEndDate, @@ -96,7 +125,19 @@ class DayPickerRangeControllerWrapper extends React.Component { } onDatesChange({ startDate, endDate }) { - this.setState({ startDate, endDate }); + const { daysViolatingMinNightsCanBeClicked, minimumNights } = this.props; + let doesNotMeetMinNights = false; + if (daysViolatingMinNightsCanBeClicked && startDate && endDate) { + const dayDiff = endDate.diff(startDate.clone().startOf('day').hour(12), 'days'); + doesNotMeetMinNights = dayDiff < minimumNights && dayDiff >= 0; + } + this.setState({ + startDate, + endDate: doesNotMeetMinNights ? null : endDate, + errorMessage: doesNotMeetMinNights + ? 'That day does not meet the minimum nights requirement' + : null, + }); } onFocusChange(focusedInput) { @@ -107,27 +148,34 @@ class DayPickerRangeControllerWrapper extends React.Component { } render() { - const { showInputs } = this.props; - const { focusedInput, startDate, endDate } = this.state; + const { renderCalendarInfo: renderCalendarInfoProp, showInputs } = this.props; + const { + errorMessage, + focusedInput, + startDate, + endDate, + } = this.state; const props = omit(this.props, [ 'autoFocus', 'autoFocusEndDate', 'initialStartDate', 'initialEndDate', + 'showInputs', ]); const startDateString = startDate && startDate.format('YYYY-MM-DD'); const endDateString = endDate && endDate.format('YYYY-MM-DD'); + const renderCalendarInfo = errorMessage ? () =>
{errorMessage}
: renderCalendarInfoProp; return ( -
- {showInputs && +
+ {showInputs && (
- } + )}
); diff --git a/examples/DayPickerSingleDateControllerWrapper.jsx b/examples/DayPickerSingleDateControllerWrapper.jsx index 200c84ec8f..44a0a5e1fb 100644 --- a/examples/DayPickerSingleDateControllerWrapper.jsx +++ b/examples/DayPickerSingleDateControllerWrapper.jsx @@ -19,6 +19,7 @@ const propTypes = forbidExtraProps({ initialDate: momentPropTypes.momentObj, showInput: PropTypes.bool, + allowUnselect: PropTypes.bool, keepOpenOnDateSelect: PropTypes.bool, isOutsideRange: PropTypes.func, isDayBlocked: PropTypes.func, @@ -34,11 +35,14 @@ const propTypes = forbidExtraProps({ navPrev: PropTypes.node, navNext: PropTypes.node, + renderNavPrevButton: PropTypes.func, + renderNavNextButton: PropTypes.func, onPrevMonthClick: PropTypes.func, onNextMonthClick: PropTypes.func, onOutsideClick: PropTypes.func, - renderDay: PropTypes.func, + renderCalendarDay: PropTypes.func, + renderDayContents: PropTypes.func, // i18n monthFormat: PropTypes.string, @@ -53,7 +57,9 @@ const defaultProps = { showInput: false, // day presentation and interaction related props - renderDay: null, + allowUnselect: false, + renderCalendarDay: undefined, + renderDayContents: null, isDayBlocked: () => false, isOutsideRange: day => !isInclusivelyAfterDay(day, moment()), isDayHighlighted: () => false, @@ -72,6 +78,8 @@ const defaultProps = { // navigation related props navPrev: null, navNext: null, + renderNavPrevButton: null, + renderNavNextButton: null, onPrevMonthClick() {}, onNextMonthClick() {}, @@ -115,11 +123,11 @@ class DayPickerSingleDateControllerWrapper extends React.Component { return (
- {showInput && + {showInput && (
- } + )} false, + isOutsideRange: day => false, + isDayHighlighted: () => false, + + // internationalization + displayFormat: () => moment.localeData().longDateFormat('L'), + monthFormat: 'MMMM YYYY', + phrases: DateRangePickerPhrases, +}; + +class DateRangePickerWrapper extends React.Component { + constructor(props) { + super(props); + + let focusedInput = null; + if (props.autoFocus) { + focusedInput = START_DATE; + } else if (props.autoFocusEndDate) { + focusedInput = END_DATE; + } + + this.state = { + focusedInput, + startDate: props.initialStartDate, + endDate: props.initialEndDate, + }; + + this.onDatesChange = this.onDatesChange.bind(this); + this.onFocusChange = this.onFocusChange.bind(this); + this.renderDatePresets = this.renderDatePresets.bind(this); + } + + onDatesChange({ startDate, endDate }) { + this.setState({ startDate, endDate }); + } + + onFocusChange(focusedInput) { + this.setState({ focusedInput }); + } + + renderDatePresets() { + const { presets, styles } = this.props; + const { startDate, endDate } = this.state; + + return ( +
+ {presets.map(({ text, start, end }) => { + const isSelected = isSameDay(start, startDate) && isSameDay(end, endDate); + return ( + + ); + })} +
+ ); + } + + render() { + const { focusedInput, startDate, endDate } = this.state; + + // autoFocus, autoFocusEndDate, initialStartDate and initialEndDate are helper props for the + // example wrapper but are not props on the SingleDatePicker itself and + // thus, have to be omitted. + const props = omit(this.props, [ + 'autoFocus', + 'autoFocusEndDate', + 'initialStartDate', + 'initialEndDate', + 'presets', + ]); + + return ( +
+ +
+ ); + } +} + +DateRangePickerWrapper.propTypes = propTypes; +DateRangePickerWrapper.defaultProps = defaultProps; + +export default withStyles(({ reactDates: { color } }) => ({ + PresetDateRangePicker_panel: { + padding: '0 22px 11px 22px', + }, + + PresetDateRangePicker_button: { + position: 'relative', + height: '100%', + textAlign: 'center', + background: 'none', + border: `2px solid ${color.core.primary}`, + color: color.core.primary, + padding: '4px 12px', + marginRight: 8, + font: 'inherit', + fontWeight: 700, + lineHeight: 'normal', + overflow: 'visible', + boxSizing: 'border-box', + cursor: 'pointer', + + ':active': { + outline: 0, + }, + }, + + PresetDateRangePicker_button__selected: { + color: color.core.white, + background: color.core.primary, + }, +}))(DateRangePickerWrapper); diff --git a/examples/SingleDatePickerWrapper.jsx b/examples/SingleDatePickerWrapper.jsx index 8562a2b933..9b3eaf8c80 100644 --- a/examples/SingleDatePickerWrapper.jsx +++ b/examples/SingleDatePickerWrapper.jsx @@ -8,7 +8,7 @@ import SingleDatePicker from '../src/components/SingleDatePicker'; import { SingleDatePickerPhrases } from '../src/defaultPhrases'; import SingleDatePickerShape from '../src/shapes/SingleDatePickerShape'; -import { HORIZONTAL_ORIENTATION, ANCHOR_LEFT } from '../src/constants'; +import { HORIZONTAL_ORIENTATION, ANCHOR_LEFT, OPEN_DOWN } from '../src/constants'; import isInclusivelyAfterDay from '../src/utils/isInclusivelyAfterDay'; const propTypes = { @@ -38,9 +38,15 @@ const defaultProps = { showClearDate: false, showDefaultInputIcon: false, customInputIcon: null, + block: false, + small: false, + regular: false, + verticalSpacing: undefined, + keepFocusOnInput: false, + autoComplete: 'off', // calendar presentation and interaction related props - renderMonth: null, + renderMonthText: null, orientation: HORIZONTAL_ORIENTATION, anchorDirection: ANCHOR_LEFT, horizontalMargin: 0, @@ -51,6 +57,7 @@ const defaultProps = { keepOpenOnDateSelect: false, reopenPickerOnClearDate: false, isRTL: false, + openDirection: OPEN_DOWN, // navigation related props navPrev: null, @@ -60,7 +67,8 @@ const defaultProps = { onClose() {}, // day presentation and interaction related props - renderDay: null, + renderCalendarDay: undefined, + renderDayContents: null, enableOutsideDays: false, isDayBlocked: () => false, isOutsideRange: day => !isInclusivelyAfterDay(day, moment()), diff --git a/install-relevant-react.sh b/install-relevant-react.sh deleted file mode 100644 index f73da9e55f..0000000000 --- a/install-relevant-react.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -REACT=${REACT:-15} - -echo "installing React $REACT" - -if [ "$REACT" = "0.14" ]; then - npm run react:14 -fi - -if [ "$REACT" = "15" ]; then - npm run react:15 -fi diff --git a/karma.conf.js b/karma.conf.js index e8b5c9a49a..6e34dceadf 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -8,14 +8,10 @@ module.exports = (config) => { frameworks: ['mocha', 'sinon', 'chai'], - files: [ - 'test/_helpers/registerReactWithStylesInterface.js', - 'test/_helpers/restoreSinonStubs.js', - 'test/utils/*', - 'test/components/*', - ], + files: ['test/browser-main.js'], webpack: { + mode: 'development', externals: { sinon: true, }, @@ -37,11 +33,15 @@ module.exports = (config) => { path.join(__dirname, 'test'), require.resolve('airbnb-js-shims'), ], - query: { - presets: ['airbnb'], + options: { + presets: [ + // setting modules to false so it does not transform twice + ['airbnb', { modules: false }], + // transform to cjs so sinon can stub properly + ['@babel/preset-env', { modules: 'cjs' }], + ], }, }, - { test: /\.json$/, loader: 'json-loader' }, // Inject the Airbnb shims into the bundle { test: /test\/_helpers/, loader: 'imports-loader?shims=airbnb-js-shims' }, diff --git a/package.json b/package.json index d9810a2907..d6c29274c5 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,27 @@ { "name": "react-dates", - "version": "15.1.0", + "version": "21.8.0", "description": "A responsive and accessible date range picker component built with React", "main": "index.js", "scripts": { - "build": "npm run clean && npm run build:cjs && npm run build:esm && npm run build:css -- --optimize ", + "prebuild": "npm run clean", + "build": "npm run build:cjs && npm run build:esm && npm run build:css -- --optimize", + "postbuild": "npm run build:test", "build:cjs": "BABEL_ENV=cjs babel src/ -d lib/", "build:esm": "BABEL_ENV=esm babel src/ -d esm/", - "prebuild:css": "rimraf lib/css && mkdir -p lib/css", + "build:test": "BABEL_ENV=test babel test/ -d test-build/", + "prebuild:css": "rimraf lib/css && mkdirp lib/css", "build:css": "node scripts/buildCSS.js", - "clean": "rimraf lib esm", - "precover": "rimraf coverage", - "cover": "cross-env NODE_ENV=test node --max-old-space-size=2048 $(which nyc) npm run mocha test", + "clean": "rimraf lib esm test-build", "lint": "eslint --ext .js,.jsx src test", - "mocha": "mocha ./test/_helpers", + "mocha": "mocha ./test-build/_helpers", "storybook:uninstall": "npm uninstall --no-save @storybook/react && rimraf node_modules/@storybook node_modules/react-modal node_modules/react-dom-factories", - "react:clean": "npm run storybook:uninstall && npm i --no-save ajv ajv-keywords && npm uninstall --no-save react react-dom react-addons-test-utils react-test-renderer && rimraf node_modules/react-test-renderer node_modules/react && npm prune", - "react:14": "rimraf node_modules/.bin/npm && npm run react:clean && npm i --no-save react@0.14 react-dom@0.14 react-addons-test-utils@0.14 enzyme-adapter-react-14 && npm run storybook:uninstall && npm prune && npm i --no-save enzyme-adapter-react-14", - "react:15": "rimraf node_modules/.bin/npm && npm run react:clean && npm i --no-save react@15 react-dom@15 react-addons-test-utils@15 react-test-renderer@15 && npm run storybook:uninstall && npm prune", + "react": "NPM_CONFIG_LEGACY_PEER_DEPS=true enzyme-adapter-react-install 16", "pretest": "npm run --silent lint", - "tests-only": "npm run mocha --silent test", + "tests-only": "cross-env NODE_ENV=test nyc npm run mocha --silent", + "pretests-karma": "npm run react", "tests-karma": "karma start", - "test": "npm run tests-only", + "test": "npm run react && npm run build && npm run build:test && npm run tests-only", "storybook": "start-storybook -p 6006", "storybook:css": "npm run build:css && start-storybook -p 6006 -c .storybook-css", "tag": "git tag v$npm_package_version", @@ -35,103 +35,109 @@ "preversion": "npm run test && npm run check-changelog && npm run check-only-changelog-changed", "postversion": "git commit package.json CHANGELOG.md -m \"Version $npm_package_version\" && npm run tag && git push && git push --tags && npm publish --registry=https://registry.npmjs.org/", "prepublish": "in-publish && safe-publish-latest && npm run build || not-in-publish", - "postpublish": "npm run gh-pages", + "postpublish": "[ \"${npm_config_tag:-latest}\" != latest ] || npm run gh-pages", "check-changelog": "expr $(git status --porcelain 2>/dev/null| grep \"^\\s*M.*CHANGELOG.md\" | wc -l) >/dev/null || (echo 'Please edit CHANGELOG.md' && exit 1)", "check-only-changelog-changed": "(expr $(git status --porcelain 2>/dev/null| grep -v \"CHANGELOG.md\" | wc -l) >/dev/null && echo 'Only CHANGELOG.md may have uncommitted changes' && exit 1) || exit 0" }, "repository": { "type": "git", - "url": "git+https://github.com/airbnb/react-dates.git" + "url": "git+https://github.com/react-dates/react-dates.git" }, "author": "Maja Wichrowska ", "license": "MIT", "bugs": { - "url": "https://github.com/airbnb/react-dates/issues" + "url": "https://github.com/react-dates/react-dates/issues" }, - "homepage": "https://github.com/airbnb/react-dates#readme", + "homepage": "https://github.com/react-dates/react-dates#readme", "devDependencies": { - "@storybook/addon-actions": "^3.2.12", - "@storybook/addon-info": "^3.2.9", - "@storybook/addon-links": "^3.2.12", - "@storybook/addon-options": "^3.2.6", - "@storybook/react": "^3.2.8", - "airbnb-js-shims": "^1.3.0", - "aphrodite": "^1.2.5", - "babel-cli": "^6.26.0", - "babel-core": "^6.26.0", - "babel-loader": "^6.4.1", - "babel-plugin-inline-react-svg": "^0.5.1", - "babel-plugin-istanbul": "^4.1.5", - "babel-plugin-syntax-jsx": "^6.18.0", - "babel-plugin-transform-replace-object-assign": "^0.2.1", - "babel-preset-airbnb": "^2.4.0", - "babel-register": "^6.26.0", - "chai": "^4.1.2", - "clean-css": "^4.1.9", - "coveralls": "^2.13.3", - "cross-env": "^5.1.0", - "enzyme": "^3.1.0", - "enzyme-adapter-react-15": "^1.0.2", - "eslint": "^4.9.0", - "eslint-config-airbnb": "^16.1.0", - "eslint-plugin-import": "^2.8.0", - "eslint-plugin-jsx-a11y": "^6.0.2", - "eslint-plugin-react": "^7.4.0", - "eslint-plugin-react-with-styles": "^1.1.1", + "@babel/cli": "^7.16.8", + "@babel/core": "^7.16.12", + "@babel/eslint-parser": "^7.16.5", + "@babel/register": "^7.16.9", + "@babel/runtime": "^7.16.7", + "@storybook/addon-actions": "^5.3.21", + "@storybook/addon-info": "^5.3.21", + "@storybook/addon-links": "^5.3.21", + "@storybook/addon-options": "^5.3.21", + "@storybook/addons": "^5.3.21", + "@storybook/react": "^5.3.21", + "@welldone-software/why-did-you-render": "^3.6.0", + "airbnb-js-shims": "^2.2.1", + "aphrodite": "^2.4.0", + "babel-loader": "^8.2.2", + "babel-plugin-import-path-replace": "^0.1.0", + "babel-plugin-inline-react-svg": "^1.1.2", + "babel-plugin-inline-svg": "^1.2.0", + "babel-plugin-istanbul": "^5.2.0", + "babel-plugin-transform-replace-object-assign": "^2.0.0", + "babel-preset-airbnb": "^4.5.0", + "chai": "^4.2.0", + "cheerio": "=1.0.0-rc.3", + "clean-css": "^4.2.3", + "cross-env": "^5.2.1", + "enzyme": "^3.11.0", + "enzyme-adapter-react-helper": "^1.3.9", + "eslint": "^8.7.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-plugin-import": "^2.25.4", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-react": "^7.28.0", + "eslint-plugin-react-hooks": "^4.3.0", + "eslint-plugin-react-with-styles": "^2.4.0", "git-directory-deploy": "^1.5.1", - "imports-loader": "^0.7.1", - "in-publish": "^2.0.0", - "json-loader": "^0.5.7", - "karma": "^1.7.1", + "imports-loader": "^0.8.0", + "in-publish": "^2.0.1", + "karma": "^6.3.11", "karma-chai": "^0.1.0", - "karma-firefox-launcher": "^1.0.1", - "karma-mocha": "^1.3.0", + "karma-firefox-launcher": "^1.2.0", + "karma-mocha": "^2.0.1", "karma-sinon": "^1.0.5", - "karma-webpack": "^2.0.5", + "karma-webpack": "^4.0.2", + "mkdirp": "^0.5.5", "mocha": "^3.5.3", - "mocha-wrap": "^2.1.1", - "moment": "^2.19.1", - "moment-jalaali": "^0.7.2", - "node-sass": "^4.5.3", - "nyc": "^11.2.1", + "mocha-wrap": "^2.1.2", + "moment": "^2.29.1", + "moment-jalaali": "^0.7.4", + "nyc": "^10.3.2", "raw-loader": "^0.5.1", - "react": "^0.14 || ^15.5.4", - "react-addons-shallow-compare": "^0.14 || ^15.5.2", - "react-addons-test-utils": "^0.14 || ^15.5.1", - "react-dom": "^0.14 || ^15.5.4", - "react-test-renderer": "^15.6.1", - "react-with-styles-interface-aphrodite": "^3.1.1", - "react-with-styles-interface-css-compiler": "^1.0.1", - "rimraf": "^2.6.2", - "safe-publish-latest": "^1.1.1", - "sass-loader": "^6.0.6", - "sinon": "^4.0.1", - "sinon-sandbox": "^1.0.2", - "style-loader": "^0.19.0", - "webpack": "^2.6.1" + "react": "^0.14 || ^15.5.4 || ^16.1.1", + "react-dom": "^0.14 || ^15.5.4 || ^16.1.1", + "react-with-styles-interface-aphrodite": "^6.0.1", + "react-with-styles-interface-css-compiler": "^2.2.0", + "rimraf": "^2.7.1", + "safe-publish-latest": "^1.1.4", + "sass-loader": "^7.3.1", + "sinon": "^7.5.0", + "sinon-sandbox": "^2.0.6", + "style-loader": "^0.20.3", + "typescript": "*", + "webpack": "^4.46.0" }, "dependencies": { - "airbnb-prop-types": "^2.8.1", - "consolidated-events": "^1.1.0", + "airbnb-prop-types": "^2.16.0", + "color2k": "~1.1.1", + "consolidated-events": "^1.1.1 || ^2.0.0", + "enzyme-shallow-equal": "^1.0.4", "is-touch-device": "^1.0.1", "lodash": "^4.1.1", - "object.assign": "^4.0.4", - "object.values": "^1.0.4", - "prop-types": "^15.5.10", - "react-moment-proptypes": "^1.5.0", - "react-portal": "^3.1.0", - "react-with-styles": "^2.2.0", - "react-with-styles-interface-css": "^3.0.0" + "object.assign": "^4.1.2", + "object.values": "^1.1.5", + "prop-types": "^15.7.2", + "raf": "^3.4.1", + "react-moment-proptypes": "^1.8.1", + "react-outside-click-handler": "^1.3.0", + "react-portal": "^4.2.1", + "react-with-direction": "^1.4.0", + "react-with-styles": "~4.1.0", + "react-with-styles-interface-css": "^6.0.0" }, "peerDependencies": { + "@babel/runtime": "^7.0.0", "moment": "^2.18.1", - "react": ">=0.14", - "react-dom": ">=0.14", - "react-addons-shallow-compare": ">=0.14" + "react": "^0.14 || ^15.5.4 || ^16.1.1", + "react-dom": "^0.14 || ^15.5.4 || ^16.1.1" }, - "greenkeeper": { - "ignore": [ - "webpack" - ] + "engines": { + "node": ">=0.10" } } diff --git a/scripts/buildCSS.js b/scripts/buildCSS.js index ab1b7a9e7c..fc77611196 100644 --- a/scripts/buildCSS.js +++ b/scripts/buildCSS.js @@ -8,20 +8,23 @@ const compileCSS = require('react-with-styles-interface-css-compiler'); const registerMaxSpecificity = require('react-with-styles-interface-css/dist/utils/registerMaxSpecificity').default; const registerCSSInterfaceWithDefaultTheme = require('../src/utils/registerCSSInterfaceWithDefaultTheme').default; +console.error = msg => { throw new SyntaxError(msg); }; +console.warn = msg => { throw new SyntaxError(msg); }; + const args = process.argv.slice(2); const optimizeForProduction = args.includes('-o') || args.includes('--optimize'); -require('../test/_helpers/ignoreSVGStrings'); - registerMaxSpecificity(0); registerCSSInterfaceWithDefaultTheme(); -const DateRangePickerPath = './src/components/DateRangePicker.jsx'; -const SingleDatePickerPath = './src/components/SingleDatePicker.jsx'; +const path = './scripts/renderAllComponents.jsx'; +const CSS = compileCSS(path); -const dateRangePickerCSS = compileCSS(DateRangePickerPath); -const singleDatePickerCSS = compileCSS(SingleDatePickerPath); -const CSS = dateRangePickerCSS + singleDatePickerCSS; +if (CSS === '') { + throw new Error('Failed to compile CSS'); +} else { + console.log('CSS compilation complete.'); +} const format = new CleanCSS({ level: optimizeForProduction ? 2 : 0, diff --git a/scripts/pure-component-fallback.js b/scripts/pure-component-fallback.js new file mode 100644 index 0000000000..8cf60d5420 --- /dev/null +++ b/scripts/pure-component-fallback.js @@ -0,0 +1,70 @@ +module.exports = function pureComponentFallback({ types: t, template }) { + const buildPureOrNormalSuperclass = () => t.LogicalExpression('||', + t.memberExpression(t.identifier('React'), t.identifier('PureComponent')), + t.memberExpression(t.identifier('React'), t.identifier('Component'))); + + const buildShouldComponentUpdate = () => { + const method = t.classMethod( + 'method', + t.logicalExpression( + '&&', + t.unaryExpression('!', t.memberExpression(t.identifier('React'), t.identifier('PureComponent'))), + t.stringLiteral('shouldComponentUpdate') + ), + [t.identifier('nextProps'), t.identifier('nextState')], + t.blockStatement([template('return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState);')()]), + true + ); + return method; + }; + + function superclassIsPureComponent(path) { + const superclass = path.get('superClass'); + // check for PureComponent + if (t.isIdentifier(superclass)) { + return superclass.node.name === 'PureComponent'; + } + + if (t.isMemberExpression(superclass)) { + // Check for React.PureComponent + return superclass.get('object').node.name === 'React' + && superclass.get('property').node.name === 'PureComponent'; + } + + return false; + } + + return { + visitor: { + Program: { + exit({ node }, { file }) { + if (file.get('addShallowEqualImport')) { + const shallowEqualImportDeclaration = t.importDeclaration([ + t.importDefaultSpecifier(t.identifier('shallowEqual')), + ], t.stringLiteral('enzyme-shallow-equal')); + node.body.unshift(shallowEqualImportDeclaration); + } + }, + }, + + ClassDeclaration(path, { file }) { + if (superclassIsPureComponent(path)) { + const superclassPath = path.get('superClass'); + + // Replace the superclass + superclassPath.replaceWith(buildPureOrNormalSuperclass()); + + // Only add an SCU if one doesn't already exist + const existingSCU = path.get('body').get('body').find(( + p => p.isClassMethod() && p.get('key').node.name === 'shouldComponentUpdate' + )); + + if (!existingSCU) { + file.set('addShallowEqualImport', true); + path.get('body').unshiftContainer('body', buildShouldComponentUpdate()); + } + } + }, + }, + }; +}; diff --git a/scripts/renderAllComponents.jsx b/scripts/renderAllComponents.jsx new file mode 100644 index 0000000000..4da9dedba6 --- /dev/null +++ b/scripts/renderAllComponents.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import DateRangePickerWrapper from '../examples/DateRangePickerWrapper'; +import SingleDatePickerWrapper from '../examples/SingleDatePickerWrapper'; +import PresetDateRangePickerWrapper from '../examples/PresetDateRangePicker'; + +if (!document.getElementById('root')) { + // Make sure the #root element is defined + const root = document.createElement('div'); + root.id = 'root'; + document.body.appendChild(root); +} + +function App() { + return ( +
+ + + +
+ ); +} + +ReactDOM.render(, document.querySelector('#root')); diff --git a/src/components/CalendarDay.jsx b/src/components/CalendarDay.jsx index ef06976f3b..036a7c7655 100644 --- a/src/components/CalendarDay.jsx +++ b/src/components/CalendarDay.jsx @@ -1,29 +1,31 @@ import React from 'react'; import PropTypes from 'prop-types'; -import shallowCompare from 'react-addons-shallow-compare'; import momentPropTypes from 'react-moment-proptypes'; import { forbidExtraProps, nonNegativeInteger } from 'airbnb-prop-types'; -import { css, withStyles, withStylesPropTypes } from 'react-with-styles'; +import { withStyles, withStylesPropTypes } from 'react-with-styles'; import moment from 'moment'; +import raf from 'raf'; import { CalendarDayPhrases } from '../defaultPhrases'; import getPhrasePropTypes from '../utils/getPhrasePropTypes'; -import getPhrase from '../utils/getPhrase'; +import getCalendarDaySettings from '../utils/getCalendarDaySettings'; +import ModifiersShape from '../shapes/ModifiersShape'; -import { BLOCKED_MODIFIER, DAY_SIZE } from '../constants'; +import { DAY_SIZE } from '../constants'; const propTypes = forbidExtraProps({ ...withStylesPropTypes, day: momentPropTypes.momentObj, daySize: nonNegativeInteger, isOutsideDay: PropTypes.bool, - modifiers: PropTypes.instanceOf(Set), + modifiers: ModifiersShape, isFocused: PropTypes.bool, tabIndex: PropTypes.oneOf([0, -1]), onDayClick: PropTypes.func, onDayMouseEnter: PropTypes.func, onDayMouseLeave: PropTypes.func, - renderDay: PropTypes.func, + renderDayContents: PropTypes.func, + ariaLabelFormat: PropTypes.string, // internationalization phrases: PropTypes.shape(getPhrasePropTypes(CalendarDayPhrases)), @@ -39,28 +41,29 @@ const defaultProps = { onDayClick() {}, onDayMouseEnter() {}, onDayMouseLeave() {}, - renderDay: null, + renderDayContents: null, + ariaLabelFormat: 'dddd, LL', // internationalization phrases: CalendarDayPhrases, }; -class CalendarDay extends React.Component { +class CalendarDay extends React.PureComponent { constructor(...args) { super(...args); this.setButtonRef = this.setButtonRef.bind(this); } - shouldComponentUpdate(nextProps, nextState) { - return shallowCompare(this, nextProps, nextState); - } - componentDidUpdate(prevProps) { const { isFocused, tabIndex } = this.props; if (tabIndex === 0) { if (isFocused || tabIndex !== prevProps.tabIndex) { - this.buttonRef.focus(); + raf(() => { + if (this.buttonRef) { + this.buttonRef.focus(); + } + }); } } } @@ -80,6 +83,15 @@ class CalendarDay extends React.Component { onDayMouseLeave(day, e); } + onKeyDown(day, e) { + const { onDayClick } = this.props; + + const { key } = e; + if (key === 'Enter' || key === ' ') { + onDayClick(day, e); + } + } + setButtonRef(ref) { this.buttonRef = ref; } @@ -87,84 +99,72 @@ class CalendarDay extends React.Component { render() { const { day, + ariaLabelFormat, daySize, isOutsideDay, modifiers, - renderDay, + renderDayContents, tabIndex, + css, styles, - phrases: { - chooseAvailableDate, - dateIsUnavailable, - }, + phrases, } = this.props; if (!day) return ; - const formattedDate = { date: `${day.format('dddd')}, ${day.format('LL')}` }; - - const ariaLabel = modifiers.has(BLOCKED_MODIFIER) - ? getPhrase(dateIsUnavailable, formattedDate) - : getPhrase(chooseAvailableDate, formattedDate); - - const daySizeStyles = { - width: daySize, - height: daySize - 1, - }; - - const useDefaultCursor = ( - modifiers.has('blocked-minimum-nights') - || modifiers.has('blocked-calendar') - || modifiers.has('blocked-out-of-range') - ); - - const selected = ( - modifiers.has('selected') - || modifiers.has('selected-start') - || modifiers.has('selected-end') - ); - - const hoveredSpan = !selected && ( - modifiers.has('hovered-span') - || modifiers.has('after-hovered-start') - ); - - const isOutsideRange = modifiers.has('blocked-out-of-range'); + const { + daySizeStyles, + useDefaultCursor, + selected, + hoveredSpan, + isOutsideRange, + ariaLabel, + } = getCalendarDaySettings(day, ariaLabelFormat, daySize, modifiers, phrases); return ( { this.onDayMouseEnter(day, e); }} + onMouseLeave={(e) => { this.onDayMouseLeave(day, e); }} + onMouseUp={(e) => { e.currentTarget.blur(); }} + onClick={(e) => { this.onDayClick(day, e); }} + onKeyDown={(e) => { this.onKeyDown(day, e); }} + tabIndex={tabIndex} > - + {renderDayContents ? renderDayContents(day, modifiers) : day.format('D')} ); } @@ -174,51 +174,48 @@ CalendarDay.propTypes = propTypes; CalendarDay.defaultProps = defaultProps; export { CalendarDay as PureCalendarDay }; -export default withStyles(({ reactDates: { color } }) => ({ - CalendarDay_container: { - border: `1px solid ${color.core.borderLight}`, - padding: 0, +export default withStyles(({ reactDates: { color, font } }) => ({ + CalendarDay: { boxSizing: 'border-box', + cursor: 'pointer', + fontSize: font.size, + textAlign: 'center', + + ':active': { + outline: 0, + }, + }, + + CalendarDay__defaultCursor: { + cursor: 'default', + }, + + CalendarDay__default: { + border: `1px solid ${color.core.borderLight}`, color: color.text, - background: '#fff', + background: color.background, ':hover': { background: color.core.borderLight, - border: `1px double ${color.core.borderLight}`, + border: `1px solid ${color.core.borderLight}`, color: 'inherit', }, }, - CalendarDay_button: { - position: 'relative', - height: '100%', - width: '100%', - textAlign: 'center', - background: 'none', - border: 0, - margin: 0, - padding: 0, + CalendarDay__hovered_offset: { + background: color.core.borderBright, + border: `1px double ${color.core.borderLight}`, color: 'inherit', - font: 'inherit', - lineHeight: 'normal', - overflow: 'visible', - boxSizing: 'border-box', - cursor: 'pointer', - - ':active': { - outline: 0, - }, - }, - - CalendarDay_button__default: { - cursor: 'default', }, CalendarDay__outside: { border: 0, - background: color.outside.backgroundColor, color: color.outside.color, + + ':hover': { + border: 0, + }, }, CalendarDay__blocked_minimum_nights: { @@ -254,58 +251,54 @@ export default withStyles(({ reactDates: { color } }) => ({ CalendarDay__selected_span: { background: color.selectedSpan.backgroundColor, - border: `1px solid ${color.selectedSpan.borderColor}`, + border: `1px double ${color.selectedSpan.borderColor}`, color: color.selectedSpan.color, ':hover': { background: color.selectedSpan.backgroundColor_hover, - border: `1px solid ${color.selectedSpan.borderColor}`, + border: `1px double ${color.selectedSpan.borderColor}`, color: color.selectedSpan.color_active, }, ':active': { background: color.selectedSpan.backgroundColor_active, - border: `1px solid ${color.selectedSpan.borderColor}`, + border: `1px double ${color.selectedSpan.borderColor}`, color: color.selectedSpan.color_active, }, }, - CalendarDay__last_in_range: { - borderRight: color.core.primary, - }, - CalendarDay__selected: { background: color.selected.backgroundColor, - border: `1px solid ${color.selected.borderColor}`, + border: `1px double ${color.selected.borderColor}`, color: color.selected.color, ':hover': { background: color.selected.backgroundColor_hover, - border: `1px solid ${color.selected.borderColor}`, + border: `1px double ${color.selected.borderColor}`, color: color.selected.color_active, }, ':active': { background: color.selected.backgroundColor_active, - border: `1px solid ${color.selected.borderColor}`, + border: `1px double ${color.selected.borderColor}`, color: color.selected.color_active, }, }, CalendarDay__hovered_span: { background: color.hoveredSpan.backgroundColor, - border: `1px solid ${color.hoveredSpan.borderColor}`, + border: `1px double ${color.hoveredSpan.borderColor}`, color: color.hoveredSpan.color, ':hover': { background: color.hoveredSpan.backgroundColor_hover, - border: `1px solid ${color.hoveredSpan.borderColor}`, + border: `1px double ${color.hoveredSpan.borderColor}`, color: color.hoveredSpan.color_active, }, ':active': { background: color.hoveredSpan.backgroundColor_active, - border: `1px solid ${color.hoveredSpan.borderColor}`, + border: `1px double ${color.hoveredSpan.borderColor}`, color: color.hoveredSpan.color_active, }, }, @@ -346,7 +339,26 @@ export default withStyles(({ reactDates: { color } }) => ({ }, }, + CalendarDay__hovered_start_first_possible_end: { + background: color.core.borderLighter, + border: `1px double ${color.core.borderLighter}`, + }, + + CalendarDay__hovered_start_blocked_min_nights: { + background: color.core.borderLighter, + border: `1px double ${color.core.borderLight}`, + }, + CalendarDay__selected_start: {}, CalendarDay__selected_end: {}, - -}))(CalendarDay); + CalendarDay__today: {}, + CalendarDay__firstDayOfWeek: {}, + CalendarDay__lastDayOfWeek: {}, + CalendarDay__after_hovered_start: {}, + CalendarDay__before_hovered_end: {}, + CalendarDay__no_selected_start_before_selected_end: {}, + CalendarDay__selected_start_in_hovered_span: {}, + CalendarDay__selected_end_in_hovered_span: {}, + CalendarDay__selected_start_no_selected_end: {}, + CalendarDay__selected_end_no_selected_start: {}, +}), { pureComponent: typeof React.PureComponent !== 'undefined' })(CalendarDay); diff --git a/src/components/CalendarMonth.jsx b/src/components/CalendarMonth.jsx index c2a1093f8d..248a486416 100644 --- a/src/components/CalendarMonth.jsx +++ b/src/components/CalendarMonth.jsx @@ -2,15 +2,15 @@ import React from 'react'; import PropTypes from 'prop-types'; -import shallowCompare from 'react-addons-shallow-compare'; import momentPropTypes from 'react-moment-proptypes'; -import { forbidExtraProps, nonNegativeInteger } from 'airbnb-prop-types'; -import { css, withStyles, withStylesPropTypes } from 'react-with-styles'; +import { forbidExtraProps, mutuallyExclusiveProps, nonNegativeInteger } from 'airbnb-prop-types'; +import { withStyles, withStylesPropTypes } from 'react-with-styles'; import moment from 'moment'; import { CalendarDayPhrases } from '../defaultPhrases'; import getPhrasePropTypes from '../utils/getPhrasePropTypes'; +import CalendarWeek from './CalendarWeek'; import CalendarDay from './CalendarDay'; import calculateDimension from '../utils/calculateDimension'; @@ -18,12 +18,12 @@ import getCalendarMonthWeeks from '../utils/getCalendarMonthWeeks'; import isSameDay from '../utils/isSameDay'; import toISODateString from '../utils/toISODateString'; +import ModifiersShape from '../shapes/ModifiersShape'; import ScrollableOrientationShape from '../shapes/ScrollableOrientationShape'; import DayOfWeekShape from '../shapes/DayOfWeekShape'; import { HORIZONTAL_ORIENTATION, - VERTICAL_ORIENTATION, VERTICAL_SCROLLABLE, DAY_SIZE, } from '../constants'; @@ -31,18 +31,24 @@ import { const propTypes = forbidExtraProps({ ...withStylesPropTypes, month: momentPropTypes.momentObj, + horizontalMonthPadding: nonNegativeInteger, isVisible: PropTypes.bool, enableOutsideDays: PropTypes.bool, - modifiers: PropTypes.object, + modifiers: PropTypes.objectOf(ModifiersShape), orientation: ScrollableOrientationShape, daySize: nonNegativeInteger, onDayClick: PropTypes.func, onDayMouseEnter: PropTypes.func, onDayMouseLeave: PropTypes.func, - renderMonth: PropTypes.func, - renderDay: PropTypes.func, + onMonthSelect: PropTypes.func, + onYearSelect: PropTypes.func, + renderMonthText: mutuallyExclusiveProps(PropTypes.func, 'renderMonthText', 'renderMonthElement'), + renderCalendarDay: PropTypes.func, + renderDayContents: PropTypes.func, + renderMonthElement: mutuallyExclusiveProps(PropTypes.func, 'renderMonthText', 'renderMonthElement'), firstDayOfWeek: DayOfWeekShape, - setMonthHeight: PropTypes.func, + setMonthTitleHeight: PropTypes.func, + verticalBorderSpacing: nonNegativeInteger, focusedDate: momentPropTypes.momentObj, // indicates focusable day isFocused: PropTypes.bool, // indicates whether or not to move focus to focusable day @@ -50,10 +56,12 @@ const propTypes = forbidExtraProps({ // i18n monthFormat: PropTypes.string, phrases: PropTypes.shape(getPhrasePropTypes(CalendarDayPhrases)), + dayAriaLabelFormat: PropTypes.string, }); const defaultProps = { month: moment(), + horizontalMonthPadding: 13, isVisible: true, enableOutsideDays: false, modifiers: {}, @@ -62,10 +70,14 @@ const defaultProps = { onDayClick() {}, onDayMouseEnter() {}, onDayMouseLeave() {}, - renderMonth: null, - renderDay: null, + onMonthSelect() {}, + onYearSelect() {}, + renderMonthText: null, + renderCalendarDay: (props) => (), + renderDayContents: null, + renderMonthElement: null, firstDayOfWeek: null, - setMonthHeight() {}, + setMonthTitleHeight: null, focusedDate: null, isFocused: false, @@ -73,9 +85,11 @@ const defaultProps = { // i18n monthFormat: 'MMMM YYYY', // english locale phrases: CalendarDayPhrases, + dayAriaLabelFormat: undefined, + verticalBorderSpacing: undefined, }; -class CalendarMonth extends React.Component { +class CalendarMonth extends React.PureComponent { constructor(props) { super(props); @@ -88,19 +102,25 @@ class CalendarMonth extends React.Component { }; this.setCaptionRef = this.setCaptionRef.bind(this); - this.setGridRef = this.setGridRef.bind(this); - this.setMonthHeight = this.setMonthHeight.bind(this); + this.setMonthTitleHeight = this.setMonthTitleHeight.bind(this); } componentDidMount() { - this.setMonthHeightTimeout = setTimeout(this.setMonthHeight, 0); + this.queueSetMonthTitleHeight(); } componentWillReceiveProps(nextProps) { const { month, enableOutsideDays, firstDayOfWeek } = nextProps; - if (!month.isSame(this.props.month) - || enableOutsideDays !== this.props.enableOutsideDays - || firstDayOfWeek !== this.props.firstDayOfWeek) { + const { + month: prevMonth, + enableOutsideDays: prevEnableOutsideDays, + firstDayOfWeek: prevFirstDayOfWeek, + } = this.props; + if ( + !month.isSame(prevMonth) + || enableOutsideDays !== prevEnableOutsideDays + || firstDayOfWeek !== prevFirstDayOfWeek + ) { this.setState({ weeks: getCalendarMonthWeeks( month, @@ -111,53 +131,65 @@ class CalendarMonth extends React.Component { } } - shouldComponentUpdate(nextProps, nextState) { - return shallowCompare(this, nextProps, nextState); + componentDidUpdate(prevProps) { + const { setMonthTitleHeight } = this.props; + + if (prevProps.setMonthTitleHeight === null && setMonthTitleHeight !== null) { + this.queueSetMonthTitleHeight(); + } } componentWillUnmount() { - if (this.setMonthHeightTimeout) { - clearTimeout(this.setMonthHeightTimeout); + if (this.setMonthTitleHeightTimeout) { + clearTimeout(this.setMonthTitleHeightTimeout); } } - setMonthHeight() { - const { setMonthHeight } = this.props; - const captionHeight = calculateDimension(this.captionRef, 'height', true, true); - const gridHeight = calculateDimension(this.gridRef, 'height'); - - setMonthHeight(captionHeight + gridHeight + 1); + setMonthTitleHeight() { + const { setMonthTitleHeight } = this.props; + if (setMonthTitleHeight) { + const captionHeight = calculateDimension(this.captionRef, 'height', true, true); + setMonthTitleHeight(captionHeight); + } } setCaptionRef(ref) { this.captionRef = ref; } - setGridRef(ref) { - this.gridRef = ref; + queueSetMonthTitleHeight() { + this.setMonthTitleHeightTimeout = window.setTimeout(this.setMonthTitleHeight, 0); } render() { const { - month, - monthFormat, - orientation, + dayAriaLabelFormat, + daySize, + focusedDate, + horizontalMonthPadding, + isFocused, isVisible, modifiers, + month, + monthFormat, onDayClick, onDayMouseEnter, onDayMouseLeave, - renderMonth, - renderDay, - daySize, - focusedDate, - isFocused, - styles, + onMonthSelect, + onYearSelect, + orientation, phrases, + renderCalendarDay, + renderDayContents, + renderMonthElement, + renderMonthText, + css, + styles, + verticalBorderSpacing, } = this.props; const { weeks } = this.state; - const monthTitle = renderMonth ? renderMonth(month) : month.format(monthFormat); + const monthTitle = renderMonthText ? renderMonthText(month) : month.format(monthFormat); const verticalScrollable = orientation === VERTICAL_SCROLLABLE; @@ -165,9 +197,7 @@ class CalendarMonth extends React.Component {
@@ -178,33 +208,47 @@ class CalendarMonth extends React.Component { verticalScrollable && styles.CalendarMonth_caption__verticalScrollable, )} > - {monthTitle} + {renderMonthElement ? ( + renderMonthElement({ + month, + onMonthSelect, + onYearSelect, + isVisible, + }) + ) : ( + + {monthTitle} + + )}
- + {weeks.map((week, i) => ( - - {week.map((day, dayOfWeek) => ( - - ))} - + + {week.map((day, dayOfWeek) => renderCalendarDay({ + key: dayOfWeek, + day, + daySize, + isOutsideDay: !day || day.month() !== month.month(), + tabIndex: isVisible && isSameDay(day, focusedDate) ? 0 : -1, + isFocused, + onDayMouseEnter, + onDayMouseLeave, + onDayClick, + renderDayContents, + phrases, + modifiers: modifiers[toISODateString(day)], + ariaLabelFormat: dayAriaLabelFormat, + }))} + ))}
@@ -220,7 +264,6 @@ export default withStyles(({ reactDates: { color, font, spacing } }) => ({ CalendarMonth: { background: color.background, textAlign: 'center', - padding: '0 13px', verticalAlign: 'top', userSelect: 'none', }, @@ -230,6 +273,10 @@ export default withStyles(({ reactDates: { color, font, spacing } }) => ({ borderSpacing: 0, }, + CalendarMonth_verticalSpacing: { + borderCollapse: 'separate', + }, + CalendarMonth_caption: { color: color.text, fontSize: font.captionSize, @@ -243,5 +290,4 @@ export default withStyles(({ reactDates: { color, font, spacing } }) => ({ paddingTop: 12, paddingBottom: 7, }, -}))(CalendarMonth); - +}), { pureComponent: typeof React.PureComponent !== 'undefined' })(CalendarMonth); diff --git a/src/components/CalendarMonthGrid.jsx b/src/components/CalendarMonthGrid.jsx index dfd43c1e7f..dd1c80bf8f 100644 --- a/src/components/CalendarMonthGrid.jsx +++ b/src/components/CalendarMonthGrid.jsx @@ -1,14 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; -import shallowCompare from 'react-addons-shallow-compare'; import momentPropTypes from 'react-moment-proptypes'; -import { forbidExtraProps, nonNegativeInteger } from 'airbnb-prop-types'; -import { css, withStyles, withStylesPropTypes } from 'react-with-styles'; +import { forbidExtraProps, mutuallyExclusiveProps, nonNegativeInteger } from 'airbnb-prop-types'; +import { withStyles, withStylesPropTypes } from 'react-with-styles'; import moment from 'moment'; import { addEventListener } from 'consolidated-events'; import { CalendarDayPhrases } from '../defaultPhrases'; import getPhrasePropTypes from '../utils/getPhrasePropTypes'; +import noflip from '../utils/noflip'; import CalendarMonth from './CalendarMonth'; @@ -16,8 +16,10 @@ import isTransitionEndSupported from '../utils/isTransitionEndSupported'; import getTransformStyles from '../utils/getTransformStyles'; import getCalendarMonthWidth from '../utils/getCalendarMonthWidth'; import toISOMonthString from '../utils/toISOMonthString'; -import isAfterDay from '../utils/isAfterDay'; +import isPrevMonth from '../utils/isPrevMonth'; +import isNextMonth from '../utils/isNextMonth'; +import ModifiersShape from '../shapes/ModifiersShape'; import ScrollableOrientationShape from '../shapes/ScrollableOrientationShape'; import DayOfWeekShape from '../shapes/DayOfWeekShape'; @@ -32,33 +34,42 @@ const propTypes = forbidExtraProps({ ...withStylesPropTypes, enableOutsideDays: PropTypes.bool, firstVisibleMonthIndex: PropTypes.number, + horizontalMonthPadding: nonNegativeInteger, initialMonth: momentPropTypes.momentObj, isAnimating: PropTypes.bool, numberOfMonths: PropTypes.number, - modifiers: PropTypes.object, + modifiers: PropTypes.objectOf(PropTypes.objectOf(ModifiersShape)), orientation: ScrollableOrientationShape, onDayClick: PropTypes.func, onDayMouseEnter: PropTypes.func, onDayMouseLeave: PropTypes.func, onMonthTransitionEnd: PropTypes.func, - renderMonth: PropTypes.func, - renderDay: PropTypes.func, - transformValue: PropTypes.string, + onMonthChange: PropTypes.func, + onYearChange: PropTypes.func, + renderMonthText: mutuallyExclusiveProps(PropTypes.func, 'renderMonthText', 'renderMonthElement'), + renderCalendarDay: PropTypes.func, + renderDayContents: PropTypes.func, + translationValue: PropTypes.number, + renderMonthElement: mutuallyExclusiveProps(PropTypes.func, 'renderMonthText', 'renderMonthElement'), daySize: nonNegativeInteger, focusedDate: momentPropTypes.momentObj, // indicates focusable day isFocused: PropTypes.bool, // indicates whether or not to move focus to focusable day firstDayOfWeek: DayOfWeekShape, - setCalendarMonthHeights: PropTypes.func, + setMonthTitleHeight: PropTypes.func, isRTL: PropTypes.bool, + transitionDuration: nonNegativeInteger, + verticalBorderSpacing: nonNegativeInteger, // i18n monthFormat: PropTypes.string, phrases: PropTypes.shape(getPhrasePropTypes(CalendarDayPhrases)), + dayAriaLabelFormat: PropTypes.string, }); const defaultProps = { enableOutsideDays: false, firstVisibleMonthIndex: 0, + horizontalMonthPadding: 13, initialMonth: moment(), isAnimating: false, numberOfMonths: 1, @@ -67,20 +78,27 @@ const defaultProps = { onDayClick() {}, onDayMouseEnter() {}, onDayMouseLeave() {}, + onMonthChange() {}, + onYearChange() {}, onMonthTransitionEnd() {}, - renderMonth: null, - renderDay: null, - transformValue: 'none', + renderMonthText: null, + renderCalendarDay: undefined, + renderDayContents: null, + translationValue: null, + renderMonthElement: null, daySize: DAY_SIZE, focusedDate: null, isFocused: false, firstDayOfWeek: null, - setCalendarMonthHeights() {}, + setMonthTitleHeight: null, isRTL: false, + transitionDuration: 200, + verticalBorderSpacing: undefined, // i18n monthFormat: 'MMMM YYYY', // english locale phrases: CalendarDayPhrases, + dayAriaLabelFormat: undefined, }; function getMonths(initialMonth, numberOfMonths, withoutTransitionMonths) { @@ -96,7 +114,7 @@ function getMonths(initialMonth, numberOfMonths, withoutTransitionMonths) { return months; } -class CalendarMonthGrid extends React.Component { +class CalendarMonthGrid extends React.PureComponent { constructor(props) { super(props); const withoutTransitionMonths = props.orientation === VERTICAL_SCROLLABLE; @@ -104,132 +122,151 @@ class CalendarMonthGrid extends React.Component { months: getMonths(props.initialMonth, props.numberOfMonths, withoutTransitionMonths), }; - this.calendarMonthHeights = []; - this.isTransitionEndSupported = isTransitionEndSupported(); this.onTransitionEnd = this.onTransitionEnd.bind(this); this.setContainerRef = this.setContainerRef.bind(this); this.locale = moment.locale(); + this.onMonthSelect = this.onMonthSelect.bind(this); + this.onYearSelect = this.onYearSelect.bind(this); } componentDidMount() { - const { setCalendarMonthHeights } = this.props; this.removeEventListener = addEventListener( this.container, 'transitionend', this.onTransitionEnd, ); - - this.setCalendarMonthHeightsTimeout = setTimeout(() => { - setCalendarMonthHeights(this.calendarMonthHeights); - }, 0); } componentWillReceiveProps(nextProps) { const { initialMonth, numberOfMonths, orientation } = nextProps; const { months } = this.state; - const hasMonthChanged = !this.props.initialMonth.isSame(initialMonth, 'month'); - const hasNumberOfMonthsChanged = this.props.numberOfMonths !== numberOfMonths; + const { + initialMonth: prevInitialMonth, + numberOfMonths: prevNumberOfMonths, + } = this.props; + const hasMonthChanged = !prevInitialMonth.isSame(initialMonth, 'month'); + const hasNumberOfMonthsChanged = prevNumberOfMonths !== numberOfMonths; let newMonths = months; - if (hasMonthChanged && !hasNumberOfMonthsChanged) { - if (isAfterDay(initialMonth, this.props.initialMonth)) { - newMonths = months.slice(1); - newMonths.push(months[months.length - 1].clone().add(1, 'month')); - } else { - newMonths = months.slice(0, months.length - 1); - newMonths.unshift(months[0].clone().subtract(1, 'month')); + if (hasMonthChanged || hasNumberOfMonthsChanged) { + if (hasMonthChanged && !hasNumberOfMonthsChanged) { + if (isNextMonth(prevInitialMonth, initialMonth)) { + newMonths = months.slice(1); + newMonths.push(months[months.length - 1].clone().add(1, 'month')); + } else if (isPrevMonth(prevInitialMonth, initialMonth)) { + newMonths = months.slice(0, months.length - 1); + newMonths.unshift(months[0].clone().subtract(1, 'month')); + } else { + const withoutTransitionMonths = orientation === VERTICAL_SCROLLABLE; + newMonths = getMonths(initialMonth, numberOfMonths, withoutTransitionMonths); + } } - } - if (hasNumberOfMonthsChanged) { - const withoutTransitionMonths = orientation === VERTICAL_SCROLLABLE; - newMonths = getMonths(initialMonth, numberOfMonths, withoutTransitionMonths); - } - - const momentLocale = moment.locale(); - if (this.locale !== momentLocale) { - this.locale = momentLocale; - newMonths = newMonths.map(m => m.locale(this.locale)); - } + if (hasNumberOfMonthsChanged) { + const withoutTransitionMonths = orientation === VERTICAL_SCROLLABLE; + newMonths = getMonths(initialMonth, numberOfMonths, withoutTransitionMonths); + } - this.setState({ - months: newMonths, - }); - } + const momentLocale = moment.locale(); + if (this.locale !== momentLocale) { + this.locale = momentLocale; + newMonths = newMonths.map((m) => m.locale(this.locale)); + } - shouldComponentUpdate(nextProps, nextState) { - return shallowCompare(this, nextProps, nextState); + this.setState({ + months: newMonths, + }); + } } - componentDidUpdate(prevProps) { - const { isAnimating, onMonthTransitionEnd, setCalendarMonthHeights } = this.props; + componentDidUpdate() { + const { + isAnimating, + transitionDuration, + onMonthTransitionEnd, + } = this.props; // For IE9, immediately call onMonthTransitionEnd instead of - // waiting for the animation to complete - if (!this.isTransitionEndSupported && isAnimating) { + // waiting for the animation to complete. Similarly, if transitionDuration + // is set to 0, also immediately invoke the onMonthTransitionEnd callback + if ((!this.isTransitionEndSupported || !transitionDuration) && isAnimating) { onMonthTransitionEnd(); } - - if (!isAnimating && prevProps.isAnimating) { - this.setCalendarMonthHeightsTimeout = setTimeout(() => { - setCalendarMonthHeights(this.calendarMonthHeights); - }, 0); - } } componentWillUnmount() { if (this.removeEventListener) this.removeEventListener(); - if (this.setCalendarMonthHeightsTimeout) { - clearTimeout(this.setCalendarMonthHeightsTimeout); - } } onTransitionEnd() { - this.props.onMonthTransitionEnd(); + const { onMonthTransitionEnd } = this.props; + onMonthTransitionEnd(); } - setContainerRef(ref) { - this.container = ref; + onMonthSelect(currentMonth, newMonthVal) { + const newMonth = currentMonth.clone(); + const { onMonthChange, orientation } = this.props; + const { months } = this.state; + const withoutTransitionMonths = orientation === VERTICAL_SCROLLABLE; + let initialMonthSubtraction = months.indexOf(currentMonth); + if (!withoutTransitionMonths) { + initialMonthSubtraction -= 1; + } + newMonth.set('month', newMonthVal).subtract(initialMonthSubtraction, 'months'); + onMonthChange(newMonth); } - setMonthHeight(height, i) { - if (this.calendarMonthHeights[i]) { - if (i === 0) { - this.calendarMonthHeights = [height].concat(this.calendarMonthHeights.slice(0, -1)); - } else if (i === this.calendarMonthHeights.length - 1) { - this.calendarMonthHeights = this.calendarMonthHeights.slice(1).concat(height); - } - } else { - this.calendarMonthHeights[i] = height; + onYearSelect(currentMonth, newYearVal) { + const newMonth = currentMonth.clone(); + const { onYearChange, orientation } = this.props; + const { months } = this.state; + const withoutTransitionMonths = orientation === VERTICAL_SCROLLABLE; + let initialMonthSubtraction = months.indexOf(currentMonth); + if (!withoutTransitionMonths) { + initialMonthSubtraction -= 1; } + newMonth.set('year', newYearVal).subtract(initialMonthSubtraction, 'months'); + onYearChange(newMonth); + } + + setContainerRef(ref) { + this.container = ref; } render() { const { enableOutsideDays, firstVisibleMonthIndex, + horizontalMonthPadding, isAnimating, modifiers, numberOfMonths, monthFormat, orientation, - transformValue, + translationValue, daySize, onDayMouseEnter, onDayMouseLeave, onDayClick, - renderMonth, - renderDay, + renderMonthText, + renderCalendarDay, + renderDayContents, + renderMonthElement, onMonthTransitionEnd, firstDayOfWeek, focusedDate, isFocused, isRTL, + css, styles, phrases, + dayAriaLabelFormat, + transitionDuration, + verticalBorderSpacing, + setMonthTitleHeight, } = this.props; const { months } = this.state; @@ -237,11 +274,17 @@ class CalendarMonthGrid extends React.Component { const isVerticalScrollable = orientation === VERTICAL_SCROLLABLE; const isHorizontal = orientation === HORIZONTAL_ORIENTATION; - const calendarMonthWidth = getCalendarMonthWidth(daySize); + const calendarMonthWidth = getCalendarMonthWidth( + daySize, + horizontalMonthPadding, + ); + + const width = isVertical || isVerticalScrollable + ? calendarMonthWidth + : (numberOfMonths + 2) * calendarMonthWidth; - const width = isVertical || isVerticalScrollable ? - calendarMonthWidth : - (numberOfMonths + 2) * calendarMonthWidth; + const transformType = (isVertical || isVerticalScrollable) ? 'translateY' : 'translateX'; + const transformValue = `${transformType}(${translationValue}px)`; return (
{ this.setMonthHeight(height, i); }} + setMonthTitleHeight={setMonthTitleHeight} + dayAriaLabelFormat={dayAriaLabelFormat} + verticalBorderSpacing={verticalBorderSpacing} + horizontalMonthPadding={horizontalMonthPadding} />
); @@ -318,10 +369,16 @@ class CalendarMonthGrid extends React.Component { CalendarMonthGrid.propTypes = propTypes; CalendarMonthGrid.defaultProps = defaultProps; -export default withStyles(({ reactDates: { color, zIndex } }) => ({ +export default withStyles(({ + reactDates: { + color, + spacing, + zIndex, + }, +}) => ({ CalendarMonthGrid: { background: color.background, - textAlign: 'left', + textAlign: noflip('left'), zIndex, }, @@ -331,7 +388,7 @@ export default withStyles(({ reactDates: { color, zIndex } }) => ({ CalendarMonthGrid__horizontal: { position: 'absolute', - left: 9, + left: noflip(spacing.dayPickerHorizontalPadding), }, CalendarMonthGrid__vertical: { @@ -340,7 +397,6 @@ export default withStyles(({ reactDates: { color, zIndex } }) => ({ CalendarMonthGrid__vertical_scrollable: { margin: '0 auto', - overflowY: 'scroll', }, CalendarMonthGrid_month__horizontal: { @@ -355,4 +411,8 @@ export default withStyles(({ reactDates: { color, zIndex } }) => ({ opacity: 0, pointerEvents: 'none', }, -}))(CalendarMonthGrid); + + CalendarMonthGrid_month__hidden: { + visibility: 'hidden', + }, +}), { pureComponent: typeof React.PureComponent !== 'undefined' })(CalendarMonthGrid); diff --git a/src/components/CalendarWeek.jsx b/src/components/CalendarWeek.jsx new file mode 100644 index 0000000000..037098a3fa --- /dev/null +++ b/src/components/CalendarWeek.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { forbidExtraProps } from 'airbnb-prop-types'; + +const propTypes = forbidExtraProps({ + children: PropTypes.node.isRequired, +}); + +export default function CalendarWeek({ children }) { + return ( + + {children} + + ); +} + +CalendarWeek.propTypes = propTypes; diff --git a/src/components/CustomizableCalendarDay.jsx b/src/components/CustomizableCalendarDay.jsx new file mode 100644 index 0000000000..ad3cdbb5d7 --- /dev/null +++ b/src/components/CustomizableCalendarDay.jsx @@ -0,0 +1,380 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import momentPropTypes from 'react-moment-proptypes'; +import { forbidExtraProps, nonNegativeInteger, or } from 'airbnb-prop-types'; +import { withStyles, withStylesPropTypes } from 'react-with-styles'; +import moment from 'moment'; +import raf from 'raf'; + +import { CalendarDayPhrases } from '../defaultPhrases'; +import getPhrasePropTypes from '../utils/getPhrasePropTypes'; +import getCalendarDaySettings from '../utils/getCalendarDaySettings'; + +import { DAY_SIZE } from '../constants'; +import DefaultTheme from '../theme/DefaultTheme'; + +const { reactDates: { color } } = DefaultTheme; + +function getStyles(stylesObj, isHovered) { + if (!stylesObj) return null; + + const { hover } = stylesObj; + if (isHovered && hover) { + return hover; + } + + return stylesObj; +} + +const DayStyleShape = PropTypes.shape({ + background: PropTypes.string, + border: or([PropTypes.string, PropTypes.number]), + color: PropTypes.string, + + hover: PropTypes.shape({ + background: PropTypes.string, + border: or([PropTypes.string, PropTypes.number]), + color: PropTypes.string, + }), +}); + +const propTypes = forbidExtraProps({ + ...withStylesPropTypes, + day: momentPropTypes.momentObj, + daySize: nonNegativeInteger, + isOutsideDay: PropTypes.bool, + modifiers: PropTypes.instanceOf(Set), + isFocused: PropTypes.bool, + tabIndex: PropTypes.oneOf([0, -1]), + onDayClick: PropTypes.func, + onDayMouseEnter: PropTypes.func, + onDayMouseLeave: PropTypes.func, + renderDayContents: PropTypes.func, + ariaLabelFormat: PropTypes.string, + + // style overrides + defaultStyles: DayStyleShape, + outsideStyles: DayStyleShape, + todayStyles: DayStyleShape, + firstDayOfWeekStyles: DayStyleShape, + lastDayOfWeekStyles: DayStyleShape, + highlightedCalendarStyles: DayStyleShape, + blockedMinNightsStyles: DayStyleShape, + blockedCalendarStyles: DayStyleShape, + blockedOutOfRangeStyles: DayStyleShape, + hoveredSpanStyles: DayStyleShape, + selectedSpanStyles: DayStyleShape, + lastInRangeStyles: DayStyleShape, + selectedStyles: DayStyleShape, + selectedStartStyles: DayStyleShape, + selectedEndStyles: DayStyleShape, + afterHoveredStartStyles: DayStyleShape, + hoveredStartFirstPossibleEndStyles: DayStyleShape, + hoveredStartBlockedMinNightsStyles: DayStyleShape, + + // internationalization + phrases: PropTypes.shape(getPhrasePropTypes(CalendarDayPhrases)), +}); + +export const defaultStyles = { + border: `1px solid ${color.core.borderLight}`, + color: color.text, + background: color.background, + + hover: { + background: color.core.borderLight, + border: `1px solid ${color.core.borderLight}`, + color: 'inherit', + }, +}; + +export const outsideStyles = { + background: color.outside.backgroundColor, + border: 0, + color: color.outside.color, +}; + +export const highlightedCalendarStyles = { + background: color.highlighted.backgroundColor, + color: color.highlighted.color, + + hover: { + background: color.highlighted.backgroundColor_hover, + color: color.highlighted.color_active, + }, +}; + +export const blockedMinNightsStyles = { + background: color.minimumNights.backgroundColor, + border: `1px solid ${color.minimumNights.borderColor}`, + color: color.minimumNights.color, + + hover: { + background: color.minimumNights.backgroundColor_hover, + color: color.minimumNights.color_active, + }, +}; + +export const blockedCalendarStyles = { + background: color.blocked_calendar.backgroundColor, + border: `1px solid ${color.blocked_calendar.borderColor}`, + color: color.blocked_calendar.color, + + hover: { + background: color.blocked_calendar.backgroundColor_hover, + border: `1px solid ${color.blocked_calendar.borderColor}`, + color: color.blocked_calendar.color_active, + }, +}; + +export const blockedOutOfRangeStyles = { + background: color.blocked_out_of_range.backgroundColor, + border: `1px solid ${color.blocked_out_of_range.borderColor}`, + color: color.blocked_out_of_range.color, + + hover: { + background: color.blocked_out_of_range.backgroundColor_hover, + border: `1px solid ${color.blocked_out_of_range.borderColor}`, + color: color.blocked_out_of_range.color_active, + }, +}; + +export const hoveredSpanStyles = { + background: color.hoveredSpan.backgroundColor, + border: `1px double ${color.hoveredSpan.borderColor}`, + color: color.hoveredSpan.color, + + hover: { + background: color.hoveredSpan.backgroundColor_hover, + border: `1px double ${color.hoveredSpan.borderColor}`, + color: color.hoveredSpan.color_active, + }, +}; + +export const selectedSpanStyles = { + background: color.selectedSpan.backgroundColor, + border: `1px double ${color.selectedSpan.borderColor}`, + color: color.selectedSpan.color, + + hover: { + background: color.selectedSpan.backgroundColor_hover, + border: `1px double ${color.selectedSpan.borderColor}`, + color: color.selectedSpan.color_active, + }, +}; + +export const lastInRangeStyles = {}; + +export const selectedStyles = { + background: color.selected.backgroundColor, + border: `1px double ${color.selected.borderColor}`, + color: color.selected.color, + + hover: { + background: color.selected.backgroundColor_hover, + border: `1px double ${color.selected.borderColor}`, + color: color.selected.color_active, + }, +}; + +const defaultProps = { + day: moment(), + daySize: DAY_SIZE, + isOutsideDay: false, + modifiers: new Set(), + isFocused: false, + tabIndex: -1, + onDayClick() {}, + onDayMouseEnter() {}, + onDayMouseLeave() {}, + renderDayContents: null, + ariaLabelFormat: 'dddd, LL', + + // style defaults + defaultStyles, + outsideStyles, + todayStyles: {}, + highlightedCalendarStyles, + blockedMinNightsStyles, + blockedCalendarStyles, + blockedOutOfRangeStyles, + hoveredSpanStyles, + selectedSpanStyles, + lastInRangeStyles, + selectedStyles, + selectedStartStyles: {}, + selectedEndStyles: {}, + afterHoveredStartStyles: {}, + firstDayOfWeekStyles: {}, + lastDayOfWeekStyles: {}, + hoveredStartFirstPossibleEndStyles: {}, + hoveredStartBlockedMinNightsStyles: {}, + + // internationalization + phrases: CalendarDayPhrases, +}; + +class CustomizableCalendarDay extends React.PureComponent { + constructor(...args) { + super(...args); + + this.state = { + isHovered: false, + }; + + this.setButtonRef = this.setButtonRef.bind(this); + } + + componentDidUpdate(prevProps) { + const { isFocused, tabIndex } = this.props; + if (tabIndex === 0) { + if (isFocused || tabIndex !== prevProps.tabIndex) { + raf(() => { + if (this.buttonRef) { + this.buttonRef.focus(); + } + }); + } + } + } + + onDayClick(day, e) { + const { onDayClick } = this.props; + onDayClick(day, e); + } + + onDayMouseEnter(day, e) { + const { onDayMouseEnter } = this.props; + this.setState({ isHovered: true }); + onDayMouseEnter(day, e); + } + + onDayMouseLeave(day, e) { + const { onDayMouseLeave } = this.props; + this.setState({ isHovered: false }); + onDayMouseLeave(day, e); + } + + onKeyDown(day, e) { + const { + onDayClick, + } = this.props; + + const { key } = e; + if (key === 'Enter' || key === ' ') { + onDayClick(day, e); + } + } + + setButtonRef(ref) { + this.buttonRef = ref; + } + + render() { + const { + day, + ariaLabelFormat, + daySize, + isOutsideDay, + modifiers, + tabIndex, + renderDayContents, + css, + styles, + phrases, + + defaultStyles: defaultStylesWithHover, + outsideStyles: outsideStylesWithHover, + todayStyles: todayStylesWithHover, + firstDayOfWeekStyles: firstDayOfWeekStylesWithHover, + lastDayOfWeekStyles: lastDayOfWeekStylesWithHover, + highlightedCalendarStyles: highlightedCalendarStylesWithHover, + blockedMinNightsStyles: blockedMinNightsStylesWithHover, + blockedCalendarStyles: blockedCalendarStylesWithHover, + blockedOutOfRangeStyles: blockedOutOfRangeStylesWithHover, + hoveredSpanStyles: hoveredSpanStylesWithHover, + selectedSpanStyles: selectedSpanStylesWithHover, + lastInRangeStyles: lastInRangeStylesWithHover, + selectedStyles: selectedStylesWithHover, + selectedStartStyles: selectedStartStylesWithHover, + selectedEndStyles: selectedEndStylesWithHover, + afterHoveredStartStyles: afterHoveredStartStylesWithHover, + hoveredStartFirstPossibleEndStyles: hoveredStartFirstPossibleEndStylesWithHover, + hoveredStartBlockedMinNightsStyles: hoveredStartBlockedMinNightsStylesWithHover, + } = this.props; + + const { isHovered } = this.state; + + if (!day) return ; + + const { + daySizeStyles, + useDefaultCursor, + selected, + hoveredSpan, + isOutsideRange, + ariaLabel, + } = getCalendarDaySettings(day, ariaLabelFormat, daySize, modifiers, phrases); + + return ( + { this.onDayMouseEnter(day, e); }} + onMouseLeave={(e) => { this.onDayMouseLeave(day, e); }} + onMouseUp={(e) => { e.currentTarget.blur(); }} + onClick={(e) => { this.onDayClick(day, e); }} + onKeyDown={(e) => { this.onKeyDown(day, e); }} + tabIndex={tabIndex} + > + {renderDayContents ? renderDayContents(day, modifiers) : day.format('D')} + + ); + } +} + +CustomizableCalendarDay.propTypes = propTypes; +CustomizableCalendarDay.defaultProps = defaultProps; + +export { CustomizableCalendarDay as PureCustomizableCalendarDay }; +export default withStyles(({ reactDates: { font } }) => ({ + CalendarDay: { + boxSizing: 'border-box', + cursor: 'pointer', + fontSize: font.size, + textAlign: 'center', + + ':active': { + outline: 0, + }, + }, + + CalendarDay__defaultCursor: { + cursor: 'default', + }, +}), { pureComponent: typeof React.PureComponent !== 'undefined' })(CustomizableCalendarDay); diff --git a/src/components/DateInput.jsx b/src/components/DateInput.jsx index 85b86983db..ff486eb155 100644 --- a/src/components/DateInput.jsx +++ b/src/components/DateInput.jsx @@ -1,18 +1,36 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { forbidExtraProps } from 'airbnb-prop-types'; -import { css, withStyles, withStylesPropTypes } from 'react-with-styles'; +import { forbidExtraProps, nonNegativeInteger } from 'airbnb-prop-types'; +import { withStyles, withStylesPropTypes } from 'react-with-styles'; import throttle from 'lodash/throttle'; import isTouchDevice from 'is-touch-device'; +import noflip from '../utils/noflip'; +import getInputHeight from '../utils/getInputHeight'; import openDirectionShape from '../shapes/OpenDirectionShape'; -import { OPEN_DOWN, OPEN_UP } from '../constants'; + +import { + OPEN_DOWN, + OPEN_UP, + FANG_HEIGHT_PX, + FANG_WIDTH_PX, + DEFAULT_VERTICAL_SPACING, + MODIFIER_KEY_NAMES, +} from '../constants'; + +const FANG_PATH_TOP = `M0,${FANG_HEIGHT_PX} ${FANG_WIDTH_PX},${FANG_HEIGHT_PX} ${FANG_WIDTH_PX / 2},0z`; +const FANG_STROKE_TOP = `M0,${FANG_HEIGHT_PX} ${FANG_WIDTH_PX / 2},0 ${FANG_WIDTH_PX},${FANG_HEIGHT_PX}`; +const FANG_PATH_BOTTOM = `M0,0 ${FANG_WIDTH_PX},0 ${FANG_WIDTH_PX / 2},${FANG_HEIGHT_PX}z`; +const FANG_STROKE_BOTTOM = `M0,0 ${FANG_WIDTH_PX / 2},${FANG_HEIGHT_PX} ${FANG_WIDTH_PX},0`; const propTypes = forbidExtraProps({ ...withStylesPropTypes, id: PropTypes.string.isRequired, - placeholder: PropTypes.string, // also used as label + placeholder: PropTypes.string, displayValue: PropTypes.string, + ariaLabel: PropTypes.string, + autoComplete: PropTypes.string, + titleText: PropTypes.string, screenReaderMessage: PropTypes.string, focused: PropTypes.bool, disabled: PropTypes.bool, @@ -20,6 +38,10 @@ const propTypes = forbidExtraProps({ readOnly: PropTypes.bool, openDirection: openDirectionShape, showCaret: PropTypes.bool, + verticalSpacing: nonNegativeInteger, + small: PropTypes.bool, + block: PropTypes.bool, + regular: PropTypes.bool, onChange: PropTypes.func, onFocus: PropTypes.func, @@ -36,6 +58,9 @@ const propTypes = forbidExtraProps({ const defaultProps = { placeholder: 'Select Date', displayValue: '', + ariaLabel: undefined, + autoComplete: 'off', + titleText: undefined, screenReaderMessage: '', focused: false, disabled: false, @@ -43,6 +68,10 @@ const defaultProps = { readOnly: null, openDirection: OPEN_DOWN, showCaret: false, + verticalSpacing: DEFAULT_VERTICAL_SPACING, + small: false, + block: false, + regular: false, onChange() {}, onFocus() {}, @@ -56,7 +85,7 @@ const defaultProps = { isFocused: false, }; -class DateInput extends React.Component { +class DateInput extends React.PureComponent { constructor(props) { super(props); @@ -68,6 +97,7 @@ class DateInput extends React.Component { this.onChange = this.onChange.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.setInputRef = this.setInputRef.bind(this); + this.throttledKeyDown = throttle(this.onFinalKeyDown, 300, { trailing: false }); } componentDidMount() { @@ -75,7 +105,8 @@ class DateInput extends React.Component { } componentWillReceiveProps(nextProps) { - if (!this.props.displayValue && nextProps.displayValue) { + const { dateString } = this.state; + if (dateString && nextProps.displayValue) { this.setState({ dateString: '', }); @@ -88,8 +119,6 @@ class DateInput extends React.Component { if (focused && isFocused) { this.inputRef.focus(); - } else { - this.inputRef.blur(); } } @@ -103,22 +132,26 @@ class DateInput extends React.Component { if (dateString[dateString.length - 1] === '?') { onKeyDownQuestionMark(e); } else { - this.setState({ dateString }); - onChange(dateString); + this.setState({ dateString }, () => onChange(dateString)); } } onKeyDown(e) { e.stopPropagation(); + if (!MODIFIER_KEY_NAMES.has(e.key)) { + this.throttledKeyDown(e); + } + } + onFinalKeyDown(e) { const { onKeyDownShiftTab, onKeyDownTab, onKeyDownArrowDown, onKeyDownQuestionMark, } = this.props; - const { key } = e; + if (key === 'Tab') { if (e.shiftKey) { onKeyDownShiftTab(e); @@ -145,6 +178,9 @@ class DateInput extends React.Component { const { id, placeholder, + ariaLabel, + autoComplete, + titleText, displayValue, screenReaderMessage, focused, @@ -154,48 +190,86 @@ class DateInput extends React.Component { required, readOnly, openDirection, + verticalSpacing, + small, + regular, + block, + css, styles, + theme: { reactDates }, } = this.props; - const value = displayValue || dateString || ''; + const value = dateString || displayValue || ''; const screenReaderMessageId = `DateInput__screen-reader-message-${id}`; - const withCaret = showCaret && focused; + const withFang = showCaret && focused; + + const inputHeight = getInputHeight(reactDates, small); return (
+ {withFang && ( + + + + + )} + {screenReaderMessage && (

{screenReaderMessage} @@ -213,123 +287,109 @@ export default withStyles(({ reactDates: { border, color, sizing, spacing, font, zIndex, }, -}) => { - const inputHeight = parseInt(font.input.lineHeight, 10) - + (2 * spacing.inputPadding) - + (2 * spacing.displayTextPaddingVertical); - - return { - DateInput: { - fontWeight: 200, - fontSize: font.input.size, - lineHeight: font.input.lineHeight, - color: color.placeholderText, - margin: 0, - padding: spacing.inputPadding, - - background: color.background, - position: 'relative', - display: 'inline-block', - width: sizing.inputWidth, - verticalAlign: 'middle', - }, - - DateInput__withCaret: { - ':before': { - content: '""', - display: 'inline-block', - position: 'absolute', - bottom: 'auto', - border: `${sizing.tooltipArrowWidth / 2}px solid transparent`, - left: 22, - zIndex: zIndex + 2, - }, - - ':after': { - content: '""', - display: 'inline-block', - position: 'absolute', - bottom: 'auto', - border: `${sizing.tooltipArrowWidth / 2}px solid transparent`, - left: 22, - zIndex: zIndex + 2, - }, - }, - - DateInput__openUp: { - ':before': { - borderBottom: 0, - top: inputHeight - spacing.inputMarginBottom, - borderTopColor: 'rgba(0, 0, 0, 0.1)', - }, - - ':after': { - borderBottom: 0, - top: inputHeight - spacing.inputMarginBottom - 1, - borderTopColor: color.background, - }, - }, - - DateInput__openDown: { - ':before': { - borderTop: 0, - top: spacing.inputMarginBottom - (sizing.tooltipArrowWidth / 2), - borderBottomColor: 'rgba(0, 0, 0, 0.1)', - }, - - ':after': { - borderTop: 0, - top: spacing.inputMarginBottom - (sizing.tooltipArrowWidth / 2) + 1, - borderBottomColor: color.background, - }, - }, - - DateInput__disabled: { - background: color.disabled, - color: color.textDisabled, - }, - - DateInput_input: { - fontWeight: 200, - fontSize: font.input.size, - color: color.text, - width: '100%', - padding: `${spacing.displayTextPaddingVertical}px ${spacing.displayTextPaddingHorizontal}px`, - border: border.input.border, - borderTop: border.input.borderTop, - borderRight: border.input.borderRight, - borderBottom: border.input.borderBottom, - borderLeft: border.input.borderLeft, - }, - - DateInput_input__readOnly: { - userSelect: 'none', - }, - - DateInput_input__focused: { - outline: border.input.outlineFocused, - background: color.backgroundFocused, - border: border.input.borderFocused, - borderTop: border.input.borderTopFocused, - borderRight: border.input.borderRightFocused, - borderBottom: border.input.borderBottomFocused, - borderLeft: border.input.borderLeftFocused, - }, - - DateInput_input__disabled: { - background: color.disabled, - fontStyle: font.input.styleDisabled, - }, - - DateInput_screenReaderMessage: { - border: 0, - clip: 'rect(0, 0, 0, 0)', - height: 1, - margin: -1, - overflow: 'hidden', - padding: 0, - position: 'absolute', - width: 1, - }, - }; -})(DateInput); +}) => ({ + DateInput: { + margin: 0, + padding: spacing.inputPadding, + background: color.background, + position: 'relative', + display: 'inline-block', + width: sizing.inputWidth, + verticalAlign: 'middle', + }, + + DateInput__small: { + width: sizing.inputWidth_small, + }, + + DateInput__block: { + width: '100%', + }, + + DateInput__disabled: { + background: color.disabled, + color: color.textDisabled, + }, + + DateInput_input: { + fontWeight: font.input.weight, + fontSize: font.input.size, + lineHeight: font.input.lineHeight, + color: color.text, + backgroundColor: color.background, + width: '100%', + padding: `${spacing.displayTextPaddingVertical}px ${spacing.displayTextPaddingHorizontal}px`, + paddingTop: spacing.displayTextPaddingTop, + paddingBottom: spacing.displayTextPaddingBottom, + paddingLeft: noflip(spacing.displayTextPaddingLeft), + paddingRight: noflip(spacing.displayTextPaddingRight), + border: border.input.border, + borderTop: border.input.borderTop, + borderRight: noflip(border.input.borderRight), + borderBottom: border.input.borderBottom, + borderLeft: noflip(border.input.borderLeft), + borderRadius: border.input.borderRadius, + }, + + DateInput_input__small: { + fontSize: font.input.size_small, + lineHeight: font.input.lineHeight_small, + letterSpacing: font.input.letterSpacing_small, + padding: `${spacing.displayTextPaddingVertical_small}px ${spacing.displayTextPaddingHorizontal_small}px`, + paddingTop: spacing.displayTextPaddingTop_small, + paddingBottom: spacing.displayTextPaddingBottom_small, + paddingLeft: noflip(spacing.displayTextPaddingLeft_small), + paddingRight: noflip(spacing.displayTextPaddingRight_small), + }, + + DateInput_input__regular: { + fontWeight: 'inherit', + }, + + DateInput_input__readOnly: { + userSelect: 'none', + }, + + DateInput_input__focused: { + outline: border.input.outlineFocused, + background: color.backgroundFocused, + border: border.input.borderFocused, + borderTop: border.input.borderTopFocused, + borderRight: noflip(border.input.borderRightFocused), + borderBottom: border.input.borderBottomFocused, + borderLeft: noflip(border.input.borderLeftFocused), + }, + + DateInput_input__disabled: { + background: color.disabled, + fontStyle: font.input.styleDisabled, + }, + + DateInput_screenReaderMessage: { + border: 0, + clip: 'rect(0, 0, 0, 0)', + height: 1, + margin: -1, + overflow: 'hidden', + padding: 0, + position: 'absolute', + width: 1, + }, + + DateInput_fang: { + position: 'absolute', + width: FANG_WIDTH_PX, + height: FANG_HEIGHT_PX, + left: 22, // TODO: should be noflip wrapped and handled by an isRTL prop + zIndex: zIndex + 2, + }, + + DateInput_fangShape: { + fill: color.background, + }, + + DateInput_fangStroke: { + stroke: color.core.border, + fill: 'transparent', + }, +}), { pureComponent: typeof React.PureComponent !== 'undefined' })(DateInput); diff --git a/src/components/DateRangePicker.jsx b/src/components/DateRangePicker.jsx index 1143009bd8..361056c7df 100644 --- a/src/components/DateRangePicker.jsx +++ b/src/components/DateRangePicker.jsx @@ -1,26 +1,27 @@ import React from 'react'; -import shallowCompare from 'react-addons-shallow-compare'; import moment from 'moment'; -import { css, withStyles, withStylesPropTypes } from 'react-with-styles'; -import Portal from 'react-portal'; +import { withStyles, withStylesPropTypes } from 'react-with-styles'; +import { Portal } from 'react-portal'; import { forbidExtraProps } from 'airbnb-prop-types'; import { addEventListener } from 'consolidated-events'; import isTouchDevice from 'is-touch-device'; +import OutsideClickHandler from 'react-outside-click-handler'; +import { darken } from 'color2k'; +import DateRangePickerShape from '../shapes/DateRangePickerShape'; import { DateRangePickerPhrases } from '../defaultPhrases'; -import OutsideClickHandler from './OutsideClickHandler'; import getResponsiveContainerStyles from '../utils/getResponsiveContainerStyles'; - +import getDetachedContainerStyles from '../utils/getDetachedContainerStyles'; +import getInputHeight from '../utils/getInputHeight'; import isInclusivelyAfterDay from '../utils/isInclusivelyAfterDay'; +import disableScroll from '../utils/disableScroll'; +import noflip from '../utils/noflip'; import DateRangePickerInputController from './DateRangePickerInputController'; import DayPickerRangeController from './DayPickerRangeController'; - import CloseButton from './CloseButton'; -import DateRangePickerShape from '../shapes/DateRangePickerShape'; - import { START_DATE, END_DATE, @@ -32,6 +33,10 @@ import { OPEN_UP, DAY_SIZE, ICON_BEFORE_POSITION, + INFO_POSITION_BOTTOM, + FANG_HEIGHT_PX, + DEFAULT_VERTICAL_SPACING, + NAV_POSITION_TOP, } from '../constants'; const propTypes = forbidExtraProps({ @@ -46,10 +51,14 @@ const defaultProps = { focusedInput: null, // input related props - startDateId: START_DATE, startDatePlaceholderText: 'Start Date', - endDateId: END_DATE, endDatePlaceholderText: 'End Date', + startDateAriaLabel: undefined, + endDateAriaLabel: undefined, + startDateTitleText: undefined, + endDateTitleText: undefined, + startDateOffset: undefined, + endDateOffset: undefined, disabled: false, required: false, readOnly: false, @@ -60,29 +69,46 @@ const defaultProps = { customInputIcon: null, customArrowIcon: null, customCloseIcon: null, + noBorder: false, + block: false, + small: false, + regular: false, + keepFocusOnInput: false, // calendar presentation and interaction related props - renderMonth: null, + renderMonthText: null, + renderWeekHeaderElement: null, orientation: HORIZONTAL_ORIENTATION, anchorDirection: ANCHOR_LEFT, openDirection: OPEN_DOWN, horizontalMargin: 0, withPortal: false, withFullScreenPortal: false, + appendToBody: false, + disableScroll: false, initialVisibleMonth: null, numberOfMonths: 2, keepOpenOnDateSelect: false, reopenPickerOnClearDates: false, renderCalendarInfo: null, + calendarInfoPosition: INFO_POSITION_BOTTOM, hideKeyboardShortcutsPanel: false, daySize: DAY_SIZE, isRTL: false, firstDayOfWeek: null, verticalHeight: null, + transitionDuration: undefined, + verticalSpacing: DEFAULT_VERTICAL_SPACING, + autoComplete: 'off', + horizontalMonthPadding: undefined, // navigation related props + dayPickerNavigationInlineStyles: null, + navPosition: NAV_POSITION_TOP, navPrev: null, navNext: null, + renderNavPrevButton: null, + renderNavNextButton: null, onPrevMonthClick() {}, onNextMonthClick() {}, @@ -90,21 +116,26 @@ const defaultProps = { onClose() {}, // day presentation and interaction related props - renderDay: null, + renderCalendarDay: undefined, + renderDayContents: null, + renderMonthElement: null, minimumNights: 1, enableOutsideDays: false, isDayBlocked: () => false, - isOutsideRange: day => !isInclusivelyAfterDay(day, moment()), + isOutsideRange: (day) => !isInclusivelyAfterDay(day, moment()), isDayHighlighted: () => false, + minDate: undefined, + maxDate: undefined, // internationalization displayFormat: () => moment.localeData().longDateFormat('L'), monthFormat: 'MMMM YYYY', weekDayFormat: 'dd', phrases: DateRangePickerPhrases, + dayAriaLabelFormat: undefined, }; -class DateRangePicker extends React.Component { +class DateRangePicker extends React.PureComponent { constructor(props) { super(props); this.state = { @@ -119,12 +150,15 @@ class DateRangePicker extends React.Component { this.onOutsideClick = this.onOutsideClick.bind(this); this.onDateRangePickerInputFocus = this.onDateRangePickerInputFocus.bind(this); this.onDayPickerFocus = this.onDayPickerFocus.bind(this); + this.onDayPickerFocusOut = this.onDayPickerFocusOut.bind(this); this.onDayPickerBlur = this.onDayPickerBlur.bind(this); this.showKeyboardShortcutsPanel = this.showKeyboardShortcutsPanel.bind(this); this.responsivizePickerPosition = this.responsivizePickerPosition.bind(this); + this.disableScroll = this.disableScroll.bind(this); this.setDayPickerContainerRef = this.setDayPickerContainerRef.bind(this); + this.setContainerRef = this.setContainerRef.bind(this); } componentDidMount() { @@ -135,8 +169,10 @@ class DateRangePicker extends React.Component { { passive: true }, ); this.responsivizePickerPosition(); + this.disableScroll(); - if (this.props.focusedInput) { + const { focusedInput } = this.props; + if (focusedInput) { this.setState({ isDateRangePickerInputFocused: true, }); @@ -145,29 +181,35 @@ class DateRangePicker extends React.Component { this.isTouchDevice = isTouchDevice(); } - shouldComponentUpdate(nextProps, nextState) { - return shallowCompare(this, nextProps, nextState); - } - componentDidUpdate(prevProps) { - if (!prevProps.focusedInput && this.props.focusedInput && this.isOpened()) { + const { focusedInput } = this.props; + if (!prevProps.focusedInput && focusedInput && this.isOpened()) { // The date picker just changed from being closed to being open. this.responsivizePickerPosition(); + this.disableScroll(); + } else if (prevProps.focusedInput && !focusedInput && !this.isOpened()) { + // The date picker just changed from being open to being closed. + if (this.enableScroll) this.enableScroll(); } } componentWillUnmount() { + this.removeDayPickerEventListeners(); if (this.removeEventListener) this.removeEventListener(); + if (this.enableScroll) this.enableScroll(); } - onOutsideClick() { + onOutsideClick(event) { const { onFocusChange, onClose, startDate, endDate, + appendToBody, } = this.props; + if (!this.isOpened()) return; + if (appendToBody && this.dayPickerContainer.contains(event.target)) return; this.setState({ isDateRangePickerInputFocused: false, @@ -180,10 +222,20 @@ class DateRangePicker extends React.Component { } onDateRangePickerInputFocus(focusedInput) { - const { onFocusChange, withPortal, withFullScreenPortal } = this.props; + const { + onFocusChange, + readOnly, + withPortal, + withFullScreenPortal, + keepFocusOnInput, + } = this.props; if (focusedInput) { - const moveFocusToDayPicker = withPortal || withFullScreenPortal || this.isTouchDevice; + const withAnyPortal = withPortal || withFullScreenPortal; + const moveFocusToDayPicker = withAnyPortal + || (readOnly && !keepFocusOnInput) + || (this.isTouchDevice && !keepFocusOnInput); + if (moveFocusToDayPicker) { this.onDayPickerFocus(); } else { @@ -205,6 +257,20 @@ class DateRangePicker extends React.Component { }); } + onDayPickerFocusOut(event) { + // In cases where **relatedTarget** is not null, it points to the right + // element here. However, in cases where it is null (such as clicking on a + // specific day) or it is **document.body** (IE11), the appropriate value is **event.target**. + // + // We handle both situations here by using the ` || ` operator to fallback + // to *event.target** when **relatedTarget** is not provided. + const relatedTarget = event.relatedTarget === document.body + ? event.target + : (event.relatedTarget || event.target); + if (this.dayPickerContainer.contains(relatedTarget)) return; + this.onOutsideClick(event); + } + onDayPickerBlur() { this.setState({ isDateRangePickerInputFocused: true, @@ -214,7 +280,35 @@ class DateRangePicker extends React.Component { } setDayPickerContainerRef(ref) { + if (ref === this.dayPickerContainer) return; + if (this.dayPickerContainer) this.removeDayPickerEventListeners(); + this.dayPickerContainer = ref; + if (!ref) return; + + this.addDayPickerEventListeners(); + } + + setContainerRef(ref) { + this.container = ref; + } + + addDayPickerEventListeners() { + // NOTE: We are using a manual event listener here, because React doesn't + // provide FocusOut, while blur and keydown don't provide the information + // needed in order to know whether we have left focus or not. + // + // For reference, this issue is further described here: + // - https://github.com/facebook/react/issues/6410 + this.removeDayPickerFocusOut = addEventListener( + this.dayPickerContainer, + 'focusout', + this.onDayPickerFocusOut, + ); + } + + removeDayPickerEventListeners() { + if (this.removeDayPickerFocusOut) this.removeDayPickerFocusOut(); } isOpened() { @@ -222,22 +316,38 @@ class DateRangePicker extends React.Component { return focusedInput === START_DATE || focusedInput === END_DATE; } + disableScroll() { + const { appendToBody, disableScroll: propDisableScroll } = this.props; + if (!appendToBody && !propDisableScroll) return; + if (!this.isOpened()) return; + + // Disable scroll for every ancestor of this DateRangePicker up to the + // document level. This ensures the input and the picker never move. Other + // sibling elements or the picker itself can scroll. + this.enableScroll = disableScroll(this.container); + } + responsivizePickerPosition() { // It's possible the portal props have been changed in response to window resizes // So let's ensure we reset this back to the base state each time - this.setState({ dayPickerContainerStyles: {} }); + const { dayPickerContainerStyles } = this.state; + + if (Object.keys(dayPickerContainerStyles).length > 0) { + this.setState({ dayPickerContainerStyles: {} }); + } if (!this.isOpened()) { return; } const { + openDirection, anchorDirection, horizontalMargin, withPortal, withFullScreenPortal, + appendToBody, } = this.props; - const { dayPickerContainerStyles } = this.state; const isAnchoredLeft = anchorDirection === ANCHOR_LEFT; if (!withPortal && !withFullScreenPortal) { @@ -248,12 +358,19 @@ class DateRangePicker extends React.Component { : containerRect[ANCHOR_LEFT]; this.setState({ - dayPickerContainerStyles: getResponsiveContainerStyles( - anchorDirection, - currentOffset, - containerEdge, - horizontalMargin, - ), + dayPickerContainerStyles: { + ...getResponsiveContainerStyles( + anchorDirection, + currentOffset, + containerEdge, + horizontalMargin, + ), + ...(appendToBody && getDetachedContainerStyles( + openDirection, + anchorDirection, + this.container, + )), + }, }); } } @@ -267,15 +384,15 @@ class DateRangePicker extends React.Component { } maybeRenderDayPickerWithPortal() { - const { withPortal, withFullScreenPortal } = this.props; + const { withPortal, withFullScreenPortal, appendToBody } = this.props; if (!this.isOpened()) { return null; } - if (withPortal || withFullScreenPortal) { + if (withPortal || withFullScreenPortal || appendToBody) { return ( - + {this.renderDayPicker()} ); @@ -294,9 +411,14 @@ class DateRangePicker extends React.Component { numberOfMonths, orientation, monthFormat, - renderMonth, + renderMonthText, + renderWeekHeaderElement, + dayPickerNavigationInlineStyles, + navPosition, navPrev, navNext, + renderNavPrevButton, + renderNavNextButton, onPrevMonthClick, onNextMonthClick, onDatesChange, @@ -307,22 +429,39 @@ class DateRangePicker extends React.Component { enableOutsideDays, focusedInput, startDate, + startDateOffset, endDate, + endDateOffset, + minDate, + maxDate, minimumNights, keepOpenOnDateSelect, - renderDay, + renderCalendarDay, + renderDayContents, renderCalendarInfo, + renderMonthElement, + calendarInfoPosition, firstDayOfWeek, initialVisibleMonth, hideKeyboardShortcutsPanel, customCloseIcon, onClose, phrases, + dayAriaLabelFormat, isRTL, weekDayFormat, + css, styles, verticalHeight, + noBorder, + transitionDuration, + verticalSpacing, + horizontalMonthPadding, + small, + disabled, + theme: { reactDates }, } = this.props; + const { dayPickerContainerStyles, isDayPickerFocused, showKeyboardShortcuts } = this.state; const onOutsideClick = (!withFullScreenPortal && withPortal) @@ -336,22 +475,33 @@ class DateRangePicker extends React.Component { ); + const inputHeight = getInputHeight(reactDates, small); + + const withAnyPortal = withPortal || withFullScreenPortal; + + /* eslint-disable jsx-a11y/no-static-element-interactions */ + /* eslint-disable jsx-a11y/click-events-have-key-events */ return ( -

{withFullScreenPortal && ( @@ -397,12 +564,15 @@ class DateRangePicker extends React.Component { type="button" onClick={this.onOutsideClick} aria-label={phrases.closeDatePicker} + tabIndex="-1" > {closeIcon} )}
); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + /* eslint-enable jsx-a11y/click-events-have-key-events */ } render() { @@ -410,9 +580,13 @@ class DateRangePicker extends React.Component { startDate, startDateId, startDatePlaceholderText, + startDateAriaLabel, + startDateTitleText, endDate, endDateId, endDatePlaceholderText, + endDateAriaLabel, + endDateTitleText, focusedInput, screenReaderInputMessage, showClearDates, @@ -424,9 +598,11 @@ class DateRangePicker extends React.Component { disabled, required, readOnly, + autoComplete, openDirection, phrases, isOutsideRange, + isDayBlocked, minimumNights, withPortal, withFullScreenPortal, @@ -436,55 +612,87 @@ class DateRangePicker extends React.Component { onDatesChange, onClose, isRTL, + noBorder, + block, + verticalSpacing, + small, + regular, + css, styles, } = this.props; const { isDateRangePickerInputFocused } = this.state; - const onOutsideClick = (!withPortal && !withFullScreenPortal) ? this.onOutsideClick : undefined; + const enableOutsideClick = (!withPortal && !withFullScreenPortal); + + const hideFang = verticalSpacing < FANG_HEIGHT_PX; + + const input = ( + + {this.maybeRenderDayPickerWithPortal()} + + ); return ( -
- - - - {this.maybeRenderDayPickerWithPortal()} - +
+ {enableOutsideClick && ( + + {input} + + )} + {enableOutsideClick || input}
); } @@ -494,12 +702,16 @@ DateRangePicker.propTypes = propTypes; DateRangePicker.defaultProps = defaultProps; export { DateRangePicker as PureDateRangePicker }; -export default withStyles(({ reactDates: { color, zIndex, spacing } }) => ({ +export default withStyles(({ reactDates: { color, zIndex } }) => ({ DateRangePicker: { position: 'relative', display: 'inline-block', }, + DateRangePicker__block: { + display: 'block', + }, + DateRangePicker_picker: { zIndex: zIndex + 1, backgroundColor: color.background, @@ -507,30 +719,22 @@ export default withStyles(({ reactDates: { color, zIndex, spacing } }) => ({ }, DateRangePicker_picker__rtl: { - direction: 'rtl', + direction: noflip('rtl'), }, DateRangePicker_picker__directionLeft: { - left: 0, + left: noflip(0), }, DateRangePicker_picker__directionRight: { - right: 0, - }, - - DateRangePicker_picker__openDown: { - top: spacing.inputMarginBottom, - }, - - DateRangePicker_picker__openUp: { - bottom: spacing.inputMarginBottom, + right: noflip(0), }, DateRangePicker_picker__portal: { backgroundColor: 'rgba(0, 0, 0, 0.3)', position: 'fixed', top: 0, - left: 0, + left: noflip(0), height: '100%', width: '100%', }, @@ -550,17 +754,17 @@ export default withStyles(({ reactDates: { color, zIndex, spacing } }) => ({ position: 'absolute', top: 0, - right: 0, + right: noflip(0), padding: 15, zIndex: zIndex + 2, ':hover': { - color: `darken(${color.core.grayLighter}, 10%)`, + color: darken(color.core.grayLighter, 0.1), textDecoration: 'none', }, ':focus': { - color: `darken(${color.core.grayLighter}, 10%)`, + color: darken(color.core.grayLighter, 0.1), textDecoration: 'none', }, }, @@ -570,4 +774,4 @@ export default withStyles(({ reactDates: { color, zIndex, spacing } }) => ({ width: 15, fill: color.core.grayLighter, }, -}))(DateRangePicker); +}), { pureComponent: typeof React.PureComponent !== 'undefined' })(DateRangePicker); diff --git a/src/components/DateRangePickerInput.jsx b/src/components/DateRangePickerInput.jsx index f561bfe0a6..b175915310 100644 --- a/src/components/DateRangePickerInput.jsx +++ b/src/components/DateRangePickerInput.jsx @@ -1,14 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { forbidExtraProps } from 'airbnb-prop-types'; -import { css, withStyles, withStylesPropTypes } from 'react-with-styles'; +import { forbidExtraProps, nonNegativeInteger } from 'airbnb-prop-types'; +import { withStyles, withStylesPropTypes } from 'react-with-styles'; import { DateRangePickerInputPhrases } from '../defaultPhrases'; import getPhrasePropTypes from '../utils/getPhrasePropTypes'; +import noflip from '../utils/noflip'; import openDirectionShape from '../shapes/OpenDirectionShape'; import DateInput from './DateInput'; import IconPositionShape from '../shapes/IconPositionShape'; +import DisabledShape from '../shapes/DisabledShape'; import RightArrow from './RightArrow'; import LeftArrow from './LeftArrow'; @@ -25,12 +27,19 @@ import { const propTypes = forbidExtraProps({ ...withStylesPropTypes, + + children: PropTypes.node, + startDateId: PropTypes.string, startDatePlaceholderText: PropTypes.string, + startDateAriaLabel: PropTypes.string, + startDateTitleText: PropTypes.string, screenReaderMessage: PropTypes.string, endDateId: PropTypes.string, endDatePlaceholderText: PropTypes.string, + endDateAriaLabel: PropTypes.string, + endDateTitleText: PropTypes.string, onStartDateFocus: PropTypes.func, onEndDateFocus: PropTypes.func, @@ -48,7 +57,7 @@ const propTypes = forbidExtraProps({ isStartDateFocused: PropTypes.bool, isEndDateFocused: PropTypes.bool, showClearDates: PropTypes.bool, - disabled: PropTypes.bool, + disabled: DisabledShape, required: PropTypes.bool, readOnly: PropTypes.bool, openDirection: openDirectionShape, @@ -58,6 +67,12 @@ const propTypes = forbidExtraProps({ customInputIcon: PropTypes.node, customArrowIcon: PropTypes.node, customCloseIcon: PropTypes.node, + noBorder: PropTypes.bool, + block: PropTypes.bool, + small: PropTypes.bool, + regular: PropTypes.bool, + verticalSpacing: nonNegativeInteger, + autoComplete: PropTypes.string, // accessibility isFocused: PropTypes.bool, // describes actual DOM focus @@ -69,11 +84,17 @@ const propTypes = forbidExtraProps({ }); const defaultProps = { + children: null, startDateId: START_DATE, endDateId: END_DATE, startDatePlaceholderText: 'Start Date', endDatePlaceholderText: 'End Date', + startDateAriaLabel: undefined, + endDateAriaLabel: undefined, + startDateTitleText: undefined, + endDateTitleText: undefined, screenReaderMessage: '', + autoComplete: 'off', onStartDateFocus() {}, onEndDateFocus() {}, onStartDateChange() {}, @@ -100,6 +121,11 @@ const defaultProps = { customInputIcon: null, customArrowIcon: null, customCloseIcon: null, + noBorder: false, + block: false, + small: false, + regular: false, + verticalSpacing: undefined, // accessibility isFocused: false, @@ -111,6 +137,7 @@ const defaultProps = { }; function DateRangePickerInput({ + children, startDate, startDateId, startDatePlaceholderText, @@ -119,6 +146,8 @@ function DateRangePickerInput({ onStartDateChange, onStartDateFocus, onStartDateShiftTab, + startDateAriaLabel, + startDateTitleText, endDate, endDateId, endDatePlaceholderText, @@ -126,6 +155,8 @@ function DateRangePickerInput({ onEndDateChange, onEndDateFocus, onEndDateTab, + endDateAriaLabel, + endDateTitleText, onKeyDownArrowDown, onKeyDownQuestionMark, onClearDates, @@ -133,6 +164,7 @@ function DateRangePickerInput({ disabled, required, readOnly, + autoComplete, showCaret, openDirection, showDefaultInputIcon, @@ -143,19 +175,36 @@ function DateRangePickerInput({ isFocused, phrases, isRTL, + noBorder, + block, + verticalSpacing, + small, + regular, + css, styles, }) { const calendarIcon = customInputIcon || ( ); - const arrowIcon = customArrowIcon || (isRTL - ? - : - ); + let arrowIcon = ; + if (isRTL) arrowIcon = ; + if (small) arrowIcon = '-'; + if (customArrowIcon) arrowIcon = customArrowIcon; + const closeIcon = customCloseIcon || ( - + ); - const screenReaderText = screenReaderMessage || phrases.keyboardNavigationInstructions; + + const screenReaderStartDateText = screenReaderMessage + || phrases.keyboardForwardNavigationInstructions; + const screenReaderEndDateText = screenReaderMessage + || phrases.keyboardBackwardNavigationInstructions; + const inputIcon = (showDefaultInputIcon || customInputIcon !== null) && ( - - {showKeyboardShortcutsPanel && -
{ + e.currentTarget.blur(); + }} > -
- {phrases.keyboardShortcuts} -
- - - -
    + + )} + {showKeyboardShortcutsPanel && ( + renderKeyboardShortcutsPanel ? ( + renderKeyboardShortcutsPanel({ + closeButtonAriaLabel: phrases.hideKeyboardShortcutsPanel, + keyboardShortcuts: this.keyboardShortcuts, + onCloseButtonClick: closeKeyboardShortcutsPanel, + onKeyDown: this.onKeyDown, + title: phrases.keyboardShortcuts, + }) + ) : ( +
    - {this.keyboardShortcuts.map(({ unicode, label, action }) => ( - - ))} -
-
- } +
+ {phrases.keyboardShortcuts} +
+ + + +
    + {this.keyboardShortcuts.map(({ unicode, label, action }) => ( + + ))} +
+
+ ) + )}
); } @@ -268,7 +282,7 @@ class DayPickerKeyboardShortcuts extends React.Component { DayPickerKeyboardShortcuts.propTypes = propTypes; DayPickerKeyboardShortcuts.defaultProps = defaultProps; -export default withStyles(({ reactDates: { color, zIndex } }) => ({ +export default withStyles(({ reactDates: { color, font, zIndex } }) => ({ DayPickerKeyboardShortcuts_buttonReset: { background: 'none', border: 0, @@ -279,6 +293,7 @@ export default withStyles(({ reactDates: { color, zIndex } }) => ({ overflow: 'visible', padding: 0, cursor: 'pointer', + fontSize: font.size, ':active': { outline: 'none', @@ -286,40 +301,62 @@ export default withStyles(({ reactDates: { color, zIndex } }) => ({ }, DayPickerKeyboardShortcuts_show: { - width: 22, + width: 33, + height: 26, position: 'absolute', zIndex: zIndex + 2, + + '::before': { + content: '""', + display: 'block', + position: 'absolute', + }, }, DayPickerKeyboardShortcuts_show__bottomRight: { - borderTop: '26px solid transparent', - borderRight: `33px solid ${color.core.primary}`, bottom: 0, right: 0, - ':hover': { + '::before': { + borderTop: '26px solid transparent', + borderRight: `33px solid ${color.core.primary}`, + bottom: 0, + right: 0, + }, + + ':hover::before': { borderRight: `33px solid ${color.core.primary_dark}`, }, }, DayPickerKeyboardShortcuts_show__topRight: { - borderBottom: '26px solid transparent', - borderRight: `33px solid ${color.core.primary}`, top: 0, right: 0, - ':hover': { + '::before': { + borderBottom: '26px solid transparent', + borderRight: `33px solid ${color.core.primary}`, + top: 0, + right: 0, + }, + + ':hover::before': { borderRight: `33px solid ${color.core.primary_dark}`, }, }, DayPickerKeyboardShortcuts_show__topLeft: { - borderBottom: '26px solid transparent', - borderLeft: `33px solid ${color.core.primary}`, top: 0, left: 0, - ':hover': { + '::before': { + borderBottom: '26px solid transparent', + borderLeft: `33px solid ${color.core.primary}`, + top: 0, + left: 0, + }, + + ':hover::before': { borderLeft: `33px solid ${color.core.primary_dark}`, }, }, @@ -331,17 +368,17 @@ export default withStyles(({ reactDates: { color, zIndex } }) => ({ DayPickerKeyboardShortcuts_showSpan__bottomRight: { bottom: 0, - right: -28, + right: 5, }, DayPickerKeyboardShortcuts_showSpan__topRight: { top: 1, - right: -28, + right: 5, }, DayPickerKeyboardShortcuts_showSpan__topLeft: { top: 1, - left: -28, + left: 5, }, DayPickerKeyboardShortcuts_panel: { @@ -357,6 +394,7 @@ export default withStyles(({ reactDates: { color, zIndex } }) => ({ zIndex: zIndex + 2, padding: 22, margin: 33, + textAlign: 'left', // TODO: investigate use of text-align throughout the library }, DayPickerKeyboardShortcuts_title: { @@ -368,6 +406,7 @@ export default withStyles(({ reactDates: { color, zIndex } }) => ({ DayPickerKeyboardShortcuts_list: { listStyle: 'none', padding: 0, + fontSize: font.size, }, DayPickerKeyboardShortcuts_close: { @@ -394,4 +433,4 @@ export default withStyles(({ reactDates: { color, zIndex } }) => ({ fill: color.core.grayLight, }, }, -}))(DayPickerKeyboardShortcuts); +}), { pureComponent: typeof React.PureComponent !== 'undefined' })(DayPickerKeyboardShortcuts); diff --git a/src/components/DayPickerNavigation.jsx b/src/components/DayPickerNavigation.jsx index 5bcd3a27a4..9b576ef3c5 100644 --- a/src/components/DayPickerNavigation.jsx +++ b/src/components/DayPickerNavigation.jsx @@ -1,24 +1,33 @@ import React from 'react'; import PropTypes from 'prop-types'; import { forbidExtraProps } from 'airbnb-prop-types'; -import { css, withStyles, withStylesPropTypes } from 'react-with-styles'; +import { withStyles, withStylesPropTypes } from 'react-with-styles'; import { DayPickerNavigationPhrases } from '../defaultPhrases'; import getPhrasePropTypes from '../utils/getPhrasePropTypes'; +import noflip from '../utils/noflip'; import LeftArrow from './LeftArrow'; import RightArrow from './RightArrow'; import ChevronUp from './ChevronUp'; import ChevronDown from './ChevronDown'; +import NavPositionShape from '../shapes/NavPositionShape'; import ScrollableOrientationShape from '../shapes/ScrollableOrientationShape'; import { HORIZONTAL_ORIENTATION, + NAV_POSITION_BOTTOM, + NAV_POSITION_TOP, VERTICAL_SCROLLABLE, } from '../constants'; const propTypes = forbidExtraProps({ ...withStylesPropTypes, + disablePrev: PropTypes.bool, + disableNext: PropTypes.bool, + inlineStyles: PropTypes.object, + isRTL: PropTypes.bool, + navPosition: NavPositionShape, navPrev: PropTypes.node, navNext: PropTypes.node, orientation: ScrollableOrientationShape, @@ -29,10 +38,18 @@ const propTypes = forbidExtraProps({ // internationalization phrases: PropTypes.shape(getPhrasePropTypes(DayPickerNavigationPhrases)), - isRTL: PropTypes.bool, + renderNavPrevButton: PropTypes.func, + renderNavNextButton: PropTypes.func, + showNavPrevButton: PropTypes.bool, + showNavNextButton: PropTypes.bool, }); const defaultProps = { + disablePrev: false, + disableNext: false, + inlineStyles: null, + isRTL: false, + navPosition: NAV_POSITION_TOP, navPrev: null, navNext: null, orientation: HORIZONTAL_ORIENTATION, @@ -42,155 +59,283 @@ const defaultProps = { // internationalization phrases: DayPickerNavigationPhrases, - isRTL: false, + + renderNavPrevButton: null, + renderNavNextButton: null, + showNavPrevButton: true, + showNavNextButton: true, }; -function DayPickerNavigation({ - navPrev, - navNext, - onPrevMonthClick, - onNextMonthClick, - orientation, - phrases, - isRTL, - styles, -}) { - const isHorizontal = orientation === HORIZONTAL_ORIENTATION; - const isVertical = orientation !== HORIZONTAL_ORIENTATION; - const isVerticalScrollable = orientation === VERTICAL_SCROLLABLE; - - let navPrevIcon = navPrev; - let navNextIcon = navNext; - let isDefaultNavPrev = false; - let isDefaultNavNext = false; - if (!navPrevIcon) { - isDefaultNavPrev = true; - let Icon = isVertical ? ChevronUp : LeftArrow; - if (isRTL && !isVertical) { - Icon = RightArrow; +class DayPickerNavigation extends React.PureComponent { + render() { + const { + inlineStyles, + isRTL, + disablePrev, + disableNext, + navPosition, + navPrev, + navNext, + onPrevMonthClick, + onNextMonthClick, + orientation, + phrases, + renderNavPrevButton, + renderNavNextButton, + showNavPrevButton, + showNavNextButton, + css, + styles, + } = this.props; + + if (!showNavNextButton && !showNavPrevButton) { + return null; } - navPrevIcon = ( - - ); - } - if (!navNextIcon) { - isDefaultNavNext = true; - let Icon = isVertical ? ChevronDown : RightArrow; - if (isRTL && !isVertical) { - Icon = LeftArrow; + const isHorizontal = orientation === HORIZONTAL_ORIENTATION; + const isVertical = orientation !== HORIZONTAL_ORIENTATION; + const isVerticalScrollable = orientation === VERTICAL_SCROLLABLE; + const isBottomNavPosition = navPosition === NAV_POSITION_BOTTOM; + const hasInlineStyles = !!inlineStyles; + + let navPrevIcon = navPrev; + let navNextIcon = navNext; + let isDefaultNavPrev = false; + let isDefaultNavNext = false; + let navPrevTabIndex = {}; + let navNextTabIndex = {}; + + if (!navPrevIcon && !renderNavPrevButton && showNavPrevButton) { + navPrevTabIndex = { tabIndex: '0' }; + isDefaultNavPrev = true; + let Icon = isVertical ? ChevronUp : LeftArrow; + if (isRTL && !isVertical) { + Icon = RightArrow; + } + navPrevIcon = ( + + ); } - navNextIcon = ( - - ); - } - return ( -
- {!isVerticalScrollable && ( - - )} - - -
- ); + {showNavPrevButton + && (renderNavPrevButton ? ( + renderNavPrevButton({ + ariaLabel: phrases.jumpToPrevMonth, + disabled: disablePrev, + onClick: disablePrev ? undefined : onPrevMonthClick, + onKeyUp: disablePrev ? undefined : (e) => { + const { key } = e; + if (key === 'Enter' || key === ' ') { + onPrevMonthClick(e); + } + }, + onMouseUp: disablePrev ? undefined : (e) => { + e.currentTarget.blur(); + }, + }) + ) : ( +
{ + const { key } = e; + if (key === 'Enter' || key === ' ') { + onPrevMonthClick(e); + } + }} + onMouseUp={disablePrev ? undefined : (e) => { + e.currentTarget.blur(); + }} + > + {navPrevIcon} +
+ ))} + + {showNavNextButton + && (renderNavNextButton ? ( + renderNavNextButton({ + ariaLabel: phrases.jumpToNextMonth, + disabled: disableNext, + onClick: disableNext ? undefined : onNextMonthClick, + onKeyUp: disableNext ? undefined : (e) => { + const { key } = e; + if (key === 'Enter' || key === ' ') { + onNextMonthClick(e); + } + }, + onMouseUp: disableNext ? undefined : (e) => { + e.currentTarget.blur(); + }, + }) + ) : ( +
{ + const { key } = e; + if (key === 'Enter' || key === ' ') { + onNextMonthClick(e); + } + }} + onMouseUp={disableNext ? undefined : (e) => { + e.currentTarget.blur(); + }} + > + {navNextIcon} +
+ ))} +
+ ); + } } DayPickerNavigation.propTypes = propTypes; DayPickerNavigation.defaultProps = defaultProps; export default withStyles(({ reactDates: { color, zIndex } }) => ({ - DayPickerNavigation_container: { + DayPickerNavigation: { position: 'relative', zIndex: zIndex + 2, }, - DayPickerNavigation_container__horizontal: { + DayPickerNavigation__horizontal: { + height: 0, }, - DayPickerNavigation_container__vertical: { - background: color.background, - boxShadow: '0 0 5px 2px rgba(0, 0, 0, 0.1)', + DayPickerNavigation__vertical: {}, + DayPickerNavigation__verticalScrollable: {}, + DayPickerNavigation__verticalScrollable_prevNav: { + zIndex: zIndex + 1, // zIndex + 2 causes the button to show on top of the day of week headers + }, + + DayPickerNavigation__verticalDefault: { position: 'absolute', - bottom: 0, - left: 0, - height: 52, width: '100%', + height: 52, + bottom: 0, + left: noflip(0), }, - DayPickerNavigation_container__verticalScrollable: { + DayPickerNavigation__verticalScrollableDefault: { position: 'relative', }, + DayPickerNavigation__bottom: { + height: 'auto', + }, + + DayPickerNavigation__bottomDefault: { + display: 'flex', + justifyContent: 'space-between', + }, + DayPickerNavigation_button: { cursor: 'pointer', - lineHeight: 0.78, userSelect: 'none', + border: 0, + padding: 0, + margin: 0, }, DayPickerNavigation_button__default: { @@ -211,37 +356,73 @@ export default withStyles(({ reactDates: { color, zIndex } }) => ({ }, }, - DayPickerNavigation_button__horizontal: { + DayPickerNavigation_button__disabled: { + cursor: 'default', + border: `1px solid ${color.disabled}`, + + ':focus': { + border: `1px solid ${color.disabled}`, + }, + + ':hover': { + border: `1px solid ${color.disabled}`, + }, + + ':active': { + background: 'none', + }, + }, + + DayPickerNavigation_button__horizontal: {}, + + DayPickerNavigation_button__horizontalDefault: { + position: 'absolute', + top: 18, + lineHeight: 0.78, borderRadius: 3, padding: '6px 9px', - top: 18, - position: 'absolute', }, - DayPickerNavigation_leftButton__horizontal: { - left: 22, + DayPickerNavigation_bottomButton__horizontalDefault: { + position: 'static', + marginLeft: 22, + marginRight: 22, + marginBottom: 30, + marginTop: -10, }, - DayPickerNavigation_rightButton__horizontal: { - right: 22, + DayPickerNavigation_leftButton__horizontalDefault: { + left: noflip(22), }, - DayPickerNavigation_button__vertical: { - display: 'inline-block', + DayPickerNavigation_rightButton__horizontalDefault: { + right: noflip(22), + }, + + DayPickerNavigation_button__vertical: {}, + + DayPickerNavigation_button__verticalDefault: { + padding: 5, + background: color.background, + boxShadow: noflip('0 0 5px 2px rgba(0, 0, 0, 0.1)'), position: 'relative', + display: 'inline-block', + textAlign: 'center', height: '100%', width: '50%', }, - DayPickerNavigation_button__vertical__default: { - padding: 5, + DayPickerNavigation_prevButton__verticalDefault: {}, + + DayPickerNavigation_nextButton__verticalDefault: { + borderLeft: noflip(0), }, - DayPickerNavigation_nextButton__vertical__default: { - borderLeft: 0, + DayPickerNavigation_nextButton__verticalScrollableDefault: { + width: '100%', }, - DayPickerNavigation_nextButton__verticalScrollable: { + DayPickerNavigation_prevButton__verticalScrollableDefault: { width: '100%', }, @@ -249,6 +430,7 @@ export default withStyles(({ reactDates: { color, zIndex } }) => ({ height: 19, width: 19, fill: color.core.grayLight, + display: 'block', }, DayPickerNavigation_svg__vertical: { @@ -256,4 +438,8 @@ export default withStyles(({ reactDates: { color, zIndex } }) => ({ width: 42, fill: color.text, }, -}))(DayPickerNavigation); + + DayPickerNavigation_svg__disabled: { + fill: color.disabled, + }, +}), { pureComponent: typeof React.PureComponent !== 'undefined' })(DayPickerNavigation); diff --git a/src/components/DayPickerRangeController.jsx b/src/components/DayPickerRangeController.jsx index 01f96bebfe..6b5cb732b1 100644 --- a/src/components/DayPickerRangeController.jsx +++ b/src/components/DayPickerRangeController.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import momentPropTypes from 'react-moment-proptypes'; -import { forbidExtraProps, nonNegativeInteger } from 'airbnb-prop-types'; +import { forbidExtraProps, mutuallyExclusiveProps, nonNegativeInteger } from 'airbnb-prop-types'; import moment from 'moment'; import values from 'object.values'; import isTouchDevice from 'is-touch-device'; @@ -14,16 +14,22 @@ import isNextDay from '../utils/isNextDay'; import isSameDay from '../utils/isSameDay'; import isAfterDay from '../utils/isAfterDay'; import isBeforeDay from '../utils/isBeforeDay'; +import isPreviousDay from '../utils/isPreviousDay'; import getVisibleDays from '../utils/getVisibleDays'; import isDayVisible from '../utils/isDayVisible'; +import getSelectedDateOffset from '../utils/getSelectedDateOffset'; + import toISODateString from '../utils/toISODateString'; -import toISOMonthString from '../utils/toISOMonthString'; +import { addModifier, deleteModifier } from '../utils/modifiers'; +import DisabledShape from '../shapes/DisabledShape'; import FocusedInputShape from '../shapes/FocusedInputShape'; import ScrollableOrientationShape from '../shapes/ScrollableOrientationShape'; import DayOfWeekShape from '../shapes/DayOfWeekShape'; +import CalendarInfoPositionShape from '../shapes/CalendarInfoPositionShape'; +import NavPositionShape from '../shapes/NavPositionShape'; import { START_DATE, @@ -31,14 +37,21 @@ import { HORIZONTAL_ORIENTATION, VERTICAL_SCROLLABLE, DAY_SIZE, + INFO_POSITION_BOTTOM, + NAV_POSITION_TOP, } from '../constants'; import DayPicker from './DayPicker'; +import getPooledMoment from '../utils/getPooledMoment'; const propTypes = forbidExtraProps({ startDate: momentPropTypes.momentObj, endDate: momentPropTypes.momentObj, onDatesChange: PropTypes.func, + startDateOffset: PropTypes.func, + endDateOffset: PropTypes.func, + minDate: momentPropTypes.momentObj, + maxDate: momentPropTypes.momentObj, focusedInput: FocusedInputShape, onFocusChange: PropTypes.func, @@ -46,12 +59,17 @@ const propTypes = forbidExtraProps({ keepOpenOnDateSelect: PropTypes.bool, minimumNights: PropTypes.number, + disabled: DisabledShape, isOutsideRange: PropTypes.func, isDayBlocked: PropTypes.func, isDayHighlighted: PropTypes.func, + getMinNightsForHoverDate: PropTypes.func, + daysViolatingMinNightsCanBeClicked: PropTypes.bool, // DayPicker props - renderMonth: PropTypes.func, + renderMonthText: mutuallyExclusiveProps(PropTypes.func, 'renderMonthText', 'renderMonthElement'), + renderMonthElement: mutuallyExclusiveProps(PropTypes.func, 'renderMonthText', 'renderMonthElement'), + renderWeekHeaderElement: PropTypes.func, enableOutsideDays: PropTypes.bool, numberOfMonths: PropTypes.number, orientation: ScrollableOrientationShape, @@ -59,27 +77,45 @@ const propTypes = forbidExtraProps({ initialVisibleMonth: PropTypes.func, hideKeyboardShortcutsPanel: PropTypes.bool, daySize: nonNegativeInteger, + noBorder: PropTypes.bool, + verticalBorderSpacing: nonNegativeInteger, + horizontalMonthPadding: nonNegativeInteger, + dayPickerNavigationInlineStyles: PropTypes.object, + navPosition: NavPositionShape, navPrev: PropTypes.node, navNext: PropTypes.node, + renderNavPrevButton: PropTypes.func, + renderNavNextButton: PropTypes.func, + noNavButtons: PropTypes.bool, + noNavNextButton: PropTypes.bool, + noNavPrevButton: PropTypes.bool, onPrevMonthClick: PropTypes.func, onNextMonthClick: PropTypes.func, onOutsideClick: PropTypes.func, - renderDay: PropTypes.func, + renderCalendarDay: PropTypes.func, + renderDayContents: PropTypes.func, renderCalendarInfo: PropTypes.func, + renderKeyboardShortcutsButton: PropTypes.func, + renderKeyboardShortcutsPanel: PropTypes.func, + calendarInfoPosition: CalendarInfoPositionShape, firstDayOfWeek: DayOfWeekShape, verticalHeight: nonNegativeInteger, + transitionDuration: nonNegativeInteger, // accessibility onBlur: PropTypes.func, isFocused: PropTypes.bool, showKeyboardShortcuts: PropTypes.bool, + onTab: PropTypes.func, + onShiftTab: PropTypes.func, // i18n monthFormat: PropTypes.string, weekDayFormat: PropTypes.string, phrases: PropTypes.shape(getPhrasePropTypes(DayPickerPhrases)), + dayAriaLabelFormat: PropTypes.string, isRTL: PropTypes.bool, }); @@ -87,7 +123,11 @@ const propTypes = forbidExtraProps({ const defaultProps = { startDate: undefined, // TODO: use null endDate: undefined, // TODO: use null + minDate: null, + maxDate: null, onDatesChange() {}, + startDateOffset: undefined, + endDateOffset: undefined, focusedInput: null, onFocusChange() {}, @@ -95,12 +135,16 @@ const defaultProps = { keepOpenOnDateSelect: false, minimumNights: 1, + disabled: false, isOutsideRange() {}, isDayBlocked() {}, isDayHighlighted() {}, + getMinNightsForHoverDate() {}, + daysViolatingMinNightsCanBeClicked: false, // DayPicker props - renderMonth: null, + renderMonthText: null, + renderWeekHeaderElement: null, enableOutsideDays: false, numberOfMonths: 1, orientation: HORIZONTAL_ORIENTATION, @@ -109,27 +153,46 @@ const defaultProps = { initialVisibleMonth: null, daySize: DAY_SIZE, + dayPickerNavigationInlineStyles: null, + navPosition: NAV_POSITION_TOP, navPrev: null, navNext: null, + renderNavPrevButton: null, + renderNavNextButton: null, + noNavButtons: false, + noNavNextButton: false, + noNavPrevButton: false, onPrevMonthClick() {}, onNextMonthClick() {}, onOutsideClick() {}, - renderDay: null, + renderCalendarDay: undefined, + renderDayContents: null, renderCalendarInfo: null, + renderMonthElement: null, + renderKeyboardShortcutsButton: undefined, + renderKeyboardShortcutsPanel: undefined, + calendarInfoPosition: INFO_POSITION_BOTTOM, firstDayOfWeek: null, verticalHeight: null, + noBorder: false, + transitionDuration: undefined, + verticalBorderSpacing: undefined, + horizontalMonthPadding: 13, // accessibility onBlur() {}, isFocused: false, showKeyboardShortcuts: false, + onTab() {}, + onShiftTab() {}, // i18n monthFormat: 'MMMM YYYY', weekDayFormat: 'dd', phrases: DayPickerPhrases, + dayAriaLabelFormat: undefined, isRTL: false, }; @@ -137,33 +200,44 @@ const defaultProps = { const getChooseAvailableDatePhrase = (phrases, focusedInput) => { if (focusedInput === START_DATE) { return phrases.chooseAvailableStartDate; - } else if (focusedInput === END_DATE) { + } + if (focusedInput === END_DATE) { return phrases.chooseAvailableEndDate; } return phrases.chooseAvailableDate; }; -export default class DayPickerRangeController extends React.Component { +export default class DayPickerRangeController extends React.PureComponent { constructor(props) { super(props); this.isTouchDevice = isTouchDevice(); this.today = moment(); this.modifiers = { - today: day => this.isToday(day), - blocked: day => this.isBlocked(day), - 'blocked-calendar': day => props.isDayBlocked(day), - 'blocked-out-of-range': day => props.isOutsideRange(day), - 'highlighted-calendar': day => props.isDayHighlighted(day), - valid: day => !this.isBlocked(day), - 'selected-start': day => this.isStartDate(day), - 'selected-end': day => this.isEndDate(day), - 'blocked-minimum-nights': day => this.doesNotMeetMinimumNights(day), - 'selected-span': day => this.isInSelectedSpan(day), - 'last-in-range': day => this.isLastInRange(day), - hovered: day => this.isHovered(day), - 'hovered-span': day => this.isInHoveredSpan(day), - 'after-hovered-start': day => this.isDayAfterHoveredStartDate(day), + today: (day) => this.isToday(day), + blocked: (day) => this.isBlocked(day), + 'blocked-calendar': (day) => props.isDayBlocked(day), + 'blocked-out-of-range': (day) => props.isOutsideRange(day), + 'highlighted-calendar': (day) => props.isDayHighlighted(day), + valid: (day) => !this.isBlocked(day), + 'selected-start': (day) => this.isStartDate(day), + 'selected-end': (day) => this.isEndDate(day), + 'blocked-minimum-nights': (day) => this.doesNotMeetMinimumNights(day), + 'selected-span': (day) => this.isInSelectedSpan(day), + 'last-in-range': (day) => this.isLastInRange(day), + hovered: (day) => this.isHovered(day), + 'hovered-span': (day) => this.isInHoveredSpan(day), + 'hovered-offset': (day) => this.isInHoveredSpan(day), + 'after-hovered-start': (day) => this.isDayAfterHoveredStartDate(day), + 'first-day-of-week': (day) => this.isFirstDayOfWeek(day), + 'last-day-of-week': (day) => this.isLastDayOfWeek(day), + 'hovered-start-first-possible-end': (day, hoverDate) => this.isFirstPossibleEndDateForHoveredStartDate(day, hoverDate), + 'hovered-start-blocked-minimum-nights': (day, hoverDate) => this.doesNotMeetMinNightsForHoveredStartDate(day, hoverDate), + 'before-hovered-end': (day) => this.isDayBeforeHoveredEndDate(day), + 'no-selected-start-before-selected-end': (day) => this.beforeSelectedEnd(day) && !props.startDate, + 'selected-start-in-hovered-span': (day, hoverDate) => this.isStartDate(day) && isAfterDay(hoverDate, day), + 'selected-start-no-selected-end': (day) => this.isStartDate(day) && !props.endDate, + 'selected-end-no-selected-start': (day) => this.isEndDate(day) && !props.startDate, }; const { currentMonth, visibleDays } = this.getStateForNewMonth(props); @@ -180,6 +254,8 @@ export default class DayPickerRangeController extends React.Component { chooseAvailableDate, }, visibleDays, + disablePrev: this.shouldDisableMonthNavigation(props.minDate, currentMonth), + disableNext: this.shouldDisableMonthNavigation(props.maxDate, currentMonth), }; this.onDayClick = this.onDayClick.bind(this); @@ -187,9 +263,11 @@ export default class DayPickerRangeController extends React.Component { this.onDayMouseLeave = this.onDayMouseLeave.bind(this); this.onPrevMonthClick = this.onPrevMonthClick.bind(this); this.onNextMonthClick = this.onNextMonthClick.bind(this); - this.onMultiplyScrollableMonths = this.onMultiplyScrollableMonths.bind(this); + this.onMonthChange = this.onMonthChange.bind(this); + this.onYearChange = this.onYearChange.bind(this); + this.onGetNextScrollableMonths = this.onGetNextScrollableMonths.bind(this); + this.onGetPrevScrollableMonths = this.onGetPrevScrollableMonths.bind(this); this.getFirstFocusableDay = this.getFirstFocusableDay.bind(this); - this.setDayPickerRef = this.setDayPickerRef.bind(this); } componentWillReceiveProps(nextProps) { @@ -197,6 +275,7 @@ export default class DayPickerRangeController extends React.Component { startDate, endDate, focusedInput, + getMinNightsForHoverDate, minimumNights, isOutsideRange, isDayBlocked, @@ -206,24 +285,40 @@ export default class DayPickerRangeController extends React.Component { numberOfMonths, enableOutsideDays, } = nextProps; + + const { + startDate: prevStartDate, + endDate: prevEndDate, + focusedInput: prevFocusedInput, + minimumNights: prevMinimumNights, + isOutsideRange: prevIsOutsideRange, + isDayBlocked: prevIsDayBlocked, + isDayHighlighted: prevIsDayHighlighted, + phrases: prevPhrases, + initialVisibleMonth: prevInitialVisibleMonth, + numberOfMonths: prevNumberOfMonths, + enableOutsideDays: prevEnableOutsideDays, + } = this.props; + + const { hoverDate } = this.state; let { visibleDays } = this.state; let recomputeOutsideRange = false; let recomputeDayBlocked = false; let recomputeDayHighlighted = false; - if (isOutsideRange !== this.props.isOutsideRange) { - this.modifiers['blocked-out-of-range'] = day => isOutsideRange(day); + if (isOutsideRange !== prevIsOutsideRange) { + this.modifiers['blocked-out-of-range'] = (day) => isOutsideRange(day); recomputeOutsideRange = true; } - if (isDayBlocked !== this.props.isDayBlocked) { - this.modifiers['blocked-calendar'] = day => isDayBlocked(day); + if (isDayBlocked !== prevIsDayBlocked) { + this.modifiers['blocked-calendar'] = (day) => isDayBlocked(day); recomputeDayBlocked = true; } - if (isDayHighlighted !== this.props.isDayHighlighted) { - this.modifiers['highlighted-calendar'] = day => isDayHighlighted(day); + if (isDayHighlighted !== prevIsDayHighlighted) { + this.modifiers['highlighted-calendar'] = (day) => isDayHighlighted(day); recomputeDayHighlighted = true; } @@ -231,17 +326,17 @@ export default class DayPickerRangeController extends React.Component { recomputeOutsideRange || recomputeDayBlocked || recomputeDayHighlighted ); - const didStartDateChange = startDate !== this.props.startDate; - const didEndDateChange = endDate !== this.props.endDate; - const didFocusChange = focusedInput !== this.props.focusedInput; + const didStartDateChange = startDate !== prevStartDate; + const didEndDateChange = endDate !== prevEndDate; + const didFocusChange = focusedInput !== prevFocusedInput; if ( - numberOfMonths !== this.props.numberOfMonths || - enableOutsideDays !== this.props.enableOutsideDays || - ( - initialVisibleMonth !== this.props.initialVisibleMonth && - !this.props.focusedInput && - didFocusChange + numberOfMonths !== prevNumberOfMonths + || enableOutsideDays !== prevEnableOutsideDays + || ( + initialVisibleMonth !== prevInitialVisibleMonth + && !prevFocusedInput + && didFocusChange ) ) { const newMonthState = this.getStateForNewMonth(nextProps); @@ -256,21 +351,47 @@ export default class DayPickerRangeController extends React.Component { let modifiers = {}; if (didStartDateChange) { - modifiers = this.deleteModifier(modifiers, this.props.startDate, 'selected-start'); + modifiers = this.deleteModifier(modifiers, prevStartDate, 'selected-start'); modifiers = this.addModifier(modifiers, startDate, 'selected-start'); + + if (prevStartDate) { + const startSpan = prevStartDate.clone().add(1, 'day'); + const endSpan = prevStartDate.clone().add(prevMinimumNights + 1, 'days'); + modifiers = this.deleteModifierFromRange(modifiers, startSpan, endSpan, 'after-hovered-start'); + + if (!endDate || !prevEndDate) { + modifiers = this.deleteModifier(modifiers, prevStartDate, 'selected-start-no-selected-end'); + } + } + + if (!prevStartDate && endDate && startDate) { + modifiers = this.deleteModifier(modifiers, endDate, 'selected-end-no-selected-start'); + modifiers = this.deleteModifier(modifiers, endDate, 'selected-end-in-hovered-span'); + + values(visibleDays).forEach((days) => { + Object.keys(days).forEach((day) => { + const momentObj = moment(day); + modifiers = this.deleteModifier(modifiers, momentObj, 'no-selected-start-before-selected-end'); + }); + }); + } } if (didEndDateChange) { - modifiers = this.deleteModifier(modifiers, this.props.endDate, 'selected-end'); + modifiers = this.deleteModifier(modifiers, prevEndDate, 'selected-end'); modifiers = this.addModifier(modifiers, endDate, 'selected-end'); + + if (prevEndDate && (!startDate || !prevStartDate)) { + modifiers = this.deleteModifier(modifiers, prevEndDate, 'selected-end-no-selected-start'); + } } if (didStartDateChange || didEndDateChange) { - if (this.props.startDate && this.props.endDate) { + if (prevStartDate && prevEndDate) { modifiers = this.deleteModifierFromRange( modifiers, - this.props.startDate, - this.props.endDate.clone().add(1, 'day'), + prevStartDate, + prevEndDate.clone().add(1, 'day'), 'selected-span', ); } @@ -290,6 +411,26 @@ export default class DayPickerRangeController extends React.Component { 'selected-span', ); } + + if (startDate && !endDate) { + modifiers = this.addModifier(modifiers, startDate, 'selected-start-no-selected-end'); + } + + if (endDate && !startDate) { + modifiers = this.addModifier(modifiers, endDate, 'selected-end-no-selected-start'); + } + + if (!startDate && endDate) { + values(visibleDays).forEach((days) => { + Object.keys(days).forEach((day) => { + const momentObj = moment(day); + + if (isBeforeDay(momentObj, endDate)) { + modifiers = this.addModifier(modifiers, momentObj, 'no-selected-start-before-selected-end'); + } + }); + }); + } } if (!this.isTouchDevice && didStartDateChange && startDate && !endDate) { @@ -298,23 +439,27 @@ export default class DayPickerRangeController extends React.Component { modifiers = this.addModifierToRange(modifiers, startSpan, endSpan, 'after-hovered-start'); } - if (minimumNights > 0 || minimumNights !== this.props.minimumNights) { - if (didFocusChange || didStartDateChange) { - const startSpan = this.props.startDate ? this.props.startDate : this.today; + if (!this.isTouchDevice && didEndDateChange && !startDate && endDate) { + const startSpan = endDate.clone().subtract(minimumNights, 'days'); + const endSpan = endDate.clone(); + modifiers = this.addModifierToRange(modifiers, startSpan, endSpan, 'before-hovered-end'); + } + + if (prevMinimumNights > 0) { + if (didFocusChange || didStartDateChange || minimumNights !== prevMinimumNights) { + const startSpan = prevStartDate || this.today; modifiers = this.deleteModifierFromRange( modifiers, startSpan, - startSpan.clone().add(minimumNights, 'days'), + startSpan.clone().add(prevMinimumNights, 'days'), 'blocked-minimum-nights', ); - } - if (startDate && focusedInput === END_DATE) { - modifiers = this.addModifierToRange( + modifiers = this.deleteModifierFromRange( modifiers, - startDate, - startDate.clone().add(minimumNights, 'days'), - 'blocked-minimum-nights', + startSpan, + startSpan.clone().add(prevMinimumNights, 'days'), + 'blocked', ); } } @@ -322,17 +467,13 @@ export default class DayPickerRangeController extends React.Component { if (didFocusChange || recomputePropModifiers) { values(visibleDays).forEach((days) => { Object.keys(days).forEach((day) => { - const momentObj = moment(day); - - if (this.isBlocked(momentObj)) { - modifiers = this.addModifier(modifiers, momentObj, 'blocked'); - } else { - modifiers = this.deleteModifier(modifiers, momentObj, 'blocked'); - } + const momentObj = getPooledMoment(day); + let isBlocked = false; if (didFocusChange || recomputeOutsideRange) { if (isOutsideRange(momentObj)) { modifiers = this.addModifier(modifiers, momentObj, 'blocked-out-of-range'); + isBlocked = true; } else { modifiers = this.deleteModifier(modifiers, momentObj, 'blocked-out-of-range'); } @@ -341,11 +482,18 @@ export default class DayPickerRangeController extends React.Component { if (didFocusChange || recomputeDayBlocked) { if (isDayBlocked(momentObj)) { modifiers = this.addModifier(modifiers, momentObj, 'blocked-calendar'); + isBlocked = true; } else { modifiers = this.deleteModifier(modifiers, momentObj, 'blocked-calendar'); } } + if (isBlocked) { + modifiers = this.addModifier(modifiers, momentObj, 'blocked'); + } else { + modifiers = this.deleteModifier(modifiers, momentObj, 'blocked'); + } + if (didFocusChange || recomputeDayHighlighted) { if (isDayHighlighted(momentObj)) { modifiers = this.addModifier(modifiers, momentObj, 'highlighted-calendar'); @@ -357,6 +505,55 @@ export default class DayPickerRangeController extends React.Component { }); } + if (!this.isTouchDevice && didFocusChange && hoverDate && !this.isBlocked(hoverDate)) { + const minNightsForHoverDate = getMinNightsForHoverDate(hoverDate); + if (minNightsForHoverDate > 0 && focusedInput === END_DATE) { + modifiers = this.deleteModifierFromRange( + modifiers, + hoverDate.clone().add(1, 'days'), + hoverDate.clone().add(minNightsForHoverDate, 'days'), + 'hovered-start-blocked-minimum-nights', + ); + + modifiers = this.deleteModifier( + modifiers, + hoverDate.clone().add(minNightsForHoverDate, 'days'), + 'hovered-start-first-possible-end', + ); + } + + if (minNightsForHoverDate > 0 && focusedInput === START_DATE) { + modifiers = this.addModifierToRange( + modifiers, + hoverDate.clone().add(1, 'days'), + hoverDate.clone().add(minNightsForHoverDate, 'days'), + 'hovered-start-blocked-minimum-nights', + ); + + modifiers = this.addModifier( + modifiers, + hoverDate.clone().add(minNightsForHoverDate, 'days'), + 'hovered-start-first-possible-end', + ); + } + } + + if (minimumNights > 0 && startDate && focusedInput === END_DATE) { + modifiers = this.addModifierToRange( + modifiers, + startDate, + startDate.clone().add(minimumNights, 'days'), + 'blocked-minimum-nights', + ); + + modifiers = this.addModifierToRange( + modifiers, + startDate, + startDate.clone().add(minimumNights, 'days'), + 'blocked', + ); + } + const today = moment(); if (!isSameDay(this.today, today)) { modifiers = this.deleteModifier(modifiers, this.today, 'today'); @@ -373,7 +570,7 @@ export default class DayPickerRangeController extends React.Component { }); } - if (didFocusChange || phrases !== this.props.phrases) { + if (didFocusChange || phrases !== prevPhrases) { // set the appropriate CalendarDay phrase based on focusedInput const chooseAvailableDate = getChooseAvailableDatePhrase(phrases, focusedInput); @@ -387,99 +584,243 @@ export default class DayPickerRangeController extends React.Component { } onDayClick(day, e) { - const { keepOpenOnDateSelect, minimumNights, onBlur } = this.props; + const { + keepOpenOnDateSelect, + minimumNights, + onBlur, + focusedInput, + onFocusChange, + onClose, + onDatesChange, + startDateOffset, + endDateOffset, + disabled, + daysViolatingMinNightsCanBeClicked, + } = this.props; + if (e) e.preventDefault(); - if (this.isBlocked(day)) return; + if (this.isBlocked(day, !daysViolatingMinNightsCanBeClicked)) return; - const { focusedInput, onFocusChange, onClose } = this.props; let { startDate, endDate } = this.props; - if (focusedInput === START_DATE) { - onFocusChange(END_DATE); + if (startDateOffset || endDateOffset) { + startDate = getSelectedDateOffset(startDateOffset, day); + endDate = getSelectedDateOffset(endDateOffset, day); - startDate = day; + if (this.isBlocked(startDate) || this.isBlocked(endDate)) { + return; + } - if (isInclusivelyAfterDay(day, endDate)) { - endDate = null; + onDatesChange({ startDate, endDate }); + + if (!keepOpenOnDateSelect) { + onFocusChange(null); + onClose({ startDate, endDate }); + } + } else if (focusedInput === START_DATE) { + const lastAllowedStartDate = endDate && endDate.clone().subtract(minimumNights, 'days'); + const isStartDateAfterEndDate = isBeforeDay(lastAllowedStartDate, day) + || isAfterDay(startDate, endDate); + const isEndDateDisabled = disabled === END_DATE; + + if (!isEndDateDisabled || !isStartDateAfterEndDate) { + startDate = day; + if (isStartDateAfterEndDate) { + endDate = null; + } + } + + onDatesChange({ startDate, endDate }); + + if (isEndDateDisabled && !isStartDateAfterEndDate) { + onFocusChange(null); + onClose({ startDate, endDate }); + } else if (!isEndDateDisabled) { + onFocusChange(END_DATE); } } else if (focusedInput === END_DATE) { const firstAllowedEndDate = startDate && startDate.clone().add(minimumNights, 'days'); if (!startDate) { endDate = day; + onDatesChange({ startDate, endDate }); onFocusChange(START_DATE); } else if (isInclusivelyAfterDay(day, firstAllowedEndDate)) { endDate = day; + onDatesChange({ startDate, endDate }); if (!keepOpenOnDateSelect) { onFocusChange(null); onClose({ startDate, endDate }); } - } else { + } else if ( + daysViolatingMinNightsCanBeClicked + && this.doesNotMeetMinimumNights(day) + ) { + endDate = day; + onDatesChange({ startDate, endDate }); + } else if (disabled !== START_DATE) { startDate = day; endDate = null; + onDatesChange({ startDate, endDate }); + } else { + onDatesChange({ startDate, endDate }); } + } else { + onDatesChange({ startDate, endDate }); } - this.props.onDatesChange({ startDate, endDate }); onBlur(); } onDayMouseEnter(day) { + /* eslint react/destructuring-assignment: 1 */ if (this.isTouchDevice) return; const { startDate, endDate, focusedInput, + getMinNightsForHoverDate, minimumNights, + startDateOffset, + endDateOffset, } = this.props; - const { hoverDate, visibleDays } = this.state; + + const { + hoverDate, + visibleDays, + dateOffset, + } = this.state; + + let nextDateOffset = null; if (focusedInput) { + const hasOffset = startDateOffset || endDateOffset; let modifiers = {}; - modifiers = this.deleteModifier(modifiers, hoverDate, 'hovered'); - modifiers = this.addModifier(modifiers, day, 'hovered'); - if (startDate && !endDate && focusedInput === END_DATE) { - if (isAfterDay(hoverDate, startDate)) { - const endSpan = hoverDate.clone().add(1, 'day'); - modifiers = this.deleteModifierFromRange(modifiers, startDate, endSpan, 'hovered-span'); - } + if (hasOffset) { + const start = getSelectedDateOffset(startDateOffset, day); + const end = getSelectedDateOffset(endDateOffset, day, (rangeDay) => rangeDay.add(1, 'day')); - if (!this.isBlocked(day) && isAfterDay(day, startDate)) { - const endSpan = day.clone().add(1, 'day'); - modifiers = this.addModifierToRange(modifiers, startDate, endSpan, 'hovered-span'); + nextDateOffset = { + start, + end, + }; + + // eslint-disable-next-line react/destructuring-assignment + if (dateOffset && dateOffset.start && dateOffset.end) { + modifiers = this.deleteModifierFromRange(modifiers, dateOffset.start, dateOffset.end, 'hovered-offset'); } + modifiers = this.addModifierToRange(modifiers, start, end, 'hovered-offset'); } - if (!startDate && endDate && focusedInput === START_DATE) { - if (isBeforeDay(hoverDate, endDate)) { - modifiers = this.deleteModifierFromRange(modifiers, hoverDate, endDate, 'hovered-span'); + if (!hasOffset) { + modifiers = this.deleteModifier(modifiers, hoverDate, 'hovered'); + modifiers = this.addModifier(modifiers, day, 'hovered'); + + if (startDate && !endDate && focusedInput === END_DATE) { + if (isAfterDay(hoverDate, startDate)) { + const endSpan = hoverDate.clone().add(1, 'day'); + modifiers = this.deleteModifierFromRange(modifiers, startDate, endSpan, 'hovered-span'); + } + + if (isBeforeDay(day, startDate) || isSameDay(day, startDate)) { + modifiers = this.deleteModifier(modifiers, startDate, 'selected-start-in-hovered-span'); + } + + if (!this.isBlocked(day) && isAfterDay(day, startDate)) { + const endSpan = day.clone().add(1, 'day'); + modifiers = this.addModifierToRange(modifiers, startDate, endSpan, 'hovered-span'); + modifiers = this.addModifier(modifiers, startDate, 'selected-start-in-hovered-span'); + } } - if (!this.isBlocked(day) && isBeforeDay(day, endDate)) { - modifiers = this.addModifierToRange(modifiers, day, endDate, 'hovered-span'); + if (!startDate && endDate && focusedInput === START_DATE) { + if (isBeforeDay(hoverDate, endDate)) { + modifiers = this.deleteModifierFromRange(modifiers, hoverDate, endDate, 'hovered-span'); + } + + if (isAfterDay(day, endDate) || isSameDay(day, endDate)) { + modifiers = this.deleteModifier(modifiers, endDate, 'selected-end-in-hovered-span'); + } + + if (!this.isBlocked(day) && isBeforeDay(day, endDate)) { + modifiers = this.addModifierToRange(modifiers, day, endDate, 'hovered-span'); + modifiers = this.addModifier(modifiers, endDate, 'selected-end-in-hovered-span'); + } } - } - if (startDate) { - const startSpan = startDate.clone().add(1, 'day'); - const endSpan = startDate.clone().add(minimumNights + 1, 'days'); - modifiers = this.deleteModifierFromRange(modifiers, startSpan, endSpan, 'after-hovered-start'); + if (startDate) { + const startSpan = startDate.clone().add(1, 'day'); + const endSpan = startDate.clone().add(minimumNights + 1, 'days'); + modifiers = this.deleteModifierFromRange(modifiers, startSpan, endSpan, 'after-hovered-start'); + + if (isSameDay(day, startDate)) { + const newStartSpan = startDate.clone().add(1, 'day'); + const newEndSpan = startDate.clone().add(minimumNights + 1, 'days'); + modifiers = this.addModifierToRange( + modifiers, + newStartSpan, + newEndSpan, + 'after-hovered-start', + ); + } + } + + if (endDate) { + const startSpan = endDate.clone().subtract(minimumNights, 'days'); + modifiers = this.deleteModifierFromRange(modifiers, startSpan, endDate, 'before-hovered-end'); + + if (isSameDay(day, endDate)) { + const newStartSpan = endDate.clone().subtract(minimumNights, 'days'); + modifiers = this.addModifierToRange( + modifiers, + newStartSpan, + endDate, + 'before-hovered-end', + ); + } + } + + if (hoverDate && !this.isBlocked(hoverDate)) { + const minNightsForPrevHoverDate = getMinNightsForHoverDate(hoverDate); + if (minNightsForPrevHoverDate > 0 && focusedInput === START_DATE) { + modifiers = this.deleteModifierFromRange( + modifiers, + hoverDate.clone().add(1, 'days'), + hoverDate.clone().add(minNightsForPrevHoverDate, 'days'), + 'hovered-start-blocked-minimum-nights', + ); + + modifiers = this.deleteModifier( + modifiers, + hoverDate.clone().add(minNightsForPrevHoverDate, 'days'), + 'hovered-start-first-possible-end', + ); + } + } - if (isSameDay(day, startDate)) { - const newStartSpan = startDate.clone().add(1, 'day'); - const newEndSpan = startDate.clone().add(minimumNights + 1, 'days'); - modifiers = this.addModifierToRange( - modifiers, - newStartSpan, - newEndSpan, - 'after-hovered-start', - ); + if (!this.isBlocked(day)) { + const minNightsForHoverDate = getMinNightsForHoverDate(day); + if (minNightsForHoverDate > 0 && focusedInput === START_DATE) { + modifiers = this.addModifierToRange( + modifiers, + day.clone().add(1, 'days'), + day.clone().add(minNightsForHoverDate, 'days'), + 'hovered-start-blocked-minimum-nights', + ); + + modifiers = this.addModifier( + modifiers, + day.clone().add(minNightsForHoverDate, 'days'), + 'hovered-start-first-possible-end', + ); + } } } this.setState({ hoverDate: day, + dateOffset: nextDateOffset, visibleDays: { ...visibleDays, ...modifiers, @@ -489,20 +830,43 @@ export default class DayPickerRangeController extends React.Component { } onDayMouseLeave(day) { - const { startDate, endDate, minimumNights } = this.props; - const { hoverDate, visibleDays } = this.state; + const { + startDate, + endDate, + focusedInput, + getMinNightsForHoverDate, + minimumNights, + } = this.props; + const { hoverDate, visibleDays, dateOffset } = this.state; + if (this.isTouchDevice || !hoverDate) return; let modifiers = {}; modifiers = this.deleteModifier(modifiers, hoverDate, 'hovered'); - if (startDate && !endDate && isAfterDay(hoverDate, startDate)) { - const endSpan = hoverDate.clone().add(1, 'day'); - modifiers = this.deleteModifierFromRange(modifiers, startDate, endSpan, 'hovered-span'); + if (dateOffset) { + modifiers = this.deleteModifierFromRange(modifiers, dateOffset.start, dateOffset.end, 'hovered-offset'); } - if (!startDate && endDate && isAfterDay(endDate, hoverDate)) { - modifiers = this.deleteModifierFromRange(modifiers, hoverDate, endDate, 'hovered-span'); + if (startDate && !endDate) { + if (isAfterDay(hoverDate, startDate)) { + const endSpan = hoverDate.clone().add(1, 'day'); + modifiers = this.deleteModifierFromRange(modifiers, startDate, endSpan, 'hovered-span'); + } + + if (isAfterDay(day, startDate)) { + modifiers = this.deleteModifier(modifiers, startDate, 'selected-start-in-hovered-span'); + } + } + + if (!startDate && endDate) { + if (isAfterDay(endDate, hoverDate)) { + modifiers = this.deleteModifierFromRange(modifiers, hoverDate, endDate, 'hovered-span'); + } + + if (isBeforeDay(day, endDate)) { + modifiers = this.deleteModifier(modifiers, endDate, 'selected-end-in-hovered-span'); + } } if (startDate && isSameDay(day, startDate)) { @@ -511,6 +875,29 @@ export default class DayPickerRangeController extends React.Component { modifiers = this.deleteModifierFromRange(modifiers, startSpan, endSpan, 'after-hovered-start'); } + if (endDate && isSameDay(day, endDate)) { + const startSpan = endDate.clone().subtract(minimumNights, 'days'); + modifiers = this.deleteModifierFromRange(modifiers, startSpan, endDate, 'before-hovered-end'); + } + + if (!this.isBlocked(hoverDate)) { + const minNightsForHoverDate = getMinNightsForHoverDate(hoverDate); + if (minNightsForHoverDate > 0 && focusedInput === START_DATE) { + modifiers = this.deleteModifierFromRange( + modifiers, + hoverDate.clone().add(1, 'days'), + hoverDate.clone().add(minNightsForHoverDate, 'days'), + 'hovered-start-blocked-minimum-nights', + ); + + modifiers = this.deleteModifier( + modifiers, + hoverDate.clone().add(minNightsForHoverDate, 'days'), + 'hovered-start-first-possible-end', + ); + } + } + this.setState({ hoverDate: null, visibleDays: { @@ -521,7 +908,13 @@ export default class DayPickerRangeController extends React.Component { } onPrevMonthClick() { - const { onPrevMonthClick, numberOfMonths, enableOutsideDays } = this.props; + const { + enableOutsideDays, + maxDate, + minDate, + numberOfMonths, + onPrevMonthClick, + } = this.props; const { currentMonth, visibleDays } = this.state; const newVisibleDays = {}; @@ -535,17 +928,25 @@ export default class DayPickerRangeController extends React.Component { const newCurrentMonth = currentMonth.clone().subtract(1, 'month'); this.setState({ currentMonth: newCurrentMonth, + disablePrev: this.shouldDisableMonthNavigation(minDate, newCurrentMonth), + disableNext: this.shouldDisableMonthNavigation(maxDate, newCurrentMonth), visibleDays: { ...newVisibleDays, ...this.getModifiers(prevMonthVisibleDays), }, + }, () => { + onPrevMonthClick(newCurrentMonth.clone()); }); - - onPrevMonthClick(newCurrentMonth.clone()); } onNextMonthClick() { - const { onNextMonthClick, numberOfMonths, enableOutsideDays } = this.props; + const { + enableOutsideDays, + maxDate, + minDate, + numberOfMonths, + onNextMonthClick, + } = this.props; const { currentMonth, visibleDays } = this.state; const newVisibleDays = {}; @@ -555,20 +956,53 @@ export default class DayPickerRangeController extends React.Component { const nextMonth = currentMonth.clone().add(numberOfMonths + 1, 'month'); const nextMonthVisibleDays = getVisibleDays(nextMonth, 1, enableOutsideDays, true); - const newCurrentMonth = currentMonth.clone().add(1, 'month'); this.setState({ currentMonth: newCurrentMonth, + disablePrev: this.shouldDisableMonthNavigation(minDate, newCurrentMonth), + disableNext: this.shouldDisableMonthNavigation(maxDate, newCurrentMonth), visibleDays: { ...newVisibleDays, ...this.getModifiers(nextMonthVisibleDays), }, + }, () => { + onNextMonthClick(newCurrentMonth.clone()); }); + } - onNextMonthClick(newCurrentMonth.clone()); + onMonthChange(newMonth) { + const { numberOfMonths, enableOutsideDays, orientation } = this.props; + const withoutTransitionMonths = orientation === VERTICAL_SCROLLABLE; + const newVisibleDays = getVisibleDays( + newMonth, + numberOfMonths, + enableOutsideDays, + withoutTransitionMonths, + ); + + this.setState({ + currentMonth: newMonth.clone(), + visibleDays: this.getModifiers(newVisibleDays), + }); } - onMultiplyScrollableMonths() { + onYearChange(newMonth) { + const { numberOfMonths, enableOutsideDays, orientation } = this.props; + const withoutTransitionMonths = orientation === VERTICAL_SCROLLABLE; + const newVisibleDays = getVisibleDays( + newMonth, + numberOfMonths, + enableOutsideDays, + withoutTransitionMonths, + ); + + this.setState({ + currentMonth: newMonth.clone(), + visibleDays: this.getModifiers(newVisibleDays), + }); + } + + onGetNextScrollableMonths() { const { numberOfMonths, enableOutsideDays } = this.props; const { currentMonth, visibleDays } = this.state; @@ -584,6 +1018,31 @@ export default class DayPickerRangeController extends React.Component { }); } + onGetPrevScrollableMonths() { + const { numberOfMonths, enableOutsideDays } = this.props; + const { currentMonth, visibleDays } = this.state; + + const firstPreviousMonth = currentMonth.clone().subtract(numberOfMonths, 'month'); + const newVisibleDays = getVisibleDays(firstPreviousMonth, numberOfMonths, enableOutsideDays, true); + + this.setState({ + currentMonth: firstPreviousMonth.clone(), + visibleDays: { + ...visibleDays, + ...this.getModifiers(newVisibleDays), + }, + }); + } + + getFirstDayOfWeek() { + const { firstDayOfWeek } = this.props; + if (firstDayOfWeek == null) { + return moment.localeData().firstDayOfWeek(); + } + + return firstDayOfWeek; + } + getFirstFocusableDay(newMonth) { const { startDate, @@ -593,7 +1052,7 @@ export default class DayPickerRangeController extends React.Component { numberOfMonths, } = this.props; - let focusedDate = newMonth.clone().startOf('month'); + let focusedDate = newMonth.clone().startOf('month').hour(12); if (focusedInput === START_DATE && startDate) { focusedDate = startDate.clone(); } else if (focusedInput === END_DATE && !endDate && startDate) { @@ -611,7 +1070,7 @@ export default class DayPickerRangeController extends React.Component { days.push(currentDay); } - const viableDays = days.filter(day => !this.isBlocked(day)); + const viableDays = days.filter((day) => !this.isBlocked(day)); if (viableDays.length > 0) { ([focusedDate] = viableDays); @@ -634,7 +1093,7 @@ export default class DayPickerRangeController extends React.Component { } getModifiersForDay(day) { - return new Set(Object.keys(this.modifiers).filter(modifier => this.modifiers[modifier](day))); + return new Set(Object.keys(this.modifiers).filter((modifier) => this.modifiers[modifier](day))); } getStateForNewMonth(nextProps) { @@ -659,60 +1118,19 @@ export default class DayPickerRangeController extends React.Component { return { currentMonth, visibleDays }; } - setDayPickerRef(ref) { - this.dayPicker = ref; - } + shouldDisableMonthNavigation(date, visibleMonth) { + if (!date) return false; - addModifier(updatedDays, day, modifier) { - const { numberOfMonths: numberOfVisibleMonths, enableOutsideDays, orientation } = this.props; - const { currentMonth: firstVisibleMonth, visibleDays } = this.state; - - let currentMonth = firstVisibleMonth; - let numberOfMonths = numberOfVisibleMonths; - if (orientation !== VERTICAL_SCROLLABLE) { - currentMonth = currentMonth.clone().subtract(1, 'month'); - numberOfMonths += 2; - } - if (!day || !isDayVisible(day, currentMonth, numberOfMonths, enableOutsideDays)) { - return updatedDays; - } + const { + numberOfMonths, + enableOutsideDays, + } = this.props; - const iso = toISODateString(day); - - let updatedDaysAfterAddition = { ...updatedDays }; - if (enableOutsideDays) { - const monthsToUpdate = Object.keys(visibleDays).filter(monthKey => ( - Object.keys(visibleDays[monthKey]).indexOf(iso) > -1 - )); - - updatedDaysAfterAddition = monthsToUpdate.reduce((days, monthIso) => { - const month = updatedDays[monthIso] || visibleDays[monthIso]; - const modifiers = new Set(month[iso]); - modifiers.add(modifier); - return { - ...days, - [monthIso]: { - ...month, - [iso]: modifiers, - }, - }; - }, updatedDaysAfterAddition); - } else { - const monthIso = toISOMonthString(day); - const month = updatedDays[monthIso] || visibleDays[monthIso]; - - const modifiers = new Set(month[iso]); - modifiers.add(modifier); - updatedDaysAfterAddition = { - ...updatedDaysAfterAddition, - [monthIso]: { - ...month, - [iso]: modifiers, - }, - }; - } + return isDayVisible(date, visibleMonth, numberOfMonths, enableOutsideDays); + } - return updatedDaysAfterAddition; + addModifier(updatedDays, day, modifier) { + return addModifier(updatedDays, day, modifier, this.props, this.state); } addModifierToRange(updatedDays, start, end, modifier) { @@ -728,55 +1146,7 @@ export default class DayPickerRangeController extends React.Component { } deleteModifier(updatedDays, day, modifier) { - const { numberOfMonths: numberOfVisibleMonths, enableOutsideDays, orientation } = this.props; - const { currentMonth: firstVisibleMonth, visibleDays } = this.state; - - let currentMonth = firstVisibleMonth; - let numberOfMonths = numberOfVisibleMonths; - if (orientation !== VERTICAL_SCROLLABLE) { - currentMonth = currentMonth.clone().subtract(1, 'month'); - numberOfMonths += 2; - } - if (!day || !isDayVisible(day, currentMonth, numberOfMonths, enableOutsideDays)) { - return updatedDays; - } - - const iso = toISODateString(day); - - let updatedDaysAfterDeletion = { ...updatedDays }; - if (enableOutsideDays) { - const monthsToUpdate = Object.keys(visibleDays).filter(monthKey => ( - Object.keys(visibleDays[monthKey]).indexOf(iso) > -1 - )); - - updatedDaysAfterDeletion = monthsToUpdate.reduce((days, monthIso) => { - const month = updatedDays[monthIso] || visibleDays[monthIso]; - const modifiers = new Set(month[iso]); - modifiers.delete(modifier); - return { - ...days, - [monthIso]: { - ...month, - [iso]: modifiers, - }, - }; - }, updatedDaysAfterDeletion); - } else { - const monthIso = toISOMonthString(day); - const month = updatedDays[monthIso] || visibleDays[monthIso]; - - const modifiers = new Set(month[iso]); - modifiers.delete(modifier); - updatedDaysAfterDeletion = { - ...updatedDaysAfterDeletion, - [monthIso]: { - ...month, - [iso]: modifiers, - }, - }; - } - - return updatedDaysAfterDeletion; + return deleteModifier(updatedDays, day, modifier, this.props, this.state); } deleteModifierFromRange(updatedDays, start, end, modifier) { @@ -807,15 +1177,35 @@ export default class DayPickerRangeController extends React.Component { return isOutsideRange(moment(day).subtract(minimumNights, 'days')); } + doesNotMeetMinNightsForHoveredStartDate(day, hoverDate) { + const { + focusedInput, + getMinNightsForHoverDate, + } = this.props; + if (focusedInput !== END_DATE) return false; + + if (hoverDate && !this.isBlocked(hoverDate)) { + const minNights = getMinNightsForHoverDate(hoverDate); + const dayDiff = day.diff(hoverDate.clone().startOf('day').hour(12), 'days'); + return dayDiff < minNights && dayDiff >= 0; + } + return false; + } + isDayAfterHoveredStartDate(day) { const { startDate, endDate, minimumNights } = this.props; const { hoverDate } = this.state || {}; - return !!startDate && !endDate && !this.isBlocked(day) && isNextDay(hoverDate, day) && - minimumNights > 0 && isSameDay(hoverDate, startDate); + return !!startDate + && !endDate + && !this.isBlocked(day) + && isNextDay(hoverDate, day) + && minimumNights > 0 + && isSameDay(hoverDate, startDate); } isEndDate(day) { - return isSameDay(day, this.props.endDate); + const { endDate } = this.props; + return isSameDay(day, endDate); } isHovered(day) { @@ -828,12 +1218,12 @@ export default class DayPickerRangeController extends React.Component { const { startDate, endDate } = this.props; const { hoverDate } = this.state || {}; - const isForwardRange = !!startDate && !endDate && - (day.isBetween(startDate, hoverDate) || - isSameDay(hoverDate, day)); - const isBackwardRange = !!endDate && !startDate && - (day.isBetween(hoverDate, endDate) || - isSameDay(hoverDate, day)); + const isForwardRange = !!startDate && !endDate && ( + day.isBetween(startDate, hoverDate) || isSameDay(hoverDate, day) + ); + const isBackwardRange = !!endDate && !startDate && ( + day.isBetween(hoverDate, endDate) || isSameDay(hoverDate, day) + ); const isValidDayHovered = hoverDate && !this.isBlocked(hoverDate); @@ -842,56 +1232,118 @@ export default class DayPickerRangeController extends React.Component { isInSelectedSpan(day) { const { startDate, endDate } = this.props; - return day.isBetween(startDate, endDate); + return day.isBetween(startDate, endDate, 'days'); } isLastInRange(day) { - return this.isInSelectedSpan(day) && isNextDay(day, this.props.endDate); + const { endDate } = this.props; + return this.isInSelectedSpan(day) && isNextDay(day, endDate); } isStartDate(day) { - return isSameDay(day, this.props.startDate); + const { startDate } = this.props; + return isSameDay(day, startDate); } - isBlocked(day) { + isBlocked(day, blockDaysViolatingMinNights = true) { const { isDayBlocked, isOutsideRange } = this.props; - return isDayBlocked(day) || isOutsideRange(day) || this.doesNotMeetMinimumNights(day); + return isDayBlocked(day) + || isOutsideRange(day) + || (blockDaysViolatingMinNights && this.doesNotMeetMinimumNights(day)); } isToday(day) { return isSameDay(day, this.today); } + isFirstDayOfWeek(day) { + return day.day() === this.getFirstDayOfWeek(); + } + + isLastDayOfWeek(day) { + return day.day() === (this.getFirstDayOfWeek() + 6) % 7; + } + + isFirstPossibleEndDateForHoveredStartDate(day, hoverDate) { + const { focusedInput, getMinNightsForHoverDate } = this.props; + if (focusedInput !== END_DATE || !hoverDate || this.isBlocked(hoverDate)) return false; + const minNights = getMinNightsForHoverDate(hoverDate); + const firstAvailableEndDate = hoverDate.clone().add(minNights, 'days'); + return isSameDay(day, firstAvailableEndDate); + } + + beforeSelectedEnd(day) { + const { endDate } = this.props; + return isBeforeDay(day, endDate); + } + + isDayBeforeHoveredEndDate(day) { + const { startDate, endDate, minimumNights } = this.props; + const { hoverDate } = this.state || {}; + + return !!endDate + && !startDate + && !this.isBlocked(day) + && isPreviousDay(hoverDate, day) + && minimumNights > 0 + && isSameDay(hoverDate, endDate); + } + render() { const { numberOfMonths, orientation, monthFormat, - renderMonth, + renderMonthText, + renderWeekHeaderElement, + dayPickerNavigationInlineStyles, + navPosition, navPrev, navNext, + renderNavPrevButton, + renderNavNextButton, + noNavButtons, + noNavNextButton, + noNavPrevButton, onOutsideClick, withPortal, enableOutsideDays, firstDayOfWeek, + renderKeyboardShortcutsButton, + renderKeyboardShortcutsPanel, hideKeyboardShortcutsPanel, daySize, focusedInput, - renderDay, + renderCalendarDay, + renderDayContents, renderCalendarInfo, + renderMonthElement, + calendarInfoPosition, onBlur, + onShiftTab, + onTab, isFocused, showKeyboardShortcuts, isRTL, weekDayFormat, + dayAriaLabelFormat, verticalHeight, + noBorder, + transitionDuration, + verticalBorderSpacing, + horizontalMonthPadding, } = this.props; - const { currentMonth, phrases, visibleDays } = this.state; + const { + currentMonth, + phrases, + visibleDays, + disablePrev, + disableNext, + } = this.state; return (
); +function renderNavPrevButton(buttonProps) { + const { + ariaLabel, + disabled, + onClick, + onKeyUp, + onMouseUp, + } = buttonProps; + + return ( + + ); +} + +function renderNavNextButton(buttonProps) { + const { + ariaLabel, + disabled, + onClick, + onKeyUp, + onMouseUp, + } = buttonProps; + + return ( + + ); +} + storiesOf('DayPicker', module) - .addWithInfo('default', () => ( + .add('default', withInfo()(() => ( - )) - .addWithInfo('with custom day size', () => ( + ))) + .add('with custom day size', withInfo()(() => ( - )) - .addWithInfo('single month', () => ( + ))) + .add('single month', withInfo()(() => ( - )) - .addWithInfo('3 months', () => ( + ))) + .add('3 months', withInfo()(() => ( - )) - .addWithInfo('vertical', () => ( + ))) + .add('vertical', withInfo()(() => ( - )) - .addWithInfo('vertically scrollable with 12 months', () => ( + ))) + .add('vertically scrollable with 12 months', withInfo()(() => (
- )) - .addWithInfo('vertical with custom day size', () => ( + ))) + .add('vertical with custom day size', withInfo()(() => ( - )) - .addWithInfo('vertical with custom height', () => ( + ))) + .add('vertical with custom height', withInfo()(() => ( - )) - .addWithInfo('with custom arrows', () => ( + ))) + .add('vertical with DirectionProvider', withInfo()(() => ( + + + + ))) + .add('vertically scrollable with DirectionProvider', withInfo()(() => ( + +
+ +
+
+ ))) + .add('with custom arrows', withInfo()(() => ( } navNext={} /> - )) - .addWithInfo('with custom details', () => ( + ))) + .add('with custom navigation buttons', withInfo()(() => ( + + ))) + .add('with custom details', withInfo()(() => ( (day.day() % 6 === 5 ? '😻' : day.format('D'))} + renderDayContents={(day) => (day.day() % 6 === 5 ? '😻' : day.format('D'))} /> - )) - .addWithInfo('vertical with fixed-width container', () => ( + ))) + .add('vertical with fixed-width container', withInfo()(() => (
- )) - .addWithInfo('with info panel', () => ( + ))) + .add('with info panel', withInfo()(() => ( ( )} /> - )) - .addWithInfo('with custom week day format', () => ( + ))) + .add('with custom week header text', withInfo()(() => ( + ( + {day.toUpperCase()} + )} + /> + ))) + .add('with custom week day format', withInfo()(() => ( + + ))) + .add('with no animation', withInfo()(() => ( - )); + ))) + .add('noBorder', withInfo()(() => ( + + ))); diff --git a/stories/DayPickerRangeController.js b/stories/DayPickerRangeController.js index 7f286175f5..39bb9716a9 100644 --- a/stories/DayPickerRangeController.js +++ b/stories/DayPickerRangeController.js @@ -1,15 +1,20 @@ import React from 'react'; import moment from 'moment'; -import { storiesOf, action } from '@storybook/react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { withInfo } from '@storybook/addon-info'; + +import InfoPanelDecorator, { monospace } from './InfoPanelDecorator'; import isSameDay from '../src/utils/isSameDay'; import isInclusivelyAfterDay from '../src/utils/isInclusivelyAfterDay'; -import { VERTICAL_ORIENTATION } from '../constants'; +import CloseButton from '../src/components/CloseButton'; +import KeyboardShortcutRow from '../src/components/KeyboardShortcutRow'; -import DayPickerRangeControllerWrapper from '../examples/DayPickerRangeControllerWrapper'; +import { NAV_POSITION_BOTTOM, VERTICAL_ORIENTATION, VERTICAL_SCROLLABLE } from '../src/constants'; -const monospace = text => `${text}`; +import DayPickerRangeControllerWrapper from '../examples/DayPickerRangeControllerWrapper'; const dayPickerRangeControllerInfo = `The ${monospace('DayPickerRangeController')} component is a fully controlled version of the ${monospace('DayPicker')} that has built-in rules for selecting a @@ -19,7 +24,7 @@ const dayPickerRangeControllerInfo = `The ${monospace('DayPickerRangeController' ${monospace('startDate')}, and ${monospace('endDate')} values in state and then pass these down as props along with ${monospace('onFocusChange')} and ${monospace('onDatesChange')} callbacks that update them appropriately. You can see an example of this implementation + "https://github.com/react-dates/react-dates/blob/HEAD/examples/DayPickerRangeControllerWrapper.jsx"> here.

Note that the ${monospace('focusedInput')} prop may be ${monospace('null')}, but if this is the case, dates are not selectable. As a result, in the example wrapper, we always force @@ -28,51 +33,68 @@ const dayPickerRangeControllerInfo = `The ${monospace('DayPickerRangeController' ${monospace('DateRangePicker')} functionality and calendar presentation, but would like to implement your own inputs.`; -const InfoPanel = ({ text }) => ( +const TestPrevIcon = () => (
- + Prev
); -const InfoPanelDecorator = story => ( -
- - {story()} +const TestNextIcon = () => ( +
+ Next
); -const TestPrevIcon = () => ( - ( +
Prev - +
); -const TestNextIcon = () => ( - ( +
Next - +
); const TestCustomInfoPanel = () => ( @@ -87,6 +109,219 @@ const TestCustomInfoPanel = () => (
); +function renderNavPrevButton(buttonProps) { + const { + ariaLabel, + disabled, + onClick, + onKeyUp, + onMouseUp, + } = buttonProps; + + return ( + + ); +} + +function renderNavNextButton(buttonProps) { + const { + ariaLabel, + disabled, + onClick, + onKeyUp, + onMouseUp, + } = buttonProps; + + return ( + + ); +} + +function renderNavPrevButtonForVerticalScrollable(buttonProps) { + const { + ariaLabel, + disabled, + onClick, + onKeyUp, + onMouseUp, + } = buttonProps; + + return ( + + ); +} + +function renderNavNextButtonForVerticalScrollable(buttonProps) { + const { + ariaLabel, + disabled, + onClick, + onKeyUp, + onMouseUp, + } = buttonProps; + + return ( + + ); +} + +function renderKeyboardShortcutsButton(buttonProps) { + const { ref, onClick, ariaLabel } = buttonProps; + + const buttonStyle = { + backgroundColor: '#914669', + border: 0, + borderRadius: 0, + color: 'inherit', + font: 'inherit', + lineHeight: 'normal', + overflow: 'visible', + padding: 0, + cursor: 'pointer', + width: 26, + height: 26, + position: 'absolute', + bottom: 0, + right: 0, + }; + + const spanStyle = { + color: 'white', + position: 'absolute', + bottom: 5, + right: 9, + }; + + return ( + + ); +} + +function renderKeyboardShortcutsPanel(panelProps) { + const { + closeButtonAriaLabel, + keyboardShortcuts, + onCloseButtonClick, + onKeyDown, + title, + } = panelProps; + + const PANEL_PADDING_PX = 50; + + return ( +
+
+
+ +
+ {title} +
+ {keyboardShortcuts.map(({ action: keyboardShortcutAction, label, unicode }) => ( + + ))} +
+
+ ); +} + const datesList = [ moment(), moment().add(1, 'days'), @@ -99,23 +334,58 @@ const datesList = [ ]; storiesOf('DayPickerRangeController', module) - .addDecorator(InfoPanelDecorator) - .addWithInfo('default', () => ( + .addDecorator(InfoPanelDecorator(dayPickerRangeControllerInfo)) + .add('default', withInfo()(() => ( - )) - .addWithInfo('with custom inputs', () => ( + ))) + .add('with 7 days range selection', withInfo()(() => ( + day.subtract(3, 'days')} + endDateOffset={(day) => day.add(3, 'days')} + /> + ))) + .add('with 45 days range selection', withInfo()(() => ( + day.subtract(22, 'days')} + endDateOffset={(day) => day.add(22, 'days')} + /> + ))) + .add('with 4 days after today range selection', withInfo()(() => ( + day.add(4, 'days')} + /> + ))) + .add('with current week range selection', withInfo()(() => ( + day.startOf('week')} + endDateOffset={(day) => day.endOf('week')} + /> + ))) + .add('with custom inputs', withInfo()(() => ( - )) - .addWithInfo('non-english locale', () => { + ))) + .add('non-english locale', withInfo()(() => { moment.locale('zh-cn'); return ( ); - }) - .addWithInfo('single month', () => ( + })) + .add('single month', withInfo()(() => ( - )) - .addWithInfo('3 months', () => ( + ))) + .add('single month, custom caption', withInfo()(() => ( + ( +
+
+ +
+
+ +
+
+ )} + /> + ))) + .add('3 months', withInfo()(() => ( - )) - .addWithInfo('vertical', () => ( + ))) + .add('vertical', withInfo()(() => ( - )) - .addWithInfo('with custom month navigation', () => ( + ))) + .add('vertical scrollable', withInfo()(() => ( +
+ +
+ ))) + .add('vertical scrollable with custom month nav', withInfo()(() => ( +
+ + + Show More Months + +
+ )} + navPrev={( +
+ + Show More Months + +
+ )} + /> +
+ ))) + .add('vertical scrollable with custom rendered month navigation', withInfo()(() => ( +
+ +
+ ))) + .add('with custom month navigation icons', withInfo()(() => ( } navNext={} /> - )) - .addWithInfo('with outside days enabled', () => ( + ))) + .add('with custom month navigation buttons', withInfo()(() => ( + + ))) + .add('with custom month navigation and blocked navigation (minDate and maxDate)', withInfo()(() => ( + } + navNext={} + /> + ))) + .add('with month navigation positioned at the bottom', withInfo()(() => ( + + ))) + .add('with custom month navigation positioned at the bottom', withInfo()(() => ( + } + navNext={} + dayPickerNavigationInlineStyles={{ + display: 'flex', + justifyContent: 'space-between', + }} + /> + ))) + .add('with outside days enabled', withInfo()(() => ( - )) - .addWithInfo('with month specified on open', () => ( + ))) + .add('with month specified on open', withInfo()(() => ( moment().add(10, 'months')} /> - )) - .addWithInfo('with minimum nights set', () => ( + ))) + .add('with minimum nights set', withInfo()(() => ( - )) - .addWithInfo('allows single day range', () => ( + ))) + .add('allows single day range', withInfo()(() => ( - )) - .addWithInfo('allows all days, including past days', () => ( + ))) + .add('allows all days, including past days', withInfo()(() => ( false} /> - )) - .addWithInfo('allows next two weeks only', () => ( + ))) + .add('allows next two weeks only', withInfo()(() => ( - !isInclusivelyAfterDay(day, moment()) || - isInclusivelyAfterDay(day, moment().add(2, 'weeks')) - } + isOutsideRange={(day) => !isInclusivelyAfterDay(day, moment()) + || isInclusivelyAfterDay(day, moment().add(2, 'weeks'))} /> - )) - .addWithInfo('with some blocked dates', () => ( + ))) + .add('with some blocked dates', withInfo()(() => ( datesList.some(day2 => isSameDay(day1, day2))} + isDayBlocked={(day1) => datesList.some((day2) => isSameDay(day1, day2))} /> - )) - .addWithInfo('with some highlighted dates', () => ( + ))) + .add('with some highlighted dates', withInfo()(() => ( datesList.some(day2 => isSameDay(day1, day2))} + isDayHighlighted={(day1) => datesList.some((day2) => isSameDay(day1, day2))} /> - )) - .addWithInfo('blocks fridays', () => ( + ))) + .add('blocks fridays', withInfo()(() => ( moment.weekdays(day.weekday()) === 'Friday'} + isDayBlocked={(day) => moment.weekdays(day.weekday()) === 'Friday'} /> - )) - .addWithInfo('with custom daily details', () => ( + ))) + .add('with navigation blocked (minDate and maxDate)', withInfo()(() => ( day.format('ddd')} /> - )) - .addWithInfo('with info panel', () => ( + ))) + .add('with custom daily details', withInfo()(() => ( + day.format('ddd')} + /> + ))) + .add('with info panel', withInfo()(() => ( )} /> - )); + ))) + .add('with no animation', withInfo()(() => ( + + ))) + .add('with vertical spacing applied', withInfo()(() => ( + + ))) + .add('with custom horizontal month spacing applied', withInfo()(() => ( +
+ +
+ ))) + .add('with no nav buttons', withInfo()(() => ( + + ))) + .add('with no nav prev button', withInfo()(() => ( +
+ +
+ ))) + .add('with no nav next button', withInfo()(() => ( +
+ +
+ ))) + .add('with minimum nights for the hovered date', withInfo()(() => ( + 2} + /> + ))) + .add('with minimum nights for the hovered date and some blocked dates', withInfo()(() => ( + 2} + isDayBlocked={(day1) => datesList.some((day2) => isSameDay(day1, day2))} + /> + ))) + .add('with custom keyboard shortcuts button', withInfo()(() => ( + + ))) + .add('with custom keyboard shortcuts panel', withInfo()(() => ( + + ))) + .add('with minimum nights set and daysViolatingMinNightsCanBeClicked set to true', withInfo()(() => ( + + ))); diff --git a/stories/DayPickerSingleDateController.js b/stories/DayPickerSingleDateController.js index a10236baea..7126f75420 100644 --- a/stories/DayPickerSingleDateController.js +++ b/stories/DayPickerSingleDateController.js @@ -1,17 +1,20 @@ import React from 'react'; import moment from 'moment'; -import { storiesOf, action } from '@storybook/react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { withInfo } from '@storybook/addon-info'; + +import InfoPanelDecorator, { monospace } from './InfoPanelDecorator'; import isSameDay from '../src/utils/isSameDay'; import isInclusivelyAfterDay from '../src/utils/isInclusivelyAfterDay'; +import CustomizableCalendarDay, { defaultStyles, selectedStyles } from '../src/components/CustomizableCalendarDay'; -import { VERTICAL_ORIENTATION } from '../constants'; +import { NAV_POSITION_BOTTOM, VERTICAL_ORIENTATION, VERTICAL_SCROLLABLE } from '../src/constants'; import DayPickerSingleDateControllerWrapper from '../examples/DayPickerSingleDateControllerWrapper'; -const monospace = text => `${text}`; - -const dayPickerRangeControllerInfo = `The ${monospace('DayPickerSingleDateController')} component is a +const dayPickerSingleDateControllerInfo = `The ${monospace('DayPickerSingleDateController')} component is a fully controlled version of the ${monospace('DayPicker')} that has built-in rules for selecting a single date. Unlike the ${monospace('DayPicker')}, which requires the consumer to explicitly define ${monospace('onDayMouseEnter')}, ${monospace('onDayMouseLeave')}, and ${monospace('onDayClick')} @@ -19,7 +22,7 @@ const dayPickerRangeControllerInfo = `The ${monospace('DayPickerSingleDateContro ${monospace('date')} values in state and then pass these down as props along with ${monospace('onFocusChange')} and ${monospace('onDateChange')} callbacks that update them appropriately. You can see an example of this implementation + "https://github.com/react-dates/react-dates/blob/HEAD/examples/DayPickerSingleDateControllerWrapper.jsx"> here.

Note that the ${monospace('focused')} prop may be ${monospace('false')}, but if this is the case, dates are not selectable. As a result, in the example wrapper, we always force @@ -28,51 +31,38 @@ const dayPickerRangeControllerInfo = `The ${monospace('DayPickerSingleDateContro ${monospace('SingleDatePicker')} functionality and calendar presentation, but would like to implement your own input.`; -const InfoPanel = ({ text }) => ( -
- -
-); - -const InfoPanelDecorator = story => ( -
- - {story()} -
-); - const TestPrevIcon = () => ( - Prev - + ); const TestNextIcon = () => ( - Next - + ); const TestCustomInfoPanel = () => ( @@ -87,6 +77,54 @@ const TestCustomInfoPanel = () => ( ); +function renderNavPrevButton(buttonProps) { + const { + ariaLabel, + disabled, + onClick, + onKeyUp, + onMouseUp, + } = buttonProps; + + return ( + + ); +} + +function renderNavNextButton(buttonProps) { + const { + ariaLabel, + disabled, + onClick, + onKeyUp, + onMouseUp, + } = buttonProps; + + return ( + + ); +} + const datesList = [ moment(), moment().add(1, 'days'), @@ -99,23 +137,31 @@ const datesList = [ ]; storiesOf('DayPickerSingleDateController', module) - .addDecorator(InfoPanelDecorator) - .addWithInfo('default', () => ( + .addDecorator(InfoPanelDecorator(dayPickerSingleDateControllerInfo)) + .add('default', withInfo()(() => ( + + ))) + .add('with day unselection', withInfo()(() => ( - )) - .addWithInfo('with custom input', () => ( + ))) + .add('with custom input', withInfo()(() => ( - )) - .addWithInfo('non-english locale', () => { + ))) + .add('non-english locale', withInfo()(() => { moment.locale('zh-cn'); return ( ); - }) - .addWithInfo('single month', () => ( + })) + .add('single month', withInfo()(() => ( - )) - .addWithInfo('3 months', () => ( + ))) + .add('single month, custom caption', withInfo()(() => ( + ( +
+
+ +
+
+ +
+
+ )} + /> + ))) + .add('3 months', withInfo()(() => ( - )) - .addWithInfo('vertical', () => ( + ))) + .add('vertical', withInfo()(() => ( - )) - .addWithInfo('with custom month navigation', () => ( + ))) + .add('verticalScrollable', withInfo()(() => ( +
+ +
+ ))) + .add('with custom month navigation icons', withInfo()(() => ( } navNext={} /> - )) - .addWithInfo('with outside days enabled', () => ( + ))) + .add('with custom month navigation buttons', withInfo()(() => ( + + ))) + .add('with month navigation positioned at the bottom', withInfo()(() => ( + + ))) + .add('with outside days enabled', withInfo()(() => ( - )) - .addWithInfo('with month specified on open', () => ( + ))) + .add('with month specified on open', withInfo()(() => ( moment().add(10, 'months')} /> - )) - .addWithInfo('allows all days, including past days', () => ( + ))) + .add('allows all days, including past days', withInfo()(() => ( false} /> - )) - .addWithInfo('allows next two weeks only', () => ( + ))) + .add('allows next two weeks only', withInfo()(() => ( - !isInclusivelyAfterDay(day, moment()) || - isInclusivelyAfterDay(day, moment().add(2, 'weeks')) - } + isOutsideRange={(day) => !isInclusivelyAfterDay(day, moment()) + || isInclusivelyAfterDay(day, moment().add(2, 'weeks'))} /> - )) - .addWithInfo('with some blocked dates', () => ( + ))) + .add('with some blocked dates', withInfo()(() => ( datesList.some(day2 => isSameDay(day1, day2))} + isDayBlocked={(day1) => datesList.some((day2) => isSameDay(day1, day2))} /> - )) - .addWithInfo('with some highlighted dates', () => ( + ))) + .add('with some highlighted dates', withInfo()(() => ( datesList.some(day2 => isSameDay(day1, day2))} + isDayHighlighted={(day1) => datesList.some((day2) => isSameDay(day1, day2))} /> - )) - .addWithInfo('blocks fridays', () => ( + ))) + .add('blocks fridays', withInfo()(() => ( moment.weekdays(day.weekday()) === 'Friday'} + isDayBlocked={(day) => moment.weekdays(day.weekday()) === 'Friday'} /> - )) - .addWithInfo('with custom daily details', () => ( + ))) + .add('with custom daily details', withInfo()(() => ( day.format('ddd')} + renderDayContents={(day) => day.format('ddd')} /> - )) - .addWithInfo('with info panel', () => ( + ))) + .add('with custom day styles', withInfo()(() => { + const customDayStyles = { + // extend and update styles with es6 spread operators + defaultStyles: { + ...defaultStyles, + color: 'blue', + hover: { + ...defaultStyles.hover, + color: 'blue', + }, + }, + }; + return ( + ( + + )} + /> + ); + })) + .add('with info panel', withInfo()(() => ( )} /> - )); + ))) + .add('with no animation', withInfo()(() => ( + + ))) + .add('with vertical spacing applied', withInfo()(() => ( + + ))) + .add('with no nav buttons', withInfo()(() => ( + + ))) + .add('with no nav prev button', withInfo()(() => ( +
+ +
+ ))) + .add('with no nav next button', withInfo()(() => ( +
+ +
+ ))); diff --git a/stories/InfoPanelDecorator.js b/stories/InfoPanelDecorator.js new file mode 100644 index 0000000000..f1f6a3d5a4 --- /dev/null +++ b/stories/InfoPanelDecorator.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export function monospace(text) { + return `${text}`; +} + +function InfoPanel({ text }) { + return ( +
+ +
+ ); +} + +InfoPanel.propTypes = { + text: PropTypes.string.isRequired, +}; + +export default function InfoPanelDecorator(text) { + return story => ( +
+ + {story()} +
+ ); +} diff --git a/stories/PresetDateRangePicker.js b/stories/PresetDateRangePicker.js new file mode 100644 index 0000000000..2d2660327a --- /dev/null +++ b/stories/PresetDateRangePicker.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { withInfo } from '@storybook/addon-info'; +import moment from 'moment'; + +import PresetDateRangePicker from '../examples/PresetDateRangePicker'; + +import InfoPanelDecorator, { monospace } from './InfoPanelDecorator'; + +const presetDateRangePickerControllerInfo = `The ${monospace('PresetDateRangePicker')} component is not + exported by ${monospace('react-dates')}. It is instead an example of how you might use the + ${monospace('DateRangePicker')} along with the ${monospace('renderCalendarInfo')} prop in + order to add preset range buttons for easy range selection. You can see the example code + + here and + + here.`; + +const today = moment(); +const tomorrow = moment().add(1, 'day'); +const presets = [{ + text: 'Today', + start: today, + end: today, +}, +{ + text: 'Tomorrow', + start: tomorrow, + end: tomorrow, +}, +{ + text: 'Next Week', + start: today, + end: moment().add(1, 'week'), +}, +{ + text: 'Next Month', + start: today, + end: moment().add(1, 'month'), +}]; + +storiesOf('PresetDateRangePicker', module) + .addDecorator(InfoPanelDecorator(presetDateRangePickerControllerInfo)) + .add('default', withInfo()(() => ( + + ))); diff --git a/stories/SingleDatePicker.js b/stories/SingleDatePicker.js index e5cd59ceb1..3a7d96e656 100644 --- a/stories/SingleDatePicker.js +++ b/stories/SingleDatePicker.js @@ -2,9 +2,15 @@ import React from 'react'; import moment from 'moment'; import momentJalaali from 'moment-jalaali'; import { storiesOf } from '@storybook/react'; +import { withInfo } from '@storybook/addon-info'; +import DirectionProvider, { DIRECTIONS } from 'react-with-direction/dist/DirectionProvider'; +import isInclusivelyBeforeDay from '../src/utils/isInclusivelyBeforeDay'; +import isInclusivelyAfterDay from '../src/utils/isInclusivelyAfterDay'; + import { VERTICAL_ORIENTATION, -} from '../constants'; + ANCHOR_RIGHT, +} from '../src/constants'; import SingleDatePickerWrapper from '../examples/SingleDatePickerWrapper'; @@ -25,18 +31,18 @@ const TestInput = props => ( ); storiesOf('SingleDatePicker (SDP)', module) - .addWithInfo('default', () => ( + .add('default', withInfo()(() => ( - )) - .addWithInfo('as part of a form', () => ( + ))) + .add('as part of a form', withInfo()(() => (
- )) - .addWithInfo('non-english locale (Chinese)', () => { + ))) + .add('non-english locale (Chinese)', withInfo()(() => { moment.locale('zh-cn'); return ( ); - }) - .addWithInfo('non-english locale (Persian)', () => { + })) + .add('non-english locale (Persian)', withInfo()(() => { moment.locale('fa'); return ( momentJalaali(month).format('jMMMM jYYYY')} - renderDay={day => momentJalaali(day).format('jD')} + renderMonthText={month => momentJalaali(month).format('jMMMM jYYYY')} + renderDayContents={day => momentJalaali(day).format('jD')} /> ); - }) - .addWithInfo('vertical with custom height', () => ( + })) + .add('with DirectionProvider', withInfo()(() => ( + + + + ))) + .add('with custom month navigation and blocked navigation (minDate and maxDate)', withInfo()(() => ( + + ))) + .add('with custom isOutsideRange and month navigation and blocked navigation (minDate and maxDate)', withInfo()(() => { + const minDate = moment().subtract(2, 'months').startOf('month') + const maxDate = moment().add(2, 'months').endOf('month') + const isOutsideRange = day => isInclusivelyBeforeDay(day, minDate) || isInclusivelyAfterDay(day, maxDate) + return ( + + )})) + .add('vertical with custom height', withInfo()(() => ( - )); + ))) + .add('with custom autoComplete attribute', withInfo()(() => ( + + ))); diff --git a/stories/SingleDatePicker_calendar.js b/stories/SingleDatePicker_calendar.js index a0eb88c453..13db3464c5 100644 --- a/stories/SingleDatePicker_calendar.js +++ b/stories/SingleDatePicker_calendar.js @@ -1,42 +1,50 @@ import React from 'react'; import moment from 'moment'; import { storiesOf } from '@storybook/react'; +import { withInfo } from '@storybook/addon-info'; import SingleDatePickerWrapper from '../examples/SingleDatePickerWrapper'; -import { VERTICAL_ORIENTATION, ANCHOR_RIGHT, OPEN_UP } from '../constants'; +import { VERTICAL_ORIENTATION, ANCHOR_RIGHT, OPEN_UP } from '../src/constants'; const TestPrevIcon = () => ( - Prev - + ); const TestNextIcon = () => ( - Next - + ); const TestCustomInfoPanel = () => (
@@ -45,112 +53,184 @@ const TestCustomInfoPanel = () => ( ); storiesOf('SDP - Calendar Props', module) - .addWithInfo('default', () => ( + .add('default', withInfo()(() => ( - )) - .addWithInfo('open up', () => ( + ))) + .add('open up', withInfo()(() => (
- )) - .addWithInfo('single month', () => ( + ))) + .add('single month', withInfo()(() => ( - )) - .addWithInfo('with custom day size', () => ( + ))) + .add('with custom day size', withInfo()(() => ( - )) - .addWithInfo('anchored right', () => ( + ))) + .add('anchored right', withInfo()(() => (
- )) - .addWithInfo('vertical', () => ( + ))) + .add('vertical', withInfo()(() => ( - )) - .addWithInfo('horizontal with portal', () => ( + ))) + .add('horizontal with portal', withInfo()(() => ( - )) - .addWithInfo('horizontal with fullscreen portal', () => ( + ))) + .add('horizontal with portal and info panel', withInfo()(() => ( + ( + + )} + /> + ))) + .add('horizontal with fullscreen portal', withInfo()(() => ( - )) - .addWithInfo('vertical with full screen portal', () => ( + ))) + .add('vertical with full screen portal', withInfo()(() => ( - )) - .addWithInfo('does not autoclose the DayPicker on date selection', () => ( + ))) + .add('disable scroll', withInfo()(() => ( +
+
This content scrolls.
+ +
+ ))) + .add('appended to body', withInfo()(() => )) + .add('appended to body (in scrollable container)', withInfo()(() => ( +
+
This content scrolls.
+
+ +
+
+ ))) + .add('does not autoclose the DayPicker on date selection', withInfo()(() => ( - )) - .addWithInfo('with month specified on open', () => ( + ))) + .add('with month specified on open', withInfo()(() => ( moment().add(10, 'months')} autoFocus /> - )) - .addWithInfo('with custom arrows', () => ( + ))) + .add('with custom arrows', withInfo()(() => ( } navNext={} autoFocus /> - )) - .addWithInfo('with outside days enabled', () => ( + ))) + .add('with outside days enabled', withInfo()(() => ( - )) - .addWithInfo('with info panel', () => ( + ))) + .add('with info panel default', withInfo()(() => ( ( + + )} + autoFocus + /> + ))) + .add('with info panel before', withInfo()(() => ( + ( )} autoFocus /> - )) - .addWithInfo('with keyboard shorcuts panel hidden', () => ( + ))) + .add('with info panel after', withInfo()(() => ( + ( + + )} + autoFocus + /> + ))) + .add('with info panel bottom', withInfo()(() => ( + ( + + )} + autoFocus + /> + ))) + .add('with info panel top', withInfo()(() => ( + ( + + )} + autoFocus + /> + ))) + .add('with keyboard shorcuts panel hidden', withInfo()(() => ( - )) - .addWithInfo('with RTL support', () => ( + ))) + .add('with RTL support', withInfo()(() => ( - )) - .addWithInfo('with custom first day of week', () => ( + ))) + .add('with custom first day of week', withInfo()(() => ( - )) - .addWithInfo('with onClose handler', () => ( + ))) + .add('with onClose handler', withInfo()(() => ( alert(`onClose: date = ${date}`)} autoFocus /> - )); - + ))) + .add('with no animation', withInfo()(() => ( + + ))) + .add('with custom vertical spacing', withInfo()(() => ( + + ))); diff --git a/stories/SingleDatePicker_day.js b/stories/SingleDatePicker_day.js index beba330472..c958ce3070 100644 --- a/stories/SingleDatePicker_day.js +++ b/stories/SingleDatePicker_day.js @@ -1,6 +1,7 @@ import React from 'react'; import moment from 'moment'; import { storiesOf } from '@storybook/react'; +import { withInfo } from '@storybook/addon-info'; import isInclusivelyAfterDay from '../src/utils/isInclusivelyAfterDay'; import isSameDay from '../src/utils/isSameDay'; @@ -19,16 +20,16 @@ const datesList = [ ]; storiesOf('SDP - Day Props', module) - .addWithInfo('default', () => ( + .add('default', withInfo()(() => ( - )) - .addWithInfo('allows all days, including past days', () => ( + ))) + .add('allows all days, including past days', withInfo()(() => ( false} autoFocus /> - )) - .addWithInfo('allows next two weeks only', () => ( + ))) + .add('allows next two weeks only', withInfo()(() => ( !isInclusivelyAfterDay(day, moment()) || @@ -36,29 +37,29 @@ storiesOf('SDP - Day Props', module) } autoFocus /> - )) - .addWithInfo('with some blocked dates', () => ( + ))) + .add('with some blocked dates', withInfo()(() => ( datesList.some(day2 => isSameDay(day1, day2))} autoFocus /> - )) - .addWithInfo('with some highlighted dates', () => ( + ))) + .add('with some highlighted dates', withInfo()(() => ( datesList.some(day2 => isSameDay(day1, day2))} autoFocus /> - )) - .addWithInfo('blocks fridays', () => ( + ))) + .add('blocks fridays', withInfo()(() => ( moment.weekdays(day.weekday()) === 'Friday'} autoFocus /> - )) - .addWithInfo('with custom daily details', () => ( + ))) + .add('with custom daily details', withInfo()(() => ( day.format('ddd')} + renderDayContents={day => day.format('ddd')} autoFocus /> - )); + ))); diff --git a/stories/SingleDatePicker_input.js b/stories/SingleDatePicker_input.js index 9ba42e1bc4..5fbdf5a369 100644 --- a/stories/SingleDatePicker_input.js +++ b/stories/SingleDatePicker_input.js @@ -1,6 +1,7 @@ import React from 'react'; import moment from 'moment'; import { storiesOf } from '@storybook/react'; +import { withInfo } from '@storybook/addon-info'; import SingleDatePickerWrapper from '../examples/SingleDatePickerWrapper'; @@ -18,57 +19,97 @@ const TestCustomInputIcon = () => ( ); storiesOf('SDP - Input Props', module) - .addWithInfo('default', () => ( + .add('default', withInfo()(() => ( - )) - .addWithInfo('disabled', () => ( + ))) + .add('disabled', withInfo()(() => ( - )) - .addWithInfo('readOnly', () => ( + ))) + .add('readOnly', withInfo()(() => ( - )) - .addWithInfo('with clear dates button', () => ( + ))) + .add('with clear dates button', withInfo()(() => ( - )) - .addWithInfo('reopens DayPicker on clear dates', () => ( + ))) + .add('reopens DayPicker on clear dates', withInfo()(() => ( - )) - .addWithInfo('with custom display format', () => ( + ))) + .add('with custom display format', withInfo()(() => ( - )) - .addWithInfo('with show calendar icon', () => ( + ))) + .add('with show calendar icon', withInfo()(() => ( - )) - .addWithInfo('with custom show calendar icon', () => ( + ))) + .add('with custom show calendar icon', withInfo()(() => ( } /> - )) - .addWithInfo('with screen reader message', () => ( + ))) + .add('with show calendar icon after input', withInfo()(() => ( + + ))) + .add('with screen reader message', withInfo()(() => ( - )); + ))) + .add('with custom title attribute', withInfo()(() => ( + + ))) + .add('noBorder', withInfo()(() => ( + + ))) + .add('block styling', withInfo()(() => ( + + ))) + .add('small styling', withInfo()(() => ( + + ))) + .add('regular styling', withInfo()(() => ( + + ))); diff --git a/stories/withStyles.js b/stories/withStyles.js index 17e05259c5..cc410fd41d 100644 --- a/stories/withStyles.js +++ b/stories/withStyles.js @@ -1,7 +1,9 @@ import React from 'react'; import { action, storiesOf } from '@storybook/react'; +import { withInfo } from '@storybook/addon-info'; import CalendarDay from '../src/components/CalendarDay'; +import CustomizableCalendarDay from '../src/components/CustomizableCalendarDay'; import CalendarMonth from '../src/components/CalendarMonth'; import CalendarMonthGrid from '../src/components/CalendarMonthGrid'; import DayPickerNavigation from '../src/components/DayPickerNavigation'; @@ -13,34 +15,64 @@ import DateRangePickerInput from '../src/components/DateRangePickerInput'; import { VERTICAL_ORIENTATION, VERTICAL_SCROLLABLE } from '../constants'; +const customStyles = { + background: '#5f4b8b', + border: '2px solid #5f4b8b', + color: '#fff', + + hover: { + background: '#c3b5e3', + border: '2px solid #c3b5e3', + color: '#402b70', + fontWeight: 'bold', + }, +}; + storiesOf('withStyles', module) - .addWithInfo('CalendarDay', () => ( + .add('CalendarDay', withInfo()(() => ( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
- )) - .addWithInfo('CalendarMonth', () => ( + ))) + .add('CalendarMonth', withInfo()(() => ( - )) - .addWithInfo('CalendarMonthGrid', () => ( + ))) + .add('CalendarMonthGrid', withInfo()(() => ( - )) - .addWithInfo('DayPickerNavigation', () => ( + ))) + .add('DayPickerNavigation', withInfo()(() => (
- )) - .addWithInfo('KeyboardShortcutRow', () => ( + ))) + .add('KeyboardShortcutRow', withInfo()(() => ( - )) - .addWithInfo('DayPickerKeyboardShortcuts', () => ( + ))) + .add('DayPickerKeyboardShortcuts', withInfo()(() => (
- )) - .addWithInfo('DateInput', () => ( + ))) + .add('DateInput', withInfo()(() => (
@@ -122,8 +154,8 @@ storiesOf('withStyles', module)
- )) - .addWithInfo('SingleDatePickerInput', () => ( + ))) + .add('SingleDatePickerInput', withInfo()(() => (
@@ -141,9 +173,9 @@ storiesOf('withStyles', module)
- )) + ))) - .addWithInfo('DateRangePickerInput', () => ( + .add('DateRangePickerInput', withInfo()(() => (
@@ -169,4 +201,4 @@ storiesOf('withStyles', module)
- )); + ))); diff --git a/test/.eslintrc b/test/.eslintrc deleted file mode 100644 index 7b52873412..0000000000 --- a/test/.eslintrc +++ /dev/null @@ -1,20 +0,0 @@ -{ - "globals": { - "describe": true, - "context": true, - "it": true, - "before": true, - "after": true, - "beforeEach": true, - "afterEach": true - }, - "extends": "airbnb", - "rules": { - "import/no-extraneous-dependencies": [2, { - "devDependencies": true - }], - "indent": [2, 2, { - "MemberExpression": "off" - }], - } -} diff --git a/test/_helpers/describeIfWindow.js b/test/_helpers/describeIfWindow.js new file mode 100644 index 0000000000..0889ec0a72 --- /dev/null +++ b/test/_helpers/describeIfWindow.js @@ -0,0 +1 @@ +export default typeof document === 'undefined' ? describe.skip : describe; diff --git a/test/_helpers/enzymeSetup.js b/test/_helpers/enzymeSetup.js index dad5556e93..5491fa359c 100644 --- a/test/_helpers/enzymeSetup.js +++ b/test/_helpers/enzymeSetup.js @@ -1,9 +1,3 @@ -const configure = require('enzyme').configure; -let Adapter; -try { - Adapter = require('enzyme-adapter-react-15'); -} catch (e) { - Adapter = require('enzyme-adapter-react-14'); -} +import configure from 'enzyme-adapter-react-helper'; -configure({ adapter: new Adapter(), disableLifecycleMethods: true }); +configure({ disableLifecycleMethods: true }); diff --git a/test/_helpers/ignoreSVGStrings.jsx b/test/_helpers/ignoreSVGStrings.jsx deleted file mode 100644 index 550e479e47..0000000000 --- a/test/_helpers/ignoreSVGStrings.jsx +++ /dev/null @@ -1,6 +0,0 @@ -// Don't load svg strings into tests -require.extensions['.svg'] = (obj) => { - obj.exports = () => ( - SVG_TEST_STUB - ); -}; diff --git a/test/_helpers/registerReactWithStylesInterface.js b/test/_helpers/registerReactWithStylesInterface.js index 5678bdb108..f0dc45750b 100644 --- a/test/_helpers/registerReactWithStylesInterface.js +++ b/test/_helpers/registerReactWithStylesInterface.js @@ -1,7 +1,16 @@ import ThemedStyleSheet from 'react-with-styles/lib/ThemedStyleSheet'; import aphroditeInterface from 'react-with-styles-interface-aphrodite'; +import { StyleSheetTestUtils } from 'aphrodite'; import DefaultTheme from '../../src/theme/DefaultTheme'; ThemedStyleSheet.registerTheme(DefaultTheme); ThemedStyleSheet.registerInterface(aphroditeInterface); + +beforeEach(() => { + StyleSheetTestUtils.suppressStyleInjection(); +}); + +afterEach(() => { + StyleSheetTestUtils.clearBufferAndResumeStyleInjection(); +}); diff --git a/test/browser-main.js b/test/browser-main.js new file mode 100644 index 0000000000..3f61c7d401 --- /dev/null +++ b/test/browser-main.js @@ -0,0 +1,6 @@ +const requireAll = (requireContext) => requireContext.keys().forEach(requireContext); + +if (typeof window !== 'undefined') { + requireAll(require.context('./_helpers', true, /.jsx?$/)); + requireAll(require.context('.', true, /.jsx?$/)); +} diff --git a/test/components/CalendarDay_spec.jsx b/test/components/CalendarDay_spec.jsx index 9493199c9e..f5fd544c29 100644 --- a/test/components/CalendarDay_spec.jsx +++ b/test/components/CalendarDay_spec.jsx @@ -3,11 +3,16 @@ import { expect } from 'chai'; import sinon from 'sinon-sandbox'; import { shallow } from 'enzyme'; import moment from 'moment'; +import raf from 'raf'; import { BLOCKED_MODIFIER } from '../../src/constants'; import CalendarDay, { PureCalendarDay } from '../../src/components/CalendarDay'; describe('CalendarDay', () => { + afterEach(() => { + sinon.restore(); + }); + describe('#render', () => { it('contains formatted day for single digit days', () => { const firstOfMonth = moment().startOf('month'); @@ -23,69 +28,255 @@ describe('CalendarDay', () => { it('contains arbitrary content if renderDay is provided', () => { const dayName = moment().format('dddd'); - const renderDay = day => day.format('dddd'); - const wrapper = shallow().dive(); + const renderDay = (day) => day.format('dddd'); + const wrapper = shallow().dive(); expect(wrapper.text()).to.equal(dayName); }); - it('passes modifiers to renderDay', () => { - const modifiers = new Set().add(BLOCKED_MODIFIER); - const renderDay = (day, mods) => `${day.format('dddd')}${mods.has(BLOCKED_MODIFIER) ? 'BLOCKED' : ''}`; + it('passes modifiers to renderDayContents', () => { + const modifiers = new Set([BLOCKED_MODIFIER]); + const renderDayContents = (day, mods) => `${day.format('dddd')}${mods.has(BLOCKED_MODIFIER) ? 'BLOCKED' : ''}`; const expected = `${moment().format('dddd')}BLOCKED`; - const wrapper = shallow().dive(); + const wrapper = shallow(( + + )).dive(); expect(wrapper.text()).to.equal(expected); }); - describe('button', () => { - it('contains a button', () => { - const wrapper = shallow().dive(); - expect(wrapper.find('button')).to.have.lengthOf(1); + it('has button role', () => { + const wrapper = shallow().dive(); + expect(wrapper.props().role).to.equal('button'); + }); + + it('has tabIndex equal to props.tabIndex', () => { + const tabIndex = -1; + const wrapper = shallow().dive(); + expect(wrapper.props().tabIndex).to.equal(tabIndex); + }); + + describe('aria-current', () => { + it('should add aria-current to date for today date', () => { + const modifiers = new Set(['today']); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.prop('aria-current')).to.equal('date'); }); - it('has tabIndex equal to props.tabIndex', () => { - const tabIndex = -1; - const wrapper = shallow().dive(); - expect(wrapper.find('button').props().tabIndex).to.equal(tabIndex); + it('should not add aria-current for not today date', () => { + const modifiers = new Set(); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper).to.not.have.property('aria-current'); }); + }); - describe('aria-label', () => { - const phrases = {}; - const day = moment('10/10/2017'); - const expectedFormattedDay = { date: 'Tuesday, October 10, 2017' }; + describe('aria-label', () => { + const phrases = {}; + const day = moment('10/10/2017', 'MM/DD/YYYY'); - beforeEach(() => { - phrases.chooseAvailableDate = sinon.stub().returns('chooseAvailableDate text'); - phrases.dateIsUnavailable = sinon.stub().returns('dateIsUnavailable text'); - }); + beforeEach(() => { + phrases.chooseAvailableDate = sinon.stub().returns('chooseAvailableDate text'); + phrases.dateIsSelected = sinon.stub().returns('dateIsSelected text'); + phrases.dateIsUnavailable = sinon.stub().returns('dateIsUnavailable text'); + phrases.dateIsSelectedAsStartDate = sinon.stub().returns('dateIsSelectedAsStartDate text'); + phrases.dateIsSelectedAsEndDate = sinon.stub().returns('dateIsSelectedAsEndDate text'); + }); - afterEach(() => { - sinon.restore(); - }); + it('is formatted with the chooseAvailableDate phrase function when day is available', () => { + const modifiers = new Set(); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.prop('aria-label')).to.equal('chooseAvailableDate text'); + }); - it('is formatted with the chooseAvailableDate phrase function when day is available', () => { - const modifiers = new Set(); + it('is formatted with the dateIsSelected phrase function when day is selected', () => { + const modifiers = new Set(['selected']); - const wrapper = shallow().dive(); + /> + )).dive(); - expect(phrases.chooseAvailableDate.calledWith(expectedFormattedDay)).to.equal(true); - expect(wrapper.find('button').prop('aria-label')).to.equal('chooseAvailableDate text'); - }); + expect(wrapper.prop('aria-label')).to.equal('dateIsSelected text'); + }); + + it('is formatted with the dateIsSelected phrase function when day is selected in a span', () => { + const modifiers = new Set(['selected-span']); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.prop('aria-label')).to.equal('dateIsSelected text'); + }); + + it('is formatted with the dateIsSelectedAsStartDate phrase function when day is selected as the start date', () => { + const modifiers = new Set().add(BLOCKED_MODIFIER).add('selected-start'); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.prop('aria-label')).to.equal('dateIsSelectedAsStartDate text'); + }); + + it('is formatted with the dateIsSelectedAsEndDate phrase function when day is selected as the end date', () => { + const modifiers = new Set().add(BLOCKED_MODIFIER).add('selected-end'); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.prop('aria-label')).to.equal('dateIsSelectedAsEndDate text'); + }); - it('is formatted with the dateIsUnavailable phrase function when day is not available', () => { - const modifiers = new Set().add(BLOCKED_MODIFIER); + it('is formatted with the dateIsUnavailable phrase function when day is not available', () => { + const modifiers = new Set([BLOCKED_MODIFIER]); - const wrapper = shallow().dive(); + /> + )).dive(); + + expect(wrapper.prop('aria-label')).to.equal('dateIsUnavailable text'); + }); + + it('should set aria-label with a value pass through ariaLabelFormat prop if it exists', () => { + const modifiers = new Set(); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.prop('aria-label')).to.equal('October 10th 2017'); + }); + }); + + describe('event handlers', () => { + const day = moment('10/10/2017', 'MM/DD/YYYY'); + + let wrapper; + beforeEach(() => { + wrapper = shallow(( + + )).dive(); + }); + + it('onMouseUp blurs the event target', () => { + const handler = wrapper.prop('onMouseUp'); + const blur = sinon.spy(); + handler({ currentTarget: { blur } }); + expect(blur).to.have.property('callCount', 1); + }); + + it('onKeyDown calls this.onKeyDown', () => { + const spy = sinon.spy(wrapper.instance(), 'onKeyDown'); + const handler = wrapper.prop('onKeyDown'); + const event = {}; + handler(event); + expect(spy).to.have.property('callCount', 1); + expect(spy.calledWith(day, event)).to.equal(true); + }); + }); - expect(phrases.dateIsUnavailable.calledWith(expectedFormattedDay)).to.equal(true); - expect(wrapper.find('button').prop('aria-label')).to.equal('dateIsUnavailable text'); + it('renders an empty when no day is given', () => { + const wrapper = shallow().dive(); + expect(wrapper.is('td')).to.equal(true); + expect(wrapper.children()).to.have.lengthOf(0); + expect(wrapper.props()).to.eql({}); + }); + }); + + describe('#onKeyDown', () => { + const day = moment('10/10/2017', 'MM/DD/YYYY'); + + let onDayClick; + let wrapper; + beforeEach(() => { + onDayClick = sinon.spy(); + wrapper = shallow(( + + )).dive(); + }); + + it('calls onDayClick with the enter key', () => { + const event = { key: 'Enter' }; + wrapper.instance().onKeyDown(day, event); + expect(onDayClick).to.have.property('callCount', 1); + expect(onDayClick.calledWith(day, event)).to.equal(true); + }); + + it('calls onDayClick with the space key', () => { + const event = { key: ' ' }; + wrapper.instance().onKeyDown(day, event); + expect(onDayClick).to.have.property('callCount', 1); + expect(onDayClick.calledWith(day, event)).to.equal(true); + }); + + it('does not call onDayClick otherwise', () => { + const event = { key: 'Shift' }; + wrapper.instance().onKeyDown(day, event); + expect(onDayClick).to.have.property('callCount', 0); + }); + }); + + describe('#componentDidUpdate', () => { + it('focuses buttonRef after a delay when isFocused, tabIndex is 0, and tabIndex was not 0', () => { + const wrapper = shallow().dive(); + const focus = sinon.spy(); + wrapper.instance().buttonRef = { focus }; + wrapper.instance().componentDidUpdate({ isFocused: true, tabIndex: -1 }); + expect(focus.callCount).to.eq(0); + + return new Promise((resolve) => { + raf(() => { + expect(focus.callCount).to.eq(1); + resolve(); }); }); }); @@ -97,12 +288,8 @@ describe('CalendarDay', () => { onDayClickSpy = sinon.spy(PureCalendarDay.prototype, 'onDayClick'); }); - afterEach(() => { - sinon.restore(); - }); - it('gets triggered by click', () => { - const wrapper = shallow().dive().find('button'); + const wrapper = shallow().dive(); wrapper.simulate('click'); expect(onDayClickSpy).to.have.property('callCount', 1); }); @@ -121,12 +308,8 @@ describe('CalendarDay', () => { onDayMouseEnterSpy = sinon.spy(PureCalendarDay.prototype, 'onDayMouseEnter'); }); - afterEach(() => { - sinon.restore(); - }); - it('gets triggered by mouseenter', () => { - const wrapper = shallow().dive().find('button'); + const wrapper = shallow().dive(); wrapper.simulate('mouseenter'); expect(onDayMouseEnterSpy).to.have.property('callCount', 1); }); @@ -145,12 +328,8 @@ describe('CalendarDay', () => { onDayMouseLeaveSpy = sinon.spy(PureCalendarDay.prototype, 'onDayMouseLeave'); }); - afterEach(() => { - sinon.restore(); - }); - it('gets triggered by mouseleave', () => { - const wrapper = shallow().dive().find('button'); + const wrapper = shallow().dive(); wrapper.simulate('mouseleave'); expect(onDayMouseLeaveSpy).to.have.property('callCount', 1); }); diff --git a/test/components/CalendarMonthGrid_spec.jsx b/test/components/CalendarMonthGrid_spec.jsx index 901386c124..3461a18ec1 100644 --- a/test/components/CalendarMonthGrid_spec.jsx +++ b/test/components/CalendarMonthGrid_spec.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { expect } from 'chai'; import { shallow } from 'enzyme'; import moment from 'moment'; +import sinon from 'sinon-sandbox'; import CalendarMonth from '../../src/components/CalendarMonth'; import CalendarMonthGrid from '../../src/components/CalendarMonthGrid'; @@ -16,9 +17,9 @@ describe('CalendarMonthGrid', () => { }); it('has style equal to getTransformStyles(foo)', () => { - const transformValue = 'foo'; - const transformStyles = getTransformStyles(transformValue); - const wrapper = shallow().dive(); + const translationValue = 100; + const transformStyles = getTransformStyles(`translateX(${translationValue}px)`); + const wrapper = shallow().dive(); Object.keys(transformStyles).forEach((key) => { expect(wrapper.prop('style')[key]).to.equal(transformStyles[key]); }); @@ -38,12 +39,27 @@ describe('CalendarMonthGrid', () => { const { months } = wrapper.state(); const collisions = months - .map(m => m.format('YYYY-MM')) + .map((m) => m.format('YYYY-MM')) .reduce((acc, m) => ({ ...acc, [m]: true }), {}); expect(Object.keys(collisions).length).to.equal(months.length); }); + it('does not setState if hasMonthChanged and hasNumberOfMonthsChanged are falsy', () => { + const setState = sinon.stub(CalendarMonthGrid.prototype, 'setState'); + const initialMonth = moment(); + const wrapper = shallow(( + + )).dive(); + + wrapper.instance().componentWillReceiveProps({ + initialMonth, + numberOfMonths: 12, + }); + + expect(setState.callCount).to.eq(0); + }); + it('works with the same number of months', () => { const initialMonth = moment(); const wrapper = shallow(( @@ -59,9 +75,31 @@ describe('CalendarMonthGrid', () => { const { months } = wrapper.state(); const collisions = months - .map(m => m.format('YYYY-MM')) + .map((m) => m.format('YYYY-MM')) .reduce((acc, m) => ({ ...acc, [m]: true }), {}); expect(Object.keys(collisions).length).to.equal(months.length); }); + + describe('#onMonthSelect', () => { + it('calls onMonthChange', () => { + const onMonthChangeSpy = sinon.spy(); + const wrapper = shallow().dive(); + const currentMonth = moment(); + const newMonthVal = (currentMonth.month() + 5) % 12; + wrapper.instance().onMonthSelect(currentMonth, newMonthVal); + expect(onMonthChangeSpy.callCount).to.equal(1); + }); + }); + + describe('#onYearSelect', () => { + it('calls onYearChange', () => { + const onYearChangeSpy = sinon.spy(); + const wrapper = shallow().dive(); + const currentMonth = moment(); + const newMonthVal = (currentMonth.month() + 5) % 12; + wrapper.instance().onYearSelect(currentMonth, newMonthVal); + expect(onYearChangeSpy.callCount).to.equal(1); + }); + }); }); diff --git a/test/components/CalendarMonth_spec.jsx b/test/components/CalendarMonth_spec.jsx index 6aa8bdf47c..1a0ab8966e 100644 --- a/test/components/CalendarMonth_spec.jsx +++ b/test/components/CalendarMonth_spec.jsx @@ -1,7 +1,9 @@ import React from 'react'; import { expect } from 'chai'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; +import sinon from 'sinon-sandbox'; import moment from 'moment'; +import describeIfWindow from '../_helpers/describeIfWindow'; import CalendarMonth from '../../src/components/CalendarMonth'; @@ -13,7 +15,7 @@ describe('CalendarMonth', () => { expect(wrapper.prop('data-visible')).to.equal(true); }); - it('data-visible attribute is falsey if !props.isVisible', () => { + it('data-visible attribute is falsy if !props.isVisible', () => { const wrapper = shallow().dive(); expect(wrapper.prop('data-visible')).to.equal(false); }); @@ -26,5 +28,61 @@ describe('CalendarMonth', () => { expect(captionWrapper.text()).to.equal(MONTH.format('MMMM YYYY')); }); }); + + it('renderMonthElement renders month element when month changes', () => { + const renderMonthElementStub = sinon.stub().returns(
); + const wrapper = shallow().dive(); + wrapper.setProps({ month: moment().subtract(1, 'months') }); + + const [{ + month, + onMonthSelect, + onYearSelect, + isVisible, + }] = renderMonthElementStub.getCall(0).args; + + expect(moment.isMoment(month)).to.equal(true); + expect(typeof onMonthSelect).to.equal('function'); + expect(typeof onYearSelect).to.equal('function'); + expect(typeof isVisible).to.equal('boolean'); + expect(wrapper.find('#month-element').exists()).to.equal(true); + }); + + describeIfWindow('setMonthTitleHeight', () => { + beforeEach(() => { + sinon.stub(window, 'setTimeout').callsFake((handler) => handler()); + }); + + it('sets the title height after mount', () => { + const setMonthTitleHeightStub = sinon.stub(); + mount( + , + ); + + expect(setMonthTitleHeightStub).to.have.property('callCount', 1); + }); + + describe('if the callbacks gets set again', () => { + it('updates the title height', () => { + const setMonthTitleHeightStub = sinon.stub(); + const wrapper = mount( + , + ); + + expect(setMonthTitleHeightStub).to.have.property('callCount', 1); + + wrapper.setProps({ setMonthTitleHeight: null }); + + wrapper.setProps({ setMonthTitleHeight: setMonthTitleHeightStub }); + expect(setMonthTitleHeightStub).to.have.property('callCount', 2); + }); + }); + }); }); }); diff --git a/test/components/CalendarWeek_spec.jsx b/test/components/CalendarWeek_spec.jsx new file mode 100644 index 0000000000..91117cbf6d --- /dev/null +++ b/test/components/CalendarWeek_spec.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { expect } from 'chai'; +import { shallow } from 'enzyme'; + +import CalendarWeek from '../../src/components/CalendarWeek'; + +import CalendarDay from '../../src/components/CalendarDay'; + +describe('CalendarWeek', () => { + it('renders a tr', () => { + const wrapper = shallow(( + + + + )); + expect(wrapper.is('tr')).to.equal(true); + }); +}); diff --git a/test/components/CustomizableCalendarDay_spec.jsx b/test/components/CustomizableCalendarDay_spec.jsx new file mode 100644 index 0000000000..0870c97783 --- /dev/null +++ b/test/components/CustomizableCalendarDay_spec.jsx @@ -0,0 +1,327 @@ +import React from 'react'; +import { expect } from 'chai'; +import sinon from 'sinon-sandbox'; +import { shallow } from 'enzyme'; +import moment from 'moment'; +import raf from 'raf'; + +import { BLOCKED_MODIFIER } from '../../src/constants'; +import CustomizableCalendarDay, { PureCustomizableCalendarDay } from '../../src/components/CustomizableCalendarDay'; + +describe('CustomizableCalendarDay', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('#render', () => { + it('contains formatted day for single digit days', () => { + const firstOfMonth = moment().startOf('month'); + const wrapper = shallow().dive(); + expect(wrapper.text()).to.equal(firstOfMonth.format('D')); + }); + + it('contains formatted day for double digit days', () => { + const lastOfMonth = moment().endOf('month'); + const wrapper = shallow().dive(); + expect(wrapper.text()).to.equal(lastOfMonth.format('D')); + }); + + it('contains arbitrary content if renderDay is provided', () => { + const dayName = moment().format('dddd'); + const renderDay = (day) => day.format('dddd'); + const wrapper = shallow().dive(); + expect(wrapper.text()).to.equal(dayName); + }); + + it('passes modifiers to renderDay', () => { + const modifiers = new Set([BLOCKED_MODIFIER]); + const renderDay = (day, mods) => `${day.format('dddd')}${mods.has(BLOCKED_MODIFIER) ? 'BLOCKED' : ''}`; + const expected = `${moment().format('dddd')}BLOCKED`; + const wrapper = shallow().dive(); + expect(wrapper.text()).to.equal(expected); + }); + + it('has button role', () => { + const wrapper = shallow().dive(); + expect(wrapper.props().role).to.equal('button'); + }); + + it('has tabIndex equal to props.tabIndex', () => { + const tabIndex = -1; + const wrapper = shallow().dive(); + expect(wrapper.props().tabIndex).to.equal(tabIndex); + }); + + describe('aria-label', () => { + const phrases = {}; + const day = moment('10/10/2017', 'MM/DD/YYYY'); + + beforeEach(() => { + phrases.chooseAvailableDate = sinon.stub().returns('chooseAvailableDate text'); + phrases.dateIsSelected = sinon.stub().returns('dateIsSelected text'); + phrases.dateIsUnavailable = sinon.stub().returns('dateIsUnavailable text'); + phrases.dateIsSelectedAsStartDate = sinon.stub().returns('dateIsSelectedAsStartDate text'); + phrases.dateIsSelectedAsEndDate = sinon.stub().returns('dateIsSelectedAsEndDate text'); + }); + + it('is formatted with the chooseAvailableDate phrase function when day is available', () => { + const modifiers = new Set(); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.prop('aria-label')).to.equal('chooseAvailableDate text'); + }); + + it('is formatted with the dateIsSelected phrase function when day is selected', () => { + const modifiers = new Set(['selected']); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.prop('aria-label')).to.equal('dateIsSelected text'); + }); + + it('is formatted with the dateIsSelectedAsStartDate phrase function when day is selected as the start date', () => { + const modifiers = new Set().add(BLOCKED_MODIFIER).add('selected-start'); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.prop('aria-label')).to.equal('dateIsSelectedAsStartDate text'); + }); + + it('is formatted with the dateIsSelectedAsEndDate phrase function when day is selected as the end date', () => { + const modifiers = new Set().add(BLOCKED_MODIFIER).add('selected-end'); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.prop('aria-label')).to.equal('dateIsSelectedAsEndDate text'); + }); + + it('is formatted with the dateIsUnavailable phrase function when day is not available', () => { + const modifiers = new Set([BLOCKED_MODIFIER]); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.prop('aria-label')).to.equal('dateIsUnavailable text'); + }); + + it('should set aria-label with a value pass through ariaLabelFormat prop if it exists', () => { + const modifiers = new Set(); + + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.prop('aria-label')).to.equal('October 10th 2017'); + }); + }); + + describe('event handlers', () => { + const day = moment('10/10/2017', 'MM/DD/YYYY'); + + let wrapper; + beforeEach(() => { + wrapper = shallow(( + + )).dive(); + }); + + it('onMouseUp blurs the event target', () => { + const handler = wrapper.prop('onMouseUp'); + const blur = sinon.spy(); + handler({ currentTarget: { blur } }); + expect(blur).to.have.property('callCount', 1); + }); + + it('onKeyDown calls this.onKeyDown', () => { + const spy = sinon.spy(wrapper.instance(), 'onKeyDown'); + const handler = wrapper.prop('onKeyDown'); + const event = {}; + handler(event); + expect(spy).to.have.property('callCount', 1); + expect(spy.calledWith(day, event)).to.equal(true); + }); + }); + + it('renders an empty when no day is given', () => { + const wrapper = shallow().dive(); + expect(wrapper.is('td')).to.equal(true); + expect(wrapper.children()).to.have.lengthOf(0); + expect(wrapper.props()).to.eql({}); + }); + }); + + describe('#componentDidUpdate', () => { + it('focuses buttonRef after a delay when isFocused, tabIndex is 0, and tabIndex was not 0', () => { + const wrapper = shallow().dive(); + const focus = sinon.spy(); + wrapper.instance().buttonRef = { focus }; + wrapper.instance().componentDidUpdate({ isFocused: true, tabIndex: -1 }); + expect(focus.callCount).to.eq(0); + + return new Promise((resolve) => { + raf(() => { + expect(focus.callCount).to.eq(1); + resolve(); + }); + }); + }); + }); + + describe('#onKeyDown', () => { + const day = moment('10/10/2017', 'MM/DD/YYYY'); + + let onDayClick; + let wrapper; + beforeEach(() => { + onDayClick = sinon.spy(); + wrapper = shallow(( + + )).dive(); + }); + + it('calls onDayClick with the enter key', () => { + const event = { key: 'Enter' }; + wrapper.instance().onKeyDown(day, event); + expect(onDayClick).to.have.property('callCount', 1); + expect(onDayClick.calledWith(day, event)).to.equal(true); + }); + + it('calls onDayClick with the space key', () => { + const event = { key: ' ' }; + wrapper.instance().onKeyDown(day, event); + expect(onDayClick).to.have.property('callCount', 1); + expect(onDayClick.calledWith(day, event)).to.equal(true); + }); + + it('does not call onDayClick otherwise', () => { + const event = { key: 'Shift' }; + wrapper.instance().onKeyDown(day, event); + expect(onDayClick).to.have.property('callCount', 0); + }); + }); + + describe('#onDayClick', () => { + let onDayClickSpy; + beforeEach(() => { + onDayClickSpy = sinon.spy(PureCustomizableCalendarDay.prototype, 'onDayClick'); + }); + + it('gets triggered by click', () => { + const wrapper = shallow().dive(); + wrapper.simulate('click'); + expect(onDayClickSpy).to.have.property('callCount', 1); + }); + + it('calls props.onDayClick', () => { + const onDayClickStub = sinon.stub(); + const wrapper = shallow().dive(); + wrapper.instance().onDayClick(); + expect(onDayClickStub).to.have.property('callCount', 1); + }); + }); + + describe('#onDayMouseEnter', () => { + let onDayMouseEnterSpy; + beforeEach(() => { + onDayMouseEnterSpy = sinon.spy(PureCustomizableCalendarDay.prototype, 'onDayMouseEnter'); + }); + + it('gets triggered by mouseenter', () => { + const wrapper = shallow().dive(); + wrapper.simulate('mouseenter'); + expect(onDayMouseEnterSpy).to.have.property('callCount', 1); + }); + + it('sets state.isHovered to false', () => { + const wrapper = shallow().dive(); + wrapper.setState({ isHovered: false }); + wrapper.instance().onDayMouseEnter(); + expect(wrapper.state().isHovered).to.equal(true); + }); + + it('calls props.onDayMouseEnter', () => { + const onMouseEnterStub = sinon.stub(); + const wrapper = shallow(( + + )).dive(); + wrapper.instance().onDayMouseEnter(); + expect(onMouseEnterStub).to.have.property('callCount', 1); + }); + }); + + describe('#onDayMouseLeave', () => { + let onDayMouseLeaveSpy; + beforeEach(() => { + onDayMouseLeaveSpy = sinon.spy(PureCustomizableCalendarDay.prototype, 'onDayMouseLeave'); + }); + + it('gets triggered by mouseleave', () => { + const wrapper = shallow().dive(); + wrapper.simulate('mouseleave'); + expect(onDayMouseLeaveSpy).to.have.property('callCount', 1); + }); + + it('sets state.isHovered to false', () => { + const wrapper = shallow().dive(); + wrapper.setState({ isHovered: true }); + wrapper.instance().onDayMouseLeave(); + expect(wrapper.state().isHovered).to.equal(false); + }); + + it('calls props.onDayMouseLeave', () => { + const onMouseLeaveStub = sinon.stub(); + const wrapper = shallow(( + + )).dive(); + wrapper.instance().onDayMouseLeave(); + expect(onMouseLeaveStub).to.have.property('callCount', 1); + }); + }); +}); diff --git a/test/components/DateInput_spec.jsx b/test/components/DateInput_spec.jsx index 059d844c20..7660846ebd 100644 --- a/test/components/DateInput_spec.jsx +++ b/test/components/DateInput_spec.jsx @@ -10,12 +10,34 @@ const event = { preventDefault() {}, stopPropagation() {} }; describe('DateInput', () => { describe('#render', () => { describe('input', () => { - it('has props.placeholder as an aria-label if prop is passed in', () => { - const placeholder = 'placeholder_foo'; + it('has props.ariaLabel as an aria-label if ariaLabel is passed in', () => { + const ariaLabel = 'ariaLabelExample'; + const wrapper = shallow().dive(); + expect(wrapper.find('input').props()['aria-label']).to.equal(ariaLabel); + }); + + it('has no aria-label if props.ariaLabel is null', () => { + const wrapper = shallow().dive(); + expect(wrapper.find('input').props()['aria-label']).to.equal(null); + }); + + it('has props.placeholder as an aria-label if ariaLabel is not passed in', () => { + const placeholder = 'placeholder foo'; const wrapper = shallow().dive(); expect(wrapper.find('input').props()['aria-label']).to.equal(placeholder); }); + it('has props.titleText as a title attribute if titleText is passed in', () => { + const titleText = 'titleTextExample'; + const wrapper = shallow().dive(); + expect(wrapper.find('input').props().title).to.equal(titleText); + }); + + it('has no title attribute if props.titleText is null', () => { + const wrapper = shallow().dive(); + expect(wrapper.find('input').props().title).to.equal(null); + }); + it('has value === props.displayValue', () => { const DISPLAY_VALUE = 'foobar'; const wrapper = shallow().dive(); @@ -31,6 +53,16 @@ describe('DateInput', () => { expect(wrapper.find('input').props().value).to.equal(DATE_STRING); }); + it('props.displayValue overrides dateString when not null', () => { + const DATE_STRING = 'foobar'; + const DISPLAY_VALUE = 'display-value'; + const wrapper = shallow().dive(); + wrapper.setState({ dateString: DATE_STRING }); + expect(wrapper.find('input').props().value).to.equal(DATE_STRING); + wrapper.setProps({ displayValue: DISPLAY_VALUE }); + expect(wrapper.find('input').props().value).to.equal(DISPLAY_VALUE); + }); + describe('props.readOnly is truthy', () => { it('sets readOnly', () => { const wrapper = shallow().dive(); @@ -38,7 +70,7 @@ describe('DateInput', () => { }); }); - describe('props.readOnly is falsey', () => { + describe('props.readOnly is falsy', () => { it('does not set readOnly', () => { const wrapper = shallow().dive(); expect(!!wrapper.find('input').prop('readOnly')).to.equal(false); @@ -73,7 +105,7 @@ describe('DateInput', () => { }); }); - describe('props.screenReaderMessage is falsey', () => { + describe('props.screenReaderMessage is falsy', () => { beforeEach(() => { wrapper = shallow().dive(); }); @@ -89,6 +121,28 @@ describe('DateInput', () => { }); }); + describe('#componentWillReceiveProps', () => { + describe('nextProps.displayValue exists', () => { + it('sets state.dateString to \'\'', () => { + const dateString = 'foo123'; + const wrapper = shallow().dive(); + wrapper.setState({ dateString }); + wrapper.instance().componentWillReceiveProps({ displayValue: '1991-07-13' }); + expect(wrapper.state()).to.have.property('dateString', ''); + }); + }); + + describe('nextProps.displayValue does not exist', () => { + it('does not change state.dateString', () => { + const dateString = 'foo123'; + const wrapper = shallow().dive(); + wrapper.setState({ dateString }); + wrapper.instance().componentWillReceiveProps({ displayValue: null }); + expect(wrapper.state()).to.have.property('dateString', dateString); + }); + }); + }); + describe('#onChange', () => { const evt = { target: { value: 'foobar' } }; it('sets state.dateString to e.target.value', () => { @@ -207,12 +261,10 @@ describe('DateInput', () => { describe('focus/isFocused', () => { const el = { - blur() {}, focus() {}, }; beforeEach(() => { - sinon.spy(el, 'blur'); sinon.spy(el, 'focus'); }); @@ -230,23 +282,8 @@ describe('DateInput', () => { wrapper.setProps({ focused: true, isFocused: true }); - expect(el.blur).to.have.property('callCount', 0); expect(el.focus).to.have.property('callCount', 1); }); - - it('blurs when becoming unfocused', () => { - const wrapper = shallow( - , - { disableLifecycleMethods: false }, - ).dive(); - - wrapper.instance().inputRef = el; - - wrapper.setProps({ focused: false, isFocused: false }); - - expect(el.blur).to.have.property('callCount', 1); - expect(el.focus).to.have.property('callCount', 0); - }); }); /* diff --git a/test/components/DateRangePickerInputController_spec.jsx b/test/components/DateRangePickerInputController_spec.jsx index 79fae574bb..a5f63e1539 100644 --- a/test/components/DateRangePickerInputController_spec.jsx +++ b/test/components/DateRangePickerInputController_spec.jsx @@ -4,8 +4,7 @@ import moment from 'moment'; import sinon from 'sinon-sandbox'; import { shallow } from 'enzyme'; -import DateRangePickerInputController - from '../../src/components/DateRangePickerInputController'; +import DateRangePickerInputController from '../../src/components/DateRangePickerInputController'; import DateRangePickerInput from '../../src/components/DateRangePickerInput'; @@ -25,6 +24,18 @@ describe('DateRangePickerInputController', () => { const wrapper = shallow(); expect(wrapper.find(DateRangePickerInput)).to.have.lengthOf(1); }); + + it('should pass children to DateRangePickerInput when provided', () => { + const Child = () =>
CHILD
; + + const wrapper = shallow(( + + + + )); + expect(wrapper.find(DateRangePickerInput)).to.have.property('children'); + expect(wrapper.find(Child)).to.have.lengthOf(1); + }); }); describe('#clearDates', () => { @@ -210,6 +221,23 @@ describe('DateRangePickerInputController', () => { expect(isSameDay(onDatesChangeArgs.endDate, futureDate)).to.equal(true); }); + it('calls props.onClose with props.startDate and provided end date', () => { + const onCloseStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onEndDateChange(validFutureDateString); + expect(onCloseStub).to.have.property('callCount', 1); + + const [onCloseArgs] = onCloseStub.getCall(0).args; + const futureDate = moment(validFutureDateString); + expect(onCloseArgs).to.have.property('startDate', startDate); + expect(isSameDay(onCloseArgs.endDate, futureDate)).to.equal(true); + }); + describe('props.onFocusChange', () => { it('is called once', () => { const onFocusChangeStub = sinon.stub(); @@ -296,7 +324,7 @@ describe('DateRangePickerInputController', () => { }); describe('matches custom display format', () => { - const customFormat = 'MM[foobar]DD'; + const customFormat = 'YY|MM[foobar]DD'; const customFormatDateString = moment(today).add(5, 'days').format(customFormat); it('calls props.onDatesChange with correct arguments', () => { const onDatesChangeStub = sinon.stub(); @@ -378,7 +406,7 @@ describe('DateRangePickerInputController', () => { describe('is outside range', () => { const futureDate = moment().add(7, 'day').toISOString(); - const isOutsideRange = day => day >= moment().add(3, 'day'); + const isOutsideRange = (day) => day >= moment().add(3, 'day'); it('calls props.onDatesChange', () => { const onDatesChangeStub = sinon.stub(); @@ -420,6 +448,37 @@ describe('DateRangePickerInputController', () => { }); }); + describe('is blocked', () => { + const futureDate = moment().add(7, 'days').format('DD/MM/YYYY'); + const isDayBlocked = sinon.stub().returns(true); + + it('calls props.onDatesChange', () => { + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onEndDateChange(futureDate); + expect(onDatesChangeStub.callCount).to.equal(1); + }); + + it('calls props.onDatesChange with endDate === null', () => { + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onEndDateChange(futureDate); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.endDate).to.equal(null); + }); + }); + describe('is inclusively before state.startDate', () => { const startDate = moment(today).add(10, 'days'); const beforeStartDateString = today.toISOString(); @@ -627,7 +686,7 @@ describe('DateRangePickerInputController', () => { }); describe('matches custom display format', () => { - const customFormat = 'MM[foobar]DD'; + const customFormat = 'YY|MM[foobar]DD'; const customFormatDateString = moment(today).add(5, 'days').format(customFormat); it('calls props.onDatesChange with correct arguments', () => { const onDatesChangeStub = sinon.stub(); @@ -708,8 +767,8 @@ describe('DateRangePickerInputController', () => { }); describe('is outside range', () => { - const futureDate = moment().add(7, 'days').toISOString(); - const isOutsideRange = day => day > moment().add(5, 'days'); + const futureDate = moment().add(7, 'days').format('YYYY/MM/DD'); + const isOutsideRange = (day) => day > moment().add(5, 'days'); it('calls props.onDatesChange', () => { const onDatesChangeStub = sinon.stub(); @@ -751,6 +810,37 @@ describe('DateRangePickerInputController', () => { expect(args.endDate).to.equal(today); }); }); + + describe('is blocked', () => { + const futureDate = moment().add(7, 'days').format('DD/MM/YYYY'); + const isDayBlocked = sinon.stub().returns(true); + + it('calls props.onDatesChange', () => { + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onStartDateChange(futureDate); + expect(onDatesChangeStub.callCount).to.equal(1); + }); + + it('calls props.onDatesChange with startDate === null', () => { + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onStartDateChange(futureDate); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate).to.equal(null); + }); + }); }); describe('#onStartDateFocus', () => { @@ -772,14 +862,61 @@ describe('DateRangePickerInputController', () => { expect(onFocusChangeStub.getCall(0).args[0]).to.equal(START_DATE); }); - describe('props.disabled = true', () => { - it('does not call props.onFocusChange', () => { - const onFocusChangeStub = sinon.stub(); - const wrapper = shallow(( - - )); - wrapper.instance().onStartDateFocus(); - expect(onFocusChangeStub).to.have.property('callCount', 0); + describe('props.disabled', () => { + describe('props.disabled=START_DATE', () => { + it('does not call props.onFocusChange', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onStartDateFocus(); + expect(onFocusChangeStub).to.have.property('callCount', 0); + }); + }); + + describe('props.disabled=END_DATE', () => { + it('does call props.onFocusChange', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onStartDateFocus(); + expect(onFocusChangeStub).to.have.property('callCount', 1); + }); + }); + + describe('props.disabled=true', () => { + it('does not call props.onFocusChange', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onStartDateFocus(); + expect(onFocusChangeStub).to.have.property('callCount', 0); + }); + }); + + describe('props.disabled=false', () => { + it('does call props.onFocusChange', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onStartDateFocus(); + expect(onFocusChangeStub).to.have.property('callCount', 1); + }); }); }); }); @@ -840,14 +977,61 @@ describe('DateRangePickerInputController', () => { }); }); - describe('props.disabled = true', () => { - it('does not call props.onFocusChange', () => { - const onFocusChangeStub = sinon.stub(); - const wrapper = shallow(( - - )); - wrapper.instance().onEndDateFocus(); - expect(onFocusChangeStub.callCount).to.equal(0); + describe('props.disabled', () => { + describe('props.disabled=START_DATE', () => { + it('does call props.onFocusChange', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onEndDateFocus(); + expect(onFocusChangeStub.callCount).to.equal(1); + }); + }); + + describe('props.disabled=END_DATE', () => { + it('does not call props.onFocusChange', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onEndDateFocus(); + expect(onFocusChangeStub.callCount).to.equal(0); + }); + }); + + describe('props.disabled=true', () => { + it('does not call props.onFocusChange', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onEndDateFocus(); + expect(onFocusChangeStub.callCount).to.equal(0); + }); + }); + + describe('props.disabled=false', () => { + it('does call props.onFocusChange', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onEndDateFocus(); + expect(onFocusChangeStub.callCount).to.equal(1); + }); }); }); }); diff --git a/test/components/DateRangePickerInput_spec.jsx b/test/components/DateRangePickerInput_spec.jsx index ba43da7980..6358c708fc 100644 --- a/test/components/DateRangePickerInput_spec.jsx +++ b/test/components/DateRangePickerInput_spec.jsx @@ -3,6 +3,8 @@ import { shallow } from 'enzyme'; import sinon from 'sinon-sandbox'; import React from 'react'; +import { START_DATE, END_DATE } from '../../src/constants'; + import DateInput from '../../src/components/DateInput'; import DateRangePickerInput from '../../src/components/DateRangePickerInput'; @@ -47,6 +49,15 @@ describe('DateRangePickerInput', () => { }); }); }); + + describe('props.children', () => { + it('should unconditionally render children when provided', () => { + const Child = () =>
CHILD
; + + const wrapper = shallow().dive(); + expect(wrapper.find('Child')).to.have.lengthOf(1); + }); + }); }); describe('props.customArrowIcon', () => { @@ -58,6 +69,26 @@ describe('DateRangePickerInput', () => { )).dive(); expect(wrapper.find('.custom-arrow-icon')).to.have.lengthOf(1); }); + + it('custom icon is rendered when in RTL mode', () => { + const wrapper = shallow( + } + isRTL + />, + ).dive(); + expect(wrapper.find('.custom-arrow-icon')).to.have.lengthOf(1); + }); + + it('custom icon is rendered when using small mode', () => { + const wrapper = shallow( + } + small + />, + ).dive(); + expect(wrapper.find('.custom-arrow-icon')).to.have.lengthOf(1); + }); }); describe('props.customCloseIcon', () => { @@ -105,4 +136,42 @@ describe('DateRangePickerInput', () => { }); }); }); + + describe('props.disabled', () => { + describe('props.disabled=START_DATE', () => { + it('First DateInput gets disabled prop, second does not', () => { + const wrapper = shallow().dive(); + const [startDateInput, endDateInput] = wrapper.find(DateInput); + expect(startDateInput.props.disabled).to.equal(true); + expect(endDateInput.props.disabled).to.equal(false); + }); + }); + + describe('props.disabled=END_DATE', () => { + it('First DateInput gets disabled prop, second does not', () => { + const wrapper = shallow().dive(); + const [startDateInput, endDateInput] = wrapper.find(DateInput); + expect(startDateInput.props.disabled).to.equal(false); + expect(endDateInput.props.disabled).to.equal(true); + }); + }); + + describe('props.disabled=true', () => { + it('First DateInput gets disabled prop, second does not', () => { + const wrapper = shallow().dive(); + const [startDateInput, endDateInput] = wrapper.find(DateInput); + expect(startDateInput.props.disabled).to.equal(true); + expect(endDateInput.props.disabled).to.equal(true); + }); + }); + + describe('props.disabled=false', () => { + it('First DateInput gets disabled prop, second does not', () => { + const wrapper = shallow().dive(); + const [startDateInput, endDateInput] = wrapper.find(DateInput); + expect(startDateInput.props.disabled).to.equal(false); + expect(endDateInput.props.disabled).to.equal(false); + }); + }); + }); }); diff --git a/test/components/DateRangePicker_spec.jsx b/test/components/DateRangePicker_spec.jsx index 1d3fdd0767..ca34ae3f8f 100644 --- a/test/components/DateRangePicker_spec.jsx +++ b/test/components/DateRangePicker_spec.jsx @@ -2,22 +2,70 @@ import React from 'react'; import moment from 'moment'; import { expect } from 'chai'; import sinon from 'sinon-sandbox'; -import { shallow } from 'enzyme'; -import Portal from 'react-portal'; +import { shallow, mount } from 'enzyme'; +import { Portal } from 'react-portal'; import DateRangePicker, { PureDateRangePicker } from '../../src/components/DateRangePicker'; import DateRangePickerInputController from '../../src/components/DateRangePickerInputController'; import DayPickerRangeController from '../../src/components/DayPickerRangeController'; +import DayPicker from '../../src/components/DayPicker'; import { HORIZONTAL_ORIENTATION, START_DATE, } from '../../src/constants'; +import describeIfWindow from '../_helpers/describeIfWindow'; + +class DateRangePickerWrapper extends React.Component { + constructor(props) { + super(props); + + this.state = { + focusedInput: null, + startDate: null, + endDate: null, + }; + + this.onDatesChange = this.onDatesChange.bind(this); + this.onFocusChange = this.onFocusChange.bind(this); + } + + onDatesChange({ startDate, endDate }) { + this.setState({ startDate, endDate }); + } + + onFocusChange(focusedInput) { + this.setState({ focusedInput }); + } + + render() { + const { focusedInput, startDate, endDate } = this.state; + + return ( +
+ + +
+ ); + } +} + const requiredProps = { onDatesChange: () => {}, onFocusChange: () => {}, + startDateId: 'startDate', + endDateId: 'endDate', }; describe('DateRangePicker', () => { @@ -76,17 +124,6 @@ describe('DateRangePicker', () => { )).dive(); expect(wrapper.find(Portal)).to.have.length(0); }); - - it('isOpened prop is true if props.focusedInput !== null', () => { - const wrapper = shallow(( - - )).dive(); - expect(wrapper.find(Portal).props().isOpened).to.equal(true); - }); }); }); @@ -114,16 +151,80 @@ describe('DateRangePicker', () => { )).dive(); expect(wrapper.find(Portal)).to.have.length(0); }); + }); + }); - it('isOpened prop is true if props.focusedInput !== null', () => { - const wrapper = shallow(( + describe('props.isDayBlocked is defined', () => { + it('should pass props.isDayBlocked to ', () => { + const isDayBlocked = sinon.stub(); + const wrapper = shallow(( + + )).dive(); + expect(wrapper.find(DateRangePickerInputController).prop('isDayBlocked')).to.equal(isDayBlocked); + }); + + it('is a noop when omitted', () => { + const wrapper = shallow(( + + )).dive(); + expect(wrapper.find(DateRangePickerInputController).prop('isDayBlocked')).not.to.throw(); + }); + }); + + describe('props.appendToBody', () => { + it('renders inside ', () => { + const wrapper = shallow(( + + )).dive(); + const portal = wrapper.find(Portal); + expect(portal).to.have.length(1); + expect(portal.find(DayPickerRangeController)).to.have.length(1); + }); + + describeIfWindow('mounted', () => { + let wrapper; + let instance; + let onCloseStub; + + beforeEach(() => { + onCloseStub = sinon.stub(); + wrapper = mount(shallow(( - )).dive(); - expect(wrapper.find(Portal).props().isOpened).to.equal(true); + )).get(0)); + instance = wrapper.instance(); + }); + + it('positions using top and transform CSS properties', () => { + const dayPickerEl = instance.dayPickerContainer; + expect(dayPickerEl.style.top).not.to.equal(''); + expect(dayPickerEl.style.transform).not.to.equal(''); + }); + + it('disables scroll', () => { + expect(instance.enableScroll).to.be.a('function'); + }); + + it('ignores click events from inside picker', () => { + const event = { target: instance.dayPickerContainer }; + instance.onOutsideClick(event); + expect(onCloseStub.callCount).to.equal(0); + }); + + it('enables scroll when closed', () => { + const enableScrollSpy = sinon.spy(instance, 'enableScroll'); + wrapper.setProps({ focusedInput: null }); + expect(enableScrollSpy.callCount).to.equal(1); + }); + + it('enables scroll when unmounted', () => { + const enableScrollSpy = sinon.spy(instance, 'enableScroll'); + wrapper.unmount(); + expect(enableScrollSpy.callCount).to.equal(1); }); }); }); @@ -285,8 +386,10 @@ describe('DateRangePicker', () => { describe('new focusedInput is truthy', () => { let onDayPickerFocusSpy; + let onDayPickerBlurSpy; beforeEach(() => { onDayPickerFocusSpy = sinon.spy(PureDateRangePicker.prototype, 'onDayPickerFocus'); + onDayPickerBlurSpy = sinon.spy(PureDateRangePicker.prototype, 'onDayPickerBlur'); }); afterEach(() => { @@ -319,8 +422,61 @@ describe('DateRangePicker', () => { expect(onDayPickerFocusSpy.callCount).to.equal(1); }); - it('calls onDayPickerBlur if focusedInput and !withPortal/!withFullScreenPortal', () => { - const onDayPickerBlurSpy = sinon.spy(PureDateRangePicker.prototype, 'onDayPickerBlur'); + it('calls onDayPickerFocus if focusedInput and readOnly', () => { + const wrapper = shallow(( + + )).dive(); + wrapper.instance().onDateRangePickerInputFocus(START_DATE); + expect(onDayPickerFocusSpy.callCount).to.equal(1); + }); + + it('calls onDayPickerFocus if focusedInput and isTouchDevice', () => { + const wrapper = shallow(( + + )).dive(); + wrapper.instance().isTouchDevice = true; + wrapper.instance().onDateRangePickerInputFocus(START_DATE); + expect(onDayPickerFocusSpy.callCount).to.equal(1); + }); + + it('calls onDayPickerBlur if focusedInput and !withPortal/!withFullScreenPortal/!readOnly and keepFocusOnInput', () => { + const wrapper = shallow(( + + )).dive(); + wrapper.instance().isTouchDevice = true; + wrapper.instance().onDateRangePickerInputFocus(START_DATE); + expect(onDayPickerBlurSpy.callCount).to.equal(1); + }); + + it('calls onDayPickerFocus if focusedInput and withPortal/withFullScreenPortal and keepFocusOnInput', () => { + const wrapper = shallow(( + + )).dive(); + wrapper.instance().onDateRangePickerInputFocus(START_DATE); + expect(onDayPickerFocusSpy.callCount).to.equal(1); + }); + + it('calls onDayPickerBlur if focusedInput and !withPortal/!withFullScreenPortal/!readOnly', () => { const wrapper = shallow(( { }); }); - describe('focusedInput is falsey', () => { + describe('focusedInput is falsy', () => { it('calls onFocusChange', () => { const onFocusChangeStub = sinon.stub(); const wrapper = shallow(( @@ -427,7 +583,24 @@ describe('DateRangePicker', () => { }); }); - describe('#onDayPickerBlur', () => { + describeIfWindow('day picker position', () => { + it('day picker is opened after the end date input when end date input is focused', () => { + const wrapper = mount(( + + )); + expect(wrapper.find(DayPicker)).to.have.length(0); + wrapper.find('input').at(0).simulate('focus'); // when focusing on start date the day picker is rendered after the start date input + expect(wrapper.find('DateRangePickerInput').children().childAt(1).find(DayPicker)).to.have.length(1); + wrapper.find('input').at(1).simulate('focus'); // when focusing on end date the day picker is rendered after the end date input + expect(wrapper.find('DateRangePickerInput').children().childAt(1).find(DayPicker)).to.have.length(0); + expect(wrapper.find('DateRangePickerInput').children().childAt(3).find(DayPicker)).to.have.length(1); + }); + }); + + describeIfWindow('#onDayPickerBlur', () => { it('sets state.isDateRangePickerInputFocused to true', () => { const wrapper = shallow(( { wrapper.instance().onDayPickerBlur(); expect(wrapper.state().showKeyboardShortcuts).to.equal(false); }); + + it('tabbing out with keyboard behaves as an outside click', () => { + const target = sinon.stub(); + const onOutsideClick = sinon.stub(); + const dayPickerContainer = { + addEventListener: sinon.stub(), + contains: sinon.stub().returns(false), + }; + const wrapper = shallow(()).dive(); + wrapper.instance().setDayPickerContainerRef(dayPickerContainer); + wrapper.instance().onOutsideClick = onOutsideClick; + expect(onOutsideClick.callCount).to.equal(0); + wrapper.instance().onDayPickerFocusOut({ key: 'Tab', shiftKey: false, target }); + expect(onOutsideClick.callCount).to.equal(1); + }); + + it('tabbing within itself does not behave as an outside click', () => { + const target = sinon.stub(); + const onOutsideClick = sinon.stub(); + const dayPickerContainer = { + addEventListener: sinon.stub(), + contains: sinon.stub().returns(true), + }; + const wrapper = shallow(()).dive(); + wrapper.instance().setDayPickerContainerRef(dayPickerContainer); + wrapper.instance().onOutsideClick = onOutsideClick; + wrapper.instance().onDayPickerFocusOut({ key: 'Tab', shiftKey: false, target }); + expect(onOutsideClick.callCount).to.equal(0); + }); }); describe('#showKeyboardShortcutsPanel', () => { @@ -574,4 +776,84 @@ describe('DateRangePicker', () => { }); }); }); + + describe('dateOffsets', () => { + describe('startDateOffset is passed in', () => { + it('Should pass startDateOffset to DayPickerRangeController', () => { + const startDate = moment('2018-10-17'); + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + date.subtract(5, 'days')} + onDatesChange={onDatesChangeStub} + focusedInput={START_DATE} + /> + )).dive(); + + const dayPicker = wrapper.find(DayPickerRangeController); + const dayPickerStartDateOffset = dayPicker.props().startDateOffset(startDate); + + expect(dayPickerStartDateOffset.format()).to.equal(startDate.format()); + }); + }); + + describe('endDateOffset is passed in', () => { + it('Should pass endDateOffset to DayPickerRangeController', () => { + const endDate = moment('2018-10-17', 'YYYY-MM-DD'); + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + date.subtract(5, 'days')} + onDatesChange={onDatesChangeStub} + focusedInput={START_DATE} + /> + )).dive(); + + const dayPicker = wrapper.find(DayPickerRangeController); + const dayPickerEndDateOffset = dayPicker.props().endDateOffset(endDate); + + expect(dayPickerEndDateOffset.format()).to.equal(endDate.format()); + }); + }); + }); + + describe('minDate and maxDate props', () => { + describe('minDate is passed in', () => { + it('Should pass minDate to DayPickerRangeController', () => { + const minDate = moment('2018-10-19'); + const wrapper = shallow(( + + )).dive(); + expect(wrapper.find(DayPickerRangeController).props().minDate).to.equal(minDate); + }); + }); + + describe('maxDate is passed in', () => { + it('Should pass maxDate to DayPickerRangeController', () => { + const maxDate = moment('2018-12-19'); + const wrapper = shallow(( + + )).dive(); + expect(wrapper.find(DayPickerRangeController).props().maxDate).to.equal(maxDate); + }); + }); + }); + + it('should pass noBorder as noBorder to ', () => { + const wrapper = shallow(( + + )).dive(); + + expect(wrapper.find(DayPickerRangeController).prop('noBorder')).to.equal(true); + }); }); diff --git a/test/components/DayPickerKeyboardShortcuts_spec.jsx b/test/components/DayPickerKeyboardShortcuts_spec.jsx index 35f15c237e..dd59f1f541 100644 --- a/test/components/DayPickerKeyboardShortcuts_spec.jsx +++ b/test/components/DayPickerKeyboardShortcuts_spec.jsx @@ -54,7 +54,7 @@ describe('DayPickerKeyboardShortcuts', () => { const openKeyboardShortcutsPanelStub = sinon.stub(); const showButtonFocusStub = sinon.stub(); - before(() => { + beforeEach(() => { const wrapper = shallow().dive(); @@ -117,24 +117,13 @@ describe('DayPickerKeyboardShortcuts', () => { }); afterEach(() => { - openKeyboardShortcutsPanelStub.reset(); + openKeyboardShortcutsPanelStub.resetHistory(); }); it('onClick calls onShowKeyboardShortcutsButtonClick', () => { buttonWrapper.simulate('click'); expect(openKeyboardShortcutsPanelStub.callCount).to.equal(1); }); - - it('onKeyDown Space calls onShowKeyboardShortcutsButtonClick', () => { - buttonWrapper.prop('onKeyDown')({ ...event, key: 'Space' }); - expect(openKeyboardShortcutsPanelStub.callCount).to.equal(1); - }); - - it('onKeyDown Enter calls e.preventDefault and NOT onShowKeyboardShortcutsButtonClick', () => { - buttonWrapper.prop('onKeyDown')({ ...event, key: 'Enter' }); - expect(event.preventDefault.callCount).to.equal(1); - expect(openKeyboardShortcutsPanelStub.notCalled).to.equal(true); - }); }); describe('when Mouse Up', () => { @@ -146,6 +135,36 @@ describe('DayPickerKeyboardShortcuts', () => { expect(mockEvent.currentTarget.blur.callCount).to.equal(1); }); }); + + describe('renderKeyboardShortcutsButton', () => { + it('renders the provided button', () => { + function Button() { + return (); + } + const props = { renderKeyboardShortcutsButton: () => (); + const wrapper = shallow( + , + ).dive(); + expect(wrapper.childAt(0).find('div[role="button"]')).to.have.lengthOf(0); + expect(renderNavPrevButtonStub).to.have.property('callCount', 1); + }); + + it('calls renderNavNextButton when custom next button is used', () => { + const renderNavNextButtonStub = sinon.stub().returns(); + const wrapper = shallow( + , + ).dive(); + expect(wrapper.childAt(1).find('div[role="button"]')).to.have.lengthOf(0); + expect(renderNavNextButtonStub).to.have.property('callCount', 1); + }); + + it('does not render default styles when custom navigation is used', () => { + const renderNavPrevButtonStub = sinon.stub().returns(); + const renderNavNextButtonStub = sinon.stub().returns(); + const wrapper = shallow( + , + ).dive(); + const wrapperDiv = wrapper.find('div').filterWhere((div) => { + const className = div.prop('className') || ''; + return className.includes('DayPickerNavigation__verticalDefault'); + }); + expect(wrapperDiv).to.have.lengthOf(0); + }); + + it('does render default styles when custom navigation is used for only one nav button', () => { + const renderNavPrevButtonStub = sinon.stub().returns(); + const wrapper = shallow( + , + ).dive(); + const wrapperDiv = wrapper.find('div').filterWhere((div) => { + const className = div.prop('className') || ''; + return className.includes('DayPickerNavigation__verticalDefault'); + }); + expect(wrapperDiv).to.have.lengthOf(1); + }); + + it('does not render default styles when custom navigation is used for only button but the other nav button is not shown', () => { + const renderNavPrevButtonStub = sinon.stub().returns(); + const wrapper = shallow( + , + ).dive(); + const wrapperDiv = wrapper.find('div').filterWhere((div) => { + const className = div.prop('className') || ''; + return className.includes('DayPickerNavigation__verticalDefault'); + }); + expect(wrapperDiv).to.have.lengthOf(0); + }); + + it('renders default styles when default navigation is used', () => { + const wrapper = shallow( + , + ).dive(); + const wrapperDiv = wrapper.find('div').filterWhere((div) => { + const className = div.prop('className') || ''; + return className.includes('DayPickerNavigation__verticalDefault'); + }); + expect(wrapperDiv).to.have.lengthOf(1); }); }); @@ -26,7 +160,7 @@ describe('DayPickerNavigation', () => { const onPrevMonthStub = sinon.stub(); const prevMonthButton = shallow().dive().find('button').at(0); + />).dive().find('[role="button"]').at(0); prevMonthButton.simulate('click'); expect(onPrevMonthStub).to.have.property('callCount', 1); }); @@ -35,9 +169,131 @@ describe('DayPickerNavigation', () => { const onNextMonthStub = sinon.stub(); const nextMonthButton = shallow().dive().find('button').at(1); + />).dive().find('[role="button"]').at(1); + nextMonthButton.simulate('click'); + expect(onNextMonthStub).to.have.property('callCount', 1); + }); + + it('props.onPrevMonthClick is not triggered by prev month disabled click', () => { + const onPrevMonthStub = sinon.stub(); + const prevMonthButton = shallow().dive().find('[role="button"]').at(0); + prevMonthButton.simulate('click'); + expect(onPrevMonthStub).to.have.property('callCount', 0); + }); + + it('props.onNextMonthClick is not triggered by prev month disabled click', () => { + const onNextMonthStub = sinon.stub(); + const nextMonthButton = shallow().dive().find('[role="button"]').at(1); + nextMonthButton.simulate('click'); + expect(onNextMonthStub).to.have.property('callCount', 0); + }); + + it('props.onPrevMonthClick is triggered by prev month button key up', () => { + const onPrevMonthStub = sinon.stub(); + const prevMonthButton = shallow().dive().find('[role="button"]').at(0); + prevMonthButton.simulate('keyup', { key: 'Enter' }); + expect(onPrevMonthStub).to.have.property('callCount', 1); + prevMonthButton.simulate('keyup', { key: ' ' }); + expect(onPrevMonthStub).to.have.property('callCount', 2); + prevMonthButton.simulate('keyup', { key: 'k' }); + expect(onPrevMonthStub).to.have.property('callCount', 2); + }); + + it('props.onNextMonthClick is triggered by next month button key up', () => { + const onNextMonthStub = sinon.stub(); + const nextMonthButton = shallow().dive().find('[role="button"]').at(1); + nextMonthButton.simulate('keyup', { key: 'Enter' }); + expect(onNextMonthStub).to.have.property('callCount', 1); + nextMonthButton.simulate('keyup', { key: ' ' }); + expect(onNextMonthStub).to.have.property('callCount', 2); + nextMonthButton.simulate('keyup', { key: 'k' }); + expect(onNextMonthStub).to.have.property('callCount', 2); + }); + + it('props.onPrevMonthClick is triggered by custom prev month button click', () => { + const onPrevMonthStub = sinon.stub(); + const renderNavPrevButtonStub = sinon.stub().onCall(0).callsFake(({ onClick }) => ); + const prevMonthButton = shallow().dive().find('button').at(0); + prevMonthButton.simulate('click'); + expect(onPrevMonthStub).to.have.property('callCount', 1); + }); + + it('props.onNextMonthClick is triggered by custom next month button click', () => { + const onNextMonthStub = sinon.stub(); + const renderNavNextButtonStub = sinon.stub().onCall(0).callsFake(({ onClick }) => ); + const nextMonthButton = shallow().dive().find('button').at(0); + nextMonthButton.simulate('click'); + expect(onNextMonthStub).to.have.property('callCount', 1); + }); + + it('props.onPrevMonthClick is not triggered by custom prev month disabled click', () => { + const onPrevMonthStub = sinon.stub(); + const renderNavPrevButtonStub = sinon.stub().onCall(0).callsFake(({ disabled, onClick }) => ); + const prevMonthButton = shallow().dive().find('button').at(0); + prevMonthButton.simulate('click'); + expect(onPrevMonthStub).to.have.property('callCount', 0); + }); + + it('props.onNextMonthClick is not triggered by custom next month disabled click', () => { + const onNextMonthStub = sinon.stub(); + const renderNavNextButtonStub = sinon.stub().onCall(0).callsFake(({ disabled, onClick }) => ); + const nextMonthButton = shallow().dive().find('button').at(0); nextMonthButton.simulate('click'); + expect(onNextMonthStub).to.have.property('callCount', 0); + }); + + it('props.onPrevMonthClick is triggered by custom prev month button key up', () => { + const onPrevMonthStub = sinon.stub(); + const renderNavPrevButtonStub = sinon.stub().onCall(0).callsFake(({ onKeyUp }) => ); + const prevMonthButton = shallow().dive().find('button').at(0); + prevMonthButton.simulate('keyup', { key: 'Enter' }); + expect(onPrevMonthStub).to.have.property('callCount', 1); + prevMonthButton.simulate('keyup', { key: ' ' }); + expect(onPrevMonthStub).to.have.property('callCount', 2); + prevMonthButton.simulate('keyup', { key: 'k' }); + expect(onPrevMonthStub).to.have.property('callCount', 2); + }); + + it('props.onNextMonthClick is triggered by custom next month button key up', () => { + const onNextMonthStub = sinon.stub(); + const renderNavNextButtonStub = sinon.stub().onCall(0).callsFake(({ onKeyUp }) => ); + const nextMonthButton = shallow().dive().find('button').at(0); + nextMonthButton.simulate('keyup', { key: 'Enter' }); expect(onNextMonthStub).to.have.property('callCount', 1); + nextMonthButton.simulate('keyup', { key: ' ' }); + expect(onNextMonthStub).to.have.property('callCount', 2); + nextMonthButton.simulate('keyup', { key: 'k' }); + expect(onNextMonthStub).to.have.property('callCount', 2); }); }); }); diff --git a/test/components/DayPickerRangeController_spec.jsx b/test/components/DayPickerRangeController_spec.jsx index 0cf38ff725..55734c7b04 100644 --- a/test/components/DayPickerRangeController_spec.jsx +++ b/test/components/DayPickerRangeController_spec.jsx @@ -7,21 +7,23 @@ import { shallow } from 'enzyme'; import DayPickerRangeController from '../../src/components/DayPickerRangeController'; import DayPicker from '../../src/components/DayPicker'; +import DayPickerNavigation from '../../src/components/DayPickerNavigation'; import toISODateString from '../../src/utils/toISODateString'; import toISOMonthString from '../../src/utils/toISOMonthString'; import isInclusivelyAfterDay from '../../src/utils/isInclusivelyAfterDay'; import isSameDay from '../../src/utils/isSameDay'; +import isBeforeDay from '../../src/utils/isBeforeDay'; import * as isDayVisible from '../../src/utils/isDayVisible'; import getVisibleDays from '../../src/utils/getVisibleDays'; -import { START_DATE, END_DATE } from '../../src/constants'; +import { START_DATE, END_DATE, VERTICAL_SCROLLABLE } from '../../src/constants'; // Set to noon to mimic how days in the picker are configured internally const today = moment().startOf('day').hours(12); function getCallsByModifier(stub, modifier) { - return stub.getCalls().filter(call => call.args[call.args.length - 1] === modifier); + return stub.getCalls().filter((call) => call.args[call.args.length - 1] === modifier); } describe('DayPickerRangeController', () => { @@ -110,7 +112,7 @@ describe('DayPickerRangeController', () => { 'getStateForNewMonth', ); const wrapper = shallow(); - getStateForNewMonthSpy.reset(); + getStateForNewMonthSpy.resetHistory(); wrapper.instance().componentWillReceiveProps({ ...props, focusedInput: START_DATE, @@ -162,7 +164,7 @@ describe('DayPickerRangeController', () => { 'getStateForNewMonth', ); const wrapper = shallow(); - getStateForNewMonthSpy.reset(); + getStateForNewMonthSpy.resetHistory(); wrapper.instance().componentWillReceiveProps({ ...props, focusedInput: null, @@ -216,7 +218,7 @@ describe('DayPickerRangeController', () => { 'getStateForNewMonth', ); const wrapper = shallow(); - getStateForNewMonthSpy.reset(); + getStateForNewMonthSpy.resetHistory(); wrapper.instance().componentWillReceiveProps({ ...props, numberOfMonths: 5, @@ -262,7 +264,7 @@ describe('DayPickerRangeController', () => { it('calls getStateForNewMonth with nextProps', () => { const getStateForNewMonthSpy = sinon.spy(DayPickerRangeController.prototype, 'getStateForNewMonth'); const wrapper = shallow(); - getStateForNewMonthSpy.reset(); + getStateForNewMonthSpy.resetHistory(); wrapper.instance().componentWillReceiveProps({ ...props, enableOutsideDays: true, @@ -297,6 +299,87 @@ describe('DayPickerRangeController', () => { expect(wrapper.instance().state.visibleDays).to.equal(visibleDays); }); }); + + describe('startDate changed from one date to another', () => { + it('removes previous `after-hovered-start` range', () => { + const minimumNights = 5; + const startDate = moment().add(7, 'days'); + const dayAfterStartDate = startDate.clone().add(1, 'day'); + const firstAvailableDate = startDate.clone().add(minimumNights + 1, 'days'); + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const nextStartDate = moment().add(4, 'days'); + const wrapper = shallow(( + + )); + deleteModifierFromRangeSpy.resetHistory(); + wrapper.instance().componentWillReceiveProps({ + ...props, + startDate: nextStartDate, + }); + const afterHoverStartCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'after-hovered-start'); + expect(afterHoverStartCalls.length).to.equal(1); + expect(isSameDay(afterHoverStartCalls[0].args[1], dayAfterStartDate)).to.equal(true); + expect(isSameDay(afterHoverStartCalls[0].args[2], firstAvailableDate)).to.equal(true); + }); + }); + + describe('endDate changed from one date to another', () => { + it('removes previous `selected-end-no-selected-start` when no start date selected', () => { + const minimumNights = 5; + const endDate = moment().add(7, 'days'); + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const nextEndDate = moment().add(4, 'days'); + const wrapper = shallow(( + + )); + deleteModifierSpy.resetHistory(); + addModifierSpy.resetHistory(); + wrapper.instance().componentWillReceiveProps({ + ...props, + endDate: nextEndDate, + }); + const selectedEndNoStartDateDelete = getCallsByModifier(deleteModifierSpy, 'selected-end-no-selected-start'); + expect(selectedEndNoStartDateDelete.length).to.equal(1); + expect(isSameDay(selectedEndNoStartDateDelete[0].args[1], endDate)).to.equal(true); + + const selectedEndNoStartDateAdd = getCallsByModifier(addModifierSpy, 'selected-end-no-selected-start'); + expect(selectedEndNoStartDateAdd.length).to.equal(1); + expect(isSameDay(selectedEndNoStartDateAdd[0].args[1], nextEndDate)).to.equal(true); + }); + + it('calls getStateForNewMonth with nextProps when date is not visible', () => { + const getStateForNewMonthSpy = sinon.spy( + DayPickerRangeController.prototype, + 'getStateForNewMonth', + ); + const endDate = moment(); + const nextEndDate = endDate.clone().add(2, 'months'); + + const wrapper = shallow(( + + )); + + getStateForNewMonthSpy.resetHistory(); + + wrapper.instance().componentWillReceiveProps({ + ...props, + endDate: nextEndDate, + }); + }); + }); }); describe('modifiers', () => { @@ -569,7 +652,7 @@ describe('DayPickerRangeController', () => { }); }); - describe('new start date is falsey', () => { + describe('new start date is falsy', () => { it('does not call addModifierToRange with `after-hovered-start`', () => { const startDate = moment(); const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); @@ -668,14 +751,42 @@ describe('DayPickerRangeController', () => { expect(isSameDay(minimumNightsCalls[0].args[2], minimumNightsEndSpan)).to.equal(true); }); }); + + describe('minimumNights changed', () => { + it('calls deleteModifierFromRange with start date + old min nights, and `blocked-minimum-nights`', () => { + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const startDate = today; + const focusedInput = START_DATE; + const minimumNights = 5; + const wrapper = shallow(); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput, + startDate, + minimumNights: 1, + }); + const minimumNightsEndSpan = startDate.clone().add(minimumNights, 'days'); + const minimumNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'blocked-minimum-nights'); + expect(minimumNightsCalls.length).to.equal(1); + expect(minimumNightsCalls[0].args[1]).to.equal(startDate); + expect(isSameDay(minimumNightsCalls[0].args[2], minimumNightsEndSpan)).to.equal(true); + }); + }); }); describe('new startDate exists', () => { describe('new focusedInput !== END_DATE', () => { it('does not call addModifierFromRange with `blocked-minimum-nights', () => { const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const startDate = moment(today); const wrapper = shallow(); wrapper.instance().componentWillReceiveProps({ @@ -687,6 +798,43 @@ describe('DayPickerRangeController', () => { const minimumNightsCalls = getCallsByModifier(addModifierToRangeSpy, 'blocked-minimum-nights'); expect(minimumNightsCalls.length).to.equal(0); }); + + it('updates state to remove `blocked-minimum-nights` and `blocked` from the appropriate days', () => { + const startDate = today; + const minimumNights = 5; + const wrapper = shallow(); + const { visibleDays } = wrapper.state(); + let day = moment(today); + for (let i = 0; i < minimumNights; i += 1) { + const monthString = toISOMonthString(day); + const dateString = toISODateString(day); + expect(visibleDays[monthString][dateString]).to.include('blocked-minimum-nights'); + expect(visibleDays[monthString][dateString]).to.include('blocked'); + day.add(1, 'day'); + } + + wrapper.instance().componentWillReceiveProps({ + ...props, + startDate, + focusedInput: START_DATE, + minimumNights, + }); + + const { visibleDays: newVisibleDays } = wrapper.state(); + day = moment(today); + for (let i = 0; i < minimumNights; i += 1) { + const monthString = toISOMonthString(day); + const dateString = toISODateString(day); + expect(newVisibleDays[monthString][dateString]).not.to.include('blocked-minimum-nights'); + expect(newVisibleDays[monthString][dateString]).not.to.include('blocked'); + day.add(1, 'day'); + } + }); }); describe('focusedInput === END_DATE', () => { @@ -711,73 +859,54 @@ describe('DayPickerRangeController', () => { expect(minimumNightsCalls[0].args[1]).to.equal(startDate); expect(isSameDay(minimumNightsCalls[0].args[2], minimumNightsEndSpan)).to.equal(true); }); - }); - }); - }); - - describe('blocked', () => { - describe('focusedInput did not change', () => { - it('does not call isBlocked', () => { - const isBlockedStub = sinon.stub(DayPickerRangeController.prototype, 'isBlocked'); - const wrapper = shallow(); - isBlockedStub.reset(); - wrapper.instance().componentWillReceiveProps({ - ...props, - }); - expect(isBlockedStub.callCount).to.equal(0); - }); - }); - - describe('focusedInput changed', () => { - const numVisibleDays = 3; - let visibleDays; - beforeEach(() => { - const startOfMonth = today.clone().startOf('month'); - visibleDays = { - [toISOMonthString(startOfMonth)]: { - [toISODateString(startOfMonth)]: [], - [toISODateString(startOfMonth.clone().add(1, 'day'))]: [], - [toISODateString(startOfMonth.clone().add(2, 'days'))]: [], - }, - }; - }); - - it('calls isBlocked for every visible day', () => { - const isBlockedStub = sinon.stub(DayPickerRangeController.prototype, 'isBlocked'); - const wrapper = shallow(); - wrapper.setState({ visibleDays }); - isBlockedStub.reset(); - wrapper.instance().componentWillReceiveProps({ - ...props, - focusedInput: END_DATE, - }); - expect(isBlockedStub.callCount).to.equal(numVisibleDays); - }); - it('if isBlocked(day) is true calls addModifier with `blocked` for each day', () => { - const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); - sinon.stub(DayPickerRangeController.prototype, 'isBlocked').returns(true); - const wrapper = shallow(); - wrapper.setState({ visibleDays }); - wrapper.instance().componentWillReceiveProps({ - ...props, - focusedInput: START_DATE, + it('updates state to include `blocked-minimum-nights` on the appropriate days', () => { + const startDate = today; + const minimumNights = 5; + const wrapper = shallow(); + wrapper.instance().componentWillReceiveProps({ + ...props, + startDate, + focusedInput: END_DATE, + minimumNights, + }); + const { visibleDays } = wrapper.state(); + const day = moment(today); + for (let i = 0; i < minimumNights; i += 1) { + const monthString = toISOMonthString(day); + const dateString = toISODateString(day); + expect(visibleDays[monthString][dateString]).to.include('blocked-minimum-nights'); + day.add(1, 'day'); + } }); - const blockedCalendarCalls = getCallsByModifier(addModifierSpy, 'blocked'); - expect(blockedCalendarCalls.length).to.equal(numVisibleDays); - }); - it('if isBlocked(day) is false calls deleteModifier with day and `blocked`', () => { - const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); - sinon.stub(DayPickerRangeController.prototype, 'isBlocked').returns(false); - const wrapper = shallow(); - wrapper.setState({ visibleDays }); - wrapper.instance().componentWillReceiveProps({ - ...props, - focusedInput: END_DATE, + it('updates state to include `blocked` on the appropriate days', () => { + const startDate = today; + const minimumNights = 5; + const wrapper = shallow(); + wrapper.instance().componentWillReceiveProps({ + ...props, + startDate, + focusedInput: END_DATE, + minimumNights, + }); + const { visibleDays } = wrapper.state(); + const day = moment(today); + for (let i = 0; i < minimumNights; i += 1) { + const monthString = toISOMonthString(day); + const dateString = toISODateString(day); + expect(visibleDays[monthString][dateString]).to.include('blocked'); + day.add(1, 'day'); + } }); - const blockedCalendarCalls = getCallsByModifier(deleteModifierSpy, 'blocked'); - expect(blockedCalendarCalls.length).to.equal(numVisibleDays); }); }); }); @@ -816,9 +945,9 @@ describe('DayPickerRangeController', () => { const startOfMonth = today.clone().startOf('month'); visibleDays = { [toISOMonthString(startOfMonth)]: { - [toISODateString(startOfMonth)]: [], - [toISODateString(startOfMonth.clone().add(1, 'day'))]: [], - [toISODateString(startOfMonth.clone().add(2, 'days'))]: [], + [toISODateString(startOfMonth)]: new Set(), + [toISODateString(startOfMonth.clone().add(1, 'day'))]: new Set(), + [toISODateString(startOfMonth.clone().add(2, 'days'))]: new Set(), }, }; }); @@ -899,9 +1028,9 @@ describe('DayPickerRangeController', () => { const startOfMonth = today.clone().startOf('month'); visibleDays = { [toISOMonthString(startOfMonth)]: { - [toISODateString(startOfMonth)]: [], - [toISODateString(startOfMonth.clone().add(1, 'day'))]: [], - [toISODateString(startOfMonth.clone().add(2, 'days'))]: [], + [toISODateString(startOfMonth)]: new Set(), + [toISODateString(startOfMonth.clone().add(1, 'day'))]: new Set(), + [toISODateString(startOfMonth.clone().add(2, 'days'))]: new Set(), }, }; }); @@ -982,9 +1111,9 @@ describe('DayPickerRangeController', () => { const startOfMonth = today.clone().startOf('month'); visibleDays = { [toISOMonthString(startOfMonth)]: { - [toISODateString(startOfMonth)]: [], - [toISODateString(startOfMonth.clone().add(1, 'day'))]: [], - [toISODateString(startOfMonth.clone().add(2, 'days'))]: [], + [toISODateString(startOfMonth)]: new Set(), + [toISODateString(startOfMonth.clone().add(1, 'day'))]: new Set(), + [toISODateString(startOfMonth.clone().add(2, 'days'))]: new Set(), }, }; }); @@ -1072,616 +1201,1898 @@ describe('DayPickerRangeController', () => { }); }); }); - }); - - describe('phrases', () => { - const phrases = { - chooseAvailableDate: 'test1', - chooseAvailableStartDate: 'test2', - chooseAvailableEndDate: 'test3', - }; - - describe('neither props.focusedInput nor props.phrases have changed', () => { - it('state.phrases does not change', () => { - const phrasesObject = { hello: 'world' }; - const wrapper = shallow(); - wrapper.setState({ phrases: phrasesObject }); - wrapper.instance().componentWillReceiveProps({ ...props, phrases }); - expect(wrapper.state().phrases).to.equal(phrasesObject); - }); - }); - describe('props.focusedInput has changed', () => { - describe('new focusedInput is START_DATE', () => { - it('state.phrases.chooseAvailableDate equals props.phrases.chooseAvailableStartDate', () => { + describe('hovered-start-blocked-minimum-nights', () => { + describe('focusedInput did not change', () => { + it('does not call getMinNightsForHoverDate', () => { + const getMinNightsForHoverDateStub = sinon.stub(); const wrapper = shallow(); - wrapper.setState({ phrases: {} }); wrapper.instance().componentWillReceiveProps({ ...props, - focusedInput: START_DATE, - phrases, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, }); - const newAvailableDatePhrase = wrapper.state().phrases.chooseAvailableDate; - expect(newAvailableDatePhrase).to.equal(phrases.chooseAvailableStartDate); + expect(getMinNightsForHoverDateStub.callCount).to.equal(0); }); }); - describe('new focusedInput is END_DATE', () => { - it('state.phrases.chooseAvailableDate equals props.phrases.chooseAvailableEndDate', () => { + describe('focusedInput did change', () => { + it('does not call getMinNightsForHoverDate when there is no hoverDate state', () => { + const getMinNightsForHoverDateStub = sinon.stub(); const wrapper = shallow(); - wrapper.setState({ phrases: {} }); wrapper.instance().componentWillReceiveProps({ ...props, - focusedInput: END_DATE, - phrases, + focusedInput: START_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, }); - const newAvailableDatePhrase = wrapper.state().phrases.chooseAvailableDate; - expect(newAvailableDatePhrase).to.equal(phrases.chooseAvailableEndDate); + expect(getMinNightsForHoverDateStub.callCount).to.equal(0); }); - }); - describe('new focusedInput is null', () => { - it('state.phrases.chooseAvailableDate equals props.phrases.chooseAvailableDate', () => { + it('calls getMinNightsForHoverDate when there is hoverDate state', () => { + const getMinNightsForHoverDateStub = sinon.stub(); const wrapper = shallow(); - wrapper.setState({ phrases: {} }); + wrapper.setState({ hoverDate: today }); wrapper.instance().componentWillReceiveProps({ ...props, - phrases, + focusedInput: START_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, }); - const newAvailableDatePhrase = wrapper.state().phrases.chooseAvailableDate; - expect(newAvailableDatePhrase).to.equal(phrases.chooseAvailableDate); + expect(getMinNightsForHoverDateStub.callCount).to.equal(1); }); - }); - }); - describe('props.phrases has changed', () => { - describe('focusedInput is START_DATE', () => { - it('state.phrases.chooseAvailableDate equals props.phrases.chooseAvailableStartDate', () => { - const wrapper = shallow(); - wrapper.setState({ phrases: {} }); - wrapper.instance().componentWillReceiveProps({ - ...props, - focusedInput: START_DATE, - phrases, + describe('focusedInput === START_DATE', () => { + it('calls addModifierToRange with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate returns a positive integer', () => { + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(addModifierToRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(1); + expect(isSameDay(hoveredStartBlockedMinNightsCalls[0].args[1], today.clone().add(1, 'days'))).to.equal(true); + expect(isSameDay(hoveredStartBlockedMinNightsCalls[0].args[2], today.clone().add(2, 'days'))).to.equal(true); }); - const newAvailableDatePhrase = wrapper.state().phrases.chooseAvailableDate; - expect(newAvailableDatePhrase).to.equal(phrases.chooseAvailableStartDate); - }); - }); - describe('focusedInput is END_DATE', () => { - it('state.phrases.chooseAvailableDate equals props.phrases.chooseAvailableEndDate', () => { - const wrapper = shallow(); - wrapper.setState({ phrases: {} }); - wrapper.instance().componentWillReceiveProps({ - ...props, - focusedInput: END_DATE, - phrases, + it('does not call addModifierToRange with `hovered-start-blocked-minimum-nights` if the hovered date is blocked', () => { + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow( + isSameDay(day, today)} + />, + ); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(addModifierToRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); }); - const newAvailableDatePhrase = wrapper.state().phrases.chooseAvailableDate; - expect(newAvailableDatePhrase).to.equal(phrases.chooseAvailableEndDate); - }); - }); - describe('focusedInput is null', () => { - it('state.phrases.chooseAvailableDate equals props.phrases.chooseAvailableDate', () => { + it('does not call addModifierToRange with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate does not return a positive integer', () => { + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(0); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(addModifierToRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + + it('does not call addModifierToRange with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate is not supplied as a prop', () => { + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + }); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(addModifierToRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + }); + + describe('focusedInput === END_DATE', () => { + it('calls deleteModifierFromRange with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate returns a positive integer', () => { + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: END_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(1); + expect(isSameDay(hoveredStartBlockedMinNightsCalls[0].args[1], today.clone().add(1, 'days'))).to.equal(true); + expect(isSameDay(hoveredStartBlockedMinNightsCalls[0].args[2], today.clone().add(2, 'days'))).to.equal(true); + }); + + it('does not call deleteModifierFromRange with `hovered-start-blocked-minimum-nights` if the hovered date is blocked', () => { + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow( + isSameDay(day, today)} + />, + ); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + + it('does not call deleteModifierFromRange with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate does not return a positive integer', () => { + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(0); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: END_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + + it('does not call deleteModifierFromRange with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate is not supplied as a prop', () => { + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: END_DATE, + }); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + }); + }); + }); + + describe('hovered-start-first-possible-end', () => { + describe('focusedInput did not change', () => { + it('does not call getMinNightsForHoverDate', () => { + const getMinNightsForHoverDateStub = sinon.stub(); const wrapper = shallow(); - wrapper.setState({ phrases: {} }); - wrapper.instance().componentWillReceiveProps({ ...props, phrases }); - const newAvailableDatePhrase = wrapper.state().phrases.chooseAvailableDate; - expect(newAvailableDatePhrase).to.equal(phrases.chooseAvailableDate); + wrapper.instance().componentWillReceiveProps({ + ...props, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + expect(getMinNightsForHoverDateStub.callCount).to.equal(0); + }); + }); + + describe('focusedInput did change', () => { + it('does not call getMinNightsForHoverDate when there is no hoverDate state', () => { + const getMinNightsForHoverDateStub = sinon.stub(); + const wrapper = shallow(); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + expect(getMinNightsForHoverDateStub.callCount).to.equal(0); + }); + + it('calls getMinNightsForHoverDate when there is hoverDate state', () => { + const getMinNightsForHoverDateStub = sinon.stub(); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + expect(getMinNightsForHoverDateStub.callCount).to.equal(1); + }); + + describe('focusedInput === START_DATE', () => { + it('calls addModifier with `hovered-start-first-possible-end` if getMinNightsForHoverDate returns a positive integer', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(addModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(1); + expect(isSameDay(hoveredStartFirstPossibleEndCalls[0].args[1], today.clone().add(2, 'days'))).to.equal(true); + }); + + it('does not call addModifierToRange with `hovered-start-first-possible-end` if the hovered date is blocked', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow( + isSameDay(day, today)} + />, + ); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(addModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); + + it('does not call addModifier with `hovered-start-first-possible-end` if getMinNightsForHoverDate does not return a positive integer', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(0); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(addModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); + + it('does not call addModifier with `hovered-start-first-possible-end` if getMinNightsForHoverDate is not supplied as a prop', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + }); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(addModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); + }); + + describe('focusedInput === END_DATE', () => { + it('calls deleteModifier with `hovered-start-first-possible-end` if getMinNightsForHoverDate returns a positive integer', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: END_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(1); + expect(isSameDay(hoveredStartFirstPossibleEndCalls[0].args[1], today.clone().add(2, 'days'))).to.equal(true); + }); + + it('does not call deleteModifierFromRange with `hovered-start-first-possible-end` if the hovered date is blocked', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow( + isSameDay(day, today)} + />, + ); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); + + it('does not call deleteModifierFromRange with `hovered-start-first-possible-end` if getMinNightsForHoverDate does not return a positive integer', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(0); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: END_DATE, + getMinNightsForHoverDate: getMinNightsForHoverDateStub, + }); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); + + it('does not call deleteModifier with `hovered-start-first-possible-end` if getMinNightsForHoverDate is not supplied as a prop', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: END_DATE, + }); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); + }); + }); + }); + + describe('no-selected-start-before-selected-end', () => { + describe('start or end date has changed, start date is falsey, and end date is truthy', () => { + it('calls addModifier with `no-selected-start-before-selected-end` if day is before selected end date', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const endDate = today.clone(); + const wrapper = shallow( + , + ); + const newEndDate = endDate.clone().add(1, 'days'); + wrapper.instance().componentWillReceiveProps({ + ...props, + endDate: newEndDate, + }); + const noSelectedStartBeforeSelectedEndCalls = getCallsByModifier(addModifierSpy, 'no-selected-start-before-selected-end'); + noSelectedStartBeforeSelectedEndCalls.forEach((eachCall) => { + const day = eachCall.args[1]; + + expect(isBeforeDay(day, newEndDate)).to.equal(true); + }); + }); + }); + + describe('start date has changed, previous start date is falsey, start and end date is truthy', () => { + it('calls deleteModifier with `no-selected-start-before-selected-end` if day is before selected end date', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const endDate = today.clone().add(10, 'days'); + const wrapper = shallow( + , + ); + const newStartDate = today; + const { visibleDays } = wrapper.instance().state; + const numberOfVisibleDays = Object.values(visibleDays).reduce( + (total, visibleDayArray) => total + Object.keys(visibleDayArray).length, + 0, + ); + wrapper.instance().componentWillReceiveProps({ + ...props, + endDate, + startDate: newStartDate, + }); + const noSelectedStartBeforeSelectedEndCalls = getCallsByModifier(deleteModifierSpy, 'no-selected-start-before-selected-end'); + expect(noSelectedStartBeforeSelectedEndCalls.length).to.equal(numberOfVisibleDays); + }); + }); + }); + + describe('selected-start-no-selected-end', () => { + describe('start date is truthy, and end date is falsey', () => { + it('calls addModifier with `selected-start-no-selected-end`', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const wrapper = shallow(); + const startDate = moment(); + wrapper.instance().componentWillReceiveProps({ ...props, startDate }); + const selectedStartNoSelectedEndCalls = getCallsByModifier(addModifierSpy, 'selected-start-no-selected-end'); + expect(selectedStartNoSelectedEndCalls.length).to.equal(1); + expect(selectedStartNoSelectedEndCalls[0].args[1]).to.equal(startDate); + }); + }); + + describe('start date has changed, and end date or previous end date are falsey', () => { + it('calls deleteModifier with `selected-start-no-selected-end`', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const startDate = moment(); + const wrapper = shallow(); + const newStartDate = startDate.clone().add(1, 'days'); + wrapper.instance().componentWillReceiveProps({ ...props, startDate: newStartDate }); + const selectedStartNoSelectedEndCalls = getCallsByModifier(deleteModifierSpy, 'selected-start-no-selected-end'); + expect(selectedStartNoSelectedEndCalls.length).to.equal(1); + expect(selectedStartNoSelectedEndCalls[0].args[1]).to.equal(startDate); + }); + }); + }); + + describe('selected-end-no-selected-start', () => { + describe('end date is truthy, and start date is falsey', () => { + it('calls addModifier with `selected-end-no-selected-start`', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const wrapper = shallow(); + const endDate = moment(); + wrapper.instance().componentWillReceiveProps({ ...props, endDate }); + const selectedStartNoSelectedEndCalls = getCallsByModifier(addModifierSpy, 'selected-end-no-selected-start'); + expect(selectedStartNoSelectedEndCalls.length).to.equal(1); + expect(selectedStartNoSelectedEndCalls[0].args[1]).to.equal(endDate); + }); + }); + + describe('end date has changed, and start date or previous start date are falsey', () => { + it('calls deleteModifier with `selected-end-no-selected-start`', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const endDate = moment(); + const wrapper = shallow(); + const newEndDate = endDate.clone().add(1, 'days'); + wrapper.instance().componentWillReceiveProps({ ...props, endDate: newEndDate }); + const selectedStartNoSelectedEndCalls = getCallsByModifier(deleteModifierSpy, 'selected-end-no-selected-start'); + expect(selectedStartNoSelectedEndCalls.length).to.equal(1); + expect(selectedStartNoSelectedEndCalls[0].args[1]).to.equal(endDate); + }); + }); + + describe('start date has changed, and start date is truthy, and previous start date was falsey', () => { + it('calls deleteModifier with `selected-end-no-selected-start`', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const endDate = moment(); + const wrapper = shallow(); + const newStartDate = endDate.clone().subtract(1, 'days'); + wrapper.instance().componentWillReceiveProps({ ...props, startDate: newStartDate }); + const selectedStartNoSelectedEndCalls = getCallsByModifier(deleteModifierSpy, 'selected-end-no-selected-start'); + expect(selectedStartNoSelectedEndCalls.length).to.equal(1); + expect(selectedStartNoSelectedEndCalls[0].args[1]).to.equal(endDate); + }); + }); + }); + + describe('before-hovered-end', () => { + describe('end date changed, end date is truthy and start date is falsey', () => { + it('calls addModifierToRange with `before-hovered-end`', () => { + const minimumNights = 1; + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const endDate = moment(); + const wrapper = shallow( + , + ); + const newEndDate = endDate.clone().add(1, 'days'); + addModifierToRangeSpy.resetHistory(); + wrapper.instance().componentWillReceiveProps({ ...props, endDate: newEndDate }); + const beforeHoveredEndCalls = getCallsByModifier(addModifierToRangeSpy, 'before-hovered-end'); + expect(beforeHoveredEndCalls.length).to.equal(1); + expect(toISODateString(beforeHoveredEndCalls[0].args[1])).to.equal( + toISODateString(newEndDate.clone().subtract(minimumNights, 'days')), + ); + expect(toISODateString(beforeHoveredEndCalls[0].args[2])).to.equal( + toISODateString(newEndDate), + ); }); }); }); + + describe('selected-end-in-hovered-span', () => { + describe('start date has changed', () => { + describe('start and end date are truthy, and previous start date is falsey', () => { + it('calls deleteModifier with `selected-end-in-hovered-span`', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const endDate = today; + const wrapper = shallow( + , + ); + const newStartDate = endDate.clone().subtract(3, 'days'); + deleteModifierSpy.resetHistory(); + wrapper.instance().componentWillReceiveProps({ + ...props, + endDate, + startDate: newStartDate, + }); + const deleteModifierCalls = getCallsByModifier(deleteModifierSpy, 'selected-end-in-hovered-span'); + expect(deleteModifierCalls.length).to.equal(1); + expect(deleteModifierCalls[0].args[1]).to.equal(endDate); + }); + }); + }); + }); + }); + + describe('phrases', () => { + const phrases = { + chooseAvailableDate: 'test1', + chooseAvailableStartDate: 'test2', + chooseAvailableEndDate: 'test3', + }; + + describe('neither props.focusedInput nor props.phrases have changed', () => { + it('state.phrases does not change', () => { + const phrasesObject = { hello: 'world' }; + const wrapper = shallow(); + wrapper.setState({ phrases: phrasesObject }); + wrapper.instance().componentWillReceiveProps({ ...props, phrases }); + expect(wrapper.state().phrases).to.equal(phrasesObject); + }); + }); + + describe('props.focusedInput has changed', () => { + describe('new focusedInput is START_DATE', () => { + it('state.phrases.chooseAvailableDate equals props.phrases.chooseAvailableStartDate', () => { + const wrapper = shallow(); + wrapper.setState({ phrases: {} }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + phrases, + }); + const newAvailableDatePhrase = wrapper.state().phrases.chooseAvailableDate; + expect(newAvailableDatePhrase).to.equal(phrases.chooseAvailableStartDate); + }); + }); + + describe('new focusedInput is END_DATE', () => { + it('state.phrases.chooseAvailableDate equals props.phrases.chooseAvailableEndDate', () => { + const wrapper = shallow(); + wrapper.setState({ phrases: {} }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: END_DATE, + phrases, + }); + const newAvailableDatePhrase = wrapper.state().phrases.chooseAvailableDate; + expect(newAvailableDatePhrase).to.equal(phrases.chooseAvailableEndDate); + }); + }); + + describe('new focusedInput is null', () => { + it('state.phrases.chooseAvailableDate equals props.phrases.chooseAvailableDate', () => { + const wrapper = shallow(); + wrapper.setState({ phrases: {} }); + wrapper.instance().componentWillReceiveProps({ + ...props, + phrases, + }); + const newAvailableDatePhrase = wrapper.state().phrases.chooseAvailableDate; + expect(newAvailableDatePhrase).to.equal(phrases.chooseAvailableDate); + }); + }); + }); + + describe('props.phrases has changed', () => { + describe('focusedInput is START_DATE', () => { + it('state.phrases.chooseAvailableDate equals props.phrases.chooseAvailableStartDate', () => { + const wrapper = shallow(); + wrapper.setState({ phrases: {} }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: START_DATE, + phrases, + }); + const newAvailableDatePhrase = wrapper.state().phrases.chooseAvailableDate; + expect(newAvailableDatePhrase).to.equal(phrases.chooseAvailableStartDate); + }); + }); + + describe('focusedInput is END_DATE', () => { + it('state.phrases.chooseAvailableDate equals props.phrases.chooseAvailableEndDate', () => { + const wrapper = shallow(); + wrapper.setState({ phrases: {} }); + wrapper.instance().componentWillReceiveProps({ + ...props, + focusedInput: END_DATE, + phrases, + }); + const newAvailableDatePhrase = wrapper.state().phrases.chooseAvailableDate; + expect(newAvailableDatePhrase).to.equal(phrases.chooseAvailableEndDate); + }); + }); + + describe('focusedInput is null', () => { + it('state.phrases.chooseAvailableDate equals props.phrases.chooseAvailableDate', () => { + const wrapper = shallow(); + wrapper.setState({ phrases: {} }); + wrapper.instance().componentWillReceiveProps({ ...props, phrases }); + const newAvailableDatePhrase = wrapper.state().phrases.chooseAvailableDate; + expect(newAvailableDatePhrase).to.equal(phrases.chooseAvailableDate); + }); + }); + }); + }); + }); + + describe('#onDayClick', () => { + describe('day argument is a blocked day', () => { + it('props.onFocusChange is not called', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow( true} + />); + wrapper.instance().onDayClick(today); + expect(onFocusChangeStub.callCount).to.equal(0); + }); + + it('props.onDatesChange is not called', () => { + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow( true} + />); + wrapper.instance().onDayClick(today); + expect(onDatesChangeStub.callCount).to.equal(0); + }); + }); + + describe('daysViolatingMinNightsCanBeClicked is true', () => { + it('props.onDatesChange is called and props.onFocusChange is not called when the day does not meet min nights', () => { + const onFocusChangeStub = sinon.stub(); + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(); + wrapper.instance().onDayClick(today.clone().add(1, 'days')); + expect(onFocusChangeStub.callCount).to.equal(0); + expect(onDatesChangeStub.callCount).to.equal(1); + }); + }); + + describe('props.focusedInput === START_DATE', () => { + describe('props.onFocusChange', () => { + it('is called once', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(); + wrapper.instance().onDayClick(today); + expect(onFocusChangeStub.callCount).to.equal(1); + }); + + it('is called with END_DATE', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(); + wrapper.instance().onDayClick(today); + expect(onFocusChangeStub.getCall(0).args[0]).to.equal(END_DATE); + }); + }); + + it('calls props.onDatesChange', () => { + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(today); + expect(onDatesChangeStub.callCount).to.equal(1); + }); + + describe('arg is after props.endDate', () => { + it('calls props.onDatesChange with startDate === arg and endDate === null', () => { + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + const tomorrow = moment(today).add(1, 'days'); + wrapper.instance().onDayClick(tomorrow); + expect(onDatesChangeStub.calledWith({ + startDate: tomorrow, + endDate: null, + })).to.equal(true); + }); + }); + + describe('arg is before props.endDate', () => { + it('calls props.onDatesChange with startDate === arg and endDate === props.endDate', () => { + const onDatesChangeStub = sinon.stub(); + const tomorrow = moment(today).add(1, 'days'); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(today); + expect(onDatesChangeStub.calledWith({ + startDate: today, + endDate: tomorrow, + })).to.equal(true); + }); + }); + + describe('props.endDate is null', () => { + it('calls props.onDatesChange with startDate === arg and endDate === null', () => { + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(today); + expect(onDatesChangeStub.calledWith({ + startDate: today, + endDate: null, + })).to.equal(true); + }); + }); + + describe('minimumNights is 0', () => { + it( + 'calls props.onDatesChange with startDate === today and endDate === today', + () => { + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(today); + expect(onDatesChangeStub.calledWith({ + startDate: today, + endDate: today, + })).to.equal(true); + }, + ); + }); + }); + + describe('props.focusedInput === END_DATE', () => { + describe('arg is before props.startDate', () => { + it('calls props.onDatesChange with startDate === arg and endDate === null', () => { + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(today); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate).to.equal(today); + expect(args.endDate).to.equal(null); + }); + }); + + describe('arg is not before props.startDate', () => { + it( + 'calls props.onDatesChange with startDate === props.startDate and endDate === arg', + () => { + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(today); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate).to.equal(wrapper.props().startDate); + expect(args.endDate).to.equal(today); + }, + ); + + describe('props.onFocusChange', () => { + describe('props.startDate === null', () => { + it('is called once', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(today); + expect(onFocusChangeStub.callCount).to.equal(1); + }); + + it('is called with START_DATE', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(today); + expect(onFocusChangeStub.getCall(0).args[0]).to.equal(START_DATE); + }); + }); + + describe('props.startDate is truthy', () => { + it('is called once', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(moment(today).add(1, 'days')); + expect(onFocusChangeStub.callCount).to.equal(1); + }); + + it('is called with null', () => { + const onFocusChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(moment(today).add(1, 'days')); + expect(onFocusChangeStub.getCall(0).args[0]).to.equal(null); + }); + }); + }); + + describe('props.onClose', () => { + describe('props.startDate is truthy', () => { + it('is called with startDate and endDate', () => { + const onCloseStub = sinon.stub(); + const wrapper = shallow(( + + )); + + const endDate = moment(today).add(1, 'days'); + + wrapper.instance().onDayClick(endDate); + const args = onCloseStub.getCall(0).args[0]; + expect(args.startDate).to.equal(today); + expect(args.endDate).to.equal(endDate); + }); + }); + }); + }); + + describe('minimumNights is 0', () => { + it( + 'calls props.onDatesChange with startDate === today and endDate === today', + () => { + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(today); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate).to.equal(today); + expect(args.endDate).to.equal(today); + }, + ); + }); }); - }); - describe('#onDayClick', () => { - describe('day argument is a blocked day', () => { - it('props.onFocusChange is not called', () => { - const onFocusChangeStub = sinon.stub(); - const wrapper = shallow( true} - />); - wrapper.instance().onDayClick(today); - expect(onFocusChangeStub.callCount).to.equal(0); + describe('props.startDateOffset / props.endDateOffset', () => { + it('calls props.onDatesChange with startDate === startDateOffset(date) and endDate === endDateOffset(date)', () => { + const clickDate = moment(today).clone().add(2, 'days'); + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + day.subtract(2, 'days')} + endDateOffset={(day) => day.add(4, 'days')} + /> + )); + wrapper.instance().onDayClick(clickDate); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate.format()).to.equal(clickDate.clone().subtract(2, 'days').format()); + expect(args.endDate.format()).to.equal(clickDate.clone().add(4, 'days').format()); }); - it('props.onDatesChange is not called', () => { + it('does not call props.onDatesChange with startDate === startDateOffset(date) and endDate === endDateOffset(date)', () => { + const clickDate = moment(today).clone().add(2, 'days'); + const onDatesChangeStub = sinon.spy(); + const wrapper = shallow(( + day.subtract(2, 'days')} + endDateOffset={(day) => day.add(4, 'days')} + isOutsideRange={(day) => day.isAfter(moment(today))} + /> + )); + wrapper.instance().onDayClick(clickDate); + expect(onDatesChangeStub).to.have.property('callCount', 0); + }); + + it('does not call props.onDatesChange when dateOffset isOutsideRange', () => { + const clickDate = moment(today); const onDatesChangeStub = sinon.stub(); - const wrapper = shallow( true} - />); - wrapper.instance().onDayClick(today); - expect(onDatesChangeStub.callCount).to.equal(0); + const wrapper = shallow(( + day.add(5, 'days')} + isOutsideRange={(day) => day.isAfter(moment(today).clone().add(1, 'days'))} + /> + )); + wrapper.instance().onDayClick(clickDate); + expect(onDatesChangeStub).to.have.property('callCount', 0); + }); + + it('calls props.onDatesChange with startDate === startDateOffset(date) and endDate === selectedDate when endDateOffset not provided', () => { + const clickDate = moment(today).clone().add(2, 'days'); + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + day.subtract(5, 'days')} + /> + )); + wrapper.instance().onDayClick(clickDate); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate.format()).to.equal(clickDate.clone().subtract(5, 'days').format()); + expect(args.endDate.format()).to.equal(clickDate.format()); + }); + + it('calls props.onDatesChange with startDate === selectedDate and endDate === endDateOffset(date) when startDateOffset not provided', () => { + const clickDate = moment(today).clone().add(12, 'days'); + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + day.add(12, 'days')} + /> + )); + wrapper.instance().onDayClick(clickDate); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate.format()).to.equal(clickDate.format()); + expect(args.endDate.format()).to.equal(clickDate.clone().add(12, 'days').format()); }); }); - describe('props.focusedInput === START_DATE', () => { - describe('props.onFocusChange', () => { - it('is called once', () => { - const onFocusChangeStub = sinon.stub(); - const wrapper = shallow( { + it('calls props.onDatesChange once when focusedInput === START_DATE', () => { + const clickDate = moment(today); + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + ); - wrapper.instance().onDayClick(today); - expect(onFocusChangeStub.callCount).to.equal(1); - }); + endDate={null} + /> + )); + wrapper.instance().onDayClick(clickDate); + expect(onDatesChangeStub).to.have.property('callCount', 1); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate.format()).to.equal(clickDate.clone().format()); + expect(args.endDate).to.equal(null); + }); - it('is called with END_DATE', () => { - const onFocusChangeStub = sinon.stub(); - const wrapper = shallow(); - wrapper.instance().onDayClick(today); - expect(onFocusChangeStub.getCall(0).args[0]).to.equal(END_DATE); - }); + it('calls props.onDatesChange once when focusedInput === END_DATE and there is no startDate', () => { + const clickDate = moment(today); + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(clickDate); + expect(onDatesChangeStub).to.have.property('callCount', 1); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate).to.equal(null); + expect(args.endDate.format()).to.equal(clickDate.clone().format()); }); - it('calls props.onDatesChange', () => { + it('calls props.onDatesChange once when focusedInput === END_DATE and the day is a valid endDate', () => { + const clickDate = moment(today); + const startDate = clickDate.clone().subtract(2, 'days'); const onDatesChangeStub = sinon.stub(); const wrapper = shallow(( - + )); - wrapper.instance().onDayClick(today); - expect(onDatesChangeStub.callCount).to.equal(1); + wrapper.instance().onDayClick(clickDate); + expect(onDatesChangeStub).to.have.property('callCount', 1); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate.format()).to.equal(startDate.clone().format()); + expect(args.endDate.format()).to.equal(clickDate.clone().format()); }); - describe('arg is after props.endDate', () => { - it('calls props.onDatesChange with startDate === arg and endDate === null', () => { - const onDatesChangeStub = sinon.stub(); - const wrapper = shallow(( - - )); - const tomorrow = moment(today).add(1, 'days'); - wrapper.instance().onDayClick(tomorrow); - expect(onDatesChangeStub.calledWith({ - startDate: tomorrow, - endDate: null, - })).to.equal(true); + it('calls props.onDatesChange once when focusedInput === END_DATE, the day is an invalid endDate, and disabled !== START_DATE', () => { + const clickDate = moment(today); + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(clickDate); + expect(onDatesChangeStub).to.have.property('callCount', 1); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate.format()).to.equal(clickDate.clone().format()); + expect(args.endDate).to.equal(null); + }); + + it('calls props.onDatesChange once when focusedInput === END_DATE and the day is an invalid endDate', () => { + const clickDate = moment(today); + const startDate = clickDate.clone().add(1, 'days'); + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(clickDate); + expect(onDatesChangeStub).to.have.property('callCount', 1); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate.format()).to.equal(startDate.clone().format()); + expect(args.endDate).to.equal(null); + }); + + it('calls props.onDatesChange once when there is a startDateOffset', () => { + const clickDate = moment(today); + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + day.subtract(2, 'days')} + /> + )); + wrapper.instance().onDayClick(clickDate); + expect(onDatesChangeStub).to.have.property('callCount', 1); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate.format()).to.equal(clickDate.clone().subtract(2, 'days').format()); + expect(args.endDate.format()).to.equal(clickDate.clone().format()); + }); + + it('calls props.onDatesChange once when there is a endDateOffset', () => { + const clickDate = moment(today); + const onDatesChangeStub = sinon.stub(); + const wrapper = shallow(( + day.add(4, 'days')} + /> + )); + wrapper.instance().onDayClick(clickDate); + expect(onDatesChangeStub).to.have.property('callCount', 1); + const args = onDatesChangeStub.getCall(0).args[0]; + expect(args.startDate.format()).to.equal(clickDate.clone().format()); + expect(args.endDate.format()).to.equal(clickDate.clone().add(4, 'days').format()); + }); + }); + + describe('logic in props.onDatesChange affects props.onFocusChange', () => { + let preventFocusChange; + let focusedInput; + let onDatesChange; + let onFocusChange; + beforeEach(() => { + preventFocusChange = false; + focusedInput = START_DATE; + onDatesChange = ({ startDate }) => { + if (isSameDay(startDate, today)) preventFocusChange = true; + }; + onFocusChange = (input) => { + if (!preventFocusChange) { + focusedInput = input; + } else { + preventFocusChange = false; + } + }; + }); + + it('calls onDayClick with a day that prevents a focus change', () => { + const clickDate = moment(today); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(clickDate); + expect(focusedInput).to.equal(START_DATE); + wrapper.instance().onDayClick(clickDate.clone().add(1, 'days')); + expect(focusedInput).to.equal(END_DATE); + }); + + it('calls onDayClick with a day that does not prevent a focus change', () => { + const clickDate = moment(today).clone().add(2, 'days'); + const wrapper = shallow(( + + )); + wrapper.instance().onDayClick(clickDate); + expect(focusedInput).to.equal(END_DATE); + }); + }); + }); + + describe('#onDayMouseEnter', () => { + it('sets state.hoverDate to the day arg', () => { + const wrapper = shallow(); + wrapper.instance().onDayMouseEnter(today); + expect(wrapper.state().hoverDate).to.equal(today); + }); + + it('sets state.dateOffset to the start and end date range when range included', () => { + const wrapper = shallow(( + day.add(2, 'days')} + /> + )); + wrapper.instance().onDayMouseEnter(today); + expect(wrapper.state().dateOffset.start.format()).to.equal(today.format()); + expect(wrapper.state().dateOffset.end.format()).to.equal(today.clone().add(3, 'days').format()); + }); + + describe('modifiers', () => { + it('calls addModifier', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const wrapper = shallow(); + wrapper.setState({ + hoverDate: null, }); + addModifierSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(today); + expect(addModifierSpy.callCount).to.equal(1); + expect(addModifierSpy.getCall(0).args[1]).to.equal(today); + expect(addModifierSpy.getCall(0).args[2]).to.equal('hovered'); }); - describe('arg is before props.endDate', () => { - it('calls props.onDatesChange with startDate === arg and endDate === props.endDate', () => { - const onDatesChangeStub = sinon.stub(); - const tomorrow = moment(today).add(1, 'days'); - const wrapper = shallow(( - - )); - wrapper.instance().onDayClick(today); - expect(onDatesChangeStub.calledWith({ - startDate: today, - endDate: tomorrow, - })).to.equal(true); + it('calls deleteModifier', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const wrapper = shallow(); + wrapper.setState({ + hoverDate: today, + }); + deleteModifierSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(moment().add(10, 'days')); + expect(deleteModifierSpy.callCount).to.equal(1); + expect(deleteModifierSpy.getCall(0).args[1]).to.equal(today); + expect(deleteModifierSpy.getCall(0).args[2]).to.equal('hovered'); + }); + + describe('startDate and !endDate and focusedInput === `END_DATE`', () => { + describe('old hoverDate is after startDate', () => { + it('calls deleteModifierFromRange with startDate, old hoverDate and `hovered-span`', () => { + const startDate = today; + const hoverDate = today.clone().add(5, 'days'); + const dayAfterHoverDate = hoverDate.clone().add(1, 'day'); + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const wrapper = shallow(( + + )); + wrapper.setState({ hoverDate }); + deleteModifierFromRangeSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(moment().add(10, 'days')); + const hoverSpanCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-span'); + expect(hoverSpanCalls.length).to.equal(1); + expect(hoverSpanCalls[0].args[1]).to.equal(startDate); + expect(isSameDay(hoverSpanCalls[0].args[2], dayAfterHoverDate)).to.equal(true); + }); }); - }); - describe('props.endDate is null', () => { - it('calls props.onDatesChange with startDate === arg and endDate === null', () => { - const onDatesChangeStub = sinon.stub(); - const wrapper = shallow(( - - )); - wrapper.instance().onDayClick(today); - expect(onDatesChangeStub.calledWith({ - startDate: today, - endDate: null, - })).to.equal(true); + describe('new hoverDate is not blocked and is after startDate', () => { + it('calls addModifierFromRange with startDate, new hoverDate, and `hovered-span`', () => { + const startDate = today; + const hoverDate = today.clone().add(5, 'days'); + const dayAfterHoverDate = hoverDate.clone().add(1, 'day'); + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const wrapper = shallow(( + + )); + wrapper.setState({ hoverDate: null }); + addModifierToRangeSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(hoverDate); + const hoverSpanCalls = getCallsByModifier(addModifierToRangeSpy, 'hovered-span'); + expect(hoverSpanCalls.length).to.equal(1); + expect(hoverSpanCalls[0].args[1]).to.equal(startDate); + expect(isSameDay(hoverSpanCalls[0].args[2], dayAfterHoverDate)).to.equal(true); + }); }); }); - }); - describe('props.focusedInput === END_DATE', () => { - describe('arg is before props.startDate', () => { - it('calls props.onDatesChange with startDate === arg and endDate === null', () => { - const onDatesChangeStub = sinon.stub(); - const wrapper = shallow(( - - )); - wrapper.instance().onDayClick(today); - const args = onDatesChangeStub.getCall(0).args[0]; - expect(args.startDate).to.equal(today); - expect(args.endDate).to.equal(null); + describe('!startDate and endDate and focusedInput === `START_DATE`', () => { + describe('old hoverDate is before endDate', () => { + it('calls deleteModifierFromRange', () => { + const hoverDate = today; + const endDate = today.clone().add(5, 'days'); + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const wrapper = shallow(( + + )); + wrapper.setState({ hoverDate }); + deleteModifierFromRangeSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(moment().add(10, 'days')); + expect(deleteModifierFromRangeSpy.callCount).to.equal(2); + expect(deleteModifierFromRangeSpy.getCall(0).args[1]).to.equal(hoverDate); + expect(deleteModifierFromRangeSpy.getCall(0).args[2]).to.equal(endDate); + expect(deleteModifierFromRangeSpy.getCall(0).args[3]).to.equal('hovered-span'); + expect(isSameDay(deleteModifierFromRangeSpy.getCall(1).args[1], endDate.subtract(DayPickerRangeController.defaultProps.minimumNights, 'days'))).to.equal(true); + expect(deleteModifierFromRangeSpy.getCall(1).args[2]).to.equal(endDate); + expect(deleteModifierFromRangeSpy.getCall(1).args[3]).to.equal('before-hovered-end'); + }); + }); + + describe('new hoverDate is not blocked and is before endDate', () => { + it('calls addModifierFromRange', () => { + const hoverDate = today; + const endDate = today.clone().add(5, 'days'); + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const wrapper = shallow(( + + )); + wrapper.setState({ hoverDate: null }); + addModifierToRangeSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(hoverDate); + expect(addModifierToRangeSpy.callCount).to.equal(1); + expect(addModifierToRangeSpy.getCall(0).args[1]).to.equal(hoverDate); + expect(addModifierToRangeSpy.getCall(0).args[2]).to.equal(endDate); + expect(addModifierToRangeSpy.getCall(0).args[3]).to.equal('hovered-span'); + }); }); }); - describe('arg is not before props.startDate', () => { - it( - 'calls props.onDatesChange with startDate === props.startDate and endDate === arg', - () => { - const onDatesChangeStub = sinon.stub(); + describe('after-hovered-start modifier', () => { + describe('startDate does not exist', () => { + it('does not remove old `after-hovered-start` range (cos it doesnt exist)', () => { + const minimumNights = 5; + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); const wrapper = shallow(( )); - wrapper.instance().onDayClick(today); - const args = onDatesChangeStub.getCall(0).args[0]; - expect(args.startDate).to.equal(wrapper.props().startDate); - expect(args.endDate).to.equal(today); - }, - ); + deleteModifierFromRangeSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(today); + const afterHoverStartCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'after-hovered-start'); + expect(afterHoverStartCalls.length).to.equal(0); + }); + }); - describe('props.onFocusChange', () => { - describe('props.startDate === null', () => { - it('is called once', () => { - const onFocusChangeStub = sinon.stub(); + describe('startDate exists', () => { + describe('hoverDate is startDate', () => { + it('adds new `after-hovered-start` range', () => { + const minimumNights = 5; + const startDate = moment().add(7, 'days'); + const dayAfterStartDate = startDate.clone().add(1, 'day'); + const firstAvailableDate = startDate.clone().add(minimumNights + 1, 'days'); + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); const wrapper = shallow(( )); - wrapper.instance().onDayClick(today); - expect(onFocusChangeStub.callCount).to.equal(1); + addModifierToRangeSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(startDate); + const afterHoverStartCalls = getCallsByModifier(addModifierToRangeSpy, 'after-hovered-start'); + expect(afterHoverStartCalls.length).to.equal(1); + expect(isSameDay(afterHoverStartCalls[0].args[1], dayAfterStartDate)).to.equal(true); + expect(isSameDay(afterHoverStartCalls[0].args[2], firstAvailableDate)).to.equal(true); }); + }); - it('is called with START_DATE', () => { - const onFocusChangeStub = sinon.stub(); + describe('hoverDate is not startDate', () => { + it('does not add new `after-hovered-start` range', () => { + const minimumNights = 5; + const startDate = moment().add(7, 'days'); + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); const wrapper = shallow(( )); - wrapper.instance().onDayClick(today); - expect(onFocusChangeStub.getCall(0).args[0]).to.equal(START_DATE); + addModifierToRangeSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(today); + const afterHoverStartCalls = getCallsByModifier(addModifierToRangeSpy, 'after-hovered-start'); + expect(afterHoverStartCalls.length).to.equal(0); }); }); + }); + }); - describe('props.startDate is truthy', () => { - it('is called once', () => { - const onFocusChangeStub = sinon.stub(); - const wrapper = shallow(( - - )); - wrapper.instance().onDayClick(moment(today).add(1, 'days')); - expect(onFocusChangeStub.callCount).to.equal(1); - }); + describe('hovered-start-first-possible-end modifier', () => { + it('does not call deleteModifier with `hovered-start-first-possible-end` if there is no previous hoverDate', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); - it('is called with null', () => { - const onFocusChangeStub = sinon.stub(); - const wrapper = shallow(( - - )); - wrapper.instance().onDayClick(moment(today).add(1, 'days')); - expect(onFocusChangeStub.getCall(0).args[0]).to.equal(null); - }); + describe('focusedInput === START_DATE', () => { + it('calls deleteModifier with `hovered-start-first-possible-end` if getMinNightsForHoverDate returns a positive integer', () => { + const hoverDate = today.clone().subtract(1, 'days'); + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.setState({ hoverDate }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(1); + expect(isSameDay(hoveredStartFirstPossibleEndCalls[0].args[1], hoverDate.clone().add(2, 'days'))).to.equal(true); }); - }); - describe('props.onClose', () => { - describe('props.startDate is truthy', () => { - it('is called with startDate and endDate', () => { - const onCloseStub = sinon.stub(); - const wrapper = shallow(( - - )); + it('does not call deleteModifier with `hovered-start-first-possible-end` if the previous hovered date is blocked', () => { + const hoverDate = today.clone().subtract(1, 'days'); + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow( isSameDay(day, hoverDate)} + />); + wrapper.setState({ hoverDate }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); + + it('does not call deleteModifier with `hovered-start-first-possible-end` if getMinNightsForHoverDate does not return a positive integer', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(0); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today.clone().subtract(1, 'days') }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); + + it('calls addModifier with `hovered-start-first-possible-end` if getMinNightsForHoverDate returns a positive integer', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(addModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(1); + expect(isSameDay(hoveredStartFirstPossibleEndCalls[0].args[1], today.clone().add(2, 'days'))).to.equal(true); + }); + + it('does not call addModifier with `hovered-start-first-possible-end` if the new hovered date is blocked', () => { + const hoverDate = today.clone().subtract(1, 'days'); + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow( isSameDay(day, today)} + />); + wrapper.setState({ hoverDate }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(addModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); - const endDate = moment(today).add(1, 'days'); + it('does not call addModifier with `hovered-start-first-possible-end` if getMinNightsForHoverDate does not return a positive integer', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(0); + const wrapper = shallow(); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(addModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); - wrapper.instance().onDayClick(endDate); - const args = onCloseStub.getCall(0).args[0]; - expect(args.startDate).to.equal(today); - expect(args.endDate).to.equal(endDate); - }); + it('does not call addModifier with `hovered-start-first-possible-end` if getMinNightsForHoverDate is not supplied as a prop', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const wrapper = shallow(); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(addModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); }); }); - }); - - describe('minimumNights is 0', () => { - it( - 'calls props.onDatesChange with startDate === today and endDate === today', - () => { - const onDatesChangeStub = sinon.stub(); - const wrapper = shallow(( - - )); - wrapper.instance().onDayClick(today); - const args = onDatesChangeStub.getCall(0).args[0]; - expect(args.startDate).to.equal(today); - expect(args.endDate).to.equal(today); - }, - ); - }); - }); - }); - describe('#onDayMouseEnter', () => { - it('sets state.hoverDate to the day arg', () => { - const wrapper = shallow(); - wrapper.instance().onDayMouseEnter(today); - expect(wrapper.state().hoverDate).to.equal(today); - }); + describe('focusedInput === END_DATE', () => { + it('does not call deleteModifier with `hovered-start-first-possible-end`', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today.clone().subtract(1, 'days') }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); - describe('modifiers', () => { - it('calls addModifier', () => { - const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); - const wrapper = shallow(); - wrapper.setState({ - hoverDate: null, + it('does not call addModifier with `hovered-start-first-possible-end`', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(addModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); }); - addModifierSpy.reset(); - wrapper.instance().onDayMouseEnter(today); - expect(addModifierSpy.callCount).to.equal(1); - expect(addModifierSpy.getCall(0).args[1]).to.equal(today); - expect(addModifierSpy.getCall(0).args[2]).to.equal('hovered'); }); - it('calls deleteModifier', () => { - const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); - const wrapper = shallow(); - wrapper.setState({ - hoverDate: today, + describe('hovered-start-blocked-minimum-nights modifier', () => { + it('does not call deleteModifierFromRange with `hovered-start-blocked-minimum-nights` if there is no previous hoverDate', () => { + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); }); - deleteModifierSpy.reset(); - wrapper.instance().onDayMouseEnter(moment().add(10, 'days')); - expect(deleteModifierSpy.callCount).to.equal(1); - expect(deleteModifierSpy.getCall(0).args[1]).to.equal(today); - expect(deleteModifierSpy.getCall(0).args[2]).to.equal('hovered'); - }); - describe('startDate and !endDate and focusedInput === `END_DATE`', () => { - describe('old hoverDate is after startDate', () => { - it('calls deleteModifierFromRange with startDate, old hoverDate and `hovered-span`', () => { - const startDate = today; - const hoverDate = today.clone().add(5, 'days'); - const dayAfterHoverDate = hoverDate.clone().add(1, 'day'); + describe('focusedInput === START_DATE', () => { + it('calls deleteModifierFromRange with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate returns a positive integer', () => { + const hoverDate = today.clone().subtract(1, 'days'); const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); - const wrapper = shallow(( - - )); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); wrapper.setState({ hoverDate }); - deleteModifierFromRangeSpy.reset(); - wrapper.instance().onDayMouseEnter(moment().add(10, 'days')); - const hoverSpanCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-span'); - expect(hoverSpanCalls.length).to.equal(1); - expect(hoverSpanCalls[0].args[1]).to.equal(startDate); - expect(isSameDay(hoverSpanCalls[0].args[2], dayAfterHoverDate)).to.equal(true); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(1); + expect(isSameDay(hoveredStartBlockedMinNightsCalls[0].args[1], hoverDate.clone().add(1, 'days'))).to.equal(true); + expect(isSameDay(hoveredStartBlockedMinNightsCalls[0].args[2], hoverDate.clone().add(2, 'days'))).to.equal(true); + }); + + it('does not call deleteModifierFromRange with `hovered-start-blocked-minimum-nights` if the previous hovered date is blocked', () => { + const hoverDate = today.clone().subtract(1, 'days'); + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow( isSameDay(day, hoverDate)} + />); + wrapper.setState({ hoverDate }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + + it('does not call deleteModifierFromRange with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate does not return a positive integer', () => { + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(0); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today.clone().subtract(1, 'days') }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + + it('calls addModifierToRange with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate returns a positive integer', () => { + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(addModifierToRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(1); + expect(isSameDay(hoveredStartBlockedMinNightsCalls[0].args[1], today.clone().add(1, 'days'))).to.equal(true); + expect(isSameDay(hoveredStartBlockedMinNightsCalls[0].args[2], today.clone().add(2, 'days'))).to.equal(true); + }); + + it('does not call addModifier with `hovered-start-blocked-minimum-nights` if the new hovered date is blocked', () => { + const hoverDate = today.clone().subtract(1, 'days'); + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow( isSameDay(day, today)} + />); + wrapper.setState({ hoverDate }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(addModifierToRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + + it('does not call addModifier with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate does not return a positive integer', () => { + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(0); + const wrapper = shallow(); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(addModifierToRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + + it('does not call addModifier with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate is not supplied as a prop', () => { + const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const wrapper = shallow(); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(addModifierToRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); }); }); - describe('new hoverDate is not blocked and is after startDate', () => { - it('calls addModifierFromRange with startDate, new hoverDate, and `hovered-span`', () => { - const startDate = today; - const hoverDate = today.clone().add(5, 'days'); - const dayAfterHoverDate = hoverDate.clone().add(1, 'day'); + describe('focusedInput === END_DATE', () => { + it('does not call deleteModifierFromRangeFromRange with `hovered-start-blocked-minimum-nights`', () => { + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today.clone().subtract(1, 'days') }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + + it('does not call addModifier with `hovered-start-blocked-minimum-nights`', () => { const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); - const wrapper = shallow(( - - )); - wrapper.setState({ hoverDate: null }); - addModifierToRangeSpy.reset(); - wrapper.instance().onDayMouseEnter(hoverDate); - const hoverSpanCalls = getCallsByModifier(addModifierToRangeSpy, 'hovered-span'); - expect(hoverSpanCalls.length).to.equal(1); - expect(hoverSpanCalls[0].args[1]).to.equal(startDate); - expect(isSameDay(hoverSpanCalls[0].args[2], dayAfterHoverDate)).to.equal(true); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(addModifierToRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); }); }); }); - describe('!startDate and endDate and focusedInput === `START_DATE`', () => { - describe('old hoverDate is before endDate', () => { - it('calls deleteModifierFromRange', () => { - const hoverDate = today; - const endDate = today.clone().add(5, 'days'); - const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); - const wrapper = shallow(( - - )); - wrapper.setState({ hoverDate }); - deleteModifierFromRangeSpy.reset(); - wrapper.instance().onDayMouseEnter(moment().add(10, 'days')); - expect(deleteModifierFromRangeSpy.callCount).to.equal(1); - expect(deleteModifierFromRangeSpy.getCall(0).args[1]).to.equal(hoverDate); - expect(deleteModifierFromRangeSpy.getCall(0).args[2]).to.equal(endDate); - expect(deleteModifierFromRangeSpy.getCall(0).args[3]).to.equal('hovered-span'); + describe('selected-start-in-hovered-span modifier', () => { + describe('end date is falsey and focusedInput === `END_DATE`', () => { + describe('day is start date or before start date', () => { + it('calls deleteModifier with `selected-start-in-hovered-span` on start date', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const startDate = today; + const wrapper = shallow(); + const yesterday = today.clone().subtract(1, 'days'); + deleteModifierSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(yesterday); + const deleteModifierCalls = getCallsByModifier(deleteModifierSpy, 'selected-start-in-hovered-span'); + expect(deleteModifierCalls.length).to.equal(1); + expect(deleteModifierCalls[0].args[1]).to.equal(startDate); + }); + }); + + describe('day is not blocked, and is after the start date', () => { + it('calls addModifier with `selected-start-in-hovered-span` on start date', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const startDate = today; + const wrapper = shallow(); + const tomorrow = today.clone().add(1, 'days'); + addModifierSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(tomorrow); + const addModifierCalls = getCallsByModifier(addModifierSpy, 'selected-start-in-hovered-span'); + expect(addModifierCalls.length).to.equal(1); + expect(addModifierCalls[0].args[1]).to.equal(startDate); + }); }); }); + }); - describe('new hoverDate is not blocked and is before endDate', () => { - it('calls addModifierFromRange', () => { - const hoverDate = today; - const endDate = today.clone().add(5, 'days'); - const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); - const wrapper = shallow(( - { + describe('start date is falsey and focusedInput === `START_DATE`', () => { + describe('day is end date or after start date', () => { + it('calls deleteModifier with `selected-end-in-hovered-span` on end date', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const endDate = today; + const wrapper = shallow(); + const tomorrow = today.clone().add(1, 'days'); + deleteModifierSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(tomorrow); + const deleteModifierCalls = getCallsByModifier(deleteModifierSpy, 'selected-end-in-hovered-span'); + expect(deleteModifierCalls.length).to.equal(1); + expect(deleteModifierCalls[0].args[1]).to.equal(endDate); + }); + }); + + describe('day is not blocked, and is before the end date', () => { + it('calls addModifier with `selected-end-in-hovered-span`', () => { + const addModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifier'); + const endDate = today; + const wrapper = shallow( - )); - wrapper.setState({ hoverDate: null }); - addModifierToRangeSpy.reset(); - wrapper.instance().onDayMouseEnter(hoverDate); - expect(addModifierToRangeSpy.callCount).to.equal(1); - expect(addModifierToRangeSpy.getCall(0).args[1]).to.equal(hoverDate); - expect(addModifierToRangeSpy.getCall(0).args[2]).to.equal(endDate); - expect(addModifierToRangeSpy.getCall(0).args[3]).to.equal('hovered-span'); + endDate={endDate} + />); + const yesterday = today.clone().subtract(1, 'days'); + addModifierSpy.resetHistory(); + wrapper.instance().onDayMouseEnter(yesterday); + const addModifierCalls = getCallsByModifier(addModifierSpy, 'selected-end-in-hovered-span'); + expect(addModifierCalls.length).to.equal(1); + expect(addModifierCalls[0].args[1]).to.equal(today); + }); }); }); }); - describe('after-hovered-start modifier', () => { - describe('startDate does not exist', () => { - it('does not remove old `after-hovered-start` range (cos it doesnt exist)', () => { - const minimumNights = 5; + describe('before-hovered-end modifier', () => { + describe('end date is truthy and focusedInput is truthy', () => { + it('calls deleteModifierFromRange with `before-hovered-end` on minimum nights days before end date', () => { const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); - const wrapper = shallow(( - - )); - deleteModifierFromRangeSpy.reset(); + const endDate = today; + const minimumNights = 5; + const wrapper = shallow(); + const minimumNightStartSpan = endDate.clone().subtract(minimumNights, 'days'); + deleteModifierFromRangeSpy.resetHistory(); wrapper.instance().onDayMouseEnter(today); - const afterHoverStartCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'after-hovered-start'); - expect(afterHoverStartCalls.length).to.equal(0); + const deleteModifierFromRangeCalls = getCallsByModifier( + deleteModifierFromRangeSpy, + 'before-hovered-end', + ); + expect(deleteModifierFromRangeCalls.length).to.equal(1); + expect(toISODateString(deleteModifierFromRangeCalls[0].args[1])).to.equal( + toISODateString(minimumNightStartSpan), + ); + expect(deleteModifierFromRangeCalls[0].args[2]).to.equal(endDate); }); }); - describe('startDate exists', () => { - it('removes previous `after-hovered-start` range', () => { + describe('day is equal to end date', () => { + it('calls addModifierToRange with `before-hovered-end`', () => { + const addModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); + const endDate = today; const minimumNights = 5; - const startDate = moment().add(7, 'days'); - const dayAfterStartDate = startDate.clone().add(1, 'day'); - const firstAvailableDate = startDate.clone().add(minimumNights + 1, 'days'); - const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); - const wrapper = shallow(( - - )); - deleteModifierFromRangeSpy.reset(); + const wrapper = shallow(); + const minimumNightStartSpan = endDate.clone().subtract(minimumNights, 'days'); + addModifierFromRangeSpy.resetHistory(); wrapper.instance().onDayMouseEnter(today); - const afterHoverStartCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'after-hovered-start'); - expect(afterHoverStartCalls.length).to.equal(1); - expect(isSameDay(afterHoverStartCalls[0].args[1], dayAfterStartDate)).to.equal(true); - expect(isSameDay(afterHoverStartCalls[0].args[2], firstAvailableDate)).to.equal(true); - }); - - describe('hoverDate is startDate', () => { - it('adds new `after-hovered-start` range', () => { - const minimumNights = 5; - const startDate = moment().add(7, 'days'); - const dayAfterStartDate = startDate.clone().add(1, 'day'); - const firstAvailableDate = startDate.clone().add(minimumNights + 1, 'days'); - const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); - const wrapper = shallow(( - - )); - addModifierToRangeSpy.reset(); - wrapper.instance().onDayMouseEnter(startDate); - const afterHoverStartCalls = getCallsByModifier(addModifierToRangeSpy, 'after-hovered-start'); - expect(afterHoverStartCalls.length).to.equal(1); - expect(isSameDay(afterHoverStartCalls[0].args[1], dayAfterStartDate)).to.equal(true); - expect(isSameDay(afterHoverStartCalls[0].args[2], firstAvailableDate)).to.equal(true); - }); - }); - - describe('hoverDate is not startDate', () => { - it('does not add new `after-hovered-start` range', () => { - const minimumNights = 5; - const startDate = moment().add(7, 'days'); - const addModifierToRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'addModifierToRange'); - const wrapper = shallow(( - - )); - addModifierToRangeSpy.reset(); - wrapper.instance().onDayMouseEnter(today); - const afterHoverStartCalls = getCallsByModifier(addModifierToRangeSpy, 'after-hovered-start'); - expect(afterHoverStartCalls.length).to.equal(0); - }); + const addModifierFromRangeCalls = getCallsByModifier(addModifierFromRangeSpy, 'before-hovered-end'); + expect(addModifierFromRangeCalls.length).to.equal(1); + expect(toISODateString(addModifierFromRangeCalls[0].args[1])).to.equal( + toISODateString(minimumNightStartSpan), + ); + expect(addModifierFromRangeCalls[0].args[2]).to.equal(endDate); }); }); }); @@ -1710,7 +3121,7 @@ describe('DayPickerRangeController', () => { wrapper.setState({ hoverDate: today, }); - deleteModifierSpy.reset(); + deleteModifierSpy.resetHistory(); wrapper.instance().onDayMouseLeave(today); expect(deleteModifierSpy.callCount).to.equal(1); expect(deleteModifierSpy.getCall(0).args[1]).to.equal(today); @@ -1732,7 +3143,7 @@ describe('DayPickerRangeController', () => { /> )); wrapper.setState({ hoverDate }); - deleteModifierFromRangeSpy.reset(); + deleteModifierFromRangeSpy.resetHistory(); wrapper.instance().onDayMouseLeave(today); const hoveredSpanCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-span'); expect(hoveredSpanCalls.length).to.equal(1); @@ -1755,7 +3166,7 @@ describe('DayPickerRangeController', () => { /> )); wrapper.setState({ hoverDate }); - deleteModifierFromRangeSpy.reset(); + deleteModifierFromRangeSpy.resetHistory(); wrapper.instance().onDayMouseLeave(today); expect(deleteModifierFromRangeSpy.callCount).to.equal(1); expect(deleteModifierFromRangeSpy.getCall(0).args[1]).to.equal(hoverDate); @@ -1781,7 +3192,7 @@ describe('DayPickerRangeController', () => { /> )); wrapper.setState({ hoverDate: today }); - deleteModifierFromRangeSpy.reset(); + deleteModifierFromRangeSpy.resetHistory(); wrapper.instance().onDayMouseLeave(startDate); const afterHoverStartCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'after-hovered-start'); expect(afterHoverStartCalls.length).to.equal(1); @@ -1790,44 +3201,261 @@ describe('DayPickerRangeController', () => { }); }); - describe('startDate exists and is not the same as arg', () => { - it('does not call deleteModifierFromRange with `after-hovered-start`', () => { - const minimumNights = 5; - const startDate = moment().add(13, 'days'); - const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + describe('startDate exists and is not the same as arg', () => { + it('does not call deleteModifierFromRange with `after-hovered-start`', () => { + const minimumNights = 5; + const startDate = moment().add(13, 'days'); + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const wrapper = shallow(( + + )); + wrapper.setState({ hoverDate: today }); + deleteModifierFromRangeSpy.resetHistory(); + wrapper.instance().onDayMouseLeave(today); + const afterHoverStartCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'after-hovered-start'); + expect(afterHoverStartCalls.length).to.equal(0); + }); + }); + + describe('startDate does not exist', () => { + it('does not call deleteModifierFromRange with `after-hovered-start`', () => { + const minimumNights = 5; + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const wrapper = shallow(( + + )); + wrapper.setState({ hoverDate: today }); + deleteModifierFromRangeSpy.resetHistory(); + wrapper.instance().onDayMouseLeave(today); + const afterHoverStartCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'after-hovered-start'); + expect(afterHoverStartCalls.length).to.equal(0); + }); + }); + }); + + describe('hovered-start-first-possible-end modifier', () => { + describe('focusedInput === START_DATE', () => { + it('calls deleteModifier with `hovered-start-first-possible-end` if getMinNightsForHoverDate returns a positive integer', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().onDayMouseLeave(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(1); + expect(isSameDay(hoveredStartFirstPossibleEndCalls[0].args[1], today.clone().add(2, 'days'))).to.equal(true); + }); + + it('does not call deleteModifier with `hovered-start-first-possible-end` if the hovered date is blocked', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow( isSameDay(day, today)} + />); + wrapper.setState({ hoverDate: today }); + wrapper.instance().onDayMouseLeave(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); + + it('does not call deleteModifier with `hovered-start-first-possible-end` if getMinNightsForHoverDate does not return a positive integer', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(0); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); + }); + + describe('focusedInput === END_DATE', () => { + it('does not call deleteModifier with `hovered-start-first-possible-end`', () => { + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today.clone().subtract(1, 'days') }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartFirstPossibleEndCalls = getCallsByModifier(deleteModifierSpy, 'hovered-start-first-possible-end'); + expect(hoveredStartFirstPossibleEndCalls.length).to.equal(0); + }); + }); + }); + + describe('hovered-start-blocked-minimum-nights modifier', () => { + describe('focusedInput === START_DATE', () => { + it('calls deleteModifierFromRange with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate returns a positive integer', () => { + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(1); + expect(isSameDay(hoveredStartBlockedMinNightsCalls[0].args[1], today.clone().add(1, 'days'))).to.equal(true); + expect(isSameDay(hoveredStartBlockedMinNightsCalls[0].args[2], today.clone().add(2, 'days'))).to.equal(true); + }); + + it('does not call deleteModifierFromRange with `hovered-start-blocked-minimum-nights` if the hovered date is blocked', () => { + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow( isSameDay(day, today)} + />); + wrapper.setState({ hoverDate: today }); + wrapper.instance().onDayMouseLeave(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + + it('does not call deleteModifierFromRange with `hovered-start-blocked-minimum-nights` if getMinNightsForHoverDate does not return a positive integer', () => { + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(0); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + }); + + describe('focusedInput === END_DATE', () => { + it('does not call deleteModifierFromRangeFromRange with `hovered-start-blocked-minimum-nights`', () => { + const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const getMinNightsForHoverDateStub = sinon.stub().returns(2); + const wrapper = shallow(); + wrapper.setState({ hoverDate: today.clone().subtract(1, 'days') }); + wrapper.instance().onDayMouseEnter(today); + const hoveredStartBlockedMinNightsCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'hovered-start-blocked-minimum-nights'); + expect(hoveredStartBlockedMinNightsCalls.length).to.equal(0); + }); + }); + }); + + describe('selected-start-in-hovered-span modifier', () => { + describe('start date is truthy, end date is falsey and day is after start date', () => { + it('calls deleteModifier with `selected-start-in-hovered-span` on start date', () => { + const startDate = today; + const dayAfterStartDate = startDate.clone().add(1, 'day'); + const hoverDate = today.clone().add(5, 'days'); + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); + const wrapper = shallow(( + + )); + wrapper.setState({ hoverDate }); + deleteModifierSpy.resetHistory(); + wrapper.instance().onDayMouseLeave(dayAfterStartDate); + const deleteModifierCalls = getCallsByModifier(deleteModifierSpy, 'selected-start-in-hovered-span'); + expect(deleteModifierCalls.length).to.equal(1); + expect(deleteModifierCalls[0].args[1]).to.equal(startDate); + }); + }); + }); + + describe('selected-end-in-hovered-span modifier', () => { + describe('end date is truthy, start date is falsey and day is before end date', () => { + it('calls deleteModifier with `selected-end-in-hovered-span` on end date', () => { + const endDate = today; + const dayBeforeEndDate = endDate.clone().subtract(1, 'day'); + const hoverDate = today.clone().add(5, 'days'); + const deleteModifierSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifier'); const wrapper = shallow(( )); - wrapper.setState({ hoverDate: today }); - deleteModifierFromRangeSpy.reset(); - wrapper.instance().onDayMouseLeave(today); - const afterHoverStartCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'after-hovered-start'); - expect(afterHoverStartCalls.length).to.equal(0); + wrapper.setState({ hoverDate }); + deleteModifierSpy.resetHistory(); + wrapper.instance().onDayMouseLeave(dayBeforeEndDate); + const deleteModifierCalls = getCallsByModifier(deleteModifierSpy, 'selected-end-in-hovered-span'); + expect(deleteModifierCalls.length).to.equal(1); + expect(deleteModifierCalls[0].args[1]).to.equal(endDate); }); }); + }); - describe('startDate does not exist', () => { - it('does not call deleteModifierFromRange with `after-hovered-start`', () => { - const minimumNights = 5; + describe('before-hovered-end modifier', () => { + describe('end date is truthy and day is end date', () => { + it('calls deleteModifierFromRange with `before-hovered-end` on span of end date to end date minus minimum nights', () => { + const endDate = today; + const hoverDate = today.clone().subtract(5, 'days'); const deleteModifierFromRangeSpy = sinon.spy(DayPickerRangeController.prototype, 'deleteModifierFromRange'); + const minimumNights = 5; + const minimumNightStartSpan = endDate.clone().subtract(minimumNights, 'days'); const wrapper = shallow(( )); - wrapper.setState({ hoverDate: today }); - deleteModifierFromRangeSpy.reset(); - wrapper.instance().onDayMouseLeave(today); - const afterHoverStartCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'after-hovered-start'); - expect(afterHoverStartCalls.length).to.equal(0); + deleteModifierFromRangeSpy.resetHistory(); + wrapper.setState({ hoverDate }); + wrapper.instance().onDayMouseLeave(endDate); + const deleteModifierFromRangeCalls = getCallsByModifier(deleteModifierFromRangeSpy, 'before-hovered-end'); + expect(deleteModifierFromRangeCalls.length).to.equal(1); + expect(toISODateString(deleteModifierFromRangeCalls[0].args[1])).to.equal( + toISODateString(minimumNightStartSpan), + ); + expect(deleteModifierFromRangeCalls[0].args[2]).to.equal(endDate); }); }); }); @@ -1846,7 +3474,7 @@ describe('DayPickerRangeController', () => { currentMonth: today, }); wrapper.instance().onPrevMonthClick(); - expect(wrapper.state().currentMonth.month()).to.equal(today.month() - 1); + expect(wrapper.state().currentMonth.month()).to.equal(today.clone().subtract(1, 'month').month()); }); it('new visibleDays has previous month', () => { @@ -1892,7 +3520,7 @@ describe('DayPickerRangeController', () => { onFocusChange={sinon.stub()} /> )); - getModifiersSpy.reset(); + getModifiersSpy.resetHistory(); wrapper.instance().onPrevMonthClick(); expect(getModifiersSpy.callCount).to.equal(1); }); @@ -1915,6 +3543,72 @@ describe('DayPickerRangeController', () => { expect(onPrevMonthClickStub.firstCall.args[0].year()).to.equal(newMonth.year()); expect(onPrevMonthClickStub.firstCall.args[0].month()).to.equal(newMonth.month()); }); + + it('calls this.shouldDisableMonthNavigation twice', () => { + const shouldDisableMonthNavigationSpy = sinon.spy(DayPickerRangeController.prototype, 'shouldDisableMonthNavigation'); + const wrapper = shallow(( + + )); + shouldDisableMonthNavigationSpy.resetHistory(); + wrapper.instance().onPrevMonthClick(); + expect(shouldDisableMonthNavigationSpy.callCount).to.equal(2); + }); + + it('sets disablePrev and disablePrev as false on onPrevMonthClick call withouth maxDate and minDate set', () => { + const numberOfMonths = 2; + const wrapper = shallow(( + + )); + wrapper.setState({ + currentMonth: today, + }); + wrapper.instance().onPrevMonthClick(); + expect(wrapper.state().disablePrev).to.equal(false); + expect(wrapper.state().disableNext).to.equal(false); + }); + + it('sets disableNext as true when maxDate is in visible month', () => { + const numberOfMonths = 2; + const wrapper = shallow(( + + )); + wrapper.setState({ + currentMonth: today, + }); + wrapper.instance().onPrevMonthClick(); + expect(wrapper.state().disableNext).to.equal(true); + expect(wrapper.state().disablePrev).to.equal(false); + }); + + it('sets disablePrev as true when minDate is in visible month', () => { + const numberOfMonths = 2; + const wrapper = shallow(( + + )); + wrapper.setState({ + currentMonth: today, + }); + wrapper.instance().onPrevMonthClick(); + expect(wrapper.state().disableNext).to.equal(false); + expect(wrapper.state().disablePrev).to.equal(true); + }); }); describe('#onNextMonthClick', () => { @@ -1929,7 +3623,7 @@ describe('DayPickerRangeController', () => { currentMonth: today, }); wrapper.instance().onNextMonthClick(); - expect(wrapper.state().currentMonth.month()).to.equal(today.month() + 1); + expect(wrapper.state().currentMonth.month()).to.equal(today.clone().add(1, 'month').month()); }); it('new visibleDays has next month', () => { @@ -1974,7 +3668,7 @@ describe('DayPickerRangeController', () => { onFocusChange={sinon.stub()} /> )); - getModifiersSpy.reset(); + getModifiersSpy.resetHistory(); wrapper.instance().onNextMonthClick(); expect(getModifiersSpy.callCount).to.equal(1); }); @@ -2015,7 +3709,7 @@ describe('DayPickerRangeController', () => { expect(firstFocusableDay.isSame(today, 'day')).to.equal(true); }); - it('returns first day of arg month if startDate is falsey', () => { + it('returns first day of arg month if startDate is falsy', () => { sinon.stub(DayPickerRangeController.prototype, 'isBlocked').returns(false); const wrapper = shallow(( { }); describe('focusedInput === END_DATE', () => { - it('returns endDate if exists and is not blocked and startDate is falsey', () => { + it('returns endDate if exists and is not blocked and startDate is falsy', () => { sinon.stub(DayPickerRangeController.prototype, 'isBlocked').returns(false); const endDate = moment().add(10, 'days'); const wrapper = shallow(( @@ -2065,7 +3759,7 @@ describe('DayPickerRangeController', () => { expect(firstFocusableDay.isSame(startDate.clone().add(minimumNights, 'days'), 'day')).to.equal(true); }); - it('returns first day of arg month if startDate and endDate are falsey', () => { + it('returns first day of arg month if startDate and endDate are falsy', () => { sinon.stub(DayPickerRangeController.prototype, 'isBlocked').returns(false); const wrapper = shallow(( { }); }); + it('time is a noon', () => { + sinon.stub(DayPickerRangeController.prototype, 'isBlocked').returns(false); + const wrapper = shallow(( + + )); + const firstFocusableDay = wrapper.instance().getFirstFocusableDay(today); + expect(firstFocusableDay.hours()).to.equal(12); + }); + describe('desired day is blocked', () => { it('returns next unblocked visible day after desired day if exists', () => { const isBlockedStub = sinon.stub(DayPickerRangeController.prototype, 'isBlocked'); @@ -2095,7 +3804,7 @@ describe('DayPickerRangeController', () => { onDatesChange={sinon.stub()} /> )); - isBlockedStub.reset(); + isBlockedStub.resetHistory(); isBlockedStub.returns(true).onCall(8).returns(false); const firstFocusableDay = wrapper.instance().getFirstFocusableDay(today); @@ -2132,7 +3841,7 @@ describe('DayPickerRangeController', () => { onFocusChange={sinon.stub()} /> )); - getModifiersForDaySpy.reset(); + getModifiersForDaySpy.resetHistory(); wrapper.instance().getModifiers(visibleDays); expect(getModifiersForDaySpy.callCount).to.equal(visibleDays[monthISO].length); @@ -2154,6 +3863,8 @@ describe('DayPickerRangeController', () => { sinon.stub(DayPickerRangeController.prototype, 'isHovered').returns(false); sinon.stub(DayPickerRangeController.prototype, 'isInHoveredSpan').returns(false); sinon.stub(DayPickerRangeController.prototype, 'isDayAfterHoveredStartDate').returns(false); + sinon.stub(DayPickerRangeController.prototype, 'isFirstDayOfWeek').returns(false); + sinon.stub(DayPickerRangeController.prototype, 'isLastDayOfWeek').returns(false); const wrapper = shallow(( { expect(Object.keys(modifiers)).to.contain(toISOMonthString(today)); }); + it('is resilient when visibleDays is an empty object', () => { + const wrapper = shallow(( + + )); + wrapper.instance().setState({ visibleDays: {} }); + const modifiers = wrapper.instance().addModifier({}, today); + expect(Object.keys(modifiers[toISOMonthString(today)])).to.contain(toISODateString(today)); + }); + it('has day ISO as key one layer down', () => { const wrapper = shallow(( { expect(Object.keys(modifiers[toISOMonthString(today)])).to.contain(toISODateString(today)); }); - it('return value no longer has modifier arg for day if was in first arg', () => { + it('return value now has modifier arg for day if was in first arg', () => { const modifierToAdd = 'foo'; const monthISO = toISOMonthString(today); const todayISO = toISODateString(today); @@ -2403,7 +4126,7 @@ describe('DayPickerRangeController', () => { expect(Array.from(modifiers[monthISO][todayISO])).to.contain(modifierToAdd); }); - it('return value no longer has modifier arg for day if was in state', () => { + it('return value now has modifier arg for day if was in state', () => { const modifierToAdd = 'foo'; const monthISO = toISOMonthString(today); const todayISO = toISODateString(today); @@ -2421,6 +4144,80 @@ describe('DayPickerRangeController', () => { const modifiers = wrapper.instance().addModifier({}, today, modifierToAdd); expect(Array.from(modifiers[monthISO][todayISO])).to.contain(modifierToAdd); }); + + it('return new modifier if vertically scrollable load more months', () => { + const modifierToAdd = 'foo'; + const numberOfMonths = 2; + const nextMonth = today.clone().add(numberOfMonths, 'month'); + const nextMonthISO = toISOMonthString(nextMonth); + const nextMonthDayISO = toISODateString(nextMonth); + const updatedDays = { + [nextMonthISO]: { [nextMonthDayISO]: new Set(['bar', 'baz']) }, + }; + const wrapper = shallow(( + + )); + wrapper.setState({ + currentMonth: today, + visibleDays: { + ...getVisibleDays(today, numberOfMonths), + ...getVisibleDays(nextMonth, numberOfMonths), + }, + }); + const modifiers = wrapper.instance().addModifier(updatedDays, nextMonth, modifierToAdd); + expect(Array.from(modifiers[nextMonthISO][nextMonthDayISO])).to.contain(modifierToAdd); + }); + }); + + it('return value now has modifier arg for day after getting next scrollable months', () => { + const modifierToAdd = 'foo'; + const futureDateAfterMultiply = today.clone().add(4, 'months'); + const monthISO = toISOMonthString(futureDateAfterMultiply); + const todayISO = toISODateString(futureDateAfterMultiply); + const updatedDays = { + [monthISO]: { [todayISO]: new Set(['bar', 'baz']) }, + }; + const wrapper = shallow(( + + )).instance(); + let modifiers = wrapper.addModifier(updatedDays, futureDateAfterMultiply, modifierToAdd); + expect(Array.from(modifiers[monthISO][todayISO])).to.not.contain(modifierToAdd); + wrapper.onGetNextScrollableMonths(); + modifiers = wrapper.addModifier(updatedDays, futureDateAfterMultiply, modifierToAdd); + expect(Array.from(modifiers[monthISO][todayISO])).to.contain(modifierToAdd); + }); + + it('return value now has modifier arg for day after getting previous scrollable months', () => { + const modifierToAdd = 'foo'; + const pastDateAfterMultiply = today.clone().subtract(3, 'months'); + const monthISO = toISOMonthString(pastDateAfterMultiply); + const dayISO = toISODateString(pastDateAfterMultiply); + const updatedDays = { + [monthISO]: { [dayISO]: new Set(['bar', 'baz']) }, + }; + const wrapper = shallow(( + + )).instance(); + let modifiers = wrapper.addModifier(updatedDays, pastDateAfterMultiply, modifierToAdd); + expect(Array.from(modifiers[monthISO][dayISO])).to.not.contain(modifierToAdd); + wrapper.onGetPrevScrollableMonths(); + modifiers = wrapper.addModifier(updatedDays, pastDateAfterMultiply, modifierToAdd); + expect(Array.from(modifiers[monthISO][dayISO])).to.contain(modifierToAdd); }); describe('#addModifierToRange', () => { @@ -2507,8 +4304,14 @@ describe('DayPickerRangeController', () => { onFocusChange={sinon.stub()} /> )); - const modifiers = wrapper.instance().deleteModifier({}, today); - expect(Object.keys(modifiers)).to.contain(toISOMonthString(today)); + + const isoMonth = toISOMonthString(today); + const isoDate = toISODateString(today); + const modifiers = wrapper.instance() + .deleteModifier({ [isoMonth]: { [isoDate]: new Set(['foo']) } }, today, 'foo'); + + expect(Object.keys(modifiers)).to.contain(isoMonth); + expect(modifiers[isoMonth][isoDate].size).to.equal(0); }); it('has day ISO as key one layer down', () => { @@ -2522,6 +4325,17 @@ describe('DayPickerRangeController', () => { expect(Object.keys(modifiers[toISOMonthString(today)])).to.contain(toISODateString(today)); }); + it('is resilient when visibleDays is an empty object', () => { + const wrapper = shallow(( + + )); + wrapper.instance().setState({ visibleDays: {} }); + expect(() => { wrapper.instance().deleteModifier({}, today); }).to.not.throw(); + }); + it('return value no longer has modifier arg for day if was in first arg', () => { const modifierToDelete = 'foo'; const monthISO = toISOMonthString(today); @@ -2557,6 +4371,34 @@ describe('DayPickerRangeController', () => { const modifiers = wrapper.instance().deleteModifier({}, today, modifierToDelete); expect(Array.from(modifiers[monthISO][todayISO])).to.not.contain(modifierToDelete); }); + + it('return new modifier if vertically scrollable load more months', () => { + const modifierToDelete = 'foo'; + const numberOfMonths = 2; + const nextMonth = today.clone().add(numberOfMonths, 'month'); + const nextMonthISO = toISOMonthString(nextMonth); + const nextMonthDayISO = toISODateString(nextMonth); + const updatedDays = { + [nextMonthISO]: { [nextMonthDayISO]: new Set(['foo', 'bar', 'baz']) }, + }; + const wrapper = shallow(( + + )); + wrapper.setState({ + currentMonth: today, + visibleDays: { + ...getVisibleDays(today, numberOfMonths), + ...getVisibleDays(nextMonth, numberOfMonths), + }, + }); + const modifiers = wrapper.instance().deleteModifier(updatedDays, nextMonth, modifierToDelete); + expect(Array.from(modifiers[nextMonthISO][nextMonthDayISO])).to.not.contain(modifierToDelete); + }); }); describe('#deleteModifierFromRange', () => { @@ -2658,7 +4500,7 @@ describe('DayPickerRangeController', () => { describe('props.startDate === null', () => { describe('props.focusedInput === END_DATE', () => { it('returns true if arg - props.minimumNights is outside allowed range', () => { - const isOutsideRange = day => !isInclusivelyAfterDay(day, today); + const isOutsideRange = (day) => !isInclusivelyAfterDay(day, today); const testDate = moment(today).add(MIN_NIGHTS - 1, 'days'); const wrapper = shallow( { }); it('returns false if arg - props.minimumNights is inside allowed range', () => { - const isOutsideRange = day => !isInclusivelyAfterDay(day, today); + const isOutsideRange = (day) => !isInclusivelyAfterDay(day, today); const testDate = moment(today).add(MIN_NIGHTS, 'days'); const wrapper = shallow( { expect(wrapper.instance().isDayAfterHoveredStartDate(testDate)).to.equal(true); }); - it('returns false if props.startDate is falsey', () => { + it('returns false if props.startDate is falsy', () => { const testDate = moment(today).add(1, 'days'); const wrapper = shallow(); wrapper.setState({ @@ -2770,7 +4612,7 @@ describe('DayPickerRangeController', () => { }); describe('#isHovered', () => { - it('returns false if focusedInput is falsey', () => { + it('returns false if focusedInput is falsy', () => { const wrapper = shallow(); wrapper.setState({ hoverDate: today, @@ -2901,6 +4743,26 @@ describe('DayPickerRangeController', () => { expect(wrapper.instance().isInSelectedSpan(testDate)).to.equal(true); }); + it('returns false if arg = props.startDate && arg < 12', () => { + const endDate = moment(today).add(5, 'days'); + const wrapper = shallow(); + const testDate = moment(today.hours(10)); + expect(wrapper.instance().isInSelectedSpan(testDate)).to.equal(false); + }); + + it('returns false if arg = props.startDate && arg > 12', () => { + const endDate = moment(today).add(5, 'days'); + const wrapper = shallow(); + const testDate = moment(today.hours(16)); + expect(wrapper.instance().isInSelectedSpan(testDate)).to.equal(false); + }); + it('returns false if arg < props.startDate', () => { const endDate = moment(today).add(5, 'days'); const wrapper = shallow( { />); expect(wrapper.instance().isBlocked(today)).to.equal(false); }); + + it('returns false if arg does not meet minimum nights but blockDaysViolatingMinNights is false', () => { + isDayBlockedStub.returns(false); + isOutsideRangeStub.returns(false); + doesNotMeetMinimumNightsStub.returns(true); + + const wrapper = shallow(); + expect(wrapper.instance().isBlocked(today, false)).to.equal(false); + }); }); describe('#isToday', () => { @@ -3059,5 +4933,140 @@ describe('DayPickerRangeController', () => { expect(wrapper.instance().isToday(moment(today).subtract(1, 'months'))).to.equal(false); }); }); + + describe('#isFirstDayOfWeek', () => { + it('returns true if first day of this week', () => { + const wrapper = shallow(); + expect(wrapper.instance().isFirstDayOfWeek(moment().startOf('week'))).to.equal(true); + }); + + it('returns true if same day as firstDayOfWeek prop', () => { + const firstDayOfWeek = 3; + const wrapper = shallow(); + expect(wrapper.instance().isFirstDayOfWeek(moment().startOf('week').day(firstDayOfWeek))).to.equal(true); + }); + + it('returns true if first day of week and prop are both zero', () => { + const firstDayOfWeek = 0; + const wrapper = shallow(); + expect(wrapper.instance().isFirstDayOfWeek(moment().startOf('week').day(firstDayOfWeek))).to.equal(true); + }); + + it('returns true if first day of week is not zero, and prop is zero', () => { + sinon.stub(moment.localeData(), 'firstDayOfWeek').returns(1); + const firstDayOfWeek = 0; + const wrapper = shallow(); + expect(wrapper.instance().isFirstDayOfWeek(moment().startOf('week').day(firstDayOfWeek))).to.equal(true); + }); + + it('returns false if not the first day of the week', () => { + const wrapper = shallow(); + expect(wrapper.instance().isFirstDayOfWeek(moment().endOf('week'))).to.equal(false); + }); + }); + + describe('#isLastDayOfWeek', () => { + it('returns true if last day of week', () => { + const wrapper = shallow(); + expect(wrapper.instance().isLastDayOfWeek(moment().endOf('week'))).to.equal(true); + }); + + it('returns true if 6 days after firstDayOfWeek prop', () => { + const firstDayOfWeek = 3; + const wrapper = shallow(); + expect(wrapper.instance().isLastDayOfWeek(moment().day(firstDayOfWeek).add(6, 'days'))).to.equal(true); + }); + + it('returns false if not last of week', () => { + const wrapper = shallow(); + expect(wrapper.instance().isLastDayOfWeek(moment().startOf('week').add(1, 'day'))).to.equal(false); + }); + }); + + describe('#beforeSelectedEnd', () => { + it('returns true if day is before end date', () => { + const endDate = today; + const dayBeforeEndDate = endDate.clone().subtract(1, 'days'); + const wrapper = shallow(); + expect(wrapper.instance().beforeSelectedEnd(dayBeforeEndDate)).to.equal(true); + }); + + it('returns false if day is after or equal to end date', () => { + const endDate = today; + const dayAfterEndDate = endDate.clone().add(1, 'days'); + const wrapper = shallow(); + expect(wrapper.instance().beforeSelectedEnd(dayAfterEndDate)).to.equal(false); + }); + }); + + describe('#isDayBeforeHoveredEndDate', () => { + it('returns false if day is after hovered end date', () => { + const endDate = today; + const dayAfterEndDate = endDate.clone().add(1, 'days'); + const wrapper = shallow(); + wrapper.setState({ hoverDate: endDate }); + expect(wrapper.instance().isDayBeforeHoveredEndDate(dayAfterEndDate)).to.equal(false); + }); + + it('returns true if day is before hovered end date', () => { + const endDate = today; + const dayBeforeEndDate = endDate.clone().subtract(1, 'days'); + const wrapper = shallow(); + wrapper.setState({ hoverDate: endDate }); + expect(wrapper.instance().isDayBeforeHoveredEndDate(dayBeforeEndDate)).to.equal(true); + }); + }); + + describe('noNavButtons prop', () => { + it('renders navigation button', () => { + const wrapper = shallow().dive().dive(); + expect(wrapper.find(DayPickerNavigation)).to.have.lengthOf(1); + }); + + it('does not render navigation button when noNavButtons prop applied', () => { + const wrapper = shallow().dive().dive(); + expect(wrapper.find(DayPickerNavigation)).to.have.lengthOf(0); + }); + }); + + describe('renderKeyboardShortcutsButton prop', () => { + it('pass down custom button render function', () => { + const testRenderKeyboardShortcutsButton = () => {}; + const wrapper = shallow( + , + ); + const dayPicker = wrapper.find(DayPicker); + expect(dayPicker).to.have.lengthOf(1); + expect(dayPicker.prop('renderKeyboardShortcutsButton')) + .to + .eql(testRenderKeyboardShortcutsButton); + }); + }); + + describe('renderKeyboardShortcutsPanel prop', () => { + it('passes down custom panel render function', () => { + const testRenderKeyboardShortcutsPanel = () => {}; + const wrapper = shallow( + , + ); + const dayPicker = wrapper.find(DayPicker); + expect(dayPicker).to.have.lengthOf(1); + expect(dayPicker.prop('renderKeyboardShortcutsPanel')) + .to + .eql(testRenderKeyboardShortcutsPanel); + }); + }); }); }); diff --git a/test/components/DayPickerSingleDateController_spec.jsx b/test/components/DayPickerSingleDateController_spec.jsx index a6bb36b559..a0ff339810 100644 --- a/test/components/DayPickerSingleDateController_spec.jsx +++ b/test/components/DayPickerSingleDateController_spec.jsx @@ -6,17 +6,20 @@ import moment from 'moment'; import DayPicker from '../../src/components/DayPicker'; import DayPickerSingleDateController from '../../src/components/DayPickerSingleDateController'; -import OutsideClickHandler from '../../src/components/OutsideClickHandler'; +import DayPickerNavigation from '../../src/components/DayPickerNavigation'; import toISODateString from '../../src/utils/toISODateString'; import toISOMonthString from '../../src/utils/toISOMonthString'; import * as isDayVisible from '../../src/utils/isDayVisible'; +import getVisibleDays from '../../src/utils/getVisibleDays'; + +import { VERTICAL_SCROLLABLE } from '../../src/constants'; // Set to noon to mimic how days in the picker are configured internally const today = moment().startOf('day').hours(12); function getCallsByModifier(stub, modifier) { - return stub.getCalls().filter(call => call.args[call.args.length - 1] === modifier); + return stub.getCalls().filter((call) => call.args[call.args.length - 1] === modifier); } describe('DayPickerSingleDateController', () => { @@ -44,6 +47,32 @@ describe('DayPickerSingleDateController', () => { onFocusChange() {}, }; + describe('props.date changed', () => { + describe('date is not visible', () => { + it('setState gets called with new month', () => { + sinon.stub(isDayVisible, 'default').returns(false); + const date = today; + const newDate = moment().add(1, 'month'); + const wrapper = shallow(); + expect(wrapper.state()).to.have.property('currentMonth', date); + wrapper.instance().componentWillReceiveProps({ ...props, date: newDate }); + expect(wrapper.state()).to.have.property('currentMonth', newDate); + }); + }); + + describe('date is visible', () => { + it('setState gets called with existing month', () => { + sinon.stub(isDayVisible, 'default').returns(true); + const date = today; + const newDate = moment().add(1, 'month'); + const wrapper = shallow(); + expect(wrapper.state()).to.have.property('currentMonth', date); + wrapper.instance().componentWillReceiveProps({ ...props, date: newDate }); + expect(wrapper.state()).to.have.property('currentMonth', date); + }); + }); + }); + describe('modifiers', () => { describe('selected modifier', () => { describe('props.date did not change', () => { @@ -94,7 +123,7 @@ describe('DayPickerSingleDateController', () => { it('does not call isBlocked', () => { const isBlockedStub = sinon.stub(DayPickerSingleDateController.prototype, 'isBlocked'); const wrapper = shallow(); - isBlockedStub.reset(); + isBlockedStub.resetHistory(); wrapper.instance().componentWillReceiveProps({ ...props, }); @@ -109,9 +138,9 @@ describe('DayPickerSingleDateController', () => { const startOfMonth = today.clone().startOf('month'); visibleDays = { [toISOMonthString(startOfMonth)]: { - [toISODateString(startOfMonth)]: [], - [toISODateString(startOfMonth.clone().add(1, 'day'))]: [], - [toISODateString(startOfMonth.clone().add(2, 'days'))]: [], + [toISODateString(startOfMonth)]: new Set(), + [toISODateString(startOfMonth.clone().add(1, 'day'))]: new Set(), + [toISODateString(startOfMonth.clone().add(2, 'days'))]: new Set(), }, }; }); @@ -120,7 +149,7 @@ describe('DayPickerSingleDateController', () => { const isBlockedStub = sinon.stub(DayPickerSingleDateController.prototype, 'isBlocked'); const wrapper = shallow(); wrapper.setState({ visibleDays }); - isBlockedStub.reset(); + isBlockedStub.resetHistory(); wrapper.instance().componentWillReceiveProps({ ...props, focused: true, @@ -192,9 +221,9 @@ describe('DayPickerSingleDateController', () => { const startOfMonth = today.clone().startOf('month'); visibleDays = { [toISOMonthString(startOfMonth)]: { - [toISODateString(startOfMonth)]: [], - [toISODateString(startOfMonth.clone().add(1, 'day'))]: [], - [toISODateString(startOfMonth.clone().add(2, 'days'))]: [], + [toISODateString(startOfMonth)]: new Set(), + [toISODateString(startOfMonth.clone().add(1, 'day'))]: new Set(), + [toISODateString(startOfMonth.clone().add(2, 'days'))]: new Set(), }, }; }); @@ -287,9 +316,9 @@ describe('DayPickerSingleDateController', () => { const startOfMonth = today.clone().startOf('month'); visibleDays = { [toISOMonthString(startOfMonth)]: { - [toISODateString(startOfMonth)]: [], - [toISODateString(startOfMonth.clone().add(1, 'day'))]: [], - [toISODateString(startOfMonth.clone().add(2, 'days'))]: [], + [toISODateString(startOfMonth)]: new Set(), + [toISODateString(startOfMonth.clone().add(1, 'day'))]: new Set(), + [toISODateString(startOfMonth.clone().add(2, 'days'))]: new Set(), }, }; }); @@ -370,9 +399,9 @@ describe('DayPickerSingleDateController', () => { const startOfMonth = today.clone().startOf('month'); visibleDays = { [toISOMonthString(startOfMonth)]: { - [toISODateString(startOfMonth)]: [], - [toISODateString(startOfMonth.clone().add(1, 'day'))]: [], - [toISODateString(startOfMonth.clone().add(2, 'days'))]: [], + [toISODateString(startOfMonth)]: new Set(), + [toISODateString(startOfMonth.clone().add(1, 'day'))]: new Set(), + [toISODateString(startOfMonth.clone().add(2, 'days'))]: new Set(), }, }; }); @@ -536,6 +565,40 @@ describe('DayPickerSingleDateController', () => { expect(onDateChangeStub.callCount).to.equal(1); }); + it('props.onDateChange receives undefined when day selected', () => { + const date = moment(); + const onDateChangeStub = sinon.stub(); + const wrapper = shallow(( + {}} + date={date} + allowUnselect + /> + )); + // Click same day as the provided date. + wrapper.instance().onDayClick(date); + expect(onDateChangeStub.callCount).to.equal(1); + expect(onDateChangeStub.getCall(0).args[0]).to.equal(undefined); + }); + + it('props.onDateChange receives day when allowUnselect is disabled', () => { + const date = moment(); + const onDateChangeStub = sinon.stub(); + const wrapper = shallow(( + {}} + date={date} + allowUnselect={false} + /> + )); + // Click same day as the provided date. + wrapper.instance().onDayClick(date); + expect(onDateChangeStub.callCount).to.equal(1); + expect(onDateChangeStub.getCall(0).args[0]).to.equal(date); + }); + describe('props.keepOpenOnDateSelect is false', () => { it('props.onFocusChange is called', () => { const onFocusChangeStub = sinon.stub(); @@ -620,7 +683,7 @@ describe('DayPickerSingleDateController', () => { wrapper.setState({ hoverDate: null, }); - addModifierSpy.reset(); + addModifierSpy.resetHistory(); wrapper.instance().onDayMouseEnter(today); expect(addModifierSpy.callCount).to.equal(1); expect(addModifierSpy.getCall(0).args[1]).to.equal(today); @@ -638,7 +701,7 @@ describe('DayPickerSingleDateController', () => { wrapper.setState({ hoverDate: today, }); - deleteModifierSpy.reset(); + deleteModifierSpy.resetHistory(); wrapper.instance().onDayMouseEnter(moment().add(10, 'days')); expect(deleteModifierSpy.callCount).to.equal(1); expect(deleteModifierSpy.getCall(0).args[1]).to.equal(today); @@ -670,7 +733,7 @@ describe('DayPickerSingleDateController', () => { wrapper.setState({ hoverDate: today, }); - deleteModifierSpy.reset(); + deleteModifierSpy.resetHistory(); wrapper.instance().onDayMouseLeave(today); expect(deleteModifierSpy.callCount).to.equal(1); expect(deleteModifierSpy.getCall(0).args[1]).to.equal(today); @@ -690,7 +753,7 @@ describe('DayPickerSingleDateController', () => { currentMonth: today, }); wrapper.instance().onPrevMonthClick(); - expect(wrapper.state().currentMonth.month()).to.equal(today.month() - 1); + expect(wrapper.state().currentMonth.month()).to.equal(today.clone().subtract(1, 'month').month()); }); it('new visibleDays has previous month', () => { @@ -736,7 +799,7 @@ describe('DayPickerSingleDateController', () => { onFocusChange={sinon.stub()} /> )); - getModifiersSpy.reset(); + getModifiersSpy.resetHistory(); wrapper.instance().onPrevMonthClick(); expect(getModifiersSpy.callCount).to.equal(1); }); @@ -759,6 +822,72 @@ describe('DayPickerSingleDateController', () => { expect(onPrevMonthClickStub.firstCall.args[0].year()).to.equal(newMonth.year()); expect(onPrevMonthClickStub.firstCall.args[0].month()).to.equal(newMonth.month()); }); + + it('calls this.shouldDisableMonthNavigation twice', () => { + const shouldDisableMonthNavigationSpy = sinon.spy(DayPickerSingleDateController.prototype, 'shouldDisableMonthNavigation'); + const wrapper = shallow(( + + )); + shouldDisableMonthNavigationSpy.resetHistory(); + wrapper.instance().onPrevMonthClick(); + expect(shouldDisableMonthNavigationSpy).to.have.property('callCount', 2); + }); + + it('sets disablePrev and disablePrev as false on onPrevMonthClick call withouth maxDate and minDate set', () => { + const numberOfMonths = 2; + const wrapper = shallow(( + + )); + wrapper.setState({ + currentMonth: today, + }); + wrapper.instance().onPrevMonthClick(); + expect(wrapper.state()).to.have.property('disablePrev', false); + expect(wrapper.state()).to.have.property('disableNext', false); + }); + + it('sets disableNext as true when maxDate is in visible month', () => { + const numberOfMonths = 2; + const wrapper = shallow(( + + )); + wrapper.setState({ + currentMonth: today, + }); + wrapper.instance().onPrevMonthClick(); + expect(wrapper.state()).to.have.property('disablePrev', false); + expect(wrapper.state()).to.have.property('disableNext', true); + }); + + it('sets disablePrev as true when minDate is in visible month', () => { + const numberOfMonths = 2; + const wrapper = shallow(( + + )); + wrapper.setState({ + currentMonth: today, + }); + wrapper.instance().onPrevMonthClick(); + expect(wrapper.state()).to.have.property('disablePrev', true); + expect(wrapper.state()).to.have.property('disableNext', false); + }); }); describe('#onNextMonthClick', () => { @@ -773,7 +902,7 @@ describe('DayPickerSingleDateController', () => { currentMonth: today, }); wrapper.instance().onNextMonthClick(); - expect(wrapper.state().currentMonth.month()).to.equal(today.month() + 1); + expect(wrapper.state().currentMonth.month()).to.equal(today.clone().add(1, 'month').month()); }); it('new visibleDays has next month', () => { @@ -818,7 +947,7 @@ describe('DayPickerSingleDateController', () => { onFocusChange={sinon.stub()} /> )); - getModifiersSpy.reset(); + getModifiersSpy.resetHistory(); wrapper.instance().onNextMonthClick(); expect(getModifiersSpy.callCount).to.equal(1); }); @@ -844,7 +973,7 @@ describe('DayPickerSingleDateController', () => { }); describe('#getFirstFocusableDay', () => { - it('returns first day of arg month if not blocked and props.date is falsey', () => { + it('returns first day of arg month if not blocked and props.date is falsy', () => { sinon.stub(DayPickerSingleDateController.prototype, 'isBlocked').returns(false); const wrapper = shallow(( { expect(firstFocusableDay.isSame(date, 'day')).to.equal(true); }); + it('time is a noon', () => { + sinon.stub(DayPickerSingleDateController.prototype, 'isBlocked').returns(false); + const wrapper = shallow(( + + )); + const firstFocusableDay = wrapper.instance().getFirstFocusableDay(today); + expect(firstFocusableDay.hours()).to.equal(12); + }); + describe('desired date is blocked', () => { it('returns first unblocked visible day if exists', () => { const isBlockedStub = sinon.stub(DayPickerSingleDateController.prototype, 'isBlocked'); @@ -883,7 +1025,7 @@ describe('DayPickerSingleDateController', () => { /> )); - isBlockedStub.reset(); + isBlockedStub.resetHistory(); isBlockedStub.returns(true); isBlockedStub.onCall(8).returns(false); const firstFocusableDay = wrapper.instance().getFirstFocusableDay(today); @@ -920,7 +1062,7 @@ describe('DayPickerSingleDateController', () => { onFocusChange={sinon.stub()} /> )); - getModifiersForDaySpy.reset(); + getModifiersForDaySpy.resetHistory(); wrapper.instance().getModifiers(visibleDays); expect(getModifiersForDaySpy.callCount).to.equal(visibleDays[monthISO].length); @@ -936,6 +1078,8 @@ describe('DayPickerSingleDateController', () => { const isDayHighlightedStub = sinon.stub().returns(false); sinon.stub(DayPickerSingleDateController.prototype, 'isSelected').returns(false); sinon.stub(DayPickerSingleDateController.prototype, 'isHovered').returns(false); + sinon.stub(DayPickerSingleDateController.prototype, 'isFirstDayOfWeek').returns(false); + sinon.stub(DayPickerSingleDateController.prototype, 'isLastDayOfWeek').returns(false); const wrapper = shallow(( { onFocusChange={sinon.stub()} /> )); - const modifiers = wrapper.instance().addModifier({}, today); + const modifiers = wrapper.instance().addModifier({}, today, 'foo'); expect(Object.keys(modifiers)).to.contain(toISOMonthString(today)); }); @@ -1098,6 +1242,18 @@ describe('DayPickerSingleDateController', () => { expect(Object.keys(modifiers[toISOMonthString(today)])).to.contain(toISODateString(today)); }); + it('is resilient when visibleDays is an empty object', () => { + const wrapper = shallow(( + + )); + wrapper.instance().setState({ visibleDays: {} }); + const modifiers = wrapper.instance().addModifier({}, today); + expect(Object.keys(modifiers[toISOMonthString(today)])).to.contain(toISODateString(today)); + }); + it('return value no longer has modifier arg for day if was in first arg', () => { const modifierToAdd = 'foo'; const monthISO = toISOMonthString(today); @@ -1133,6 +1289,82 @@ describe('DayPickerSingleDateController', () => { const modifiers = wrapper.instance().addModifier({}, today, modifierToAdd); expect(Array.from(modifiers[monthISO][todayISO])).to.contain(modifierToAdd); }); + + it('return new modifier if vertically scrollable load more months', () => { + const modifierToAdd = 'foo'; + const numberOfMonths = 2; + const nextMonth = today.clone().add(numberOfMonths, 'month'); + const nextMonthISO = toISOMonthString(nextMonth); + const nextMonthDayISO = toISODateString(nextMonth); + const updatedDays = { + [nextMonthISO]: { [nextMonthDayISO]: new Set(['bar', 'baz']) }, + }; + const wrapper = shallow(( + + )); + wrapper.setState({ + currentMonth: today, + visibleDays: { + ...getVisibleDays(today, numberOfMonths), + ...getVisibleDays(nextMonth, numberOfMonths), + }, + }); + const modifiers = wrapper.instance().addModifier(updatedDays, nextMonth, modifierToAdd); + expect(Array.from(modifiers[nextMonthISO][nextMonthDayISO])).to.contain(modifierToAdd); + }); + + it('return value now has modifier arg for day after getting next scrollable months', () => { + const modifierToAdd = 'foo'; + const numberOfMonths = 2; + const nextMonth = today.clone().add(numberOfMonths, 'month'); + const nextMonthISO = toISOMonthString(nextMonth); + const nextMonthDayISO = toISODateString(nextMonth); + const updatedDays = { + [nextMonthISO]: { [nextMonthDayISO]: new Set(['bar', 'baz']) }, + }; + const wrapper = shallow(( + + )).instance(); + let modifiers = wrapper.addModifier(updatedDays, nextMonth, modifierToAdd); + expect(Array.from(modifiers[nextMonthISO][nextMonthDayISO])).to.not.contain(modifierToAdd); + wrapper.onGetNextScrollableMonths(); + modifiers = wrapper.addModifier(updatedDays, nextMonth, modifierToAdd); + expect(Array.from(modifiers[nextMonthISO][nextMonthDayISO])).to.contain(modifierToAdd); + }); + + it('return value now has modifier arg for day after getting previous scrollable months', () => { + const modifierToAdd = 'foo'; + const numberOfMonths = 2; + const pastDateAfterMultiply = today.clone().subtract(numberOfMonths, 'months'); + const monthISO = toISOMonthString(pastDateAfterMultiply); + const dayISO = toISODateString(pastDateAfterMultiply); + const updatedDays = { + [monthISO]: { [dayISO]: new Set(['bar', 'baz']) }, + }; + const wrapper = shallow(( + + )).instance(); + let modifiers = wrapper.addModifier(updatedDays, pastDateAfterMultiply, modifierToAdd); + expect(Array.from(modifiers[monthISO][dayISO])).to.not.contain(modifierToAdd); + wrapper.onGetPrevScrollableMonths(); + modifiers = wrapper.addModifier(updatedDays, pastDateAfterMultiply, modifierToAdd); + expect(Array.from(modifiers[monthISO][dayISO])).to.contain(modifierToAdd); + }); }); describe('#deleteModifier', () => { @@ -1168,8 +1400,14 @@ describe('DayPickerSingleDateController', () => { onFocusChange={sinon.stub()} /> )); - const modifiers = wrapper.instance().deleteModifier({}, today); - expect(Object.keys(modifiers)).to.contain(toISOMonthString(today)); + + const isoMonth = toISOMonthString(today); + const isoDate = toISODateString(today); + const modifiers = wrapper.instance() + .deleteModifier({ [isoMonth]: { [isoDate]: new Set(['foo']) } }, today, 'foo'); + + expect(Object.keys(modifiers)).to.contain(isoMonth); + expect(modifiers[isoMonth][isoDate].size).to.equal(0); }); it('has day ISO as key one layer down', () => { @@ -1183,6 +1421,17 @@ describe('DayPickerSingleDateController', () => { expect(Object.keys(modifiers[toISOMonthString(today)])).to.contain(toISODateString(today)); }); + it('is resilient when visibleDays is an empty object', () => { + const wrapper = shallow(( + + )); + wrapper.instance().setState({ visibleDays: {} }); + expect(() => { wrapper.instance().deleteModifier({}, today); }).to.not.throw(); + }); + it('return value no longer has modifier arg for day if was in first arg', () => { const modifierToDelete = 'foo'; const monthISO = toISOMonthString(today); @@ -1218,6 +1467,34 @@ describe('DayPickerSingleDateController', () => { const modifiers = wrapper.instance().deleteModifier({}, today, modifierToDelete); expect(Array.from(modifiers[monthISO][todayISO])).to.not.contain(modifierToDelete); }); + + it('return new modifier if vertically scrollable load more months', () => { + const modifierToDelete = 'foo'; + const numberOfMonths = 2; + const nextMonth = today.clone().add(numberOfMonths, 'month'); + const nextMonthISO = toISOMonthString(nextMonth); + const nextMonthDayISO = toISODateString(nextMonth); + const updatedDays = { + [nextMonthISO]: { [nextMonthDayISO]: new Set(['foo', 'bar', 'baz']) }, + }; + const wrapper = shallow(( + + )); + wrapper.setState({ + currentMonth: today, + visibleDays: { + ...getVisibleDays(today, numberOfMonths), + ...getVisibleDays(nextMonth, numberOfMonths), + }, + }); + const modifiers = wrapper.instance().deleteModifier(updatedDays, nextMonth, modifierToDelete); + expect(Array.from(modifiers[nextMonthISO][nextMonthDayISO])).to.not.contain(modifierToDelete); + }); }); describe('modifiers', () => { @@ -1344,6 +1621,55 @@ describe('DayPickerSingleDateController', () => { expect(wrapper.instance().isToday(moment(today).subtract(1, 'months'))).to.equal(false); }); }); + + describe('#isFirstDayOfWeek', () => { + it('returns true if first day of this week', () => { + const wrapper = shallow(); + expect(wrapper.instance().isFirstDayOfWeek(moment().startOf('week'))).to.equal(true); + }); + + it('returns true if same day as firstDayOfWeek prop', () => { + const firstDayOfWeek = 3; + const wrapper = shallow(); + expect(wrapper.instance().isFirstDayOfWeek(moment().startOf('week').day(firstDayOfWeek))).to.equal(true); + }); + + it('returns true if first day of week and prop are both zero', () => { + const firstDayOfWeek = 0; + const wrapper = shallow(); + expect(wrapper.instance().isFirstDayOfWeek(moment().startOf('week').day(firstDayOfWeek))).to.equal(true); + }); + + it('returns true if first day of week is not zero, and prop is zero', () => { + sinon.stub(moment.localeData(), 'firstDayOfWeek').returns(1); + const firstDayOfWeek = 0; + const wrapper = shallow(); + expect(wrapper.instance().isFirstDayOfWeek(moment().startOf('week').day(firstDayOfWeek))).to.equal(true); + }); + + it('returns false if not the first day of the week', () => { + const wrapper = shallow(); + expect(wrapper.instance().isFirstDayOfWeek(moment().endOf('week'))).to.equal(false); + }); + }); + + describe('#isLastDayOfWeek', () => { + it('returns true if last day of week', () => { + const wrapper = shallow(); + expect(wrapper.instance().isLastDayOfWeek(moment().endOf('week'))).to.equal(true); + }); + + it('returns true if 6 days after firstDayOfWeek prop', () => { + const firstDayOfWeek = 3; + const wrapper = shallow(); + expect(wrapper.instance().isLastDayOfWeek(moment().day(firstDayOfWeek).add(6, 'days'))).to.equal(true); + }); + + it('returns false if not last of week', () => { + const wrapper = shallow(); + expect(wrapper.instance().isLastDayOfWeek(moment().startOf('week').add(1, 'day'))).to.equal(false); + }); + }); }); describe('initialVisibleMonth', () => { @@ -1391,17 +1717,17 @@ describe('DayPickerSingleDateController', () => { expect(dayPicker.props().initialVisibleMonth().isSame(today, 'day')).to.equal(true); }); }); - }); - describe('onOutsideClick', () => { - it('should render OutsideClickHandler as it has onOutsideClick prop', () => { - const wrapper = shallow( null} />); - expect(wrapper.find(OutsideClickHandler)).to.have.lengthOf(1); - }); + describe('noNavButtons prop', () => { + it('renders navigation button', () => { + const wrapper = shallow().dive().dive(); + expect(wrapper.find(DayPickerNavigation)).to.have.lengthOf(1); + }); - it('should NOT render OutsideClickHandler without onOutsideClick prop', () => { - const wrapper = shallow(); - expect(wrapper.find(OutsideClickHandler)).to.have.lengthOf(0); + it('does not render navigation button when noNavButtons prop applied', () => { + const wrapper = shallow().dive().dive(); + expect(wrapper.find(DayPickerNavigation)).to.have.lengthOf(0); + }); }); }); }); diff --git a/test/components/DayPicker_spec.jsx b/test/components/DayPicker_spec.jsx index 0402562477..be120be3c6 100644 --- a/test/components/DayPicker_spec.jsx +++ b/test/components/DayPicker_spec.jsx @@ -1,28 +1,32 @@ import React from 'react'; -import moment from 'moment'; +import moment from 'moment/min/moment-with-locales'; import { expect } from 'chai'; import sinon from 'sinon-sandbox'; import { mount, shallow } from 'enzyme'; +import { cloneDeep } from 'lodash'; import * as isDayVisible from '../../src/utils/isDayVisible'; +import isSameMonth from '../../src/utils/isSameMonth'; -import DayPicker, { PureDayPicker } from '../../src/components/DayPicker'; +import DayPicker, { PureDayPicker, defaultProps as DayPickerDefaultProps } from '../../src/components/DayPicker'; import CalendarMonthGrid from '../../src/components/CalendarMonthGrid'; import DayPickerNavigation from '../../src/components/DayPickerNavigation'; import DayPickerKeyboardShortcuts from '../../src/components/DayPickerKeyboardShortcuts'; import { + DAY_SIZE, HORIZONTAL_ORIENTATION, VERTICAL_ORIENTATION, VERTICAL_SCROLLABLE, + NAV_POSITION_BOTTOM, } from '../../src/constants'; -const today = moment(); +const today = moment().locale('en'); const event = { preventDefault() {}, stopPropagation() {} }; describe('DayPicker', () => { + let adjustDayPickerHeightSpy; beforeEach(() => { - sinon.stub(PureDayPicker.prototype, 'adjustDayPickerHeight'); - sinon.stub(PureDayPicker.prototype, 'updateStateAfterMonthTransition'); + adjustDayPickerHeightSpy = sinon.stub(PureDayPicker.prototype, 'adjustDayPickerHeight'); }); afterEach(() => { @@ -39,6 +43,23 @@ describe('DayPicker', () => { }); }); + describe('props.renderWeekHeaderElement', () => { + it('there are 7 custom elements on each .DayPicker__week-header class', () => { + const testWeekHeaderClassName = 'test-week-header'; + const wrapper = shallow( + ({day}) + } + />, + ).dive(); + const weekHeaders = wrapper.find('.DayPicker__week-header'); + weekHeaders.forEach((weekHeader) => { + expect(weekHeader.find(`.${testWeekHeaderClassName}`)).to.have.lengthOf(7); + }); + }); + }); + describe('props.orientation === HORIZONTAL_ORIENTATION', () => { it('props.numberOfMonths ul (week header) elements exists', () => { const NUM_OF_MONTHS = 3; @@ -80,7 +101,7 @@ describe('DayPicker', () => { expect(CalendarMonthGridComponent.prop('isAnimating')).to.equal(true); }); - it('is false if state.monthTransition is falsey', () => { + it('is false if state.monthTransition is falsy', () => { const wrapper = shallow().dive(); wrapper.setState({ monthTransition: null }); const CalendarMonthGridComponent = wrapper.find(CalendarMonthGrid); @@ -89,6 +110,32 @@ describe('DayPicker', () => { }); }); + describe('DayPickerNavigation', () => { + it('is rendered before CalendarMonthGrid in DayPicker_focusRegion', () => { + const wrapper = shallow().dive(); + expect(wrapper.find(DayPickerNavigation)).to.have.lengthOf(1); + expect( + wrapper + .find('[className^="DayPicker_focusRegion"]') + .childAt(0) + .type(), + ).to.equal(DayPickerNavigation); + }); + + describe('navPosition === NAV_POSITION_BOTTOM', () => { + it('is rendered after CalendarMonthGrid in DayPicker_focusRegion', () => { + const wrapper = shallow().dive(); + expect(wrapper.find(DayPickerNavigation)).to.have.lengthOf(1); + expect( + wrapper + .find('[className^="DayPicker_focusRegion"]') + .childAt(1) + .type(), + ).to.equal(DayPickerNavigation); + }); + }); + }); + describe('DayPickerKeyboardShortcuts', () => { it('component exists if state.isTouchDevice is false and hideKeyboardShortcutsPanel is false', () => { const wrapper = shallow().dive(); @@ -106,6 +153,30 @@ describe('DayPicker', () => { const wrapper = shallow().dive(); expect(wrapper.find(DayPickerKeyboardShortcuts)).to.have.lengthOf(0); }); + + it('component exists with custom button render function if renderKeyboardShortcutsButton is passed down', () => { + const testRenderKeyboardShortcutsButton = () => {}; + const wrapper = shallow( + , + ).dive(); + const dayPickerKeyboardShortcuts = wrapper.find(DayPickerKeyboardShortcuts); + expect(dayPickerKeyboardShortcuts).to.have.lengthOf(1); + expect(dayPickerKeyboardShortcuts.prop('renderKeyboardShortcutsButton')) + .to + .eql(testRenderKeyboardShortcutsButton); + }); + + it('component exists with custom panel render function if renderKeyboardShortcutsPanel is passed down', () => { + const testRenderKeyboardShortcutsPanel = () => {}; + const wrapper = shallow( + , + ).dive(); + const dayPickerKeyboardShortcuts = wrapper.find(DayPickerKeyboardShortcuts); + expect(dayPickerKeyboardShortcuts).to.have.lengthOf(1); + expect(dayPickerKeyboardShortcuts.prop('renderKeyboardShortcutsPanel')) + .to + .eql(testRenderKeyboardShortcutsPanel); + }); }); }); @@ -140,13 +211,32 @@ describe('DayPicker', () => { }); describe('props.orientation === VERTICAL_SCROLLABLE', () => { - it('uses multiplyScrollableMonths instead of onNextMonthClick', () => { + it('renders two DayPickerNavigations', () => { + const wrapper = shallow( + , + { disableLifecycleMethods: false }, + ).dive(); + expect(wrapper.find(DayPickerNavigation)).to.have.length(2); + }); + + it('uses getNextScrollableMonths instead of onNextMonthClick', () => { const wrapper = shallow( , { disableLifecycleMethods: false }, ).dive(); - const nav = wrapper.find(DayPickerNavigation); - expect(nav.prop('onNextMonthClick')).to.equal(wrapper.instance().multiplyScrollableMonths); + expect(wrapper.find(DayPickerNavigation)).to.have.length(2); + const nav = wrapper.find(DayPickerNavigation).get(1); + expect(nav.props.onNextMonthClick).to.equal(wrapper.instance().getNextScrollableMonths); + }); + + it('uses getPrevScrollableMonths instead of onNextMonthClick', () => { + const wrapper = shallow( + , + { disableLifecycleMethods: false }, + ).dive(); + expect(wrapper.find(DayPickerNavigation)).to.have.length(2); + const nav = wrapper.find(DayPickerNavigation).get(0); + expect(nav.props.onPrevMonthClick).to.equal(wrapper.instance().getPrevScrollableMonths); }); }); @@ -207,6 +297,58 @@ describe('DayPicker', () => { const arg = maybeTransitionPrevMonthSpy.getCall(0).args[0]; expect(arg.isSame(oneDayBefore, 'day')).to.equal(true); }); + + it('arg is end of previous month', () => { + const startOfThisMonth = today.clone().startOf('month'); + const endOfPrevMonth = startOfThisMonth.clone().subtract(1, 'day'); + + const maybeTransitionPrevMonthSpy = sinon.spy(PureDayPicker.prototype, 'maybeTransitionPrevMonth'); + const wrapper = shallow().dive(); + wrapper.setState({ + focusedDate: startOfThisMonth, + }); + wrapper.instance().onKeyDown({ ...event, key: 'ArrowLeft' }); + const arg = maybeTransitionPrevMonthSpy.getCall(0).args[0]; + expect(arg.isSame(endOfPrevMonth, 'day')).to.equal(true); + }); + }); + + describe('ArrowLeft -- RTL', () => { + it('calls maybeTransitionNextMonth', () => { + const maybeTransitionNextMonthSpy = sinon.spy(PureDayPicker.prototype, 'maybeTransitionNextMonth'); + const wrapper = shallow().dive(); + wrapper.setState({ + focusedDate: today, + }); + wrapper.instance().onKeyDown({ ...event, key: 'ArrowLeft' }); + expect(maybeTransitionNextMonthSpy.callCount).to.equal(1); + }); + + it('arg is 1 day after focusedDate', () => { + const oneDayAfter = today.clone().add(1, 'day'); + const maybeTransitionNextMonthSpy = sinon.spy(PureDayPicker.prototype, 'maybeTransitionNextMonth'); + const wrapper = shallow().dive(); + wrapper.setState({ + focusedDate: today, + }); + wrapper.instance().onKeyDown({ ...event, key: 'ArrowLeft' }); + const arg = maybeTransitionNextMonthSpy.getCall(0).args[0]; + expect(arg.isSame(oneDayAfter, 'day')).to.equal(true); + }); + + it('arg is start of next month', () => { + const endOfThisMonth = today.clone().endOf('month'); + const startOfNextMonth = endOfThisMonth.clone().add(1, 'day'); + + const maybeTransitionNextMonthSpy = sinon.spy(PureDayPicker.prototype, 'maybeTransitionNextMonth'); + const wrapper = shallow().dive(); + wrapper.setState({ + focusedDate: endOfThisMonth, + }); + wrapper.instance().onKeyDown({ ...event, key: 'ArrowLeft' }); + const arg = maybeTransitionNextMonthSpy.getCall(0).args[0]; + expect(arg.isSame(startOfNextMonth, 'day')).to.equal(true); + }); }); describe('Home', () => { @@ -303,6 +445,58 @@ describe('DayPicker', () => { const arg = maybeTransitionNextMonthSpy.getCall(0).args[0]; expect(arg.isSame(oneDayAfter, 'day')).to.equal(true); }); + + it('arg is start of next month', () => { + const endOfThisMonth = today.clone().endOf('month'); + const startOfNextMonth = endOfThisMonth.clone().add(1, 'day'); + + const maybeTransitionNextMonthSpy = sinon.spy(PureDayPicker.prototype, 'maybeTransitionNextMonth'); + const wrapper = shallow().dive(); + wrapper.setState({ + focusedDate: endOfThisMonth, + }); + wrapper.instance().onKeyDown({ ...event, key: 'ArrowRight' }); + const arg = maybeTransitionNextMonthSpy.getCall(0).args[0]; + expect(arg.isSame(startOfNextMonth, 'day')).to.equal(true); + }); + }); + + describe('ArrowRight -- RTL', () => { + it('calls maybeTransitionPrevMonth', () => { + const maybeTransitionPrevMonthSpy = sinon.spy(PureDayPicker.prototype, 'maybeTransitionPrevMonth'); + const wrapper = shallow().dive(); + wrapper.setState({ + focusedDate: today, + }); + wrapper.instance().onKeyDown({ ...event, key: 'ArrowRight' }); + expect(maybeTransitionPrevMonthSpy.callCount).to.equal(1); + }); + + it('arg is 1 day before focusedDate', () => { + const oneDayBefore = today.clone().subtract(1, 'day'); + const maybeTransitionPrevMonthSpy = sinon.spy(PureDayPicker.prototype, 'maybeTransitionPrevMonth'); + const wrapper = shallow().dive(); + wrapper.setState({ + focusedDate: today, + }); + wrapper.instance().onKeyDown({ ...event, key: 'ArrowRight' }); + const arg = maybeTransitionPrevMonthSpy.getCall(0).args[0]; + expect(arg.isSame(oneDayBefore, 'day')).to.equal(true); + }); + + it('arg is end of previous month', () => { + const startOfThisMonth = today.clone().startOf('month'); + const endOfPrevMonth = startOfThisMonth.clone().subtract(1, 'day'); + + const maybeTransitionPrevMonthSpy = sinon.spy(PureDayPicker.prototype, 'maybeTransitionPrevMonth'); + const wrapper = shallow().dive(); + wrapper.setState({ + focusedDate: startOfThisMonth, + }); + wrapper.instance().onKeyDown({ ...event, key: 'ArrowRight' }); + const arg = maybeTransitionPrevMonthSpy.getCall(0).args[0]; + expect(arg.isSame(endOfPrevMonth, 'day')).to.equal(true); + }); }); describe('End', () => { @@ -398,9 +592,35 @@ describe('DayPicker', () => { expect(onBlurStub.callCount).to.equal(1); }); }); + + describe('Tab', () => { + it('triggers onShiftTab when shift tab is pressed', () => { + const onTabStub = sinon.stub(); + const onShiftTabStub = sinon.stub(); + const wrapper = shallow( + , + ).dive(); + wrapper.setState({ focusedDate: today }); + wrapper.instance().onKeyDown({ ...event, key: 'Tab', shiftKey: true }); + expect(onTabStub.callCount).to.equal(0); + expect(onShiftTabStub.callCount).to.equal(1); + }); + + it('triggers onTab', () => { + const onTabStub = sinon.stub(); + const onShiftTabStub = sinon.stub(); + const wrapper = shallow( + , + ).dive(); + wrapper.setState({ focusedDate: today }); + wrapper.instance().onKeyDown({ ...event, key: 'Tab' }); + expect(onTabStub.callCount).to.equal(1); + expect(onShiftTabStub.callCount).to.equal(0); + }); + }); }); - describe('focusedDate is falsey', () => { + describe('focusedDate is falsy', () => { it('does not call maybeTransitionPrevMonth', () => { const maybeTransitionPrevMonthSpy = sinon.spy(PureDayPicker.prototype, 'maybeTransitionPrevMonth'); const wrapper = shallow().dive(); @@ -423,32 +643,111 @@ describe('DayPicker', () => { }); }); + describe('#onMonthChange', () => { + it('sets state.monthTransition to "month_selection"', () => { + const wrapper = shallow().dive(); + const date = moment(); + wrapper.instance().onMonthChange(date); + expect(wrapper.state().monthTransition).to.equal('month_selection'); + }); + + it('sets state.nextFocusedDate to passed in date', () => { + const wrapper = shallow().dive(); + const date = moment(); + wrapper.instance().onMonthChange(date); + expect(wrapper.state().nextFocusedDate).to.equal(date); + }); + + it('sets state.currentMonth to passed in month', () => { + const wrapper = shallow().dive(); + const date = moment(); + wrapper.instance().onMonthChange(date); + expect(wrapper.state().currentMonth).to.equal(date); + }); + }); + + describe('#onYearChange', () => { + it('sets state.yearTransition to "year_selection"', () => { + const wrapper = shallow().dive(); + const date = moment(); + wrapper.instance().onYearChange(date); + expect(wrapper.state().monthTransition).to.equal('year_selection'); + }); + + it('sets state.nextFocusedDate to passed in date', () => { + const wrapper = shallow().dive(); + const date = moment(); + wrapper.instance().onYearChange(date); + expect(wrapper.state().nextFocusedDate).to.equal(date); + }); + + it('sets state.currentMonth to passed in year', () => { + const wrapper = shallow().dive(); + const date = moment(); + wrapper.instance().onYearChange(date); + expect(wrapper.state().currentMonth).to.equal(date); + }); + }); + describe('#onPrevMonthClick', () => { - it('sets state.monthTransition to "prev"', () => { + it('calls onPrevMonthTransition', () => { + const onPrevMonthTransitionSpy = sinon.spy(PureDayPicker.prototype, 'onPrevMonthTransition'); const wrapper = shallow().dive(); wrapper.instance().onPrevMonthClick(); + expect(onPrevMonthTransitionSpy.callCount).to.equal(1); + }); + }); + + describe('#onPrevMonthTransition', () => { + it('sets state.monthTransition to "prev"', () => { + const wrapper = shallow().dive(); + wrapper.instance().onPrevMonthTransition(); expect(wrapper.state().monthTransition).to.equal('prev'); }); it('sets state.nextFocusedDate to first arg', () => { const test = 'FOOBARBAZ'; const wrapper = shallow().dive(); - wrapper.instance().onPrevMonthClick(test); + wrapper.instance().onPrevMonthTransition(test); expect(wrapper.state().nextFocusedDate).to.equal(test); }); }); + describe('monthTitleHeight', () => { + it('change monthTitleHeight correctly with setMonthTitleHeight', () => { + const wrapper = shallow().dive(); + wrapper.instance().setMonthTitleHeight(80); + expect(wrapper.state().monthTitleHeight).to.equal(80); + }); + + it('do not change monthTitleHeight with renderMonthText === prevRenderMonthText', () => { + const wrapper = shallow( 'foo'} />).dive(); + wrapper.instance().setMonthTitleHeight(80); + wrapper.setProps({ renderMonthText: () => 'foo' }); + expect(wrapper.state().monthTitleHeight).to.equal(80); + }); + }); + describe('#onNextMonthClick', () => { - it('sets state.monthTransition to "next"', () => { + it('calls onNextMonthTransition', () => { + const onNextMonthTransitionSpy = sinon.spy(PureDayPicker.prototype, 'onNextMonthTransition'); const wrapper = shallow().dive(); wrapper.instance().onNextMonthClick(); + expect(onNextMonthTransitionSpy.callCount).to.equal(1); + }); + }); + + describe('#onNextMonthTransition', () => { + it('sets state.monthTransition to "next"', () => { + const wrapper = shallow().dive(); + wrapper.instance().onNextMonthTransition(); expect(wrapper.state().monthTransition).to.equal('next'); }); it('sets state.nextFocusedDate to first arg', () => { const test = 'FOOBARBAZ'; const wrapper = shallow().dive(); - wrapper.instance().onNextMonthClick(test); + wrapper.instance().onNextMonthTransition(test); expect(wrapper.state().nextFocusedDate).to.equal(test); }); }); @@ -460,7 +759,8 @@ describe('DayPicker', () => { const wrapper = shallow(( )).dive(); - getFirstFocusableDayStub.reset(); // getFirstFocusableDay gets called in the constructor + // getFirstFocusableDay gets called in the constructor + getFirstFocusableDayStub.resetHistory(); wrapper.instance().getFocusedDay(); expect(getFirstFocusableDayStub.callCount).to.equal(1); @@ -471,7 +771,8 @@ describe('DayPicker', () => { const wrapper = shallow(( )).dive(); - getFirstFocusableDayStub.reset(); // getFirstFocusableDay gets called in the constructor + // getFirstFocusableDay gets called in the constructor + getFirstFocusableDayStub.resetHistory(); wrapper.instance().getFocusedDay(today); expect(getFirstFocusableDayStub.getCall(0).args[0].isSame(today, 'day')).to.equal(true); @@ -496,7 +797,7 @@ describe('DayPicker', () => { }); }); - describe('props.getFirstFocusableDay is falsey', () => { + describe('props.getFirstFocusableDay is falsy', () => { it('returns undefined if no arg', () => { const wrapper = shallow().dive(); expect(wrapper.instance().getFocusedDay()).to.equal(undefined); @@ -512,13 +813,13 @@ describe('DayPicker', () => { describe('#maybeTransitionNextMonth', () => { describe('arg has same month as state.focusedDate', () => { - it('does not call `onNextMonthClick`', () => { - const onNextMonthClickSpy = sinon.spy(PureDayPicker.prototype, 'onNextMonthClick'); + it('does not call `onNextMonthTransition`', () => { + const onNextMonthTransitionSpy = sinon.spy(PureDayPicker.prototype, 'onNextMonthTransition'); const firstOfTodaysMonth = moment().startOf('month'); const wrapper = shallow().dive(); wrapper.state().focusedDate = firstOfTodaysMonth; wrapper.instance().maybeTransitionNextMonth(today); - expect(onNextMonthClickSpy.callCount).to.equal(0); + expect(onNextMonthTransitionSpy.callCount).to.equal(0); }); it('returns false', () => { @@ -532,13 +833,13 @@ describe('DayPicker', () => { describe('arg has different month as state.focusedDate', () => { describe('arg is visible', () => { it('does not call `onNextMonthClick`', () => { - const onNextMonthClickSpy = sinon.spy(PureDayPicker.prototype, 'onNextMonthClick'); + const onNextMonthTransitionSpy = sinon.spy(PureDayPicker.prototype, 'onNextMonthTransition'); sinon.stub(isDayVisible, 'default').returns(true); const nextMonth = moment().add(1, 'month'); const wrapper = shallow().dive(); wrapper.state().focusedDate = nextMonth; wrapper.instance().maybeTransitionNextMonth(today); - expect(onNextMonthClickSpy.callCount).to.equal(0); + expect(onNextMonthTransitionSpy.callCount).to.equal(0); }); it('returns false', () => { @@ -551,14 +852,14 @@ describe('DayPicker', () => { }); describe('arg is not visible', () => { - it('calls `onNextMonthClick`', () => { - const onNextMonthClickSpy = sinon.spy(PureDayPicker.prototype, 'onNextMonthClick'); + it('calls `onNextMonthTransition`', () => { + const onNextMonthTransitionSpy = sinon.spy(PureDayPicker.prototype, 'onNextMonthTransition'); sinon.stub(isDayVisible, 'default').returns(false); const nextMonth = moment().add(1, 'month'); const wrapper = shallow().dive(); wrapper.state().focusedDate = nextMonth; wrapper.instance().maybeTransitionNextMonth(today); - expect(onNextMonthClickSpy.callCount).to.equal(1); + expect(onNextMonthTransitionSpy.callCount).to.equal(1); }); it('returns true', () => { @@ -574,13 +875,13 @@ describe('DayPicker', () => { describe('#maybeTransitionPrevMonth', () => { describe('arg has same month as state.focusedDate', () => { - it('does not call `onPrevMonthClick`', () => { - const onPrevMonthClickSpy = sinon.spy(PureDayPicker.prototype, 'onPrevMonthClick'); + it('does not call `onPrevMonthTransition`', () => { + const onPrevMonthTransitionSpy = sinon.spy(PureDayPicker.prototype, 'onPrevMonthTransition'); const firstOfTodaysMonth = moment().startOf('month'); const wrapper = shallow().dive(); wrapper.state().focusedDate = firstOfTodaysMonth; wrapper.instance().maybeTransitionPrevMonth(today); - expect(onPrevMonthClickSpy.callCount).to.equal(0); + expect(onPrevMonthTransitionSpy.callCount).to.equal(0); }); it('returns false', () => { @@ -593,14 +894,14 @@ describe('DayPicker', () => { describe('arg has different month as state.focusedDate', () => { describe('arg is visible', () => { - it('does not call `onPrevMonthClick`', () => { - const onPrevMonthClickSpy = sinon.spy(PureDayPicker.prototype, 'onPrevMonthClick'); + it('does not call `onPrevMonthTransition`', () => { + const onPrevMonthTransitionSpy = sinon.spy(PureDayPicker.prototype, 'onPrevMonthTransition'); sinon.stub(isDayVisible, 'default').returns(true); const nextMonth = moment().add(1, 'month'); const wrapper = shallow().dive(); wrapper.state().focusedDate = nextMonth; wrapper.instance().maybeTransitionPrevMonth(today); - expect(onPrevMonthClickSpy.callCount).to.equal(0); + expect(onPrevMonthTransitionSpy.callCount).to.equal(0); }); it('returns false', () => { @@ -613,14 +914,14 @@ describe('DayPicker', () => { }); describe('arg is not visible', () => { - it('calls `onPrevMonthClick`', () => { - const onPrevMonthClickSpy = sinon.spy(PureDayPicker.prototype, 'onPrevMonthClick'); + it('calls `onPrevMonthTransition`', () => { + const onPrevMonthTransitionSpy = sinon.spy(PureDayPicker.prototype, 'onPrevMonthTransition'); sinon.stub(isDayVisible, 'default').returns(false); const nextMonth = moment().add(1, 'month'); const wrapper = shallow().dive(); wrapper.state().focusedDate = nextMonth; wrapper.instance().maybeTransitionPrevMonth(today); - expect(onPrevMonthClickSpy.callCount).to.equal(1); + expect(onPrevMonthTransitionSpy.callCount).to.equal(1); }); it('returns true', () => { @@ -634,17 +935,20 @@ describe('DayPicker', () => { }); }); - describe('#multiplyScrollableMonths', () => { + describe('#getNextScrollableMonths', () => { it('increments scrollableMonthMultiple', () => { const wrapper = shallow().dive(); - wrapper.instance().multiplyScrollableMonths(event); + wrapper.instance().getNextScrollableMonths(event); expect(wrapper.state().scrollableMonthMultiple).to.equal(2); }); + }); - it('increments scrollableMonthMultiple without an event', () => { + describe('#getPrevScrollableMonths', () => { + it('increments scrollableMonthMultiple and updates currentMonth', () => { const wrapper = shallow().dive(); - wrapper.instance().multiplyScrollableMonths(); + wrapper.instance().getPrevScrollableMonths(); expect(wrapper.state().scrollableMonthMultiple).to.equal(2); + expect(isSameMonth(wrapper.state().currentMonth, moment().subtract(2, 'month'))).to.equal(true); }); }); @@ -701,39 +1005,146 @@ describe('DayPicker', () => { }); }); - describe.skip('life cycle methods', () => { - let adjustDayPickerHeightSpy; - beforeEach(() => { - adjustDayPickerHeightSpy = sinon.stub(PureDayPicker.prototype, 'adjustDayPickerHeight'); + describe('#weekHeaderNames', () => { + it('returns weekheaders in fr', () => { + const INITIAL_MONTH = moment().locale('fr'); + const wrapper = shallow( INITIAL_MONTH} />).dive(); + const instance = wrapper.instance(); + expect(instance.getWeekHeaders()).to.be.eql(INITIAL_MONTH.localeData().weekdaysMin()); }); + }); + + describe('#getWeekHeaders', () => { + it('returns unmutated weekday headers for currentMonth in a future', () => { + sinon.stub(PureDayPicker.prototype, 'render').returns(null); + + const getWeekHeadersSpy = sinon.spy(PureDayPicker.prototype, 'getWeekHeaders'); + const INITIAL_MONTH = moment().add(2, 'Months').week(3).weekday(3); + const wrapper = shallow( INITIAL_MONTH} />).dive(); + const instance = wrapper.instance(); + const state = cloneDeep(wrapper.state()); - describe('#componentDidMount', () => { + expect(instance.getWeekHeaders()).to.be.eql(INITIAL_MONTH.localeData().weekdaysMin()); + expect(instance.state).not.to.equal(state); + expect(instance.state).to.eql(state); + expect(getWeekHeadersSpy).to.have.property('callCount', 1); + }); + }); + + describe('life cycle methods', () => { + describe.skip('#componentDidMount', () => { describe('props.orientation === HORIZONTAL_ORIENTATION', () => { it('calls adjustDayPickerHeight', () => { mount(); expect(adjustDayPickerHeightSpy).to.have.property('callCount', 1); }); + + it('does not update state.currentMonthScrollTop', () => { + sinon.spy(PureDayPicker.prototype, 'setTransitionContainerRef'); + const wrapper = mount(); + expect(wrapper.state().currentMonthScrollTop).to.equal(null); + }); }); - describe('props.orientation === VERTICAL_ORIENTATION', () => { + describe.skip('props.orientation === VERTICAL_ORIENTATION', () => { it('does not call adjustDayPickerHeight', () => { mount(); expect(adjustDayPickerHeightSpy.called).to.equal(false); }); + + it('does not update state.currentMonthScrollTop', () => { + sinon.spy(PureDayPicker.prototype, 'setTransitionContainerRef'); + const wrapper = mount(); + expect(wrapper.state().currentMonthScrollTop).to.equal(null); + }); + }); + + describe('props.orientation === VERTICAL_SCROLLABLE', () => { + it('updates state.currentMonthScrollTop', () => { + sinon.spy(PureDayPicker.prototype, 'setTransitionContainerRef'); + const wrapper = mount(); + expect(wrapper.state().currentMonthScrollTop).to.not.equal(null); + }); + }); + }); + + describe('#componentWillReceiveProps', () => { + describe.skip('props.orientation === VERTICAL_SCROLLABLE', () => { + it('updates state.currentMonthScrollTop', () => { + sinon.spy(PureDayPicker.prototype, 'setTransitionContainerRef'); + const wrapper = mount(); + const prevCurrentMonthScrollTop = wrapper.state().currentMonthScrollTop; + wrapper.setState({ + currentMonth: moment().subtract(1, 'months'), + }); + wrapper.setProps({ initialVisibleMonth: () => moment().subtract(1, 'month') }); + expect(wrapper.state().currentMonthScrollTop).to.not.equal(prevCurrentMonthScrollTop); + }); + }); + + describe('props.date changed', () => { + const props = { + ...DayPickerDefaultProps, + onDateChange() {}, + onFocusChange() {}, + initialVisibleMonth() { return today; }, + theme: { reactDates: { spacing: {} } }, + styles: {}, + css() {}, + }; + + describe('date is not visible', () => { + it('setState gets called with new month', () => { + sinon.stub(isDayVisible, 'default').returns(false); + const date = today; + const newDate = date.clone().add(1, 'month'); + const wrapper = shallow(); + expect(wrapper.state().currentMonth).to.eql(date); + wrapper.instance().componentWillReceiveProps( + { + ...props, + date: newDate, + initialVisibleMonth() { return newDate; }, + }, + {}, + ); + expect(wrapper.state().currentMonth).to.eql(newDate); + }); + }); + + describe('date is visible', () => { + it('setState gets called with existing month', () => { + sinon.stub(isDayVisible, 'default').returns(true); + const date = today; + const newDate = date.clone().add(1, 'month'); + const wrapper = shallow(); + expect(wrapper.state().currentMonth).to.eql(date); + wrapper.instance().componentWillReceiveProps( + { + ...props, + date: newDate, + initialVisibleMonth() { return newDate; }, + }, + {}, + ); + expect(wrapper.state().currentMonth).to.eql(date); + }); + }); }); }); describe('#componentDidUpdate', () => { let updateStateAfterMonthTransitionSpy; + beforeEach(() => { updateStateAfterMonthTransitionSpy = sinon.stub( - DayPicker.prototype, + PureDayPicker.prototype, 'updateStateAfterMonthTransition', ); }); describe('props.orientation === HORIZONTAL_ORIENTATION', () => { - it('calls adjustDayPickerHeight if state.monthTransition is truthy', () => { + it.skip('calls adjustDayPickerHeight if state.monthTransition is truthy', () => { const wrapper = mount(); wrapper.setState({ monthTransition: 'foo', @@ -741,7 +1152,7 @@ describe('DayPicker', () => { expect(adjustDayPickerHeightSpy).to.have.property('callCount', 2); }); - it('does not call adjustDayPickerHeight if state.monthTransition is falsey', () => { + it.skip('does not call adjustDayPickerHeight if state.monthTransition is falsy', () => { const wrapper = mount(); wrapper.setState({ monthTransition: null, @@ -749,7 +1160,24 @@ describe('DayPicker', () => { expect(adjustDayPickerHeightSpy.calledTwice).to.equal(false); }); - it('calls updateStateAfterMonthTransition if state.monthTransition is truthy', () => { + it.skip('calls adjustDayPickerHeight if orientation has changed from HORIZONTAL_ORIENTATION to VERTICAL_ORIENTATION', () => { + const wrapper = mount(); + wrapper.setState({ + orientation: VERTICAL_ORIENTATION, + }); + expect(adjustDayPickerHeightSpy).to.have.property('callCount', 2); + }); + + it.skip('calls adjustDayPickerHeight if daySize has changed', () => { + const wrapper = mount(); + wrapper.setState({ + daySize: 40, + orientation: HORIZONTAL_ORIENTATION, + }); + expect(adjustDayPickerHeightSpy).to.have.property('callCount', 2); + }); + + it.skip('calls updateStateAfterMonthTransition if state.monthTransition is truthy', () => { const wrapper = mount(); wrapper.setState({ monthTransition: 'foo', @@ -757,16 +1185,36 @@ describe('DayPicker', () => { expect(updateStateAfterMonthTransitionSpy).to.have.property('callCount', 1); }); - it('does not call updateStateAfterMonthTransition if state.monthTransition is falsey', () => { + it.skip('does not call updateStateAfterMonthTransition if state.monthTransition is falsy', () => { const wrapper = mount(); wrapper.setState({ monthTransition: null, }); expect(updateStateAfterMonthTransitionSpy.calledOnce).to.equal(false); }); + + it('calls adjustDayPickerHeightSpy if props.numberOfMonths changes', () => { + const wrapper = shallow().dive(); + wrapper.instance().componentDidUpdate({ + daySize: DAY_SIZE, + numberOfMonths: 3, + orientation: HORIZONTAL_ORIENTATION, + }); + expect(adjustDayPickerHeightSpy.callCount).to.equal(1); + }); + + it('does not call adjustDayPickerHeightSpy if props.numberOfMonths does not change', () => { + const wrapper = shallow().dive(); + wrapper.instance().componentDidUpdate({ + daySize: DAY_SIZE, + numberOfMonths: 2, + orientation: HORIZONTAL_ORIENTATION, + }); + expect(adjustDayPickerHeightSpy.called).to.equal(false); + }); }); - describe('props.orientation === VERTICAL_ORIENTATION', () => { + describe.skip('props.orientation === VERTICAL_ORIENTATION', () => { it('does not call adjustDayPickerHeight if state.monthTransition is truthy', () => { const wrapper = mount(); wrapper.setState({ @@ -775,7 +1223,7 @@ describe('DayPicker', () => { expect(adjustDayPickerHeightSpy.called).to.equal(false); }); - it('does not call adjustDayPickerHeight if state.monthTransition is falsey', () => { + it('does not call adjustDayPickerHeight if state.monthTransition is falsy', () => { const wrapper = mount(); wrapper.setState({ monthTransition: null, @@ -783,6 +1231,23 @@ describe('DayPicker', () => { expect(adjustDayPickerHeightSpy.called).to.equal(false); }); + it('calls adjustDayPickerHeight if orientation has changed from VERTICAL_ORIENTATION to HORIZONTAL_ORIENTATION', () => { + const wrapper = mount(); + wrapper.setState({ + orientation: HORIZONTAL_ORIENTATION, + }); + expect(adjustDayPickerHeightSpy).to.have.property('callCount', 2); + }); + + it('calls adjustDayPickerHeight if daySize has changed', () => { + const wrapper = mount(); + wrapper.setState({ + daySize: 40, + orientation: VERTICAL_ORIENTATION, + }); + expect(adjustDayPickerHeightSpy).to.have.property('callCount', 2); + }); + it('calls updateStateAfterMonthTransition if state.monthTransition is truthy', () => { const wrapper = mount(); wrapper.setState({ @@ -791,7 +1256,7 @@ describe('DayPicker', () => { expect(updateStateAfterMonthTransitionSpy).to.have.property('callCount', 1); }); - it('does not call updateStateAfterMonthTransition if state.monthTransition is falsey', () => { + it('does not call updateStateAfterMonthTransition if state.monthTransition is falsy', () => { const wrapper = mount(); wrapper.setState({ monthTransition: null, @@ -800,7 +1265,29 @@ describe('DayPicker', () => { }); }); - describe('when isFocused is updated to true', () => { + describe.skip('props.orientation === VERTICAL_SCROLLABLE', () => { + it('does not update transitionContainer ref`s scrollTop currentMonth stays the same', () => { + sinon.spy(PureDayPicker.prototype, 'setTransitionContainerRef'); + const wrapper = mount(); + const prevScrollTop = wrapper.transitionContainer.scrollTop; + wrapper.setState({ + currentMonth: moment(), + }); + expect(wrapper.transitionContainer).to.have.property('scrollTop', prevScrollTop); + }); + + it('updates transitionContainer ref`s scrollTop currentMonth changes', () => { + sinon.spy(PureDayPicker.prototype, 'setTransitionContainerRef'); + const wrapper = mount(); + const prevScrollTop = wrapper.transitionContainer.scrollTop; + wrapper.setState({ + currentMonth: moment().subtract(1, 'months'), + }); + expect(wrapper.transitionContainer).to.not.have.property('scrollTop', prevScrollTop); + }); + }); + + describe.skip('when isFocused is updated to true', () => { const prevProps = { isFocused: false }; const newProps = { isFocused: true }; @@ -814,7 +1301,7 @@ describe('DayPicker', () => { }); afterEach(() => { - containerFocusStub.reset(); + containerFocusStub.resetHistory(); }); describe('when focusedDate is not defined', () => { diff --git a/test/components/OutsideClickHandler_spec.jsx b/test/components/OutsideClickHandler_spec.jsx deleted file mode 100644 index 8e4580487e..0000000000 --- a/test/components/OutsideClickHandler_spec.jsx +++ /dev/null @@ -1,135 +0,0 @@ -import React from 'react'; -import { expect } from 'chai'; -import sinon from 'sinon-sandbox'; -import { shallow, mount } from 'enzyme'; -import wrap from 'mocha-wrap'; - -import OutsideClickHandler from '../../src/components/OutsideClickHandler'; - -describe('OutsideClickHandler', () => { - describe('basics', () => { - it('renders a div', () => { - expect(shallow().is('div')).to.equal(true); - }); - - it('renders a span when no children are provided', () => { - expect(shallow().children().is('span')).to.equal(true); - }); - - it('renders the children it‘s given', () => { - const wrapper = shallow(( - -
-