From f6c4762df80f3d0c8ce132a4de82bde5661271f7 Mon Sep 17 00:00:00 2001 From: Nacho Justicia Date: Mon, 10 Sep 2018 11:30:23 +0200 Subject: [PATCH] Merge latest changes from upstream/develop to graphext branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix tabs querySelector on jsdom (#2761) * match event handler prop name with code and docs (#2760) * matched icons with the original design (#2780) * [Popover] captureDismiss=false by default (#2776) * disable captureDismiss by default as it breaks links * fix tests * redirect old v1, v2 sites to new URLs (#2773) * Remove `HACKHACK` (#2790) BP requires `"@types/react": "^16.0.34"` and it is pinned by `yarn.lock`. For this version of typings, it is provided an overload, then the `HACKHACK` does not persist. ```ts function createElement

( type: SFC

| ComponentClass

| string, props?: Attributes & P, ...children: ReactNode[]): ReactElement

; ``` See issue: https://github.com/palantir/blueprint/issues/2785 * [RadioGroup] [HtmlSelect] options support className and disabled (#2783) * Fix: passes custom className to options (+ test) * Fix: passes custom className to options (+ test) * HTMLSelect supports disabled * test all options props * remove className test * [DatePicker] ❤️ (#2789) * tests for DateUtils.getDateTime() * replace setStateWithValueIfUncontrolled with setState & updateValue helper refactor handlers to reduce let vars and clarify logic * refactor constructor into helper functions * merge disabled checks * fix test for initial state * massage imports * little bug fixes - ignoring next month change when null - correct day calculation - comments! * bump react-day-picker * areSameDay calls areSameMonth * reduce nesting * fix tests * Add Popover support for "auto-start" and "auto-end" (#2772) * #2770: Popover now accepts 'auto-start' and 'auto-end' positions * Update example * Update docs * Oops, undo unintended changes to example * [Button] Utils.isEmptyReactNode solves icon regression (#2775) * Utils.isEmptyReactNode solves button regression * naming and test * rename in tests * Publish - @blueprintjs/core@3.3.0 * updated Sketch file (#2813) * [TagInput] On paste, don't tag-ify text if no separator included (#2804) * [TagInput] Leave a delimiter-less value in the input on paste * Update tests * Update docs * [DateRangePicker] all tests use enzyme (#2793) * DRP tests use enzyme everywhere with a cool harness * name clash * fix tests in React 15 by using accessor to find latest element when needed * [new] Divider component (#2854) * add Divider component * example * docs edits * replace all modifiers with borders * ignore coverage * remove fill from example * english is hard * [DatePicker] reuse existing components in caption (#2792) * HTMLSelect: add iconProps, fix dark icon color * use HTMLSelect in caption massive reduction in styles * DatePickerNavbar renders prev/next Buttons use Button for another reduction in styles * cache month widths * DatePicker uses single handleMonthChange for all DayPicker events caption (month/year select) and navbar (prev/next buttons) now all use the same logic for changes! * add DPNavbar to DRP * fix & refactor caption & DP tests assertSelectedDays() helper in DP tests replaces getSelectedDays() * add $datepicker-padding variable * adjust paddings to use standard buttons in navbar * month icon won't exceed select bounds * dateinput tests * renames * replace borders with Divider elements also remove all negative margins * copyright, test helper * use Divider component * fix DRP tests * adjust month select icon position * margin only on caption * fix R15 tests * [DatePicker] time support: timePrecision & timePickerProps (#2856) * add TimePicker props right in DatePicker! * getDateTime() to merge date and time * remove "none" from allowed timePrecision * add style for TP in DP * refactors to MomentDate and PrecisionSelect to support time * add PrecisionSelect to DP and DRP examples * import types, update styles (no divider) * revert some example changes * check null in caption * revert DRP example change * DateInput only renders DatePicker * deprecate DateTimePicker * fix dateinput test * tests for time! * strict boolean, no only * top margin on timepicker * refactors to reduce some complexity (#2858) * [DateRangePicker] Shortcuts component and renderCalendars method (#2859) * Shortcuts component and renderCalendars method to greatly simplify render() * bind handleNextState * Publish - @blueprintjs/icons@3.1.0 * remove dependencies section * [table] fix invisible table menu icon (#2866) Fixes #2865 * [Spinner] restore IE support (#2868) * fix Spinner in IE by adding HTML wrapper tag for the animation * fix loading button spinner position * add tagName test * attempt to fix changing value on IE * added latest version (#2862) * Publish - @blueprintjs/core@3.4.0 - @blueprintjs/table@3.1.1 * sketch file updated date * sandbox link in readme * remove quotes on $ns variable value (#2881) * [Icon] render HTML element & tagName prop (#2884) * Icon tagName prop and set `.bp3-icon-{name}` on element * refactor icon styles so svg is child - only render font glyph if the icon element is :empty * icon docs * color prop becomes fill attribute on svg, overrides css colors * fix tests * oops fix icon classes on non-icon elements (like callouts & buttons) * fix text ref (#2888) * Skeleton: fix FF support! (#2887) and refactor styles for simpler keyframes * [OverflowList] fix browser zoom behavior (#2886) * fix OverflowList when zoomed * less magical number * [docs] better version tag styles (#2889) * [docs] better version tag styles * remove obsolete flex override on tag icons * center docs-nav-buttons and key-combo * [Skeleton] Increase animation contrast (#2885) * [Spinner] add additional child element to isolate spinner from parent (#2890) * add spinner-animation element to isolate spinner from parent * comments about elements * Publish - @blueprintjs/core@3.5.0 * [Icon] revert to inline-block (#2896) * clean up comments * take no chances * revert to inline-block and set block on svg instead of relying on flex child fixes all noted regressions * Publish - @blueprintjs/core@3.5.1 * Incorrect argument name (#2898) Copied this over and realized that the argument should not be item but rather film. * [Suggest] Added selectedItem prop (#2874) * Added selected item prop on the ISuggestProps def. * Init the Suggest state with the selected item prop. * Fixed Suggest support for controlled mode. * Fixed a tslint coma issue. * Made the state the only source of truth, added tests. * Added more tests, improved controlled mode support Now the Suggest does not update its underlying state in controlled mode, just like the EditableText component. * Added support for controlled empty selection. * prop docs * whitespace * [TagInput] Use $input-padding-horizontal when empty for consistency with (#2900) * Use -padding-horizontal in empty * Remove -empty class, use pure CSS approach * Add left-icon toggle to example * 🔧 switch to tree-sitter-typescript (#2908) * switch to tree-sitter-typescript, move syntax pkgs to docs-data * update syntax tokens * 🔧 switch to circle-github-bot (#2907) * add circle-github-bot * new preview script * restore cache in circle job * delete old scripts * [timepicker] Fix allowing to type time that exceeds time bounds (#2795) * [DateRangePicker] support time selection (#2895) * add time selection unit tests * add maybeRender placeholder * add wrapper div for calendars + time * add time precision to DRP example * reorder function * DateRangePicker now has time selection field * Updated DateRangePicker example to show time when precision is selected * Updated props to include timePickerProps and fixed/cleaned up tests for DateRangePicker * renamed timepicker classes appropiately, and code cleanup * updated momentTime to pass props * clicking on a shortcut doesn't change the time * removed only from describe in tests * removed unneeded CSS property and added test for making sure that time is preserved when un-selecting / reselecting date * Um/fix collapse animation (#2911) * fixes collapse opening animation on first open * fix documentation * Add condensed property to HTML tables (#2904) * Add condensed property to HTML tables * Move description to small instead of condensed * Deprecate small property on HTML tables * comment format * move collapse animation state docs onto the enum values, CLOSING_END -> CLOSING (#2914) * bump sass-inline-svg (#2915) * [docs] Modifiers & update DTP deprecation (#2909) * add modifiers docs * update DTP deprecation notice * back to present tense since we're ready to ship * [Switch] fix switch styles variables for dark theme (#2912) * [Select] Add resetOnQuery prop (#2894) * [Select] Add resetActiveItemOnQuery prop * Switch prop name from resetActiveItemOnQuery to resetOnQuery and default prop to true * Match documentation to current functionality * Move default prop down to the lowest level, queryList --- .circleci/config.yml | 6 +- README.md | 9 +- package.json | 4 +- packages/core/package.json | 4 +- packages/core/src/common/_variables.scss | 3 +- packages/core/src/common/classes.ts | 4 + packages/core/src/common/utils.ts | 17 + packages/core/src/components/_index.scss | 1 + .../core/src/components/button/_button.scss | 5 +- .../src/components/button/abstractButton.tsx | 4 +- .../core/src/components/collapse/collapse.tsx | 101 +- packages/core/src/components/components.md | 1 + .../core/src/components/divider/_divider.scss | 19 + .../core/src/components/divider/divider.md | 17 + .../core/src/components/divider/divider.tsx | 31 + .../core/src/components/forms/_controls.scss | 16 +- .../core/src/components/forms/radioGroup.tsx | 3 +- .../core/src/components/hotkeys/_hotkeys.scss | 5 +- .../components/html-select/_html-select.scss | 1 + .../src/components/html-select/htmlSelect.tsx | 23 +- .../components/html-table/_html-table.scss | 4 +- .../src/components/html-table/htmlTable.tsx | 20 +- packages/core/src/components/icon/_icon.scss | 43 +- packages/core/src/components/icon/icon.md | 7 +- packages/core/src/components/icon/icon.tsx | 54 +- packages/core/src/components/index.ts | 1 + .../components/overflow-list/overflowList.tsx | 4 +- .../core/src/components/popover/popover.md | 12 +- .../core/src/components/popover/popover.tsx | 2 +- .../popover/popoverMigrationUtils.ts | 7 +- .../components/popover/popoverSharedProps.ts | 4 +- .../components/resize-sensor/resize-sensor.md | 2 +- .../core/src/components/skeleton/_common.scss | 5 +- .../src/components/skeleton/_skeleton.scss | 19 +- .../core/src/components/spinner/_spinner.scss | 17 +- .../core/src/components/spinner/spinner.tsx | 43 +- packages/core/src/components/tabs/tabs.tsx | 2 +- .../src/components/tag-input/_tag-input.scss | 7 + .../src/components/tag-input/tagInput.tsx | 26 +- packages/core/src/components/tag/_common.scss | 1 - packages/core/src/components/text/text.tsx | 10 +- packages/core/src/components/tree/_tree.scss | 2 +- .../core/src/components/tree/treeNode.tsx | 2 +- packages/core/src/docs/classes.md | 53 +- packages/core/test/buttons/buttonTests.tsx | 10 +- packages/core/test/common/utilsTests.tsx | 14 + .../core/test/controls/radioGroupTests.tsx | 9 +- .../core/test/html-select/htmlSelectTests.tsx | 36 + packages/core/test/icon/iconTests.tsx | 18 +- packages/core/test/index.ts | 1 + packages/core/test/popover/popoverTests.tsx | 4 +- packages/core/test/spinner/spinnerTests.tsx | 6 + .../core/test/tag-input/tagInputTests.tsx | 33 +- packages/datetime/package.json | 2 +- packages/datetime/src/_common.scss | 2 + packages/datetime/src/_datepicker.scss | 154 +- packages/datetime/src/_daterangepicker.scss | 25 +- packages/datetime/src/_timepicker.scss | 11 + packages/datetime/src/common/classes.ts | 2 + packages/datetime/src/common/dateUtils.ts | 15 +- packages/datetime/src/common/monthAndYear.ts | 4 + packages/datetime/src/dateInput.tsx | 33 +- packages/datetime/src/datePicker.tsx | 308 ++-- packages/datetime/src/datePickerCaption.tsx | 115 +- packages/datetime/src/datePickerCore.tsx | 21 + packages/datetime/src/datePickerNavbar.tsx | 46 + packages/datetime/src/dateRangePicker.tsx | 430 +++--- packages/datetime/src/dateTimePicker.tsx | 1 + packages/datetime/src/datetimepicker.md | 7 + packages/datetime/src/shortcuts.tsx | 88 ++ packages/datetime/src/timePicker.tsx | 7 +- .../datetime/test/common/dateUtilsTests.tsx | 26 +- packages/datetime/test/dateInputTests.tsx | 60 +- .../datetime/test/datePickerCaptionTests.tsx | 19 +- packages/datetime/test/datePickerTests.tsx | 178 ++- .../datetime/test/dateRangePickerTests.tsx | 1282 +++++++---------- packages/datetime/test/timePickerTests.tsx | 36 + .../docs-app/src/components/navHeader.tsx | 5 +- .../examples/core-examples/dividerExample.tsx | 44 + .../src/examples/core-examples/index.ts | 1 + .../examples/core-examples/popoverExample.tsx | 10 +- .../core-examples/tagInputExample.tsx | 6 +- .../datetime-examples/common/momentDate.tsx | 16 +- .../common/precisionSelect.tsx | 8 +- .../datetime-examples/dateInputExample.tsx | 8 +- .../datetime-examples/datePickerExample.tsx | 31 +- .../dateRangePickerExample.tsx | 36 +- .../select-examples/selectExample.tsx | 8 + .../select-examples/suggestExample.tsx | 8 + packages/docs-app/src/resources.md | 2 +- packages/docs-app/src/styles/_nav.scss | 3 +- packages/docs-data/docsUtils.js | 2 +- packages/docs-data/package.json | 5 +- packages/docs-theme/src/styles/_navbar.scss | 2 + packages/docs-theme/src/styles/_syntax.scss | 2 + packages/icons/package.json | 2 +- packages/select/src/common/listItemsProps.ts | 7 + .../src/components/query-list/queryList.tsx | 7 +- .../src/components/select/select-component.md | 2 +- .../select/src/components/select/suggest.tsx | 32 +- packages/select/test/selectComponentSuite.tsx | 26 +- packages/select/test/suggestTests.tsx | 42 + packages/table/package.json | 4 +- packages/table/src/headers/_headers.scss | 1 + resources/sketch/Core Kit.sketch | Bin 16367616 -> 3629699 bytes scripts/artifact-variables | 51 - scripts/preview.js | 17 + scripts/submit-preview-comment | 45 - site/docs/v1/index.html | 6 + site/docs/v2/index.html | 6 + site/docs/versions/index.html | 5 + yarn.lock | 33 +- 112 files changed, 2286 insertions(+), 1821 deletions(-) create mode 100644 packages/core/src/components/divider/_divider.scss create mode 100644 packages/core/src/components/divider/divider.md create mode 100644 packages/core/src/components/divider/divider.tsx create mode 100644 packages/core/test/html-select/htmlSelectTests.tsx create mode 100644 packages/datetime/src/datePickerNavbar.tsx create mode 100644 packages/datetime/src/shortcuts.tsx create mode 100644 packages/docs-app/src/examples/core-examples/dividerExample.tsx delete mode 100755 scripts/artifact-variables create mode 100644 scripts/preview.js delete mode 100755 scripts/submit-preview-comment create mode 100644 site/docs/v1/index.html create mode 100644 site/docs/v2/index.html create mode 100644 site/docs/versions/index.html diff --git a/.circleci/config.yml b/.circleci/config.yml index 1b2f087ebf..4e39c6e87c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -92,7 +92,6 @@ jobs: - restore_cache: *restore_cache - run: yarn dist:libs - run: yarn dist:apps - # skip dist:docs because we do not publish GitHub Pages from CI - persist_to_workspace: *persist_to_workspace test-react-16: &test-react @@ -134,8 +133,7 @@ jobs: - checkout - attach_workspace: at: '.' - - store_artifacts: - path: docs + - restore_cache: *restore_cache - store_artifacts: path: packages/docs-app/dist - store_artifacts: @@ -144,7 +142,7 @@ jobs: path: packages/table-dev-app/dist - run: name: Submit Github comment with links to built artifacts - command: ./scripts/submit-preview-comment + command: node ./scripts/preview.js deploy-npm: <<: *setup_env diff --git a/README.md b/README.md index 890068063b..8417172f5b 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,13 @@ Blueprint is a React-based UI toolkit for the web. It is optimized for building complex, data-dense web interfaces for _desktop applications_. If you rely heavily on mobile interactions and are looking for a mobile-first UI toolkit, this may not be for you. + [**Read the introductory blog post ▸**](https://medium.com/@palantir/scaling-product-design-with-blueprint-25492827bb4a) [**View the full documentation ▸**](http://blueprintjs.com/docs) +[**Try it out on CodeSandbox ▸**](https://codesandbox.io/s/rypm429574) + [**Read our FAQ on the wiki ▸**](https://github.com/palantir/blueprint/wiki/Frequently-Asked-Questions) ## :tada: 3.0 is here! :tada: @@ -97,12 +100,6 @@ Run `yarn dev` from the root directory to watch changes across all packages and Alternately, each library has its own dev script to run the docs app and watch changes to just that package (and its dependencies): `yarn dev:core`, `yarn dev:datetime`, etc. One exception is `table`: since it has its own dev application, the `dev:table` script runs `table-dev-app` instead of the docs. -### Updating dependencies - -1. Edit the `package.json` where you wish to change dependencies. -1. Run `yarn` at the root to update lockfiles. -1. Commit the result. - ### Updating documentation Much of Blueprint's documentation lives inside source code as JSDoc comments in `.tsx` files and KSS markup in `.scss` files. This documentation is extracted and converted into static JSON data using [documentalist](https://github.com/palantir/documentalist/). diff --git a/package.json b/package.json index c848c4e521..22450d7f5d 100644 --- a/package.json +++ b/package.json @@ -46,13 +46,11 @@ "@types/react-transition-group": "^2.0.6", "@types/sinon": "^4.1.2", "@types/webpack": "^3.8.8", - "better-handlebars": "github:wmeldon/better-handlebars", "chai": "^4.1.2", + "circle-github-bot": "^1.0.0", "cross-env": "^5.1.3", "gh-pages": "^1.1.0", "http-server": "^0.11.1", - "language-less": "github:atom/language-less", - "language-typescript": "github:giladgray/language-typescript#10.1.15", "lerna": "^2.7.1", "npm-run-all": "^4.1.2", "sinon": "^4.1.4", diff --git a/packages/core/package.json b/packages/core/package.json index cf9dd67c68..ffb8ad4fe1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@blueprintjs/core", - "version": "3.2.0", + "version": "3.5.1", "description": "Core styles & components", "main": "lib/cjs/index.js", "module": "lib/esm/index.js", @@ -64,7 +64,7 @@ "react": "^16.2.0", "react-dom": "^16.2.0", "react-test-renderer": "^16.2.0", - "sass-inline-svg": "^1.1.0", + "sass-inline-svg": "^1.2.0", "typescript": "~2.8.3", "webpack": "^3.10.0" }, diff --git a/packages/core/src/common/_variables.scss b/packages/core/src/common/_variables.scss index 4f050a5eee..89839c8a5b 100644 --- a/packages/core/src/common/_variables.scss +++ b/packages/core/src/common/_variables.scss @@ -6,7 +6,8 @@ @import "mixins"; // Namespace appended to the beginning of each CSS class: `.#{$ns}-button`. -$ns: "bp3" !default; +// Do not quote this value, for Less consumers. +$ns: bp3 !default; // easily the most important variable, so it comes up top // (so other variables can use it to define themselves) diff --git a/packages/core/src/common/classes.ts b/packages/core/src/common/classes.ts index 887cf17b9a..c801dbb149 100644 --- a/packages/core/src/common/classes.ts +++ b/packages/core/src/common/classes.ts @@ -14,6 +14,7 @@ const NS = process.env.BLUEPRINT_NAMESPACE || "bp3"; export const ACTIVE = `${NS}-active`; export const ALIGN_LEFT = `${NS}-align-left`; export const ALIGN_RIGHT = `${NS}-align-right`; +export const CONDENSED = `${NS}-condensed`; export const DARK = `${NS}-dark`; export const DISABLED = `${NS}-disabled`; export const FILL = `${NS}-fill`; @@ -100,6 +101,8 @@ export const DIALOG_FOOTER = `${DIALOG}-footer`; export const DIALOG_FOOTER_ACTIONS = `${DIALOG}-footer-actions`; export const DIALOG_HEADER = `${DIALOG}-header`; +export const DIVIDER = `${NS}-divider`; + export const EDITABLE_TEXT = `${NS}-editable-text`; export const EDITABLE_TEXT_CONTENT = `${EDITABLE_TEXT}-content`; export const EDITABLE_TEXT_EDITING = `${EDITABLE_TEXT}-editing`; @@ -206,6 +209,7 @@ export const START = `${NS}-start`; export const END = `${NS}-end`; export const SPINNER = `${NS}-spinner`; +export const SPINNER_ANIMATION = `${SPINNER}-animation`; export const SPINNER_HEAD = `${SPINNER}-head`; export const SPINNER_NO_SPIN = `${NS}-no-spin`; export const SPINNER_TRACK = `${SPINNER}-track`; diff --git a/packages/core/src/common/utils.ts b/packages/core/src/common/utils.ts index a1a7a02589..46cbcfbf8a 100644 --- a/packages/core/src/common/utils.ts +++ b/packages/core/src/common/utils.ts @@ -24,6 +24,23 @@ export function isFunction(value: any): value is Function { return typeof value === "function"; } +/** + * Returns true if `node` is null/undefined, false, empty string, or an array + * composed of those. If `node` is an array, only one level of the array is + * checked, for performance reasons. + */ +export function isReactNodeEmpty(node?: React.ReactNode, skipArray = false): boolean { + return ( + node == null || + node === "" || + node === false || + (!skipArray && + Array.isArray(node) && + // only recurse one level through arrays, for performance + (node.length === 0 || node.every(n => isReactNodeEmpty(n, true)))) + ); +} + /** * Converts a React child to an element: non-empty string or number or * `React.Fragment` (React 16.3+) is wrapped in given tag name; empty strings diff --git a/packages/core/src/components/_index.scss b/packages/core/src/components/_index.scss index aea2fc86f8..f887c9aa65 100644 --- a/packages/core/src/components/_index.scss +++ b/packages/core/src/components/_index.scss @@ -9,6 +9,7 @@ @import "card/card"; @import "collapse/collapse"; @import "context-menu/context-menu"; +@import "divider/divider"; @import "dialog/dialog"; @import "editable-text/editable-text"; @import "forms/index"; diff --git a/packages/core/src/components/button/_button.scss b/packages/core/src/components/button/_button.scss index 88efd04057..6a5e370d40 100644 --- a/packages/core/src/components/button/_button.scss +++ b/packages/core/src/components/button/_button.scss @@ -88,10 +88,9 @@ Styleguide button } .#{$ns}-button-spinner { + // spinner appears centered within button position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + margin: 0; } > :not(.#{$ns}-button-spinner) { diff --git a/packages/core/src/components/button/abstractButton.tsx b/packages/core/src/components/button/abstractButton.tsx index 97015c1bc8..a14eabf550 100644 --- a/packages/core/src/components/button/abstractButton.tsx +++ b/packages/core/src/components/button/abstractButton.tsx @@ -11,7 +11,7 @@ import { Alignment } from "../../common/alignment"; import * as Classes from "../../common/classes"; import * as Keys from "../../common/keys"; import { IActionProps } from "../../common/props"; -import { safeInvoke } from "../../common/utils"; +import { isReactNodeEmpty, safeInvoke } from "../../common/utils"; import { Icon, IconName } from "../icon/icon"; import { Spinner } from "../spinner/spinner"; @@ -149,7 +149,7 @@ export abstract class AbstractButton> extend return [ loading && , , - ((text != null && text !== "") || (children != null && children !== "")) && ( + (!isReactNodeEmpty(text) || !isReactNodeEmpty(children)) && ( {text} {children} diff --git a/packages/core/src/components/collapse/collapse.tsx b/packages/core/src/components/collapse/collapse.tsx index 73c4b90be6..84759da09a 100644 --- a/packages/core/src/components/collapse/collapse.tsx +++ b/packages/core/src/components/collapse/collapse.tsx @@ -33,8 +33,10 @@ export interface ICollapseProps extends IProps { keepChildrenMounted?: boolean; /** - * The length of time the transition takes, in milliseconds. This must match the duration of the animation in CSS. - * Only set this prop if you override Blueprint's default transitions with new transitions of a different length. + * The length of time the transition takes, in milliseconds. This must match + * the duration of the animation in CSS. Only set this prop if you override + * Blueprint's default transitions with new transitions of a different + * length. * @default 200 */ transitionDuration?: number; @@ -48,41 +50,51 @@ export interface ICollapseState { animationState: AnimationStates; } +/** + * `Collapse` can be in one of six states, enumerated here. + * When changing the `isOpen` prop, the following happens to the states: + * isOpen={true} : CLOSED -> OPEN_START -> OPENING -> OPEN + * isOpen={false} : OPEN -> CLOSING_START -> CLOSING -> CLOSED + */ export enum AnimationStates { - CLOSED, + /** + * The body is re-rendered, height is set to the measured body height and + * the body Y is set to 0. + */ + OPEN_START, + + /** + * Animation begins, height is set to auto. This is all animated, and on + * complete, the state changes to OPEN. + */ OPENING, + + /** + * The collapse height is set to auto, and the body Y is set to 0 (so the + * element can be seen as normal). + */ OPEN, + + /** + * Height has been changed from auto to the measured height of the body to + * prepare for the closing animation in CLOSING. + */ CLOSING_START, - CLOSING_END, + + /** + * Height is set to 0 and the body Y is at -height. Both of these properties + * are transformed, and then after the animation is complete, the state + * changes to CLOSED. + */ + CLOSING, + + /** + * The contents of the collapse is not rendered, the collapse height is 0, + * and the body Y is at -height (so that the bottom of the body is at Y=0). + */ + CLOSED, } -/* - * A collapse can be in one of 5 states: - * CLOSED - * When in this state, the contents of the collapse is not rendered, the collapse height is 0, - * and the body Y is at -height (so that the bottom of the body is at Y=0). - * - * OPEN - * When in this state, the collapse height is set to auto, and the body Y is set to 0 (so the element can be seen - * as normal). - * - * CLOSING_START - * When in this state, height has been changed from auto to the measured height of the body to prepare for the - * closing animation in CLOSING_END. - * - * CLOSING_END - * When in this state, the height is set to 0 and the body Y is at -height. Both of these properties are transformed, - * and then after the animation is complete, the state changes to CLOSED. - * - * OPENING - * When in this state, the body is re-rendered, height is set to the measured body height and the body Y is set to 0. - * This is all animated, and on complete, the state changes to OPEN. - * - * When changing the isOpen prop, the following happens to the states: - * isOpen = true : CLOSED -> OPENING -> OPEN - * isOpen = false: OPEN -> CLOSING_START -> CLOSING_END -> CLOSED - * These are all animated. - */ export class Collapse extends AbstractPureComponent { public static displayName = `${DISPLAYNAME_PREFIX}.Collapse`; @@ -104,9 +116,6 @@ export class Collapse extends AbstractPureComponent this.onDelayedStateChange(), this.props.transitionDuration); } } } @@ -127,7 +134,7 @@ export class Collapse extends AbstractPureComponent this.setState({ - animationState: AnimationStates.CLOSING_END, + animationState: AnimationStates.CLOSING, height: "0px", }), ); this.setTimeout(() => this.onDelayedStateChange(), this.props.transitionDuration); } + if (this.state.animationState === AnimationStates.OPEN_START) { + this.setState({ + animationState: AnimationStates.OPENING, + height: this.height + "px", + }); + this.setTimeout(() => this.onDelayedStateChange(), this.props.transitionDuration); + } } private contentsRefHandler = (el: HTMLElement) => { @@ -197,7 +212,7 @@ export class Collapse extends AbstractPureComponent { + /** + * HTML tag to use for element. + * @default "div" + */ + tagName?: keyof JSX.IntrinsicElements; +} + +// this component is simple enough that tests would be purely tautological. +/* istanbul ignore next */ +export class Divider extends React.PureComponent { + public static displayName = `${DISPLAYNAME_PREFIX}.Divider`; + + public render() { + const { className, tagName: TagName = "div", ...htmlProps } = this.props; + const classes = classNames(DIVIDER, className); + return ; + } +} diff --git a/packages/core/src/components/forms/_controls.scss b/packages/core/src/components/forms/_controls.scss index c47402859e..da536a6b27 100644 --- a/packages/core/src/components/forms/_controls.scss +++ b/packages/core/src/components/forms/_controls.scss @@ -378,17 +378,17 @@ $control-indicator-spacing: $pt-grid-size !default; .#{$ns}-dark & { @include indicator-colors( "", - $switch-background-color, - $switch-background-color-hover, - $switch-background-color-active, - $switch-background-color-disabled + $dark-switch-background-color, + $dark-switch-background-color-hover, + $dark-switch-background-color-active, + $dark-switch-background-color-disabled ); @include indicator-colors( ":checked", - $switch-checked-background-color, - $switch-checked-background-color-hover, - $switch-checked-background-color-active, - $switch-checked-background-color-disabled + $dark-switch-checked-background-color, + $dark-switch-checked-background-color-hover, + $dark-switch-checked-background-color-active, + $dark-switch-checked-background-color-disabled ); .#{$ns}-control-indicator::before { diff --git a/packages/core/src/components/forms/radioGroup.tsx b/packages/core/src/components/forms/radioGroup.tsx index 8e58725dd9..3a9e2e51de 100644 --- a/packages/core/src/components/forms/radioGroup.tsx +++ b/packages/core/src/components/forms/radioGroup.tsx @@ -97,9 +97,10 @@ export class RadioGroup extends AbstractPureComponent { private getRadioProps(optionProps: IOptionProps): IRadioProps { const { name } = this.props; - const { disabled, value } = optionProps; + const { className, disabled, value } = optionProps; return { checked: value === this.props.selectedValue, + className, disabled: disabled || this.props.disabled, inline: this.props.inline, name: name == null ? this.autoGroupName : name, diff --git a/packages/core/src/components/hotkeys/_hotkeys.scss b/packages/core/src/components/hotkeys/_hotkeys.scss index 4560b4098f..bbcb5c132d 100644 --- a/packages/core/src/components/hotkeys/_hotkeys.scss +++ b/packages/core/src/components/hotkeys/_hotkeys.scss @@ -3,8 +3,9 @@ @import "../../common/variables"; @import "../../common/mixins"; -.#{$ns}-key-combo .#{$ns}-key:not(:last-child) { - margin-right: $pt-grid-size / 2; +.#{$ns}-key-combo { + @include pt-flex-container(row, $pt-grid-size / 2); + align-items: center; } .#{$ns}-hotkey-dialog { diff --git a/packages/core/src/components/html-select/_html-select.scss b/packages/core/src/components/html-select/_html-select.scss index a1cb35b4ee..6288cf14f8 100644 --- a/packages/core/src/components/html-select/_html-select.scss +++ b/packages/core/src/components/html-select/_html-select.scss @@ -49,6 +49,7 @@ Styleguide select .#{$ns}-icon { @extend %pt-select-arrow; + @include pt-icon-colors(); } &.#{$ns}-minimal select { diff --git a/packages/core/src/components/html-select/htmlSelect.tsx b/packages/core/src/components/html-select/htmlSelect.tsx index c736bf2eea..03d26b92f2 100644 --- a/packages/core/src/components/html-select/htmlSelect.tsx +++ b/packages/core/src/components/html-select/htmlSelect.tsx @@ -9,7 +9,7 @@ import * as React from "react"; import { DISABLED, FILL, HTML_SELECT, LARGE, MINIMAL } from "../../common/classes"; import { IOptionProps } from "../../common/props"; import { IElementRefProps } from "../html/html"; -import { Icon } from "../icon/icon"; +import { Icon, IIconProps } from "../icon/icon"; export interface IHTMLSelectProps extends IElementRefProps, @@ -20,6 +20,9 @@ export interface IHTMLSelectProps /** Whether this element should fill its container. */ fill?: boolean; + /** Props to spread to the `` element. */ + iconProps?: Partial; + /** Whether to use large styles. */ large?: boolean; @@ -47,7 +50,17 @@ export interface IHTMLSelectProps /* istanbul ignore next */ export class HTMLSelect extends React.PureComponent { public render() { - const { className, disabled, elementRef, fill, large, minimal, options = [], ...htmlProps } = this.props; + const { + className, + disabled, + elementRef, + fill, + iconProps, + large, + minimal, + options = [], + ...htmlProps + } = this.props; const classes = classNames( HTML_SELECT, { @@ -60,8 +73,8 @@ export class HTMLSelect extends React.PureComponent { ); const optionChildren = options.map(option => { - const { value, label }: IOptionProps = typeof option === "object" ? option : { value: option }; - return

+ You can also specify a specific initial position (e.g. `LEFT`, `TOP_RIGHT`) and still update the Popover's position automatically by enabling the modifiers `flip` and `preventOverflow`. [See below](#core/components/popover.modifiers) for information about modifiers. +
@### Modifiers @@ -257,7 +265,7 @@ Cancel the dismiss behavior on subtrees by nesting originating inside disabled elements (either via the `disabled` attribute or `Classes.DISABLED`) will never dismiss a popover. -Additionally, the prop `captureDismiss` (enabled by default) will prevent click +Additionally, the prop `captureDismiss` (disabled by default) will prevent click events from dismissing _grandparent_ popovers (not the `Popover` immediately containing the dismiss element). `MenuItem` disables this feature such that clicking any submenu item will close all submenus, which is desirable behavior diff --git a/packages/core/src/components/popover/popover.tsx b/packages/core/src/components/popover/popover.tsx index 1dd36a76dc..6646fc279f 100644 --- a/packages/core/src/components/popover/popover.tsx +++ b/packages/core/src/components/popover/popover.tsx @@ -86,7 +86,7 @@ export class Popover extends AbstractPureComponent public static displayName = `${DISPLAYNAME_PREFIX}.Popover`; public static defaultProps: IPopoverProps = { - captureDismiss: true, + captureDismiss: false, defaultIsOpen: false, disabled: false, hasBackdrop: false, diff --git a/packages/core/src/components/popover/popoverMigrationUtils.ts b/packages/core/src/components/popover/popoverMigrationUtils.ts index 574c3e8d29..234cb45d39 100644 --- a/packages/core/src/components/popover/popoverMigrationUtils.ts +++ b/packages/core/src/components/popover/popoverMigrationUtils.ts @@ -11,7 +11,7 @@ import { Position } from "../../common/position"; * Convert a position to a placement. * @param position the position to convert */ -export function positionToPlacement(position: Position | "auto"): Placement { +export function positionToPlacement(position: Position | "auto" | "auto-start" | "auto-end"): Placement { /* istanbul ignore next */ switch (position) { case Position.TOP_LEFT: @@ -39,7 +39,10 @@ export function positionToPlacement(position: Position | "auto"): Placement { case Position.LEFT_TOP: return "left-start"; case "auto": - return "auto"; + case "auto-start": + case "auto-end": + // Return the string unchanged. + return position; default: return assertNever(position); } diff --git a/packages/core/src/components/popover/popoverSharedProps.ts b/packages/core/src/components/popover/popoverSharedProps.ts index 04916ed21a..4663069cda 100644 --- a/packages/core/src/components/popover/popoverSharedProps.ts +++ b/packages/core/src/components/popover/popoverSharedProps.ts @@ -21,7 +21,7 @@ export interface IPopoverSharedProps extends IOverlayableProps, IProps { * element will close the parent popover. * * See http://blueprintjs.com/docs/#core/components/popover.closing-on-click - * @default true + * @default false */ captureDismiss?: boolean; @@ -109,7 +109,7 @@ export interface IPopoverSharedProps extends IOverlayableProps, IProps { * user scrolls around. * @default "auto" */ - position?: Position | "auto"; + position?: Position | "auto" | "auto-start" | "auto-end"; /** * Space-delimited string of class names applied to the target element. diff --git a/packages/core/src/components/resize-sensor/resize-sensor.md b/packages/core/src/components/resize-sensor/resize-sensor.md index fec5f444b3..7274da7a1c 100644 --- a/packages/core/src/components/resize-sensor/resize-sensor.md +++ b/packages/core/src/components/resize-sensor/resize-sensor.md @@ -17,7 +17,7 @@ function handleResize(entries: IResizeEntry[]) { console.log(entries.map(e => `${e.contentRect.width} x ${e.contentRect.height}`)); } - +
``` diff --git a/packages/core/src/components/skeleton/_common.scss b/packages/core/src/components/skeleton/_common.scss index e1c9e25339..9bb6580ccf 100644 --- a/packages/core/src/components/skeleton/_common.scss +++ b/packages/core/src/components/skeleton/_common.scss @@ -2,6 +2,7 @@ @import "../../common/variables"; -$skeleton-animation: ($pt-transition-duration * 20) linear infinite glow !default; -$skeleton-color-start: rgba($gray4, 0.2) !default; +$skeleton-animation: + ($pt-transition-duration * 10) linear infinite alternate skeleton-glow !default; +$skeleton-color-start: rgba($light-gray1, 0.2) !default; $skeleton-color-end: rgba($gray1, 0.2) !default; diff --git a/packages/core/src/components/skeleton/_skeleton.scss b/packages/core/src/components/skeleton/_skeleton.scss index 461e653daf..67ac4392bd 100644 --- a/packages/core/src/components/skeleton/_skeleton.scss +++ b/packages/core/src/components/skeleton/_skeleton.scss @@ -21,27 +21,28 @@ Markup: Styleguide skeleton */ -@keyframes glow { - 0%, - 100% { +@keyframes skeleton-glow { + from { border-color: $skeleton-color-start; - background-color: $skeleton-color-start; + background: $skeleton-color-start; } - 50% { + to { border-color: $skeleton-color-end; - background-color: $skeleton-color-end; + background: $skeleton-color-end; } } -// This class hides content with a glowing, rounded rectangle. CSS properties that should always -// override consumer values use the "!important" rule. +// This class hides content with a glowing, rounded rectangle. +// CSS properties that should always override consumer values use the "!important" rule. /* stylelint-disable declaration-no-important */ .#{$ns}-skeleton { border-color: $skeleton-color-start !important; border-radius: 2px; box-shadow: none !important; - background: $skeleton-color-start !important; + + // do not !important this for Firefox support + background: $skeleton-color-start; // Prevent background color from extending to the border and overlappping background-clip: padding-box !important; diff --git a/packages/core/src/components/spinner/_spinner.scss b/packages/core/src/components/spinner/_spinner.scss index ce32b8ec3c..1da5bb6e10 100644 --- a/packages/core/src/components/spinner/_spinner.scss +++ b/packages/core/src/components/spinner/_spinner.scss @@ -10,10 +10,19 @@ } .#{$ns}-spinner { + // center animation container inside parent element to isolate layout + display: flex; + align-items: center; + justify-content: center; + // allow paths to overflow container -- critical for edges of circles! overflow: visible; vertical-align: middle; + svg { + display: block; + } + path { fill-opacity: 0; } @@ -21,7 +30,6 @@ .#{$ns}-spinner-head { transform-origin: center; transition: stroke-dashoffset ($pt-transition-duration * 2) $pt-transition-ease; - animation: pt-spinner-animation ($pt-transition-duration * 5) linear infinite; stroke: $progress-head-color; stroke-linecap: round; } @@ -29,8 +37,13 @@ .#{$ns}-spinner-track { stroke: $progress-track-color; } +} + +// put the animation on a child HTML element to isolate it from display of parent +.#{$ns}-spinner-animation { + animation: pt-spinner-animation ($pt-transition-duration * 5) linear infinite; - &.#{$ns}-no-spin .#{$ns}-spinner-head { + .#{$ns}-no-spin > & { animation: none; } } diff --git a/packages/core/src/components/spinner/spinner.tsx b/packages/core/src/components/spinner/spinner.tsx index 91cc83c4c0..24c9a77c8c 100644 --- a/packages/core/src/components/spinner/spinner.tsx +++ b/packages/core/src/components/spinner/spinner.tsx @@ -34,6 +34,13 @@ export interface ISpinnerProps extends IProps, IIntentProps { */ size?: number; + /** + * HTML tag for the wrapper element. If rendering a `` inside an + * ``, change this to an SVG element like `"g"`. + * @default "div" + */ + tagName?: keyof JSX.IntrinsicElements; + /** * A value between 0 and 1 (inclusive) representing how far along the operation is. * Values below 0 or above 1 will be interpreted as 0 or 1 respectively. @@ -49,8 +56,15 @@ export class Spinner extends AbstractPureComponent { public static readonly SIZE_STANDARD = 50; public static readonly SIZE_LARGE = 100; + public componentDidUpdate(prevProps: ISpinnerProps) { + if (prevProps.value !== this.props.value) { + // IE/Edge: re-render after changing value to force SVG update + this.forceUpdate(); + } + } + public render() { - const { className, intent, value } = this.props; + const { className, intent, value, tagName: TagName = "div" } = this.props; const size = this.getSize(); const classes = classNames( @@ -65,17 +79,24 @@ export class Spinner extends AbstractPureComponent { const strokeOffset = PATH_LENGTH - PATH_LENGTH * (value == null ? 0.25 : clamp(value, 0, 1)); + // multiple DOM elements around SVG are necessary to properly isolate animation: + // - SVG elements in IE do not support anim/trans so they must be set on a parent HTML element. + // - SPINNER_ANIMATION isolates svg from parent display and is always centered inside root element. return ( - - - - + + + + + + + + ); } diff --git a/packages/core/src/components/tabs/tabs.tsx b/packages/core/src/components/tabs/tabs.tsx index fcc0c4498e..38ca271e93 100644 --- a/packages/core/src/components/tabs/tabs.tsx +++ b/packages/core/src/components/tabs/tabs.tsx @@ -259,7 +259,7 @@ export class Tabs extends AbstractPureComponent { * Store the CSS values so the transition animation can start. */ private moveSelectionIndicator() { - if (this.tablistElement === undefined || !this.props.animate) { + if (this.tablistElement == null || !this.props.animate) { return; } diff --git a/packages/core/src/components/tag-input/_tag-input.scss b/packages/core/src/components/tag-input/_tag-input.scss index 772ff279fc..1b096991e9 100644 --- a/packages/core/src/components/tag-input/_tag-input.scss +++ b/packages/core/src/components/tag-input/_tag-input.scss @@ -37,6 +37,13 @@ $tag-input-icon-padding-large: ($pt-input-height-large - $pt-icon-size-large) / margin-top: $tag-input-padding; margin-right: $tag-input-icon-padding; + // use the larger, conventional input padding when there are no tags and no left icon present. + // see: https://github.com/palantir/blueprint/issues/2872 + &:first-child .#{$ns}-input-ghost:first-child { + // recall that some padding-left is already applied on the root component. + padding-left: $input-padding-horizontal - $tag-input-padding; + } + > * { margin-bottom: $tag-input-padding; } diff --git a/packages/core/src/components/tag-input/tagInput.tsx b/packages/core/src/components/tag-input/tagInput.tsx index dfa0a8fced..90ae8b48e1 100644 --- a/packages/core/src/components/tag-input/tagInput.tsx +++ b/packages/core/src/components/tag-input/tagInput.tsx @@ -24,8 +24,14 @@ export interface ITagInputProps extends IProps { addOnBlur?: boolean; /** - * If true, `onAdd` will be invoked when the user pastes text into the - * input. Otherwise, pasted text will remain in the input. + * If true, `onAdd` will be invoked when the user pastes text containing the `separator` + * into the input. Otherwise, pasted text will remain in the input. + * + * __Note:__ For example, if `addOnPaste=true` and `separator="\n"` (new line), then: + * - Pasting `"hello"` will _not_ invoke `onAdd` + * - Pasting `"hello\n"` will invoke `onAdd` with `["hello"]` + * - Pasting `"hello\nworld"` will invoke `onAdd` with `["hello", "world"]` + * * @default true */ addOnPaste?: boolean; @@ -385,11 +391,21 @@ export class TagInput extends AbstractPureComponent) => { + const { separator } = this.props; const value = event.clipboardData.getData("text"); - if (this.props.addOnPaste && value.length > 0) { - event.preventDefault(); - this.addTags(value); + + if (!this.props.addOnPaste || value.length === 0) { + return; + } + + // special case as a UX nicety: if the user pasted only one value with no delimiters in it, leave that value in + // the input field so that the user can refine it before converting it to a tag manually. + if (separator === false || value.split(separator).length === 1) { + return; } + + event.preventDefault(); + this.addTags(value); }; private handleRemoveTag = (event: React.MouseEvent) => { diff --git a/packages/core/src/components/tag/_common.scss b/packages/core/src/components/tag/_common.scss index f3ff833bc1..1f3245a024 100644 --- a/packages/core/src/components/tag/_common.scss +++ b/packages/core/src/components/tag/_common.scss @@ -57,7 +57,6 @@ $tag-round-adjustment: 2px !default; } > #{$icon-classes} { - flex: 0 0 auto; fill: $white; } } diff --git a/packages/core/src/components/text/text.tsx b/packages/core/src/components/text/text.tsx index 53911fba8d..f04ce7a0e0 100644 --- a/packages/core/src/components/text/text.tsx +++ b/packages/core/src/components/text/text.tsx @@ -38,10 +38,7 @@ export class Text extends React.PureComponent { textContent: "", }; - private textRef: HTMLDivElement; - private refHandlers = { - text: (overflowElement: HTMLDivElement) => (this.textRef = overflowElement), - }; + private textRef: HTMLElement | null = null; public componentDidMount() { this.update(); @@ -62,7 +59,7 @@ export class Text extends React.PureComponent { return ( (this.textRef = ref)} title={this.state.isContentOverflowing ? this.state.textContent : undefined} > {this.props.children} @@ -71,6 +68,9 @@ export class Text extends React.PureComponent { } private update() { + if (this.textRef == null) { + return; + } const newState = { isContentOverflowing: this.props.ellipsize && this.textRef.scrollWidth > this.textRef.clientWidth, textContent: this.textRef.textContent, diff --git a/packages/core/src/components/tree/_tree.scss b/packages/core/src/components/tree/_tree.scss index 9f629d731a..9cedf40996 100644 --- a/packages/core/src/components/tree/_tree.scss +++ b/packages/core/src/components/tree/_tree.scss @@ -97,7 +97,7 @@ $tree-icon-spacing: ($tree-row-height - $pt-icon-size-standard) / 2 !default; // CSS API support &.#{$ns}-icon-standard::before { - content: $pt-icon-caret-right; + content: $pt-icon-chevron-right; } .#{$ns}-icon { diff --git a/packages/core/src/components/tree/treeNode.tsx b/packages/core/src/components/tree/treeNode.tsx index bc6ff47e2f..9a1fffae63 100644 --- a/packages/core/src/components/tree/treeNode.tsx +++ b/packages/core/src/components/tree/treeNode.tsx @@ -116,7 +116,7 @@ export class TreeNode extends React.Component, {}> { ref={this.handleContentRef} > - {showCaret && } + {showCaret && } {label} diff --git a/packages/core/src/docs/classes.md b/packages/core/src/docs/classes.md index f1c3f31161..4e24102cf7 100644 --- a/packages/core/src/docs/classes.md +++ b/packages/core/src/docs/classes.md @@ -4,7 +4,11 @@ tag: new @# Classes -Blueprint packages provide React components in JS files and associated styles in a CSS file. Each package exports a `Classes` constants object in JavaScript that contains keys of the form `NAMED_CONSTANT` for every CSS class used. This separation allows us to change CSS classes between versions without breaking downstream users (although in practice this happens very rarely). +Blueprint packages provide React components in JS files and associated styles in +a CSS file. Each package exports a `Classes` constants object in JavaScript that +contains keys of the form `NAMED_CONSTANT` for every CSS class used. This +separation allows us to change CSS classes between versions without breaking +downstream users (although in practice this happens very rarely). **Avoid referencing hardcoded Blueprint class names in your JS or CSS code.** @@ -13,7 +17,8 @@ Blueprint packages provide React components in JS files and associated styles in ``` -The **best practice** is to add your own class to an element and then reference that class whenever needed. +The **best practice** is to add your own class to an element and then reference +that class whenever needed. ```tsx + +// Don't do this! + +``` + +Another important note: Since modifiers typically correspond directly to CSS classes, they will often +cascade to children and _cannot be disabled_ on descendants. If a `` +is marked `minimal={true}`, then setting `
+ ); + // assign default empty object here to prevent mutation const { popoverProps = {} } = this.props; const inputProps = this.getInputPropsWithDefaults(); diff --git a/packages/datetime/src/datePicker.tsx b/packages/datetime/src/datePicker.tsx index 609fab89bd..b152968e24 100644 --- a/packages/datetime/src/datePicker.tsx +++ b/packages/datetime/src/datePicker.tsx @@ -4,26 +4,18 @@ * Licensed under the terms of the LICENSE file distributed with this project. */ -import { - AbstractPureComponent, - Button, - Classes as CoreClasses, - DISPLAYNAME_PREFIX, - IProps, - Utils, -} from "@blueprintjs/core"; +import { AbstractPureComponent, Button, DISPLAYNAME_PREFIX, Divider, IProps, Utils } from "@blueprintjs/core"; import classNames from "classnames"; import * as React from "react"; -import ReactDayPicker from "react-day-picker"; -import { DayModifiers } from "react-day-picker/types/common"; -import { CaptionElementProps, DayPickerProps } from "react-day-picker/types/props"; +import DayPicker, { CaptionElementProps, DayModifiers, DayPickerProps, NavbarElementProps } from "react-day-picker"; import * as Classes from "./common/classes"; import * as DateUtils from "./common/dateUtils"; import * as Errors from "./common/errors"; - import { DatePickerCaption } from "./datePickerCaption"; import { getDefaultMaxDate, getDefaultMinDate, IDatePickerBaseProps } from "./datePickerCore"; +import { DatePickerNavbar } from "./datePickerNavbar"; +import { TimePicker } from "./timePicker"; export interface IDatePickerProps extends IDatePickerBaseProps, IProps { /** @@ -70,10 +62,10 @@ export interface IDatePickerProps extends IDatePickerBaseProps, IProps { } export interface IDatePickerState { - displayMonth?: number; - displayYear?: number; - selectedDay?: number; - value?: Date; + displayMonth: number; + displayYear: number; + selectedDay: number | null; + value: Date | null; } export class DatePicker extends AbstractPureComponent { @@ -84,43 +76,21 @@ export class DatePicker extends AbstractPureComponent - - {showActionsBar ? this.renderOptionsBar() : null} + {this.maybeRenderTimePicker()} + {showActionsBar && this.renderOptionsBar()} ); } public componentWillReceiveProps(nextProps: IDatePickerProps) { - if (nextProps.value !== this.props.value) { - let { displayMonth, displayYear, selectedDay } = this.state; - if (nextProps.value != null) { - displayMonth = nextProps.value.getMonth(); - displayYear = nextProps.value.getFullYear(); - selectedDay = nextProps.value.getDate(); - } + super.componentWillReceiveProps(nextProps); + const { value } = nextProps; + if (value === this.props.value) { + // no action needed + return; + } else if (value == null) { + // clear the value + this.setState({ value }); + } else { this.setState({ - displayMonth, - displayYear, - selectedDay, - value: nextProps.value, + displayMonth: value.getMonth(), + displayYear: value.getFullYear(), + selectedDay: value.getDate(), + value, }); } - - super.componentWillReceiveProps(nextProps); } protected validateProps(props: IDatePickerProps) { @@ -212,162 +184,146 @@ export class DatePicker extends AbstractPureComponent ); + private renderNavbar = (props: NavbarElementProps) => ( + + ); + private renderOptionsBar() { + return [ + , +
+
, + ]; + } + + private maybeRenderTimePicker() { + const { timePrecision, timePickerProps } = this.props; + if (timePrecision == null && timePickerProps === DatePicker.defaultProps.timePickerProps) { + return null; + } return ( -
-
+ ); } private handleDayClick = (day: Date, modifiers: DayModifiers, e: React.MouseEvent) => { Utils.safeInvoke(this.props.dayPickerProps.onDayClick, day, modifiers, e); - - let newValue = day; - - if (this.props.canClearSelection && modifiers.selected) { - newValue = null; + if (modifiers.disabled) { + return; } - if (this.props.value === undefined) { - // component is uncontrolled - if (!modifiers.disabled) { - const displayMonth = day.getMonth(); - const displayYear = day.getFullYear(); - const selectedDay = day.getDate(); - this.setState({ - displayMonth, - displayYear, - selectedDay, - value: newValue, - }); - } + // set now if uncontrolled, otherwise they'll be updated in `componentWillReceiveProps` + this.setState({ + displayMonth: day.getMonth(), + displayYear: day.getFullYear(), + selectedDay: day.getDate(), + }); } - - if (!modifiers.disabled) { - Utils.safeInvoke(this.props.onChange, newValue, true); - if (this.state.value != null && this.state.value.getMonth() !== day.getMonth()) { - this.ignoreNextMonthChange = true; - } - } else { - // rerender base component to get around bug where you can navigate past bounds by clicking days - this.forceUpdate(); + if (this.state.value == null || this.state.value.getMonth() !== day.getMonth()) { + this.ignoreNextMonthChange = true; } + + // allow toggling selected date by clicking it again (if prop enabled) + const newValue = + this.props.canClearSelection && modifiers.selected ? null : DateUtils.getDateTime(day, this.state.value); + this.updateValue(newValue, true); }; private computeValidDateInSpecifiedMonthYear(displayYear: number, displayMonth: number): Date { const { minDate, maxDate } = this.props; + const { selectedDay } = this.state; + // month is 0-based, date is 1-based. date 0 is last day of previous month. const maxDaysInMonth = new Date(displayYear, displayMonth + 1, 0).getDate(); - let { selectedDay } = this.state; - - if (selectedDay > maxDaysInMonth) { - selectedDay = maxDaysInMonth; - } - - // matches the underlying react-day-picker timestamp behavior - let value = new Date(displayYear, displayMonth, selectedDay, 12); + const displayDate = selectedDay == null ? 1 : Math.min(selectedDay, maxDaysInMonth); + // 12:00 matches the underlying react-day-picker timestamp behavior + const value = DateUtils.getDateTime(new Date(displayYear, displayMonth, displayDate, 12), this.state.value); + // clamp between min and max dates if (value < minDate) { - value = minDate; + return minDate; } else if (value > maxDate) { - value = maxDate; + return maxDate; } - return value; } + private handleClearClick = () => this.updateValue(null, true); + private handleMonthChange = (newDate: Date) => { - const displayMonth = newDate.getMonth(); - const displayYear = newDate.getFullYear(); - let { value } = this.state; - - if (value !== null) { - value = this.computeValidDateInSpecifiedMonthYear(displayYear, displayMonth); - if (this.ignoreNextMonthChange) { - this.ignoreNextMonthChange = false; - } else { - // if handleDayClick just got run, it means the user selected a date in a new month, - // so don't run onChange again - Utils.safeInvoke(this.props.onChange, value, false); - } + const date = this.computeValidDateInSpecifiedMonthYear(newDate.getFullYear(), newDate.getMonth()); + this.setState({ displayMonth: date.getMonth(), displayYear: date.getFullYear() }); + if (this.state.value !== null) { + // if handleDayClick just got run (so this flag is set), then the + // user selected a date in a new month, so don't invoke onChange a + // second time + this.updateValue(date, false, this.ignoreNextMonthChange); + this.ignoreNextMonthChange = false; } - - Utils.safeInvoke(this.props.dayPickerProps.onMonthChange, value); - - this.setStateWithValueIfUncontrolled({ displayMonth, displayYear }, value); + Utils.safeInvoke(this.props.dayPickerProps.onMonthChange, date); }; - private handleMonthSelectChange = (displayMonth: number) => { - let { value } = this.state; - - if (value !== null) { - value = this.computeValidDateInSpecifiedMonthYear(value.getFullYear(), displayMonth); - Utils.safeInvoke(this.props.onChange, value, false); - } - - Utils.safeInvoke(this.props.dayPickerProps.onMonthChange, value); - - this.setStateWithValueIfUncontrolled({ displayMonth }, value); + private handleTodayClick = () => { + const value = new Date(); + const displayMonth = value.getMonth(); + const displayYear = value.getFullYear(); + const selectedDay = value.getDate(); + this.setState({ displayMonth, displayYear, selectedDay }); + this.updateValue(value, true); }; - private handleYearSelectChange = (displayYear: number) => { - let { displayMonth, value } = this.state; - - if (value !== null) { - value = this.computeValidDateInSpecifiedMonthYear(displayYear, displayMonth); - Utils.safeInvoke(this.props.onChange, value, false); - displayMonth = value.getMonth(); - } else { - const { minDate, maxDate } = this.props; - const minYear = minDate.getFullYear(); - const maxYear = maxDate.getFullYear(); - const minMonth = minDate.getMonth(); - const maxMonth = maxDate.getMonth(); - - if (displayYear === minYear && displayMonth < minMonth) { - displayMonth = minMonth; - } else if (displayYear === maxYear && displayMonth > maxMonth) { - displayMonth = maxMonth; - } - } - - Utils.safeInvoke(this.props.dayPickerProps.onMonthChange, value); - - this.setStateWithValueIfUncontrolled({ displayMonth, displayYear }, value); + private handleTimeChange = (time: Date) => { + Utils.safeInvoke(this.props.timePickerProps.onChange, time); + const { value } = this.state; + const newValue = DateUtils.getDateTime(value != null ? value : new Date(), time); + this.updateValue(newValue, true); }; - private setStateWithValueIfUncontrolled(newState: IDatePickerState, value: Date) { + /** + * Update `value` by invoking `onChange` (always) and setting state (if uncontrolled). + */ + private updateValue(value: Date, isUserChange: boolean, skipOnChange = false) { + if (!skipOnChange) { + Utils.safeInvoke(this.props.onChange, value, isUserChange); + } if (this.props.value === undefined) { - // uncontrolled mode means we track value in state - newState.value = value; + this.setState({ value }); } - return this.setState(newState); } +} - private handleClearClick = () => { - if (this.props.value === undefined) { - this.setState({ value: null }); - } - Utils.safeInvoke(this.props.onChange, null, true); - }; +function getInitialValue(props: IDatePickerProps): Date | null { + // !== because `null` is a valid value (no date) + if (props.value !== undefined) { + return props.value; + } + if (props.defaultValue !== undefined) { + return props.defaultValue; + } + return null; +} - private handleTodayClick = () => { - const value = new Date(); - const displayMonth = value.getMonth(); - const displayYear = value.getFullYear(); - const selectedDay = value.getDate(); - if (this.props.value === undefined) { - this.setState({ displayMonth, displayYear, selectedDay, value }); - } else { - this.setState({ displayMonth, displayYear, selectedDay }); - } - Utils.safeInvoke(this.props.onChange, value, true); - }; +function getInitialMonth(props: IDatePickerProps, value: Date | null): Date { + const today = new Date(); + // != because we must have a real `Date` to begin the calendar on. + if (props.initialMonth != null) { + return props.initialMonth; + } else if (value != null) { + return value; + } else if (DateUtils.isDayInRange(today, [props.minDate, props.maxDate])) { + return today; + } else { + return DateUtils.getDateBetween([props.minDate, props.maxDate]); + } } diff --git a/packages/datetime/src/datePickerCaption.tsx b/packages/datetime/src/datePickerCaption.tsx index 8a7a309f3a..2f55d6623c 100644 --- a/packages/datetime/src/datePickerCaption.tsx +++ b/packages/datetime/src/datePickerCaption.tsx @@ -4,33 +4,36 @@ * Licensed under the terms of the LICENSE file distributed with this project. */ -import { Icon, Utils as BlueprintUtils } from "@blueprintjs/core"; +import { Divider, HTMLSelect, Icon, IOptionProps, Utils } from "@blueprintjs/core"; import * as React from "react"; import { CaptionElementProps } from "react-day-picker/types/props"; import * as Classes from "./common/classes"; -import * as Utils from "./common/utils"; +import { clone } from "./common/dateUtils"; +import { measureTextWidth } from "./common/utils"; export interface IDatePickerCaptionProps extends CaptionElementProps { maxDate: Date; minDate: Date; onMonthChange?: (month: number) => void; onYearChange?: (year: number) => void; + /** Callback invoked when the month or year ` - {monthOptionElements} - - - + ); const yearSelect = ( -
- - -
+ ); const orderedSelects = this.props.reverseMonthAndYearMenus @@ -109,8 +88,11 @@ export class DatePickerCaption extends React.PureComponent - {orderedSelects} +
+
(this.containerElement = ref)}> + {orderedSelects} +
+
); } @@ -123,25 +105,26 @@ export class DatePickerCaption extends React.PureComponent (this.containerElement = r); - private positionArrows() { // measure width of text as rendered inside our container element. - const monthWidth = Utils.measureTextWidth( + const monthTextWidth = measureTextWidth( this.displayedMonthText, Classes.DATEPICKER_CAPTION_MEASURE, this.containerElement, ); - this.setState({ monthWidth }); + const monthSelectWidth = + this.containerElement == null ? 0 : this.containerElement.firstElementChild.clientWidth; + const rightOffset = Math.max(2, monthSelectWidth - monthTextWidth - Icon.SIZE_STANDARD - 2); + this.setState({ monthRightOffset: rightOffset }); } - private handleMonthSelectChange = (e: React.FormEvent) => { - const month = parseInt((e.target as HTMLSelectElement).value, 10); - BlueprintUtils.safeInvoke(this.props.onMonthChange, month); - }; - - private handleYearSelectChange = (e: React.FormEvent) => { - const year = parseInt((e.target as HTMLSelectElement).value, 10); - BlueprintUtils.safeInvoke(this.props.onYearChange, year); - }; + private dateChangeHandler(updater: (date: Date, value: number) => void, handler?: (value: number) => void) { + return (e: React.FormEvent) => { + const value = parseInt((e.target as HTMLSelectElement).value, 10); + const newDate = clone(this.props.date); + updater(newDate, value); + Utils.safeInvoke(this.props.onDateChange, newDate); + Utils.safeInvoke(handler, value); + }; + } } diff --git a/packages/datetime/src/datePickerCore.tsx b/packages/datetime/src/datePickerCore.tsx index 6f2139d5df..b7b0cf4314 100644 --- a/packages/datetime/src/datePickerCore.tsx +++ b/packages/datetime/src/datePickerCore.tsx @@ -6,6 +6,7 @@ import { LocaleUtils } from "react-day-picker/types/utils"; import { Months } from "./common/months"; +import { ITimePickerProps, TimePrecision } from "./timePicker"; // DatePicker supports a simpler set of modifiers (for now). // also we need an interface for the dictionary without `today` and `outside` injected by r-d-p. @@ -59,6 +60,26 @@ export interface IDatePickerBaseProps { * @default false */ reverseMonthAndYearMenus?: boolean; + + /** + * The precision of time selection that accompanies the calendar. Passing a + * `TimePrecision` value (or providing `timePickerProps`) shows a + * `TimePicker` below the calendar. Time is preserved across date changes. + * + * This is shorthand for `timePickerProps.precision` and is a quick way to + * enable time selection. + */ + timePrecision?: TimePrecision; + + /** + * Further configure the `TimePicker` that appears beneath the calendar. + * `onChange` and `value` are ignored in favor of the corresponding + * top-level props on this component. + * + * Passing any defined value to this prop (even `{}`) will cause the + * `TimePicker` to appear. + */ + timePickerProps?: ITimePickerProps; } export const DISABLED_MODIFIER = "disabled"; diff --git a/packages/datetime/src/datePickerNavbar.tsx b/packages/datetime/src/datePickerNavbar.tsx new file mode 100644 index 0000000000..717b2ebd23 --- /dev/null +++ b/packages/datetime/src/datePickerNavbar.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2018 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the terms of the LICENSE file distributed with this project. + */ + +import { Button } from "@blueprintjs/core"; +import * as React from "react"; +import { NavbarElementProps } from "react-day-picker/types/props"; + +import classNames from "classnames"; +import * as Classes from "./common/classes"; +import { areSameMonth } from "./common/dateUtils"; + +export interface IDatePickerNavbarProps extends NavbarElementProps { + maxDate: Date; + minDate: Date; +} + +export class DatePickerNavbar extends React.PureComponent { + public render() { + const { classNames: classes, month, maxDate, minDate } = this.props; + + return ( +
+
+ ); + } + + private handleNextClick = () => this.props.onNextClick(); + private handlePreviousClick = () => this.props.onPreviousClick(); +} diff --git a/packages/datetime/src/dateRangePicker.tsx b/packages/datetime/src/dateRangePicker.tsx index 688df11ae0..e811b10bc9 100644 --- a/packages/datetime/src/dateRangePicker.tsx +++ b/packages/datetime/src/dateRangePicker.tsx @@ -4,21 +4,12 @@ * Licensed under the terms of the LICENSE file distributed with this project. */ -import { - AbstractPureComponent, - Boundary, - Classes, - DISPLAYNAME_PREFIX, - IProps, - Menu, - MenuItem, - Utils, -} from "@blueprintjs/core"; +import { AbstractPureComponent, Boundary, DISPLAYNAME_PREFIX, Divider, IProps, Utils } from "@blueprintjs/core"; import classNames from "classnames"; import * as React from "react"; -import ReactDayPicker from "react-day-picker"; +import DayPicker from "react-day-picker"; import { DayModifiers } from "react-day-picker/types/common"; -import { CaptionElementProps, DayPickerProps } from "react-day-picker/types/props"; +import { CaptionElementProps, DayPickerProps, NavbarElementProps } from "react-day-picker/types/props"; import * as DateClasses from "./common/classes"; import * as DateUtils from "./common/dateUtils"; @@ -26,7 +17,6 @@ import DateRange = DateUtils.DateRange; import * as Errors from "./common/errors"; import { MonthAndYear } from "./common/monthAndYear"; - import { DatePickerCaption } from "./datePickerCaption"; import { combineModifiers, @@ -37,7 +27,10 @@ import { IDatePickerModifiers, SELECTED_RANGE_MODIFIER, } from "./datePickerCore"; +import { DatePickerNavbar } from "./datePickerNavbar"; import { DateRangeSelectionStrategy } from "./dateRangeSelectionStrategy"; +import { Shortcuts } from "./shortcuts"; +import { TimePicker } from "./timePicker"; export interface IDateRangeShortcut { label: string; @@ -120,6 +113,7 @@ export interface IDateRangePickerState { leftView?: MonthAndYear; rightView?: MonthAndYear; value?: DateRange; + time?: DateRange; } export class DateRangePicker extends AbstractPureComponent { @@ -131,14 +125,11 @@ export class DateRangePicker extends AbstractPureComponent { @@ -148,9 +139,8 @@ export class DateRangePicker extends AbstractPureComponent DateUtils.areSameDay(this.state.value[0], day), [`${SELECTED_RANGE_MODIFIER}-end`]: day => DateUtils.areSameDay(this.state.value[1], day), - [HOVERED_RANGE_MODIFIER]: (day: Date) => { - const { hoverValue, value } = this.state; - const [selectedStart, selectedEnd] = value; + [HOVERED_RANGE_MODIFIER]: day => { + const { hoverValue, value: [selectedStart, selectedEnd] } = this.state; if (selectedStart == null && selectedEnd == null) { return false; } @@ -159,14 +149,14 @@ export class DateRangePicker extends AbstractPureComponent { + [`${HOVERED_RANGE_MODIFIER}-start`]: day => { const { hoverValue } = this.state; if (hoverValue == null || hoverValue[0] == null) { return false; } return DateUtils.areSameDay(hoverValue[0], day); }, - [`${HOVERED_RANGE_MODIFIER}-end`]: (day: Date) => { + [`${HOVERED_RANGE_MODIFIER}-end`]: day => { const { hoverValue } = this.state; if (hoverValue == null || hoverValue[1] == null) { return false; @@ -175,33 +165,11 @@ export class DateRangePicker extends AbstractPureComponent - {this.maybeRenderShortcuts()} - - - ); - } else { - // const rightMonth = contiguousCalendarMonths ? rightView.getFullDate() - return ( -
- {this.maybeRenderShortcuts()} - - + // use the left DayPicker when we only need one + return ( +
+ {this.maybeRenderShortcuts()} +
+ {this.renderCalendars(isShowingOneMonth)} + {this.maybeRenderTimePickers()}
- ); - } +
+ ); } public componentWillReceiveProps(nextProps: IDateRangePickerProps) { @@ -351,31 +262,130 @@ export class DateRangePicker extends AbstractPureComponent, + , + ]; + } + + private maybeRenderTimePickers() { + const { timePrecision, timePickerProps } = this.props; + if (timePrecision == null && timePickerProps === DateRangePicker.defaultProps.timePickerProps) { + return null; + } + return ( +
+ + +
+ ); + } + + private handleTimeChange = (newTime: Date, dateIndex: number) => { + Utils.safeInvoke(this.props.timePickerProps.onChange, newTime); + const { value, time } = this.state; + const newValue = DateUtils.getDateTime( + value[dateIndex] != null ? DateUtils.clone(value[dateIndex]) : new Date(), + newTime, + ); + const newDateRange: DateRange = [value[0], value[1]]; + newDateRange[dateIndex] = newValue; + const newTimeRange: DateRange = [time[0], time[1]]; + newTimeRange[dateIndex] = newTime; + Utils.safeInvoke(this.props.onChange, newDateRange); + this.setState({ value: newDateRange, time: newTimeRange }); + }; + + private handleTimeChangeLeftCalendar = (time: Date) => { + this.handleTimeChange(time, 0); + }; - const shortcutElements = shortcuts.map((s, i) => { + private handleTimeChangeRightCalendar = (time: Date) => { + this.handleTimeChange(time, 1); + }; + + private renderCalendars(isShowingOneMonth: boolean) { + const { contiguousCalendarMonths, dayPickerProps, locale, localeUtils, maxDate, minDate } = this.props; + const dayPickerBaseProps: DayPickerProps = { + locale, + localeUtils, + modifiers: combineModifiers(this.modifiers, this.props.modifiers), + showOutsideDays: true, + ...dayPickerProps, + disabledDays: this.getDisabledDaysModifier(), + onDayClick: this.handleDayClick, + onDayMouseEnter: this.handleDayMouseEnter, + onDayMouseLeave: this.handleDayMouseLeave, + selectedDays: this.state.value, + }; + + if (contiguousCalendarMonths || isShowingOneMonth) { return ( - ); - }); - - return {shortcutElements}; + } else { + return [ + , + , + ]; + } } + private renderNavbar = (navbarProps: NavbarElementProps) => ( + + ); + private renderSingleCaption = (captionProps: CaptionElementProps) => ( this.handleNextState(nextValue); - } - - private handleNextState(nextValue: DateRange) { + private handleNextState = (nextValue: DateRange) => { const { value } = this.state; + nextValue[0] = DateUtils.getDateTime(nextValue[0], this.state.time[0]); + nextValue[1] = DateUtils.getDateTime(nextValue[1], this.state.time[1]); + const nextState = getStateChange(value, nextValue, this.state, this.props.contiguousCalendarMonths); - if (!this.isControlled) { + if (this.props.value == null) { this.setState(nextState); } - Utils.safeInvoke(this.props.onChange, nextValue); - } + }; private handleLeftMonthChange = (newDate: Date) => { - const leftView = new MonthAndYear(newDate.getMonth(), newDate.getFullYear()); + const leftView = MonthAndYear.fromDate(newDate); Utils.safeInvoke(this.props.dayPickerProps.onMonthChange, leftView.getFullDate()); this.updateLeftView(leftView); }; private handleRightMonthChange = (newDate: Date) => { - const rightView = new MonthAndYear(newDate.getMonth(), newDate.getFullYear()); + const rightView = MonthAndYear.fromDate(newDate); Utils.safeInvoke(this.props.dayPickerProps.onMonthChange, rightView.getFullDate()); this.updateRightView(rightView); }; @@ -548,8 +556,8 @@ export class DateRangePicker extends AbstractPureComponent void) => { - const returnVal = DateUtils.clone(today); - action(returnVal); - returnVal.setDate(returnVal.getDate() + 1); - return returnVal; - }; - - const yesterday = makeDate(d => d.setDate(d.getDate() - 2)); - const oneWeekAgo = makeDate(d => d.setDate(d.getDate() - 7)); - const oneMonthAgo = makeDate(d => d.setMonth(d.getMonth() - 1)); - const threeMonthsAgo = makeDate(d => d.setMonth(d.getMonth() - 3)); - const sixMonthsAgo = makeDate(d => d.setMonth(d.getMonth() - 6)); - const oneYearAgo = makeDate(d => d.setFullYear(d.getFullYear() - 1)); - const twoYearsAgo = makeDate(d => d.setFullYear(d.getFullYear() - 2)); - - const singleDayShortcuts = allowSingleDayRange - ? [createShortcut("Today", [today, today]), createShortcut("Yesterday", [yesterday, yesterday])] - : []; - - return [ - ...singleDayShortcuts, - createShortcut("Past week", [oneWeekAgo, today]), - createShortcut("Past month", [oneMonthAgo, today]), - createShortcut("Past 3 months", [threeMonthsAgo, today]), - createShortcut("Past 6 months", [sixMonthsAgo, today]), - createShortcut("Past year", [oneYearAgo, today]), - createShortcut("Past 2 years", [twoYearsAgo, today]), - ]; + // != because we must have a real `Date` to begin the calendar on. + if (props.initialMonth != null) { + return props.initialMonth; + } else if (value[0] != null) { + return DateUtils.clone(value[0]); + } else if (value[1] != null) { + const month = DateUtils.clone(value[1]); + if (!DateUtils.areSameMonth(month, props.minDate)) { + month.setMonth(month.getMonth() - 1); + } + return month; + } else if (DateUtils.isDayInRange(today, [props.minDate, props.maxDate])) { + return today; + } else { + return DateUtils.getDateBetween([props.minDate, props.maxDate]); + } } diff --git a/packages/datetime/src/dateTimePicker.tsx b/packages/datetime/src/dateTimePicker.tsx index 6af6b1c5f4..f6cccbf598 100644 --- a/packages/datetime/src/dateTimePicker.tsx +++ b/packages/datetime/src/dateTimePicker.tsx @@ -57,6 +57,7 @@ export interface IDateTimePickerState { timeValue?: Date; } +/** @deprecated since 3.4.0. Prefer `` with `timePrecision` and `timePickerProps`. */ export class DateTimePicker extends AbstractPureComponent { public static defaultProps: IDateTimePickerProps = { canClearSelection: true, diff --git a/packages/datetime/src/datetimepicker.md b/packages/datetime/src/datetimepicker.md index 5633442f25..3c5bd7d577 100644 --- a/packages/datetime/src/datetimepicker.md +++ b/packages/datetime/src/datetimepicker.md @@ -3,6 +3,13 @@ `DateTimePicker` composes a [`DatePicker`](#datetime/datepicker) and a [`TimePicker`](#datetime/timepicker) into one container. +
+

Deprecated: use [Date picker](#datetime/datepicker)

+ This component is **deprecated since @blueprintjs/datetime v3.2.0** with the addition + of `` `timePrecision` and `timePickerProps` props to trivially + compose time selection with the existing date selection. +
+ @reactExample DateTimePickerExample @## Props diff --git a/packages/datetime/src/shortcuts.tsx b/packages/datetime/src/shortcuts.tsx new file mode 100644 index 0000000000..b668a5b7a5 --- /dev/null +++ b/packages/datetime/src/shortcuts.tsx @@ -0,0 +1,88 @@ +/* + * Copyright 2018 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the terms of the LICENSE file distributed with this project. + */ + +import { Classes, Menu, MenuItem } from "@blueprintjs/core"; +import React from "react"; +import { DATERANGEPICKER_SHORTCUTS } from "./common/classes"; +import { clone, DateRange, isDayRangeInRange } from "./common/dateUtils"; + +export interface IDateRangeShortcut { + label: string; + dateRange: DateRange; +} + +export interface IShortcutsProps { + allowSingleDayRange: boolean; + minDate: Date; + maxDate: Date; + shortcuts: IDateRangeShortcut[] | true; + onShortcutClick: (shortcut: DateRange) => void; +} + +export class Shortcuts extends React.PureComponent { + public render() { + const shortcuts = + this.props.shortcuts === true + ? createDefaultShortcuts(this.props.allowSingleDayRange) + : this.props.shortcuts; + + const shortcutElements = shortcuts.map((s, i) => ( + + )); + + return {shortcutElements}; + } + + private getShorcutClickHandler(nextValue: DateRange) { + return () => this.props.onShortcutClick(nextValue); + } + + private isShortcutInRange(shortcutDateRange: DateRange) { + return isDayRangeInRange(shortcutDateRange, [this.props.minDate, this.props.maxDate]); + } +} + +function createShortcut(label: string, dateRange: DateRange): IDateRangeShortcut { + return { dateRange, label }; +} + +function createDefaultShortcuts(allowSingleDayRange: boolean) { + const today = new Date(); + const makeDate = (action: (d: Date) => void) => { + const returnVal = clone(today); + action(returnVal); + returnVal.setDate(returnVal.getDate() + 1); + return returnVal; + }; + + const yesterday = makeDate(d => d.setDate(d.getDate() - 2)); + const oneWeekAgo = makeDate(d => d.setDate(d.getDate() - 7)); + const oneMonthAgo = makeDate(d => d.setMonth(d.getMonth() - 1)); + const threeMonthsAgo = makeDate(d => d.setMonth(d.getMonth() - 3)); + const sixMonthsAgo = makeDate(d => d.setMonth(d.getMonth() - 6)); + const oneYearAgo = makeDate(d => d.setFullYear(d.getFullYear() - 1)); + const twoYearsAgo = makeDate(d => d.setFullYear(d.getFullYear() - 2)); + + const singleDayShortcuts = allowSingleDayRange + ? [createShortcut("Today", [today, today]), createShortcut("Yesterday", [yesterday, yesterday])] + : []; + + return [ + ...singleDayShortcuts, + createShortcut("Past week", [oneWeekAgo, today]), + createShortcut("Past month", [oneMonthAgo, today]), + createShortcut("Past 3 months", [threeMonthsAgo, today]), + createShortcut("Past 6 months", [sixMonthsAgo, today]), + createShortcut("Past year", [oneYearAgo, today]), + createShortcut("Past 2 years", [twoYearsAgo, today]), + ]; +} diff --git a/packages/datetime/src/timePicker.tsx b/packages/datetime/src/timePicker.tsx index c5b8b5cb13..f400f10283 100644 --- a/packages/datetime/src/timePicker.tsx +++ b/packages/datetime/src/timePicker.tsx @@ -354,11 +354,10 @@ export class TimePicker extends React.Component) { - return (e.currentTarget as HTMLInputElement).value; + return (e.target as HTMLInputElement).value; } interface IKeyEventMap { diff --git a/packages/datetime/test/common/dateUtilsTests.tsx b/packages/datetime/test/common/dateUtilsTests.tsx index be5a3060d8..cd29c5999c 100644 --- a/packages/datetime/test/common/dateUtilsTests.tsx +++ b/packages/datetime/test/common/dateUtilsTests.tsx @@ -11,7 +11,7 @@ import * as DateUtils from "../../src/common/dateUtils"; import { Months } from "../../src/common/months"; import { assertTimeIs, createTimeObject } from "./dateTestUtils"; -describe("dateUtils", () => { +describe("DateUtils", () => { describe("areRangesEqual", () => { const DATE_1 = new Date(2017, Months.JANUARY, 1); const DATE_2 = new Date(2017, Months.JANUARY, 2); @@ -324,4 +324,28 @@ describe("dateUtils", () => { expect(() => DateUtils.get24HourFrom12Hour(12, true)).to.not.throw(); }); }); + + describe("getDateTime", () => { + const DATE = new Date("July 1 1999 4:30"); + + it("null date returns null", () => expect(DateUtils.getDateTime(null)).to.be.null); + + it("clears time if time arg omitted", () => { + assertDateTime(DateUtils.getDateTime(DATE)); + }); + + it("null time arg clears time", () => { + assertDateTime(DateUtils.getDateTime(DATE, null)); + }); + + it("sets time if given", () => { + const time = createTimeObject(12, 12, 12, 12); + assertDateTime(DateUtils.getDateTime(DATE, time), time); + }); + + function assertDateTime(date: Date, time: Date = createTimeObject(0)) { + expect(date.toDateString()).to.equal(DATE.toDateString(), "date not preserved"); + expect(date.toTimeString()).to.equal(time.toTimeString()); + } + }); }); diff --git a/packages/datetime/test/dateInputTests.tsx b/packages/datetime/test/dateInputTests.tsx index 75dd5c7f6f..c081fe76b6 100644 --- a/packages/datetime/test/dateInputTests.tsx +++ b/packages/datetime/test/dateInputTests.tsx @@ -116,26 +116,26 @@ describe("", () => { it("Popover should not close if focus moves to month select", () => { const defaultValue = new Date(2018, Months.FEBRUARY, 6, 15, 0, 0, 0); - const wrapper = mount(); - wrapper.setState({ isOpen: true }); - wrapper + const { root, changeSelect } = wrap(); + root.setState({ isOpen: true }); + root .find("input") .simulate("focus") .simulate("blur"); - wrapper.find(`.${Classes.DATEPICKER_MONTH_SELECT}`).simulate("change", { value: Months.FEBRUARY.toString() }); - assert.isTrue(wrapper.find(Popover).prop("isOpen")); + changeSelect(Classes.DATEPICKER_MONTH_SELECT, Months.FEBRUARY); + assert.isTrue(root.find(Popover).prop("isOpen")); }); it("Popover should not close if focus moves to year select", () => { const defaultValue = new Date(2018, Months.FEBRUARY, 6, 15, 0, 0, 0); - const wrapper = mount(); - wrapper.setState({ isOpen: true }); - wrapper + const { root, changeSelect } = wrap(); + root.setState({ isOpen: true }); + root .find("input") .simulate("focus") .simulate("blur"); - wrapper.find(`.${Classes.DATEPICKER_YEAR_SELECT}`).simulate("change", { value: "2016" }); - assert.isTrue(wrapper.find(Popover).prop("isOpen")); + changeSelect(Classes.DATEPICKER_YEAR_SELECT, 2016); + assert.isTrue(root.find(Popover).prop("isOpen")); }); it("setting timePrecision renders a TimePicker", () => { @@ -192,8 +192,8 @@ describe("", () => { const timePicker = wrapper.find(TimePicker); - // ensure the top-level props override props by the same name in timePickerProps - assert.equal(timePicker.prop("precision"), TimePrecision.SECOND); + // value > timePickerProps > timePrecision + assert.equal(timePicker.prop("precision"), TimePrecision.MILLISECOND); assert.notEqual(timePicker.prop("onChange"), onChange); DateTestUtils.assertDatesEqual(timePicker.prop("value"), value); @@ -321,12 +321,15 @@ describe("", () => { it("Popover doesn't close when month changes", () => { const defaultValue = new Date(2017, Months.JANUARY, 1); - const wrapper = mount(); - wrapper.setState({ isOpen: true }); - wrapper - .find(`.${Classes.DATEPICKER_MONTH_SELECT}`) - .simulate("change", { value: Months.FEBRUARY.toString() }); - assert.isTrue(wrapper.find(Popover).prop("isOpen")); + const { root, changeSelect } = wrap( + , + ); + changeSelect(Classes.DATEPICKER_MONTH_SELECT, Months.FEBRUARY); + assert.isTrue(root.find(Popover).prop("isOpen")); }); it("Popover doesn't close when time changes", () => { @@ -552,13 +555,14 @@ describe("", () => { it("isUserChange is false when month changes", () => { const onChange = sinon.spy(); - const wrapper = mount(); - wrapper.setState({ isOpen: true }); - - wrapper - .find(`.${Classes.DATEPICKER_MONTH_SELECT}`) - .simulate("change", { value: Months.FEBRUARY.toString() }); - + wrap( + , + ).changeSelect(Classes.DATEPICKER_MONTH_SELECT, Months.FEBRUARY); assert.isTrue(onChange.calledOnce); assert.isFalse(onChange.args[0][1], "expected isUserChange to be false"); }); @@ -616,6 +620,12 @@ describe("", () => { function wrap(dateInput: JSX.Element) { const wrapper = mount(dateInput); return { + changeSelect: (className: string, value: React.ReactText) => { + return wrapper + .find(`.${className}`) + .find("select") + .simulate("change", { target: { value: value.toString() } }); + }, getDay: (dayNumber = 1) => { return wrapper .find(`.${Classes.DATEPICKER_DAY}`) diff --git a/packages/datetime/test/datePickerCaptionTests.tsx b/packages/datetime/test/datePickerCaptionTests.tsx index 74377d9376..27e57e1f47 100644 --- a/packages/datetime/test/datePickerCaptionTests.tsx +++ b/packages/datetime/test/datePickerCaptionTests.tsx @@ -9,6 +9,7 @@ import { mount } from "enzyme"; import * as React from "react"; import * as sinon from "sinon"; +import { HTMLSelect } from "@blueprintjs/core"; import { ClassNames } from "react-day-picker/types/common"; import { DatePickerCaption, IDatePickerCaptionProps } from "../src/datePickerCaption"; import { Classes, IDatePickerLocaleUtils } from "../src/index"; @@ -42,14 +43,14 @@ describe("", () => { const onYearChange = sinon.spy(); const { month, year } = renderDatePickerCaption({ onMonthChange, onYearChange }); - assert.isTrue(onMonthChange.notCalled); + assert.isTrue(onMonthChange.notCalled, "onMonthChange before"); month.simulate("change", { target: { value: 11 } }); - assert.isTrue(onMonthChange.calledOnce); + assert.isTrue(onMonthChange.calledOnce, "onMonthChange after"); assert.strictEqual(onMonthChange.args[0][0], 11); - assert.isTrue(onYearChange.notCalled); + assert.isTrue(onYearChange.notCalled, "onYearChange before"); year.simulate("change", { target: { value: 2014 } }); - assert.isTrue(onYearChange.calledOnce); + assert.isTrue(onYearChange.calledOnce, "onYearChange after"); assert.strictEqual(onYearChange.args[0][0], 2014); }); @@ -87,9 +88,15 @@ describe("", () => { ); return { - month: wrapper.find(`.${Classes.DATEPICKER_MONTH_SELECT}`), + month: wrapper + .find(HTMLSelect) + .filter({ className: Classes.DATEPICKER_MONTH_SELECT }) + .find("select"), root: wrapper, - year: wrapper.find(`.${Classes.DATEPICKER_YEAR_SELECT}`), + year: wrapper + .find(HTMLSelect) + .filter({ className: Classes.DATEPICKER_YEAR_SELECT }) + .find("select"), }; } }); diff --git a/packages/datetime/test/datePickerTests.tsx b/packages/datetime/test/datePickerTests.tsx index faa9b78f97..6005c3d99e 100644 --- a/packages/datetime/test/datePickerTests.tsx +++ b/packages/datetime/test/datePickerTests.tsx @@ -10,13 +10,14 @@ import * as React from "react"; import ReactDayPicker from "react-day-picker"; import * as sinon from "sinon"; -import { Button } from "@blueprintjs/core"; +import { Button, HTMLSelect } from "@blueprintjs/core"; import { expectPropValidationError } from "@blueprintjs/test-commons"; import * as DateUtils from "../src/common/dateUtils"; import * as Errors from "../src/common/errors"; import { Months } from "../src/common/months"; -import { Classes, DatePicker, IDatePickerModifiers, IDatePickerProps } from "../src/index"; +import { IDatePickerState } from "../src/datePicker"; +import { Classes, DatePicker, IDatePickerModifiers, IDatePickerProps, TimePicker, TimePrecision } from "../src/index"; import { assertDatesEqual, assertDayDisabled, assertDayHidden } from "./common/dateTestUtils"; describe("", () => { @@ -25,9 +26,9 @@ describe("", () => { }); it("no day is selected by default", () => { - const { getSelectedDays, root } = wrap(); - assert.lengthOf(getSelectedDays(), 0); - assert.isUndefined(root.state("selectedDay")); + const { assertSelectedDays, root } = wrap(); + assertSelectedDays(); + assert.isNull(root.state("selectedDay")); }); describe("reconciliates dayPickerProps", () => { @@ -87,11 +88,10 @@ describe("", () => { it("disables out-of-range min dates", () => { const defaultValue = new Date(2017, Months.SEPTEMBER, 1); - const { getDay, root } = wrap( + const { getDay, clickPreviousMonth } = wrap( , ); - const prevMonthButton = root.find(".DayPicker-NavButton--prev").first(); - prevMonthButton.simulate("click"); + clickPreviousMonth(); assertDayDisabled(getDay(10)); assertDayDisabled(getDay(21), false); }); @@ -160,6 +160,7 @@ describe("", () => { root .find({ className: Classes.DATEPICKER_MONTH_SELECT }) .first() + .find("select") .simulate("change"); assert.isTrue(onMonthChange.called); }); @@ -170,6 +171,7 @@ describe("", () => { root .find({ className: Classes.DATEPICKER_YEAR_SELECT }) .first() + .find("select") .simulate("change"); assert.isTrue(onMonthChange.called); }); @@ -333,37 +335,31 @@ describe("", () => { describe("when controlled", () => { it("value initially selects a day", () => { const value = new Date(2010, Months.JANUARY, 1); - const { getSelectedDays } = wrap( + const { assertSelectedDays } = wrap( , ); - assert.lengthOf(getSelectedDays(), 1); - assert.equal( - getSelectedDays() - .at(0) - .text(), - value.getDate().toString(), - ); + assertSelectedDays(value.getDate()); }); it("selection does not update automatically", () => { - const { getDay, getSelectedDays } = wrap(); - assert.lengthOf(getSelectedDays(), 0); + const { getDay, assertSelectedDays } = wrap(); + assertSelectedDays(); getDay().simulate("click"); - assert.lengthOf(getSelectedDays(), 0); + assertSelectedDays(); }); it("selected day doesn't update on current month view change", () => { const value = new Date(2010, Months.JANUARY, 2); - const { months, root, getSelectedDays, years } = wrap(); - root.find(".DayPicker-NavButton--prev").simulate("click"); + const { assertSelectedDays, clickPreviousMonth, months, years } = wrap(); + clickPreviousMonth(); - assert.lengthOf(getSelectedDays(), 1); + assertSelectedDays(2); months.simulate("change", { target: { value: Months.JUNE } }); - assert.lengthOf(getSelectedDays(), 0); + assertSelectedDays(); years.simulate("change", { target: { value: 2014 } }); - assert.lengthOf(getSelectedDays(), 0); + assertSelectedDays(); }); it("onChange fired when a day is clicked", () => { @@ -377,9 +373,9 @@ describe("", () => { it("onChange fired when month is changed", () => { const value = new Date(2010, Months.JANUARY, 2); const onChange = sinon.spy(); - const { months, root } = wrap(); + const { months, clickPreviousMonth } = wrap(); - root.find(".DayPicker-NavButton--prev").simulate("click"); + clickPreviousMonth(); assert.isTrue(onChange.calledOnce, "expected onChange called"); assert.isFalse(onChange.firstCall.args[1], "expected isUserChange to be false"); @@ -405,9 +401,8 @@ describe("", () => { describe("when uncontrolled", () => { it("defaultValue initially selects a day", () => { const today = new Date(); - const selectedDays = wrap().getSelectedDays(); - assert.lengthOf(selectedDays, 1); - assert.equal(selectedDays.at(0).text(), today.getDate().toString()); + const { assertSelectedDays } = wrap(); + assertSelectedDays(today.getDate()); }); it("onChange fired when a day is clicked", () => { @@ -419,47 +414,46 @@ describe("", () => { }); it("selected day updates are automatic", () => { - const { getDay, getSelectedDays } = wrap(); - assert.lengthOf(getSelectedDays(), 0); + const { assertSelectedDays, getDay } = wrap(); + assertSelectedDays(); getDay(3).simulate("click"); - assert.deepEqual(getSelectedDays().map(d => d.text()), ["3"]); + assertSelectedDays(3); }); it("selected day is preserved when selections are changed", () => { const initialMonth = new Date(2015, Months.JULY, 1); - const { getDay, getSelectedDays, months } = wrap(); + const { assertSelectedDays, getDay, months } = wrap(); getDay(31).simulate("click"); months.simulate("change", { target: { value: Months.AUGUST } }); - assert.deepEqual(getSelectedDays().map(d => d.text()), ["31"]); + assertSelectedDays(31); }); it("selected day is changed if necessary when selections are changed", () => { const initialMonth = new Date(2015, Months.JULY, 1); - const { getDay, getSelectedDays, root } = wrap(); + const { assertSelectedDays, getDay, clickPreviousMonth } = wrap(); getDay(31).simulate("click"); - root.find(".DayPicker-NavButton--prev").simulate("click"); - assert.deepEqual(getSelectedDays().map(d => d.text()), ["30"]); + clickPreviousMonth(); + assertSelectedDays(30); + // remembers actual date that was clicked and restores if possible + clickPreviousMonth(); + assertSelectedDays(31); }); it("selected day is changed to minDate or maxDate if selections are changed outside bounds", () => { const initialMonth = new Date(2015, Months.JULY, 1); const minDate = new Date(2015, Months.MARCH, 13); const maxDate = new Date(2015, Months.NOVEMBER, 21); - const { getDay, getSelectedDays, months } = wrap( + const { assertSelectedDays, getDay, months } = wrap( , ); getDay(1).simulate("click"); months.simulate("change", { target: { value: Months.MARCH } }); - let selectedDayElements = getSelectedDays(); - assert.lengthOf(selectedDayElements, 1); - assert.equal(selectedDayElements.at(0).text(), minDate.getDate().toString()); + assertSelectedDays(minDate.getDate()); getDay(25).simulate("click"); months.simulate("change", { target: { value: Months.NOVEMBER } }); - selectedDayElements = getSelectedDays(); - assert.lengthOf(selectedDayElements, 1); - assert.equal(selectedDayElements.at(0).text(), maxDate.getDate().toString()); + assertSelectedDays(maxDate.getDate()); }); it("can change displayed date with the dropdowns in the caption", () => { @@ -474,6 +468,66 @@ describe("", () => { }); }); + describe("time selection", () => { + const defaultValue = new Date(2012, 2, 5, 6, 5, 40); + + it("setting timePrecision shows a TimePicker", () => { + const { root } = wrap(); + assert.isFalse(root.find(TimePicker).exists()); + root.setProps({ timePrecision: "minute" }); + assert.isTrue(root.find(TimePicker).exists()); + }); + + it("setting timePickerProps shows a TimePicker", () => { + const { root } = wrap(); + assert.isTrue(root.find(TimePicker).exists()); + }); + + it("onChange fired when the time is changed", () => { + const onChangeSpy = sinon.spy(); + const { root } = wrap( + , + ); + assert.isTrue(onChangeSpy.notCalled); + root + .find(`.${Classes.TIMEPICKER_ARROW_BUTTON}.${Classes.TIMEPICKER_HOUR}`) + .first() + .simulate("click"); + assert.isTrue(onChangeSpy.calledOnce); + const cbHour = onChangeSpy.firstCall.args[0].getHours(); + assert.strictEqual(cbHour, defaultValue.getHours() + 1); + }); + + it("changing date does not change time", () => { + const onChangeSpy = sinon.spy(); + wrap() + .getDay(16) + .simulate("click"); + assert.isTrue(DateUtils.areSameTime(onChangeSpy.firstCall.args[0] as Date, defaultValue)); + }); + + it("changing time does not change date", () => { + const onChangeSpy = sinon.spy(); + const { setTimeInput } = wrap( + , + ); + setTimeInput("minute", 45); + assert.isTrue(DateUtils.areSameDay(onChangeSpy.firstCall.args[0] as Date, defaultValue)); + }); + + it("changing time without date uses today", () => { + const onChangeSpy = sinon.spy(); + // no date set via props + const { setTimeInput } = wrap(); + setTimeInput("minute", 45); + assert.isTrue(DateUtils.areSameDay(onChangeSpy.firstCall.args[0] as Date, new Date())); + }); + }); + it("onChange correctly passes a Date and never null when canClearSelection is false", () => { const onChange = sinon.spy(); const { getDay } = wrap(); @@ -519,17 +573,37 @@ describe("", () => { }); function wrap(datepicker: JSX.Element) { - const wrapper = mount(datepicker); + const wrapper = mount(datepicker); return { - getDay: (dayNumber = 1) => { - return wrapper + /** Asserts that the given days are selected. No arguments asserts that selection is empty. */ + assertSelectedDays: (...days: number[]) => + assert.sameMembers(wrapper.find(`.${Classes.DATEPICKER_DAY_SELECTED}`).map(d => +d.text()), days), + clickNextMonth: () => + wrapper + .find(Button) + .last() + .simulate("click"), + clickPreviousMonth: () => + wrapper + .find(Button) + .first() + .simulate("click"), + getDay: (dayNumber = 1) => + wrapper .find(`.${Classes.DATEPICKER_DAY}`) - .filterWhere(day => day.text() === "" + dayNumber && !day.hasClass(Classes.DATEPICKER_DAY_OUTSIDE)); - }, - getSelectedDays: () => wrapper.find(`.${Classes.DATEPICKER_DAY_SELECTED}`), - months: wrapper.find(`.${Classes.DATEPICKER_MONTH_SELECT}`), + .filterWhere(day => day.text() === "" + dayNumber && !day.hasClass(Classes.DATEPICKER_DAY_OUTSIDE)), + setTimeInput: (precision: TimePrecision | "hour", value: number) => + wrapper.find(`.${Classes.TIMEPICKER}-${precision}`).simulate("blur", { target: { value } }), + + months: wrapper + .find(HTMLSelect) + .filter({ className: Classes.DATEPICKER_MONTH_SELECT }) + .find("select"), root: wrapper, - years: wrapper.find(`.${Classes.DATEPICKER_YEAR_SELECT}`), + years: wrapper + .find(HTMLSelect) + .filter({ className: Classes.DATEPICKER_YEAR_SELECT }) + .find("select"), }; } }); diff --git a/packages/datetime/test/dateRangePickerTests.tsx b/packages/datetime/test/dateRangePickerTests.tsx index fe32b5f78e..1b14a1375d 100644 --- a/packages/datetime/test/dateRangePickerTests.tsx +++ b/packages/datetime/test/dateRangePickerTests.tsx @@ -4,13 +4,11 @@ * Licensed under the terms of the LICENSE file distributed with this project. */ -import { Classes } from "@blueprintjs/core"; +import { Button } from "@blueprintjs/core"; import { assert } from "chai"; import { mount, ReactWrapper } from "enzyme"; import * as React from "react"; import ReactDayPicker from "react-day-picker"; -import * as ReactDOM from "react-dom"; -import * as TestUtils from "react-dom/test-utils"; import * as sinon from "sinon"; import { expectPropValidationError } from "@blueprintjs/test-commons"; @@ -18,49 +16,38 @@ import { expectPropValidationError } from "@blueprintjs/test-commons"; import * as DateUtils from "../src/common/dateUtils"; import * as Errors from "../src/common/errors"; import { Months } from "../src/common/months"; -import { IDateRangePickerState, IDateRangeShortcut } from "../src/dateRangePicker"; +import { DatePickerNavbar } from "../src/datePickerNavbar"; +import { IDateRangePickerState } from "../src/dateRangePicker"; import { Classes as DateClasses, DateRange, DateRangePicker, IDatePickerModifiers, IDateRangePickerProps, + TimePicker, + TimePrecision, } from "../src/index"; -import { assertDatesEqual, assertDayDisabled, assertDayHidden } from "./common/dateTestUtils"; +import { assertDayDisabled } from "./common/dateTestUtils"; describe("", () => { - let testsContainerElement: Element; - let dateRangePicker: DateRangePicker; - let onDateRangePickerChangeSpy: sinon.SinonSpy; - let onDateRangePickerHoverChangeSpy: sinon.SinonSpy; - - before(() => { - // this is essentially what TestUtils.renderIntoDocument does - testsContainerElement = document.createElement("div"); - document.body.appendChild(testsContainerElement); - }); - - afterEach(() => { - ReactDOM.unmountComponentAtNode(testsContainerElement); - }); + let onChangeSpy: sinon.SinonSpy; + let onHoverChangeSpy: sinon.SinonSpy; it("renders its template", () => { - renderDateRangePicker(); - assert.lengthOf(document.getElementsByClassName(DateClasses.DATERANGEPICKER), 1); + const { wrapper } = render(); + assert.isTrue(wrapper.find(`.${DateClasses.DATERANGEPICKER}`).exists()); }); - it("no day is selected by default", () => { - renderDateRangePicker(); - assert.lengthOf(getSelectedDayElements(), 0); + it("no days are selected by default", () => { + const { wrapper, assertSelectedDays } = render(); + assert.deepEqual(wrapper.state("value"), [null, null]); + assertSelectedDays(); }); it("user-provided modifiers are applied", () => { - renderDateRangePicker({ - modifiers: { odd: (d: Date) => d.getDate() % 2 === 1 }, - }); - - assert.isFalse(getDayElement(4).classList.contains("DayPicker-Day--odd")); - assert.isTrue(getDayElement(5).classList.contains("DayPicker-Day--odd")); + const { left } = render({ modifiers: { odd: d => d.getDate() % 2 === 1 } }); + assert.isFalse(left.findDay(4).hasClass("DayPicker-Day--odd")); + assert.isTrue(left.findDay(5).hasClass("DayPicker-Day--odd")); }); describe("reconciliates dayPickerProps", () => { @@ -71,42 +58,12 @@ describe("", () => { assert.equal(firstWeekday.prop("weekday"), selectedFirstDay); }); - it("shows outside days by default", () => { - const defaultValue = [new Date(2017, Months.SEPTEMBER, 1), null] as DateRange; - const firstDayInView = new Date(2017, Months.AUGUST, 27, 12, 0); - const { leftView } = wrap(); - const firstDay = leftView.find("Day").first(); - assertDatesEqual(new Date(firstDay.prop("day")), firstDayInView); - }); - - it("doesn't show outside days if showOutsideDays=false", () => { - const defaultValue = [new Date(2017, Months.SEPTEMBER, 1, 12), null] as DateRange; - const { leftView, rightView } = wrap( - , - ); - const leftDays = leftView.find("Day"); - const rightDays = rightView.find("Day"); - - assertDayHidden(leftDays.at(0)); - assertDayHidden(leftDays.at(1)); - assertDayHidden(leftDays.at(2)); - assertDayHidden(leftDays.at(3)); - assertDayHidden(leftDays.at(4)); - assertDayHidden(leftDays.at(5), false); - - assertDayHidden(rightDays.at(30), false); - assertDayHidden(rightDays.at(31)); - assertDayHidden(rightDays.at(32)); - assertDayHidden(rightDays.at(33)); - assertDayHidden(rightDays.at(34)); - }); - it("disables days according to custom modifiers in addition to default modifiers", () => { const disableFridays = { daysOfWeek: [5] }; const defaultValue = [new Date(2017, Months.SEPTEMBER, 1), null] as DateRange; const maxDate = new Date(2017, Months.OCTOBER, 20); - const { getDayLeftView, getDayRightView } = wrap( + const { left, right } = wrap( ", () => { />, ); - assertDayDisabled(getDayLeftView(15)); - assertDayDisabled(getDayRightView(21)); - assertDayDisabled(getDayLeftView(10), false); + assertDayDisabled(left.findDay(15)); + assertDayDisabled(right.findDay(21)); + assertDayDisabled(left.findDay(10), false); }); it("disables out-of-range max dates", () => { - const defaultValue = [new Date(2017, Months.AUGUST, 1), null] as DateRange; - const { getDayRightView } = wrap( - , + const { right } = wrap( + , ); - assertDayDisabled(getDayRightView(21)); - assertDayDisabled(getDayRightView(10), false); + assertDayDisabled(right.findDay(21)); + assertDayDisabled(right.findDay(10), false); }); it("disables out-of-range min dates", () => { - const defaultValue = [new Date(2017, Months.SEPTEMBER, 1), null] as DateRange; - const { getDayLeftView, root } = wrap( - , + const { left } = wrap( + , ); - const prevMonthButton = root.find(".DayPicker-NavButton--prev").first(); - prevMonthButton.simulate("click"); - assertDayDisabled(getDayLeftView(10)); - assertDayDisabled(getDayLeftView(21), false); + assertDayDisabled(left.findDay(10)); + assertDayDisabled(left.findDay(21), false); }); it("allows top-level locale, localeUtils, and modifiers to be overridden by same props in dayPickerProps", () => { @@ -167,10 +126,10 @@ describe("", () => { }; const wrapper = mount(); - const DayPicker = wrapper.find("DayPicker").first(); - assert.equal(DayPicker.prop("locale"), dayPickerProps.locale); - assert.equal(DayPicker.prop("localeUtils"), dayPickerProps.localeUtils); - assert.equal(DayPicker.prop("modifiers"), dayPickerProps.modifiers); + const dayPicker = wrapper.find("DayPicker").first(); + assert.equal(dayPicker.prop("locale"), dayPickerProps.locale); + assert.equal(dayPicker.prop("localeUtils"), dayPickerProps.localeUtils); + assert.equal(dayPicker.prop("modifiers"), dayPickerProps.modifiers); }); describe("event handlers", () => { @@ -179,129 +138,118 @@ describe("", () => { it("calls onMonthChange on button next click", () => { const onMonthChange = sinon.spy(); - const { leftDayPickerNavbar } = wrap( - , + wrap().clickNavButton( + "next", ); - leftDayPickerNavbar.find(".DayPicker-NavButton--next").simulate("click"); assert.isTrue(onMonthChange.called); }); it("calls onMonthChange on button prev click", () => { const onMonthChange = sinon.spy(); - const { leftDayPickerNavbar } = wrap( - , + wrap().clickNavButton( + "prev", ); - leftDayPickerNavbar.find(".DayPicker-NavButton--prev").simulate("click"); assert.isTrue(onMonthChange.called); }); it("calls onMonthChange on button next click of left calendar", () => { const onMonthChange = sinon.spy(); - const { leftDayPickerNavbar } = wrap( + wrap( , - ); - leftDayPickerNavbar.find(".DayPicker-NavButton--next").simulate("click"); + ).clickNavButton("next"); assert.isTrue(onMonthChange.called); }); it("calls onMonthChange on button prev click of left calendar", () => { const onMonthChange = sinon.spy(); - const { leftDayPickerNavbar } = wrap( + wrap( , - ); - leftDayPickerNavbar.find(".DayPicker-NavButton--prev").simulate("click"); + ).clickNavButton("prev"); assert.isTrue(onMonthChange.called); }); it("calls onMonthChange on button next click of right calendar", () => { const onMonthChange = sinon.spy(); - const { rightDayPickerNavbar } = wrap( + wrap( , - ); - rightDayPickerNavbar.find(".DayPicker-NavButton--next").simulate("click"); + ).clickNavButton("next", 1); assert.isTrue(onMonthChange.called); }); it("calls onMonthChange on button prev click of right calendar", () => { const onMonthChange = sinon.spy(); - const { rightDayPickerNavbar } = wrap( + wrap( , - ); - rightDayPickerNavbar.find(".DayPicker-NavButton--prev").simulate("click"); + ).clickNavButton("prev", 1); assert.isTrue(onMonthChange.called); }); it("calls onMonthChange on month select change in left calendar", () => { const onMonthChange = sinon.spy(); - const { leftView } = wrap( + wrap( , - ); - leftView.find({ className: DateClasses.DATEPICKER_MONTH_SELECT }).simulate("change"); + ).left.monthSelect.simulate("change"); assert.isTrue(onMonthChange.called); }); it("calls onMonthChange on month select change in right calendar", () => { const onMonthChange = sinon.spy(); - const { rightView } = wrap( + wrap( , - ); - rightView.find({ className: DateClasses.DATEPICKER_MONTH_SELECT }).simulate("change"); + ).right.monthSelect.simulate("change"); assert.isTrue(onMonthChange.called); }); it("calls onMonthChange on year select change in left calendar", () => { const onMonthChange = sinon.spy(); - const { leftView } = wrap( + wrap( , - ); - leftView.find({ className: DateClasses.DATEPICKER_YEAR_SELECT }).simulate("change"); + ).left.monthSelect.simulate("change"); assert.isTrue(onMonthChange.called); }); it("calls onMonthChange on year select change in right calendar", () => { const onMonthChange = sinon.spy(); - const { rightView } = wrap( + wrap( , - ); - rightView.find({ className: DateClasses.DATEPICKER_YEAR_SELECT }).simulate("change"); + ).right.monthSelect.simulate("change"); assert.isTrue(onMonthChange.called); }); it("calls onDayMouseEnter", () => { const onDayMouseEnter = sinon.spy(); - renderDateRangePicker({ defaultValue, dayPickerProps: { onDayMouseEnter } }); - mouseEnterDay(14); + render({ defaultValue, dayPickerProps: { onDayMouseEnter } }).left.mouseEnterDay(14); assert.isTrue(onDayMouseEnter.called); }); it("calls onDayMouseLeave", () => { const onDayMouseLeave = sinon.spy(); - renderDateRangePicker({ defaultValue, dayPickerProps: { onDayMouseLeave } }); - mouseEnterDay(14); - mouseLeaveDay(14); + render({ defaultValue, dayPickerProps: { onDayMouseLeave } }) + .left.mouseEnterDay(14) + .findDay(14) + .simulate("mouseleave"); assert.isTrue(onDayMouseLeave.called); }); it("calls onDayClick", () => { const onDayClick = sinon.spy(); - renderDateRangePicker({ defaultValue, dayPickerProps: { onDayClick } }); - clickDay(14); + render({ defaultValue, dayPickerProps: { onDayClick } }).left.clickDay(14); assert.isTrue(onDayClick.called); }); }); @@ -313,58 +261,45 @@ describe("", () => { const initialMonth = new Date(2002, Months.MARCH, 1); const maxDate = new Date(2020, Months.JANUARY); const minDate = new Date(2000, Months.JANUARY); - renderDateRangePicker({ defaultValue, initialMonth, maxDate, minDate }); - assert.equal(dateRangePicker.state.leftView.getYear(), 2002); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MARCH); + render({ defaultValue, initialMonth, maxDate, minDate }).left.assertMonthYear(Months.MARCH, 2002); }); it("is defaultValue if set and initialMonth not set", () => { const defaultValue = [new Date(2007, Months.APRIL, 4), null] as DateRange; const maxDate = new Date(2020, Months.JANUARY); const minDate = new Date(2000, Months.JANUARY); - renderDateRangePicker({ defaultValue, maxDate, minDate }); - assert.equal(dateRangePicker.state.leftView.getYear(), 2007); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.APRIL); + render({ defaultValue, maxDate, minDate }).left.assertMonthYear(Months.APRIL, 2007); }); it("is value if set and initialMonth not set", () => { const maxDate = new Date(2020, Months.JANUARY); const minDate = new Date(2000, Months.JANUARY); const value = [new Date(2007, Months.APRIL, 4), null] as DateRange; - renderDateRangePicker({ maxDate, minDate, value }); - assert.equal(dateRangePicker.state.leftView.getYear(), 2007); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.APRIL); + render({ maxDate, minDate, value }).left.assertMonthYear(Months.APRIL, 2007); }); it("is (endDate - 1 month) if only endDate is set", () => { const value = [null, new Date(2007, Months.APRIL, 4)] as DateRange; - renderDateRangePicker({ value }); - assert.equal(dateRangePicker.state.leftView.getYear(), 2007); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MARCH); + render({ value }).left.assertMonthYear(Months.MARCH, 2007); }); it("is endDate if only endDate is set and endDate === minDate month", () => { const minDate = new Date(2007, Months.APRIL); const value = [null, new Date(2007, Months.APRIL, 4)] as DateRange; - renderDateRangePicker({ minDate, value }); - assert.equal(dateRangePicker.state.leftView.getYear(), 2007); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.APRIL); + render({ minDate, value }).left.assertMonthYear(Months.APRIL, 2007); }); it("is today if only maxDate/minDate set and today is in date range", () => { const maxDate = new Date(2020, Months.JANUARY); const minDate = new Date(2000, Months.JANUARY); const today = new Date(); - renderDateRangePicker({ maxDate, minDate }); - assert.equal(dateRangePicker.state.leftView.getYear(), today.getFullYear()); - assert.equal(dateRangePicker.state.leftView.getMonth(), today.getMonth()); + render({ maxDate, minDate }).left.assertMonthYear(today.getMonth(), today.getFullYear()); }); it("is a day between minDate and maxDate if only maxDate/minDate set and today is not in range", () => { const maxDate = new Date(2005, Months.JANUARY); const minDate = new Date(2000, Months.JANUARY); - renderDateRangePicker({ maxDate, minDate }); - const leftView = dateRangePicker.state.leftView; + const leftView = render({ maxDate, minDate }).wrapper.state("leftView"); assert.isTrue( DateUtils.isDayInRange(new Date(leftView.getYear(), leftView.getMonth()), [minDate, maxDate]), ); @@ -377,19 +312,14 @@ describe("", () => { const maxDate = new Date(MAX_YEAR, Months.DECEMBER, 31); const minDate = new Date(2000, 0); - renderDateRangePicker({ initialMonth, maxDate, minDate }); - - assert.equal(dateRangePicker.state.leftView.getYear(), MAX_YEAR); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.NOVEMBER); + render({ initialMonth, maxDate, minDate }).left.assertMonthYear(Months.NOVEMBER, MAX_YEAR); }); it("is value - 1 if set and initialMonth not set and value month === maxDate month", () => { const value = [new Date(2017, Months.OCTOBER, 4), null] as DateRange; const maxDate = new Date(2017, Months.OCTOBER, 15); - renderDateRangePicker({ maxDate, value }); - assert.equal(dateRangePicker.state.leftView.getYear(), 2017); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.SEPTEMBER); + render({ maxDate, value }).left.assertMonthYear(Months.SEPTEMBER, 2017); }); it("is initialMonth if initialMonth === minDate month and initialMonth === maxDate month", () => { @@ -399,174 +329,137 @@ describe("", () => { const maxDate = new Date(YEAR, Months.DECEMBER, 15); const minDate = new Date(YEAR, Months.DECEMBER, 1); - renderDateRangePicker({ initialMonth, maxDate, minDate }); - - assert.equal(dateRangePicker.state.leftView.getYear(), YEAR); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.DECEMBER); + render({ initialMonth, maxDate, minDate }).left.assertMonthYear(Months.DECEMBER, YEAR); }); it("right calendar shows the month immediately after the left view by default", () => { const startDate = new Date(2017, Months.MAY, 5); const endDate = new Date(2017, Months.JULY, 5); - renderDateRangePicker({ value: [startDate, endDate] }); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.JUNE); + render({ value: [startDate, endDate] }).right.assertMonthYear(Months.JUNE, 2017); }); }); describe("left/right calendar when not sequential", () => { + function assertFirstLastMonths(monthSelect: ReactWrapper, first: Months, last: Months) { + const options = monthSelect.find("option"); + assert.equal(options.first().prop("value"), first); + assert.equal(options.last().prop("value"), last); + } + it("only shows one calendar when minDate and maxDate are in the same month", () => { - const contiguousCalendarMonths = false; const minDate = new Date(2015, Months.DECEMBER, 1); const maxDate = new Date(2015, Months.DECEMBER, 15); - - renderDateRangePicker({ contiguousCalendarMonths, maxDate, minDate }); - assert.lengthOf(document.getElementsByClassName("DayPicker"), 1); - // react-day-picker still renders the navigation but with a interaction disabled class - assert.lengthOf(document.getElementsByClassName("DayPicker-NavButton--interactionDisabled"), 2); + const { wrapper, right } = render({ contiguousCalendarMonths: false, maxDate, minDate }); + assert.isFalse(right.wrapper.exists()); + // nav buttons are disabled + assert.isTrue(wrapper.find(Button).every({ disabled: true })); }); it("left calendar is bound between minDate and (maxDate - 1 month)", () => { - const contiguousCalendarMonths = false; const minDate = new Date(2015, Months.JANUARY, 1); const maxDate = new Date(2015, Months.DECEMBER, 15); - - renderDateRangePicker({ contiguousCalendarMonths, maxDate, minDate }); - const monthSelects = getMonthSelect().children; - const assertValueAt = (index: number, month: Months) => - assert.equal(monthSelects[index].getAttribute("value"), month.toString()); - assertValueAt(0, Months.JANUARY); - assertValueAt(monthSelects.length - 1, Months.NOVEMBER); + const { monthSelect } = render({ contiguousCalendarMonths: false, maxDate, minDate }).left; + assertFirstLastMonths(monthSelect, Months.JANUARY, Months.NOVEMBER); }); it("right calendar is bound between (minDate + 1 month) and maxDate", () => { - const contiguousCalendarMonths = false; const minDate = new Date(2015, Months.JANUARY, 1); const maxDate = new Date(2015, Months.DECEMBER, 15); - - renderDateRangePicker({ contiguousCalendarMonths, maxDate, minDate }); - const monthSelects = getMonthSelect(false).children; - const assertValueAt = (index: number, month: Months) => - assert.equal(monthSelects[index].getAttribute("value"), month.toString()); - assertValueAt(0, Months.FEBRUARY); - assertValueAt(monthSelects.length - 1, Months.DECEMBER); + const { monthSelect } = render({ contiguousCalendarMonths: false, maxDate, minDate }).right; + assertFirstLastMonths(monthSelect, Months.FEBRUARY, Months.DECEMBER); }); it("left calendar can be altered independently of right calendar", () => { - const contiguousCalendarMonths = false; const initialMonth = new Date(2015, Months.MAY, 5); - - renderDateRangePicker({ initialMonth, contiguousCalendarMonths }); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MAY); - const prevBtn = document.querySelectorAll(".DayPicker-NavButton--prev"); - const nextBtn = document.querySelectorAll(".DayPicker-NavButton--next"); - - TestUtils.Simulate.click(prevBtn[0]); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.APRIL); - TestUtils.Simulate.click(nextBtn[0]); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MAY); + const { left, clickNavButton } = render({ initialMonth, contiguousCalendarMonths: false }); + left.assertMonthYear(Months.MAY); + clickNavButton("prev"); + left.assertMonthYear(Months.APRIL); + clickNavButton("next"); + left.assertMonthYear(Months.MAY); }); it("right calendar can be altered independently of left calendar", () => { - const contiguousCalendarMonths = false; const initialMonth = new Date(2015, Months.MAY, 5); - renderDateRangePicker({ initialMonth, contiguousCalendarMonths }); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.JUNE); - const prevBtn = document.querySelectorAll(".DayPicker-NavButton--prev"); - const nextBtn = document.querySelectorAll(".DayPicker-NavButton--next"); - - TestUtils.Simulate.click(nextBtn[1]); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.JULY); - TestUtils.Simulate.click(prevBtn[1]); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.JUNE); + const { right, clickNavButton } = render({ initialMonth, contiguousCalendarMonths: false }); + right.assertMonthYear(Months.JUNE); + clickNavButton("prev", 1); + right.assertMonthYear(Months.MAY); + clickNavButton("next", 1); + right.assertMonthYear(Months.JUNE); }); it("changing left calendar with month dropdown to be equal or after right calendar, shifts the right", () => { - const contiguousCalendarMonths = false; const initialMonth = new Date(2015, Months.MAY, 5); - renderDateRangePicker({ initialMonth, contiguousCalendarMonths }); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MAY); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.JUNE); - TestUtils.Simulate.change(getMonthSelect(), { target: { value: Months.AUGUST } } as any); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.AUGUST); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.SEPTEMBER); + const { left, right } = render({ initialMonth, contiguousCalendarMonths: false }); + left.assertMonthYear(Months.MAY); + right.assertMonthYear(Months.JUNE); + left.monthSelect.simulate("change", { target: { value: Months.AUGUST } }); + left.assertMonthYear(Months.AUGUST); + right.assertMonthYear(Months.SEPTEMBER); }); it("changing right calendar with month dropdown to be equal or before left calendar, shifts the left", () => { - const contiguousCalendarMonths = false; const initialMonth = new Date(2015, Months.MAY, 5); - renderDateRangePicker({ initialMonth, contiguousCalendarMonths }); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MAY); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.JUNE); - TestUtils.Simulate.change(getMonthSelect(false), { target: { value: Months.APRIL } } as any); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MARCH); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.APRIL); + const { left, right } = render({ initialMonth, contiguousCalendarMonths: false }); + left.assertMonthYear(Months.MAY); + right.assertMonthYear(Months.JUNE); + right.monthSelect.simulate("change", { target: { value: Months.APRIL } }); + left.assertMonthYear(Months.MARCH); + right.assertMonthYear(Months.APRIL); }); it("changing left calendar with year dropdown to be equal or after right calendar, shifts the right", () => { - const contiguousCalendarMonths = false; const initialMonth = new Date(2013, Months.MAY, 5); const NEW_YEAR = 2014; - renderDateRangePicker({ initialMonth, contiguousCalendarMonths }); - TestUtils.Simulate.change(getYearSelect(), { target: { value: NEW_YEAR } } as any); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MAY); - assert.equal(dateRangePicker.state.leftView.getYear(), NEW_YEAR); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.JUNE); - assert.equal(dateRangePicker.state.rightView.getYear(), NEW_YEAR); + const { left, right } = render({ initialMonth, contiguousCalendarMonths: false }); + left.yearSelect.simulate("change", { target: { value: NEW_YEAR } }); + left.assertMonthYear(Months.MAY, NEW_YEAR); + right.assertMonthYear(Months.JUNE, NEW_YEAR); }); it("changing right calendar with year dropdown to be equal or before left calendar, shifts the left", () => { - const contiguousCalendarMonths = false; const initialMonth = new Date(2013, Months.MAY, 5); const NEW_YEAR = 2012; - renderDateRangePicker({ initialMonth, contiguousCalendarMonths }); - TestUtils.Simulate.change(getYearSelect(false), { target: { value: NEW_YEAR } } as any); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MAY); - assert.equal(dateRangePicker.state.leftView.getYear(), NEW_YEAR); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.JUNE); - assert.equal(dateRangePicker.state.rightView.getYear(), NEW_YEAR); + const { left, right } = render({ initialMonth, contiguousCalendarMonths: false }); + right.yearSelect.simulate("change", { target: { value: NEW_YEAR } }); + left.assertMonthYear(Months.MAY, NEW_YEAR); + right.assertMonthYear(Months.JUNE, NEW_YEAR); }); it("changing left calendar with navButton to equal right calendar, shifts the right", () => { - const contiguousCalendarMonths = false; const initialMonth = new Date(2015, Months.MAY, 5); - renderDateRangePicker({ initialMonth, contiguousCalendarMonths }); - const nextBtn = document.querySelectorAll(".DayPicker-NavButton--next"); - - TestUtils.Simulate.click(nextBtn[0]); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.JUNE); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.JULY); + const { left, right, clickNavButton } = render({ initialMonth, contiguousCalendarMonths: false }); + clickNavButton("next"); + left.assertMonthYear(Months.JUNE); + right.assertMonthYear(Months.JULY); }); it("changing right calendar with navButton to equal left calendar, shifts the left", () => { - const contiguousCalendarMonths = false; const initialMonth = new Date(2015, Months.MAY, 5); - renderDateRangePicker({ initialMonth, contiguousCalendarMonths }); - const prevBtn = document.querySelectorAll(".DayPicker-NavButton--prev"); - - TestUtils.Simulate.click(prevBtn[1]); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.APRIL); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.MAY); + const { left, right, clickNavButton } = render({ initialMonth, contiguousCalendarMonths: false }); + clickNavButton("prev", 1); + left.assertMonthYear(Months.APRIL); + right.assertMonthYear(Months.MAY); }); it("right calendar shows the month containing the selected end date", () => { const startDate = new Date(2017, Months.MAY, 5); const endDate = new Date(2017, Months.JULY, 5); - renderDateRangePicker({ contiguousCalendarMonths: false, value: [startDate, endDate] }); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.JULY); + render({ contiguousCalendarMonths: false, value: [startDate, endDate] }).right.assertMonthYear(Months.JULY); }); it("right calendar shows the month immediately after the left view if startDate === endDate month", () => { const startDate = new Date(2017, Months.MAY, 5); const endDate = new Date(2017, Months.MAY, 15); - renderDateRangePicker({ contiguousCalendarMonths: false, value: [startDate, endDate] }); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.JUNE); + render({ contiguousCalendarMonths: false, value: [startDate, endDate] }).right.assertMonthYear(Months.JUNE); }); }); @@ -585,11 +478,9 @@ describe("", () => { it("only days outside bounds have disabled class", () => { const minDate = new Date(2000, Months.JANUARY, 10); const initialMonth = minDate; - renderDateRangePicker({ initialMonth, minDate }); - const disabledDay = getDayElement(8); - const selectableDay = getDayElement(10); - assert.isTrue(disabledDay.classList.contains(DateClasses.DATEPICKER_DAY_DISABLED)); - assert.isFalse(selectableDay.classList.contains(DateClasses.DATEPICKER_DAY_DISABLED)); + const { left } = render({ initialMonth, minDate }); + assert.isTrue(left.findDay(8).hasClass(DateClasses.DATEPICKER_DAY_DISABLED)); + assert.isFalse(left.findDay(10).hasClass(DateClasses.DATEPICKER_DAY_DISABLED)); }); it("an error is thrown if defaultValue is outside bounds", () => { @@ -619,7 +510,7 @@ describe("", () => { const maxDate = new Date(2015, Months.JANUARY, 7); const initialMonth = new Date(2015, Months.JANUARY, 12); assert.doesNotThrow(() => { - renderDateRangePicker({ initialMonth, minDate, maxDate }); + render({ initialMonth, minDate, maxDate }); }); }); @@ -637,351 +528,289 @@ describe("", () => { it("onChange not fired when a day outside of bounds is clicked", () => { const minDate = new Date(2015, Months.JANUARY, 5); const maxDate = new Date(2015, Months.JANUARY, 7); - renderDateRangePicker({ minDate, maxDate }); - assert.isTrue(onDateRangePickerChangeSpy.notCalled); - clickDay(10); - assert.isTrue(onDateRangePickerChangeSpy.notCalled); + const { left } = render({ minDate, maxDate }); + assert.isTrue(onChangeSpy.notCalled); + left.findDay(10).simulate("click"); + assert.isTrue(onChangeSpy.notCalled); }); it("caption options are only displayed for possible months and years", () => { const minDate = new Date(2015, Months.JANUARY, 5); const maxDate = new Date(2015, Months.JANUARY, 7); - renderDateRangePicker({ minDate, maxDate }); - const monthOptions = getOptionsText(DateClasses.DATEPICKER_MONTH_SELECT); - const yearOptions = getOptionsText(DateClasses.DATEPICKER_YEAR_SELECT); - assert.lengthOf(monthOptions, 1); - assert.isTrue(monthOptions[0] === "January"); - assert.lengthOf(yearOptions, 1); - assert.isTrue(yearOptions[0] === "2015"); - }); - - it("can change month down to start date with arrow button", () => { - const minDate = new Date(2015, Months.JANUARY, 5); - const initialMonth = new Date(2015, Months.FEBRUARY, 5); - renderDateRangePicker({ initialMonth, minDate }); - assert.strictEqual(dateRangePicker.state.leftView.getMonth(), Months.FEBRUARY); - assert.lengthOf(document.querySelectorAll(".DayPicker-NavButton--interactionDisabled"), 0); - - TestUtils.Simulate.click(document.querySelectorAll(".DayPicker-NavButton--prev")[0]); - assert.strictEqual(dateRangePicker.state.leftView.getMonth(), Months.JANUARY); - assert.lengthOf(document.querySelectorAll(".DayPicker-NavButton--interactionDisabled"), 1); + const { left } = render({ minDate, maxDate }); + const monthOptions = left.monthSelect.find("option").map(o => o.text()); + const yearOptions = left.yearSelect.find("option").map(o => o.text()); + assert.sameMembers(monthOptions, ["January"]); + assert.sameMembers(yearOptions, ["2015"]); }); it("disables shortcuts that begin earlier than minDate", () => { - const minDate = TWO_WEEKS_AGO_START; - const initialMonth = TODAY; - const shortcuts: IDateRangeShortcut[] = [ - { label: "last week", dateRange: [LAST_WEEK_START, TODAY] }, - { label: "last month", dateRange: [LAST_MONTH_START, TODAY] }, - ]; - - renderDateRangePicker({ initialMonth, minDate, shortcuts }); - assert.isFalse(isShortcutDisabled(0)); - assert.isTrue(isShortcutDisabled(1)); + const { shortcuts } = render({ + initialMonth: TODAY, + minDate: TWO_WEEKS_AGO_START, + shortcuts: [ + { label: "last week", dateRange: [LAST_WEEK_START, TODAY] }, + { label: "last month", dateRange: [LAST_MONTH_START, TODAY] }, + ], + }); + assert.isFalse(shortcuts.childAt(0).prop("disabled")); + assert.isTrue(shortcuts.childAt(1).prop("disabled")); }); it("disables shortcuts that end later than maxDate", () => { - const maxDate = TWO_WEEKS_AGO_START; - const initialMonth = TWO_WEEKS_AGO_START; - const shortcuts: IDateRangeShortcut[] = [ - { label: "last week", dateRange: [LAST_WEEK_START, TODAY] }, - { label: "last month", dateRange: [LAST_MONTH_START, TODAY] }, - ]; - - renderDateRangePicker({ initialMonth, maxDate, shortcuts }); - assert.isTrue(isShortcutDisabled(0)); - assert.isTrue(isShortcutDisabled(1)); + const { shortcuts } = render({ + initialMonth: TWO_WEEKS_AGO_START, + maxDate: TWO_WEEKS_AGO_START, + shortcuts: [ + { label: "last week", dateRange: [LAST_WEEK_START, TODAY] }, + { label: "last month", dateRange: [LAST_MONTH_START, TODAY] }, + ], + }); + assert.isTrue(shortcuts.childAt(0).prop("disabled")); + assert.isTrue(shortcuts.childAt(1).prop("disabled")); }); }); describe("hover interactions", () => { describe("when neither start nor end date is defined", () => { it("should show a hovered range of [day, null]", () => { - renderDateRangePicker(); - assert.lengthOf(getHoveredRangeDayElements(), 0); - mouseEnterDay(14); - assert.lengthOf(getHoveredRangeDayElements(), 0); - assert.equal(getHoveredRangeStartDayElement().textContent, "14"); - assert.isNull(getHoveredRangeEndDayElement()); + const { left, assertHoveredDays } = render(); + left.mouseEnterDay(14); + assertHoveredDays(14, null); }); }); describe("when only start date is defined", () => { it("should show a hovered range of [start, day] if day > start", () => { - renderDateRangePicker(); - clickDay(14); - mouseEnterDay(18); - assert.lengthOf(getHoveredRangeDayElements(), 3); - assert.equal(getHoveredRangeStartDayElement().textContent, "14"); - assert.equal(getHoveredRangeEndDayElement().textContent, "18"); + const { left, assertHoveredDays } = render(); + left.clickDay(14).mouseEnterDay(18); + assertHoveredDays(14, 18); }); it("should show a hovered range of [null, null] if day === start", () => { - renderDateRangePicker(); - clickDay(14); - mouseEnterDay(14); - assert.lengthOf(getHoveredRangeDayElements(), 0); - assert.isNull(getHoveredRangeStartDayElement()); - assert.isNull(getHoveredRangeEndDayElement()); + const { left, assertHoveredDays } = render(); + left.clickDay(14).mouseEnterDay(14); + assertHoveredDays(null, null); }); it("should show a hovered range of [day, start] if day < start", () => { - renderDateRangePicker(); - clickDay(14); - mouseEnterDay(10); - assert.lengthOf(getHoveredRangeDayElements(), 3); - assert.equal(getHoveredRangeStartDayElement().textContent, "10"); - assert.equal(getHoveredRangeEndDayElement().textContent, "14"); + const { left, assertHoveredDays } = render(); + left.clickDay(14).mouseEnterDay(10); + assertHoveredDays(10, 14); }); it("should not show a hovered range when mousing over a disabled date", () => { - renderDateRangePicker({ - maxDate: new Date(2017, Months.FEBRUARY, 1), + const { left, right, assertHoveredDays } = render({ + maxDate: new Date(2017, Months.FEBRUARY, 10), minDate: new Date(2017, Months.JANUARY, 1), }); - clickDay(14); // Jan 14th - mouseEnterDay(5, false); // Feb 5th - assert.lengthOf(getHoveredRangeDayElements(), 0); - assert.equal(getHoveredRangeStartDayElement().textContent, "14"); - assert.isNull(getHoveredRangeEndDayElement()); + left.clickDay(14); // Jan 14th + right.mouseEnterDay(14); // Feb 14th + assertHoveredDays(14, null); }); }); describe("when only end date is defined", () => { it("should show a hovered range of [end, day] if day > end", () => { - renderDateRangePicker(); - clickDay(14); - clickDay(18); - clickDay(14); // deselect start date - - mouseEnterDay(22); - assert.lengthOf(getHoveredRangeDayElements(), 3); - assert.equal(getHoveredRangeStartDayElement().textContent, "18"); - assert.equal(getHoveredRangeEndDayElement().textContent, "22"); + const { left, assertHoveredDays } = render(); + left + .clickDay(14) + .clickDay(18) + .clickDay(14) // deselect start date + .mouseEnterDay(22); + assertHoveredDays(18, 22); }); it("should show a hovered range of [null, null] if day === end", () => { - renderDateRangePicker(); - clickDay(14); - clickDay(18); - clickDay(14); - - mouseEnterDay(18); - assert.lengthOf(getHoveredRangeDayElements(), 0); - assert.isNull(getHoveredRangeStartDayElement()); - assert.isNull(getHoveredRangeEndDayElement()); + const { left, assertHoveredDays } = render(); + left + .clickDay(14) + .clickDay(18) + .clickDay(14) + .mouseEnterDay(18); + assertHoveredDays(null, null); }); it("should show a hovered range of [day, end] if day < end", () => { - renderDateRangePicker(); - clickDay(14); - clickDay(18); - clickDay(14); - - mouseEnterDay(14); - assert.lengthOf(getHoveredRangeDayElements(), 3); - assert.equal(getHoveredRangeStartDayElement().textContent, "14"); - assert.equal(getHoveredRangeEndDayElement().textContent, "18"); + const { left, assertHoveredDays } = render(); + left + .clickDay(14) + .clickDay(18) + .clickDay(14) + .mouseEnterDay(14); + assertHoveredDays(14, 18); }); }); describe("when both start and end date are defined", () => { it("should show a hovered range of [null, end] if day === start", () => { - renderDateRangePicker(); - clickDay(14); - clickDay(18); - - mouseEnterDay(14); - assert.lengthOf(getHoveredRangeDayElements(), 0); - assert.isNull(getHoveredRangeStartDayElement()); - assert.equal(getHoveredRangeEndDayElement().textContent, "18"); + const { left, assertHoveredDays } = render(); + left + .clickDay(14) + .clickDay(18) + .mouseEnterDay(14); + assertHoveredDays(null, 18); }); it("should show a hovered range of [start, null] if day === end", () => { - renderDateRangePicker(); - clickDay(14); - clickDay(18); - - mouseEnterDay(18); - assert.lengthOf(getHoveredRangeDayElements(), 0); - assert.equal(getHoveredRangeStartDayElement().textContent, "14"); - assert.isNull(getHoveredRangeEndDayElement()); + const { left, assertHoveredDays } = render(); + left + .clickDay(14) + .clickDay(18) + .mouseEnterDay(18); + assertHoveredDays(14, null); }); it("should show a hovered range of [day, null] if start < day < end", () => { - renderDateRangePicker(); - clickDay(14); - clickDay(18); - - mouseEnterDay(16); - assert.lengthOf(getHoveredRangeDayElements(), 0); - assert.equal(getHoveredRangeStartDayElement().textContent, "16"); - assert.isNull(getHoveredRangeEndDayElement()); + const { left, assertHoveredDays } = render(); + left + .clickDay(14) + .clickDay(18) + .mouseEnterDay(16); + assertHoveredDays(16, null); }); it("should show a hovered range of [day, null] if day < start", () => { - renderDateRangePicker(); - clickDay(14); - clickDay(18); - - mouseEnterDay(10); - assert.lengthOf(getHoveredRangeDayElements(), 0); - assert.equal(getHoveredRangeStartDayElement().textContent, "10"); - assert.isNull(getHoveredRangeEndDayElement()); + const { left, assertHoveredDays } = render(); + left + .clickDay(14) + .clickDay(18) + .mouseEnterDay(10); + assertHoveredDays(10, null); }); it("should show a hovered range of [day, null] if day > end", () => { - renderDateRangePicker(); - clickDay(14); - clickDay(18); - - mouseEnterDay(22); - assert.lengthOf(getHoveredRangeDayElements(), 0); - assert.equal(getHoveredRangeStartDayElement().textContent, "22"); - assert.isNull(getHoveredRangeEndDayElement()); + const { left, assertHoveredDays } = render(); + left + .clickDay(14) + .clickDay(18) + .mouseEnterDay(22); + assertHoveredDays(22, null); }); it("should show a hovered range of [null, null] if start === day === end", () => { - renderDateRangePicker({ allowSingleDayRange: true }); - clickDay(14); - clickDay(14); - - mouseEnterDay(14); - assert.lengthOf(getHoveredRangeDayElements(), 0); - assert.isNull(getHoveredRangeStartDayElement()); - assert.isNull(getHoveredRangeEndDayElement()); + const { left, assertHoveredDays } = render({ allowSingleDayRange: true }); + left + .clickDay(14) + .clickDay(14) + .mouseEnterDay(14); + assertHoveredDays(null, null); }); }); it("hovering on day in month prior to selected start date's month, should not shift calendar view", () => { const INITIAL_MONTH = Months.MARCH; const MONTH_OUT_OF_VIEW = Months.JANUARY; - renderDateRangePicker({ initialMonth: new Date(2017, INITIAL_MONTH, 1) }); - - clickDay(14); - clickDay(18); - - TestUtils.Simulate.change(getMonthSelect(), { target: { value: MONTH_OUT_OF_VIEW } } as any); + const { left, right } = render({ initialMonth: new Date(2017, INITIAL_MONTH, 1) }); + left.clickDay(14).clickDay(18); + left.monthSelect.simulate("change", { target: { value: MONTH_OUT_OF_VIEW } }); // hover on left month - mouseEnterDay(14); - assert.equal(dateRangePicker.state.leftView.getMonth(), MONTH_OUT_OF_VIEW); + left.mouseEnterDay(14); + left.assertMonthYear(MONTH_OUT_OF_VIEW); // hover on right month - mouseEnterDay(14, false); - assert.equal(dateRangePicker.state.leftView.getMonth(), MONTH_OUT_OF_VIEW); + right.mouseEnterDay(14); + left.assertMonthYear(MONTH_OUT_OF_VIEW); }); // verifies the fix for https://github.com/palantir/blueprint/issues/1048 it("hovering when contiguousCalendarMonths=false shows a hovered range", () => { - renderDateRangePicker({ contiguousCalendarMonths: false }); - clickDay(14); - // hover on right month - mouseEnterDay(18, false); - assert.equal(getHoveredRangeStartDayElement().textContent, "14"); - assert.equal(getHoveredRangeEndDayElement().textContent, "18"); + const { left, right, assertHoveredDays } = render({ contiguousCalendarMonths: false }); + left.clickDay(14); + right.mouseEnterDay(18); + assertHoveredDays(14, 18); }); }); describe("when controlled", () => { it("value initially selects a day", () => { - const defaultValue = [new Date(2010, Months.FEBRUARY, 2), null] as DateRange; - const value = [new Date(2010, Months.JANUARY, 1), null] as DateRange; - renderDateRangePicker({ defaultValue, value }); - const selectedDays = getSelectedDayElements(); - assert.lengthOf(selectedDays, 1); - assert.equal(selectedDays[0].textContent, value[0].getDate().toString()); + const defaultValue: DateRange = [new Date(2010, Months.FEBRUARY, 2), null]; + const value: DateRange = [new Date(2010, Months.JANUARY, 1), null]; + render({ defaultValue, value }).assertSelectedDays(value[0].getDate()); }); it("onChange fired when a day is clicked", () => { - renderDateRangePicker({ value: [null, null] }); - assert.isTrue(onDateRangePickerChangeSpy.notCalled); - clickDay(); - assert.isTrue(onDateRangePickerChangeSpy.calledOnce); + const { left } = render({ value: [null, null] }); + assert.isTrue(onChangeSpy.notCalled); + left.clickDay(); + assert.isTrue(onChangeSpy.calledOnce); }); it("onHoverChange fired on mouseenter within a day", () => { - renderDateRangePicker({ value: [null, null] }); - assert.isTrue(onDateRangePickerHoverChangeSpy.notCalled); - mouseEnterDay(); - assert.isTrue(onDateRangePickerHoverChangeSpy.calledOnce); + const { left } = render({ value: [null, null] }); + assert.isTrue(onHoverChangeSpy.notCalled); + left.mouseEnterDay(); + assert.isTrue(onHoverChangeSpy.calledOnce); }); it("onHoverChange fired on mouseleave within a day", () => { - renderDateRangePicker({ value: [null, null] }); - assert.isTrue(onDateRangePickerHoverChangeSpy.notCalled); - mouseLeaveDay(); - assert.isTrue(onDateRangePickerHoverChangeSpy.calledOnce); + const { left } = render({ value: [null, null] }); + assert.isTrue(onHoverChangeSpy.notCalled); + left.findDay().simulate("mouseleave"); + assert.isTrue(onHoverChangeSpy.calledOnce); }); it("selected day updates are not automatic", () => { - renderDateRangePicker({ value: [null, null] }); - assert.lengthOf(getSelectedDayElements(), 0); - clickDay(); - assert.lengthOf(getSelectedDayElements(), 0); + const { left, assertSelectedDays } = render({ value: [null, null] }); + assertSelectedDays(); + left.clickDay(); + assertSelectedDays(); }); it("can change displayed date with the dropdowns in the caption", () => { - renderDateRangePicker({ initialMonth: new Date(2015, Months.MARCH, 2), value: [null, null] }); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MARCH); - assert.equal(dateRangePicker.state.leftView.getYear(), 2015); - - TestUtils.Simulate.change(getMonthSelect(), { target: { value: Months.JANUARY } } as any); - TestUtils.Simulate.change(getYearSelect(), { target: { value: 2014 } } as any); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.JANUARY); - assert.equal(dateRangePicker.state.leftView.getYear(), 2014); + const { left } = render({ initialMonth: new Date(2015, Months.MARCH, 2), value: [null, null] }); + left.assertMonthYear(Months.MARCH, 2015); + left.monthSelect.simulate("change", { target: { value: Months.JANUARY } }); + left.yearSelect.simulate("change", { target: { value: 2014 } }); + left.assertMonthYear(Months.JANUARY, 2014); }); it("shortcuts fire onChange with correct values", () => { - renderDateRangePicker(); - clickFirstShortcut(); + render().clickShortcut(); const today = new Date(); const aWeekAgo = DateUtils.clone(today); aWeekAgo.setDate(today.getDate() - 6); - assert.isTrue(onDateRangePickerChangeSpy.calledOnce); - assert.isTrue(DateUtils.areSameDay(aWeekAgo, onDateRangePickerChangeSpy.args[0][0][0])); - assert.isTrue(DateUtils.areSameDay(today, onDateRangePickerChangeSpy.args[0][0][1])); + assert.isTrue(onChangeSpy.calledOnce, "called"); + const value = onChangeSpy.args[0][0]; + assert.isTrue(DateUtils.areSameDay(aWeekAgo, value[0])); + assert.isTrue(DateUtils.areSameDay(today, value[1])); }); it("shortcuts fire onChange with correct values when single day range enabled", () => { - renderDateRangePicker({ allowSingleDayRange: true }); - clickFirstShortcut(); + render({ allowSingleDayRange: true }).clickShortcut(); const today = new Date(); - assert.isTrue(onDateRangePickerChangeSpy.calledOnce); - assert.isTrue(DateUtils.areSameDay(today, onDateRangePickerChangeSpy.args[0][0][0])); - assert.isTrue(DateUtils.areSameDay(today, onDateRangePickerChangeSpy.args[0][0][1])); + assert.isTrue(onChangeSpy.calledOnce); + const value = onChangeSpy.args[0][0]; + assert.isTrue(DateUtils.areSameDay(today, value[0])); + assert.isTrue(DateUtils.areSameDay(today, value[1])); }); it("custom shortcuts select the correct values", () => { const dateRange = [new Date(2015, Months.JANUARY, 1), new Date(2015, Months.JANUARY, 5)] as DateRange; - renderDateRangePicker({ + render({ initialMonth: new Date(2015, Months.JANUARY, 1), shortcuts: [{ label: "custom shortcut", dateRange }], - }); - - clickFirstShortcut(); - assert.isTrue(onDateRangePickerChangeSpy.calledOnce); - assert.isTrue(DateUtils.areSameDay(dateRange[0], onDateRangePickerChangeSpy.args[0][0][0])); - assert.isTrue(DateUtils.areSameDay(dateRange[1], onDateRangePickerChangeSpy.args[0][0][1])); + }).clickShortcut(); + assert.isTrue(onChangeSpy.calledOnce); + const value = onChangeSpy.args[0][0]; + assert.isTrue(DateUtils.areSameDay(dateRange[0], value[0])); + assert.isTrue(DateUtils.areSameDay(dateRange[1], value[1])); }); it("custom shortcuts set the displayed months correctly when start month changes", () => { const dateRange = [new Date(2016, Months.JANUARY, 1), new Date(2016, Months.DECEMBER, 31)] as DateRange; - renderDateRangePicker({ + const { left, right } = render({ initialMonth: new Date(2015, Months.JANUARY, 1), shortcuts: [{ label: "custom shortcut", dateRange }], - }); - - clickFirstShortcut(); - assert.isTrue(onDateRangePickerChangeSpy.calledOnce); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.JANUARY); - assert.equal(dateRangePicker.state.leftView.getYear(), 2016); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.FEBRUARY); - assert.equal(dateRangePicker.state.rightView.getYear(), 2016); + }).clickShortcut(); + assert.isTrue(onChangeSpy.calledOnce); + left.assertMonthYear(Months.JANUARY, 2016); + right.assertMonthYear(Months.FEBRUARY, 2016); }); it( @@ -989,377 +818,358 @@ describe("", () => { "and contiguousCalendarMonths is false", () => { const dateRange = [new Date(2016, Months.JANUARY, 1), new Date(2016, Months.DECEMBER, 31)] as DateRange; - renderDateRangePicker({ + const { left, right } = render({ contiguousCalendarMonths: false, initialMonth: new Date(2015, Months.JANUARY, 1), shortcuts: [{ label: "custom shortcut", dateRange }], - }); - - clickFirstShortcut(); - assert.isTrue(onDateRangePickerChangeSpy.calledOnce); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.JANUARY); - assert.equal(dateRangePicker.state.leftView.getYear(), 2016); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.DECEMBER); - assert.equal(dateRangePicker.state.rightView.getYear(), 2016); + }).clickShortcut(); + assert.isTrue(onChangeSpy.calledOnce); + left.assertMonthYear(Months.JANUARY, 2016); + right.assertMonthYear(Months.DECEMBER, 2016); }, ); it("custom shortcuts set the displayed months correctly when start month stays the same", () => { const dateRange = [new Date(2016, Months.JANUARY, 1), new Date(2016, Months.DECEMBER, 31)] as DateRange; - renderDateRangePicker({ + const { clickShortcut, left, right } = render({ initialMonth: new Date(2016, Months.JANUARY, 1), shortcuts: [{ label: "custom shortcut", dateRange }], }); - clickFirstShortcut(); - assert.isTrue(onDateRangePickerChangeSpy.calledOnce); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.JANUARY); - assert.equal(dateRangePicker.state.leftView.getYear(), 2016); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.FEBRUARY); - assert.equal(dateRangePicker.state.rightView.getYear(), 2016); + clickShortcut(); + assert.isTrue(onChangeSpy.calledOnce); + left.assertMonthYear(Months.JANUARY, 2016); + right.assertMonthYear(Months.FEBRUARY, 2016); - clickFirstShortcut(); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.JANUARY); - assert.equal(dateRangePicker.state.leftView.getYear(), 2016); - assert.equal(dateRangePicker.state.rightView.getMonth(), Months.FEBRUARY); - assert.equal(dateRangePicker.state.rightView.getYear(), 2016); + clickShortcut(); + left.assertMonthYear(Months.JANUARY, 2016); + right.assertMonthYear(Months.FEBRUARY, 2016); }); }); describe("when uncontrolled", () => { it("defaultValue initially selects a day", () => { const today = new Date(); - renderDateRangePicker({ defaultValue: [today, null] }); - const selectedDays = getSelectedDayElements(); - assert.lengthOf(selectedDays, 1); - assert.equal(selectedDays[0].textContent, today.getDate().toString()); + render({ defaultValue: [today, null] }).assertSelectedDays(today.getDate()); }); it("onChange fired when a day is clicked", () => { - renderDateRangePicker(); - assert.isTrue(onDateRangePickerChangeSpy.notCalled); - clickDay(); - assert.isTrue(onDateRangePickerChangeSpy.calledOnce); + const { left } = render(); + assert.isTrue(onChangeSpy.notCalled); + left.clickDay(); + assert.isTrue(onChangeSpy.calledOnce); }); it("onHoverChange fired with correct values when a day is clicked", () => { const dateRange = [new Date(2015, Months.JANUARY, 1), new Date(2015, Months.JANUARY, 5)] as DateRange; - renderDateRangePicker({ initialMonth: new Date(2015, Months.JANUARY, 1) }); - assert.isTrue(onDateRangePickerHoverChangeSpy.notCalled); - clickDay(1); - assert.isTrue(onDateRangePickerHoverChangeSpy.calledOnce); - assert.isTrue(DateUtils.areSameDay(dateRange[0], onDateRangePickerHoverChangeSpy.args[0][0][0])); - assert.isNull(onDateRangePickerHoverChangeSpy.args[0][0][1]); + const { left } = render({ initialMonth: new Date(2015, Months.JANUARY, 1) }); + assert.isTrue(onHoverChangeSpy.notCalled); + left.clickDay(1); + assert.isTrue(onHoverChangeSpy.calledOnce); + assert.isTrue(DateUtils.areSameDay(dateRange[0], onHoverChangeSpy.args[0][0][0])); + assert.isNull(onHoverChangeSpy.args[0][0][1]); }); it("onHoverChange fired with correct values on mouseenter within a day", () => { const dateRange = [new Date(2015, Months.JANUARY, 1), new Date(2015, Months.JANUARY, 5)] as DateRange; - renderDateRangePicker({ initialMonth: new Date(2015, Months.JANUARY, 1) }); - assert.isTrue(onDateRangePickerHoverChangeSpy.notCalled); - clickDay(1); - mouseEnterDay(5); - assert.isTrue(onDateRangePickerHoverChangeSpy.calledTwice); - assert.isTrue(DateUtils.areSameDay(dateRange[0], onDateRangePickerHoverChangeSpy.args[1][0][0])); - assert.isTrue(DateUtils.areSameDay(dateRange[1], onDateRangePickerHoverChangeSpy.args[1][0][1])); + const { left } = render({ initialMonth: new Date(2015, Months.JANUARY, 1) }); + assert.isTrue(onHoverChangeSpy.notCalled); + left.clickDay(1).mouseEnterDay(5); + assert.isTrue(onHoverChangeSpy.calledTwice); + assert.isTrue(DateUtils.areSameDay(dateRange[0], onHoverChangeSpy.args[1][0][0])); + assert.isTrue(DateUtils.areSameDay(dateRange[1], onHoverChangeSpy.args[1][0][1])); }); it("onHoverChange fired with `undefined` on mouseleave within a day", () => { - renderDateRangePicker({ initialMonth: new Date(2015, Months.JANUARY, 1) }); - assert.isTrue(onDateRangePickerHoverChangeSpy.notCalled); - clickDay(1); - mouseLeaveDay(5); - assert.isTrue(onDateRangePickerHoverChangeSpy.calledTwice); - assert.isUndefined(onDateRangePickerHoverChangeSpy.args[1][0]); + const { left } = render({ initialMonth: new Date(2015, Months.JANUARY, 1) }); + assert.isTrue(onHoverChangeSpy.notCalled); + left + .clickDay(1) + .findDay(5) + .simulate("mouseleave"); + assert.isTrue(onHoverChangeSpy.calledTwice); + assert.isUndefined(onHoverChangeSpy.args[1][0]); }); it("selected day updates are automatic", () => { - renderDateRangePicker(); - assert.lengthOf(getSelectedDayElements(), 0); - clickDay(3); - const selectedDayElements = getSelectedDayElements(); - assert.lengthOf(selectedDayElements, 1); - assert.equal(selectedDayElements[0].textContent, "3"); + const { assertSelectedDays, left } = render(); + assertSelectedDays(); + left.clickDay(3); + assertSelectedDays(3); }); it("selects a range of dates when two days are clicked", () => { - renderDateRangePicker({ initialMonth: new Date(2015, Months.JANUARY, 1) }); - assert.lengthOf(getSelectedDayElements(), 0); - assert.lengthOf(getSelectedRangeDayElements(), 0); - - clickDay(10); - clickDay(14); - assert.lengthOf(getSelectedDayElements(), 2); - assert.lengthOf(getSelectedRangeDayElements(), 3); + const { assertSelectedDays, left } = render({ initialMonth: new Date(2015, Months.JANUARY, 1) }); + assertSelectedDays(); + left.clickDay(10).clickDay(14); + assertSelectedDays(10, 14); }); it("selects a range of dates when days are clicked in reverse", () => { - renderDateRangePicker({ initialMonth: new Date(2015, Months.JANUARY, 1) }); - assert.lengthOf(getSelectedDayElements(), 0); - assert.lengthOf(getSelectedRangeDayElements(), 0); - - clickDay(14); - clickDay(10); - assert.lengthOf(getSelectedDayElements(), 2); - assert.lengthOf(getSelectedRangeDayElements(), 3); + const { assertSelectedDays, left } = render({ initialMonth: new Date(2015, Months.JANUARY, 1) }); + assertSelectedDays(); + left.clickDay(14).clickDay(10); + assertSelectedDays(10, 14); }); it("deselects everything when only selected day is clicked", () => { - renderDateRangePicker({ initialMonth: new Date(2015, Months.JANUARY, 1) }); - assert.lengthOf(getSelectedDayElements(), 0); - assert.lengthOf(getSelectedRangeDayElements(), 0); - - clickDay(10); - assert.lengthOf(getSelectedDayElements(), 1); - assert.lengthOf(getSelectedRangeDayElements(), 0); - - clickDay(10); - assert.lengthOf(getSelectedDayElements(), 0); - assert.lengthOf(getSelectedRangeDayElements(), 0); + const { assertSelectedDays, left } = render({ initialMonth: new Date(2015, Months.JANUARY, 1) }); + left.clickDay(10).clickDay(10); + assertSelectedDays(); }); it("starts a new selection when a non-endpoint is clicked in the current selection", () => { - renderDateRangePicker({ initialMonth: new Date(2015, Months.JANUARY, 1) }); - clickDay(10); - clickDay(14); - - clickDay(11, false); - assert.lengthOf(getSelectedDayElements(), 1); - assert.lengthOf(getSelectedRangeDayElements(), 0); - assert.deepEqual(getSelectedDayElements()[0].textContent, "11"); + const { assertSelectedDays, left, right } = render({ initialMonth: new Date(2015, Months.JANUARY, 1) }); + left.clickDay(10).clickDay(14); + right.clickDay(11); + assertSelectedDays(11); }); it("deselects endpoint when an endpoint of the current selection is clicked", () => { - renderDateRangePicker({ initialMonth: new Date(2015, Months.JANUARY, 1) }); - clickDay(10); - clickDay(14); - - clickDay(10); - assert.lengthOf(getSelectedDayElements(), 1); - assert.lengthOf(getSelectedRangeDayElements(), 0); - assert.deepEqual(getSelectedDayElements()[0].textContent, "14"); + const { assertSelectedDays, left } = render({ initialMonth: new Date(2015, Months.JANUARY, 1) }); + left + .clickDay(10) + .clickDay(14) + .clickDay(10); + assertSelectedDays(14); - clickDay(10); - clickDay(14); - assert.lengthOf(getSelectedDayElements(), 1); - assert.lengthOf(getSelectedRangeDayElements(), 0); - assert.deepEqual(getSelectedDayElements()[0].textContent, "10"); + left.clickDay(10).clickDay(14); + assertSelectedDays(10); }); it("allowSingleDayRange={true} allows start and end to be the same day", () => { - renderDateRangePicker({ allowSingleDayRange: true, initialMonth: new Date(2015, Months.JANUARY, 1) }); - clickDay(10); - clickDay(10); - - const days = getSelectedDayElements(); - assert.lengthOf(days, 1); - assert.deepEqual(days[0].textContent, "10"); - assert.lengthOf(getSelectedRangeDayElements(), 0); + const { assertSelectedDays, left } = render({ + allowSingleDayRange: true, + initialMonth: new Date(2015, Months.JANUARY, 1), + }); + left.clickDay(10).clickDay(10); + assertSelectedDays(10); }); it("shortcuts select values", () => { - renderDateRangePicker(); - - clickFirstShortcut(); + const { wrapper } = render().clickShortcut(); const today = new Date(); const aWeekAgo = DateUtils.clone(today); aWeekAgo.setDate(today.getDate() - 6); - assert.isTrue(DateUtils.areSameDay(aWeekAgo, dateRangePicker.state.value[0])); - assert.isTrue(DateUtils.areSameDay(today, dateRangePicker.state.value[1])); + + const [start, end] = wrapper.state("value"); + assert.isTrue(DateUtils.areSameDay(aWeekAgo, start)); + assert.isTrue(DateUtils.areSameDay(today, end)); }); it("custom shortcuts select the correct values", () => { const dateRange = [new Date(2015, Months.JANUARY, 1), new Date(2015, Months.JANUARY, 5)] as DateRange; - renderDateRangePicker({ + render({ initialMonth: new Date(2015, Months.JANUARY, 1), shortcuts: [{ label: "custom shortcut", dateRange }], - }); - - clickFirstShortcut(); - assert.lengthOf(getSelectedDayElements(), 2); - assert.lengthOf(getSelectedRangeDayElements(), 3); - assert.deepEqual(getSelectedDayElements()[0].textContent, "1"); - assert.deepEqual(getSelectedDayElements()[1].textContent, "5"); + }) + .clickShortcut() + .assertSelectedDays(1, 5); }); it("can change displayed date with the dropdowns in the caption", () => { - renderDateRangePicker({ initialMonth: new Date(2015, Months.MARCH, 2) }); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MARCH); - assert.equal(dateRangePicker.state.leftView.getYear(), 2015); - - TestUtils.Simulate.change(getMonthSelect(), { target: { value: Months.JANUARY } } as any); - TestUtils.Simulate.change(getYearSelect(), { target: { value: 2014 } } as any); - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.JANUARY); - assert.equal(dateRangePicker.state.leftView.getYear(), 2014); + const { left } = render({ initialMonth: new Date(2015, Months.MARCH, 2) }); + left.assertMonthYear(Months.MARCH, 2015); + left.monthSelect.simulate("change", { target: { value: Months.JANUARY } }); + left.yearSelect.simulate("change", { target: { value: 2014 } }); + left.assertMonthYear(Months.JANUARY, 2014); }); it("does not change display month when selecting dates from left month", () => { - renderDateRangePicker({ initialMonth: new Date(2015, Months.MARCH, 2) }); - clickDay(2); - clickDay(15, false); - - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MARCH); - assert.equal(dateRangePicker.state.leftView.getYear(), 2015); + render({ initialMonth: new Date(2015, Months.MARCH, 2) }) + .left.clickDay(2) + .clickDay(15) + .assertMonthYear(Months.MARCH, 2015); }); it("does not change display month when selecting dates from right month", () => { - renderDateRangePicker({ initialMonth: new Date(2015, Months.MARCH, 2) }); - clickDay(2, false); - clickDay(15, false); - - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MARCH); - assert.equal(dateRangePicker.state.leftView.getYear(), 2015); + render({ initialMonth: new Date(2015, Months.MARCH, 2) }) + .right.clickDay(2) + .clickDay(15) + .assertMonthYear(Months.APRIL, 2015); }); it("does not change display month when selecting dates from left and right month", () => { - renderDateRangePicker({ initialMonth: new Date(2015, Months.MARCH, 2) }); - clickDay(2); - clickDay(15, false); - - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.MARCH); - assert.equal(dateRangePicker.state.leftView.getYear(), 2015); + const { left, right } = render({ initialMonth: new Date(2015, Months.MARCH, 2) }); + right.clickDay(15); + left.clickDay(2).assertMonthYear(Months.MARCH, 2015); }); it("does not change display month when selecting dates across December (left) and January (right)", () => { - renderDateRangePicker(); - TestUtils.Simulate.change(getMonthSelect(), { target: { value: Months.DECEMBER } } as any); - TestUtils.Simulate.change(getYearSelect(), { target: { value: 2015 } } as any); - clickDay(15); - clickDay(2, false); - - assert.equal(dateRangePicker.state.leftView.getMonth(), Months.DECEMBER); - assert.equal(dateRangePicker.state.leftView.getYear(), 2015); + const { left, right } = render({ initialMonth: new Date(2015, Months.DECEMBER, 2) }); + left.clickDay(15); + right.clickDay(2); + left.assertMonthYear(Months.DECEMBER, 2015); }); }); - function renderDateRangePicker(props?: IDateRangePickerProps) { - onDateRangePickerChangeSpy = sinon.spy(); - onDateRangePickerHoverChangeSpy = sinon.spy(); - dateRangePicker = ReactDOM.render( - , - testsContainerElement, - ) as DateRangePicker; - } - - function wrap(datepicker: JSX.Element) { - const wrapper = mount(datepicker); - // Don't cache the left/right day pickers into variables in this scope, - // because as of Enzyme 3.0 they can get stale if the views change. - return { - getDayLeftView: (dayNumber = 1) => { - return getLeftDayPicker(wrapper) - .find(`.${DateClasses.DATEPICKER_DAY}`) - .filterWhere( - day => day.text() === "" + dayNumber && !day.hasClass(DateClasses.DATEPICKER_DAY_OUTSIDE), - ); - }, - getDayRightView: (dayNumber = 1) => { - return getRightDayPicker(wrapper) - .find(`.${DateClasses.DATEPICKER_DAY}`) - .filterWhere( - day => day.text() === "" + dayNumber && !day.hasClass(DateClasses.DATEPICKER_DAY_OUTSIDE), - ); - }, - leftDayPickerNavbar: wrapper.find("Navbar").at(0), - leftView: getLeftDayPicker(wrapper), - rightDayPickerNavbar: wrapper.find("Navbar").at(1) || wrapper.find("Navbar").at(0), - rightView: getRightDayPicker(wrapper), - root: wrapper, - }; - } - - function getLeftDayPicker(wrapper: ReactWrapper) { - return getDayPickers(wrapper).at(0); - } + describe("time selection", () => { + const defaultRange: DateRange = [new Date(2012, 2, 5, 6, 5, 40), new Date(2012, 4, 5, 7, 8, 20)]; - function getRightDayPicker(wrapper: ReactWrapper) { - const dayPickers = getDayPickers(wrapper); - return dayPickers.length > 1 ? dayPickers.at(1) : dayPickers.at(0); - } - - function getDayPickers(wrapper: ReactWrapper) { - return wrapper.find(ReactDayPicker).find("Month"); - } - - function clickDay(dayNumber = 1, fromLeftMonth = true) { - TestUtils.Simulate.click(getDayElement(dayNumber, fromLeftMonth)); - } - - function mouseEnterDay(dayNumber = 1, fromLeftMonth = true) { - TestUtils.Simulate.mouseEnter(getDayElement(dayNumber, fromLeftMonth)); - } - - function mouseLeaveDay(dayNumber = 1, fromLeftMonth = true) { - TestUtils.Simulate.mouseLeave(getDayElement(dayNumber, fromLeftMonth)); - } + it("setting timePrecision shows a TimePicker", () => { + const { wrapper } = render(); + assert.isFalse(wrapper.find(TimePicker).exists()); + wrapper.setProps({ timePrecision: "minute" }); + assert.isTrue(wrapper.find(TimePicker).exists()); + }); - function getShortcut(index: number) { - return document.querySelectorAll(`.${DateClasses.DATERANGEPICKER_SHORTCUTS} .${Classes.MENU_ITEM}`)[index]; - } + it("setting timePickerProps shows a TimePicker", () => { + const { wrapper } = render({ timePickerProps: {} }); + assert.isTrue(wrapper.find(TimePicker).exists()); + }); - function isShortcutDisabled(index: number) { - return getShortcut(index).classList.contains(Classes.DISABLED); - } + it("onChange fired when the time is changed", () => { + const { wrapper } = render({ timePickerProps: { showArrowButtons: true }, defaultValue: defaultRange }); + assert.isTrue(onChangeSpy.notCalled); + wrapper + .find(`.${DateClasses.TIMEPICKER_ARROW_BUTTON}.${DateClasses.TIMEPICKER_HOUR}`) + .first() + .simulate("click"); + assert.isTrue(onChangeSpy.calledOnce); + const cbHour = onChangeSpy.firstCall.args[0][0].getHours(); + assert.strictEqual(cbHour, defaultRange[0].getHours() + 1); + }); - function clickFirstShortcut() { - TestUtils.Simulate.click(getShortcut(0)); - } + it("changing date does not change time", () => { + render({ timePrecision: "minute", defaultValue: defaultRange }).left.clickDay(16); + assert.isTrue(DateUtils.areSameTime(onChangeSpy.firstCall.args[0][0] as Date, defaultRange[0])); + }); - function getDayElement(dayNumber = 1, fromLeftMonth = true) { - const month = document.querySelectorAll(".DayPicker-Month")[fromLeftMonth ? 0 : 1]; - const days = Array.from(month.querySelectorAll(`.${DateClasses.DATEPICKER_DAY}`)); - return days.filter( - d => d.textContent === dayNumber.toString() && !d.classList.contains(DateClasses.DATEPICKER_DAY_OUTSIDE), - )[0]; - } + it("changing time does not change date", () => { + render({ timePrecision: "minute", defaultValue: defaultRange }).setTimeInput("minute", 10, "left"); + assert.isTrue(DateUtils.areSameDay(onChangeSpy.firstCall.args[0][0] as Date, defaultRange[0])); + }); - function getMonthSelect(fromLeftView: boolean = true) { - const monthSelect = document.getElementsByClassName(DateClasses.DATEPICKER_MONTH_SELECT); - return fromLeftView ? monthSelect[0] : monthSelect[1]; - } + it("changing time without date uses today", () => { + render({ timePrecision: "minute" }).setTimeInput("minute", 45, "left"); + assert.isTrue(DateUtils.areSameDay(onChangeSpy.firstCall.args[0][0] as Date, new Date())); + }); - function getOptionsText(selectElementClass: string): string[] { - return Array.from(document.querySelectorAll(`.DayPicker-Month:last-child .${selectElementClass} option`)).map( - (e: HTMLElement) => e.innerText, - ); - } + it("clicking a shortcut doesn't change time", () => { + render({ timePrecision: "minute", defaultValue: defaultRange }).clickShortcut(); + assert.isTrue(DateUtils.areSameTime(onChangeSpy.firstCall.args[0][0] as Date, defaultRange[0])); + }); - function getSelectedDayElements() { - return document.querySelectorAll( - `.${DateClasses.DATEPICKER_DAY_SELECTED}:not(.${DateClasses.DATEPICKER_DAY_OUTSIDE})`, - ); - } + it("selecting and unselecting a day doesn't change time", () => { + const leftDatePicker = render({ timePrecision: "minute", defaultValue: defaultRange }).left; + leftDatePicker.clickDay(5); + leftDatePicker.clickDay(5); + assert.isTrue(DateUtils.areSameTime(onChangeSpy.secondCall.args[0][0] as Date, defaultRange[0])); + }); + }); - /** - * Returns the selected range excluding endpoints. - */ - function getSelectedRangeDayElements() { - const selectedRange = DateClasses.DATERANGEPICKER_DAY_SELECTED_RANGE; - return document.querySelectorAll(`.${selectedRange}:not(.${DateClasses.DATEPICKER_DAY_OUTSIDE})`); + function dayNotOutside(day: ReactWrapper) { + return !day.hasClass(DateClasses.DATEPICKER_DAY_OUTSIDE); } - /** - * Returns the hovered range excluding endpoints. - */ - function getHoveredRangeDayElements() { - const selectedRange = DateClasses.DATERANGEPICKER_DAY_HOVERED_RANGE; - return document.querySelectorAll(`.${selectedRange}:not(.${DateClasses.DATEPICKER_DAY_OUTSIDE})`); + function render(props?: IDateRangePickerProps) { + onChangeSpy = sinon.spy(); + onHoverChangeSpy = sinon.spy(); + const wrapper = wrap(); + return wrapper; } - function getHoveredRangeStartDayElement() { - return document.querySelector(`.${DateClasses.DATERANGEPICKER_DAY_HOVERED_RANGE}-start`); + function wrap(datepicker: JSX.Element) { + const wrapper = mount(datepicker); + // Don't cache the left/right day pickers into variables in this scope, + // because as of Enzyme 3.0 they can get stale if the views change. + const harness = { + wrapper, + + left: wrapDayPicker(wrapper, "left"), + right: wrapDayPicker(wrapper, "right"), + shortcuts: wrapper.find(`.${DateClasses.DATERANGEPICKER_SHORTCUTS}`).hostNodes(), + + assertHoveredDays: (fromDate: number | null, toDate: number | null) => { + const [from, to] = wrapper.state("hoverValue"); + fromDate == null ? assert.isNull(from) : assert.equal(from.getDate(), fromDate); + toDate == null ? assert.isNull(to) : assert.equal(to.getDate(), toDate); + return harness; + }, + assertSelectedDays: (from?: number, to?: number) => { + const [one, two] = harness.getDays(DateClasses.DATEPICKER_DAY_SELECTED).map(d => +d.text()); + assert.equal(one, from); + assert.equal(two, to); + if (from != null && to != null) { + assert.lengthOf( + harness.getDays(DateClasses.DATERANGEPICKER_DAY_SELECTED_RANGE), + Math.max(0, to - from - 1), + ); + } + }, + clickNavButton: (which: "next" | "prev", navIndex = 0) => { + wrapper + .find(DatePickerNavbar) + .at(navIndex) + .find(`.DayPicker-NavButton--${which}`) + .hostNodes() + .simulate("click"); + return harness; + }, + clickShortcut: (index = 0) => { + harness.shortcuts + .find("a") + .at(index) + .simulate("click"); + return harness; + }, + getDays: (className: string) => { + return wrapper.find(`.${className}`).filterWhere(dayNotOutside); + }, + setTimeInput: (precision: TimePrecision | "hour", value: number, which: "left" | "right") => + harness.wrapper + .find(`.${DateClasses.TIMEPICKER}-${precision}`) + .at(which === "left" ? 0 : 1) + .simulate("blur", { target: { value } }), + }; + return harness; } - function getHoveredRangeEndDayElement() { - return document.querySelector(`.${DateClasses.DATERANGEPICKER_DAY_HOVERED_RANGE}-end`); - } + function wrapDayPicker( + parent: ReactWrapper, + which: "left" | "right", + ) { + const harness = { + get wrapper() { + // use accessor to ensure it's always the latest reference + return parent + .find(ReactDayPicker) + .find("Month") + .at(which === "left" ? 0 : 1); + }, + get monthSelect() { + return harness.wrapper.find({ className: DateClasses.DATEPICKER_MONTH_SELECT }).find("select"); + }, + get yearSelect() { + return harness.wrapper.find({ className: DateClasses.DATEPICKER_YEAR_SELECT }).find("select"); + }, - function getYearSelect(fromLeftView: boolean = true) { - const yearSelect = document.getElementsByClassName(DateClasses.DATEPICKER_YEAR_SELECT); - return fromLeftView ? yearSelect[0] : yearSelect[1]; + assertMonthYear: (month: number, year?: number) => { + const view = parent.state(which === "left" ? "leftView" : "rightView"); + assert.equal(view.getMonth(), month, "month"); + if (year != null) { + assert.equal(view.getYear(), year, "year"); + } + return harness; + }, + clickDay: (dayNumber = 1) => { + harness.findDay(dayNumber).simulate("click"); + return harness; + }, + findDay: (dayNumber = 1) => { + return harness + .findDays() + .filterWhere(day => day.text() === "" + dayNumber) + .filterWhere(day => !day.hasClass(DateClasses.DATEPICKER_DAY_OUTSIDE)) + .first(); + }, + findDays: () => harness.wrapper.find(`.${DateClasses.DATEPICKER_DAY}`), + mouseEnterDay: (dayNumber = 1) => { + harness.findDay(dayNumber).simulate("mouseenter"); + return harness; + }, + }; + return harness; } }); diff --git a/packages/datetime/test/timePickerTests.tsx b/packages/datetime/test/timePickerTests.tsx index 6fd376124d..b4bea3f849 100644 --- a/packages/datetime/test/timePickerTests.tsx +++ b/packages/datetime/test/timePickerTests.tsx @@ -281,6 +281,42 @@ describe("", () => { assertTimeIs(timePicker.state.value, 2, 0, 0, 0); }); + it("can not type time greater than maxTime", () => { + const defaultValue = createTimeObject(10, 20); + const wrapper = mount(); + + wrapper.setProps({ + maxTime: createTimeObject(21), + minTime: createTimeObject(18), + }); + + const hourInput = wrapper + .find(`.${Classes.TIMEPICKER_INPUT}.${Classes.TIMEPICKER_HOUR}`) + .getDOMNode() as HTMLInputElement; + + changeInputThenBlur(hourInput, "22"); + + assert.strictEqual(hourInput.getAttribute("value"), "18"); + }); + + it("can not type time smaller than minTime", () => { + const defaultValue = createTimeObject(10, 20); + const wrapper = mount(); + + wrapper.setProps({ + maxTime: createTimeObject(21), + minTime: createTimeObject(18), + }); + + const hourInput = wrapper + .find(`.${Classes.TIMEPICKER_INPUT}.${Classes.TIMEPICKER_HOUR}`) + .getDOMNode() as HTMLInputElement; + + changeInputThenBlur(hourInput, "16"); + + assert.strictEqual(hourInput.getAttribute("value"), "18"); + }); + it("time can't be smaller minTime, while decrementing unit", () => { renderTimePicker({ minTime: createTimeObject(15, 32, 20, 600), diff --git a/packages/docs-app/src/components/navHeader.tsx b/packages/docs-app/src/components/navHeader.tsx index c6e5e332f8..a8f14672be 100644 --- a/packages/docs-app/src/components/navHeader.tsx +++ b/packages/docs-app/src/components/navHeader.tsx @@ -9,7 +9,6 @@ import { Hotkey, Hotkeys, HotkeysTarget, - Icon, Menu, MenuItem, NavbarHeading, @@ -87,8 +86,8 @@ export class NavHeader extends React.PureComponent { .map(v => ); return ( - - v{major(current)} + + v{major(current)} {releaseItems} diff --git a/packages/docs-app/src/examples/core-examples/dividerExample.tsx b/packages/docs-app/src/examples/core-examples/dividerExample.tsx new file mode 100644 index 0000000000..b9cdc05c76 --- /dev/null +++ b/packages/docs-app/src/examples/core-examples/dividerExample.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2015 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the terms of the LICENSE file distributed with this project. + */ + +import * as React from "react"; + +import { Button, ButtonGroup, Divider, H5, Switch } from "@blueprintjs/core"; +import { Example, handleBooleanChange, IExampleProps } from "@blueprintjs/docs-theme"; + +export interface IDividerExampleState { + vertical: boolean; +} + +export class DividerExample extends React.PureComponent { + public state: IDividerExampleState = { vertical: false }; + + private handleVerticalChange = handleBooleanChange(vertical => this.setState({ vertical })); + + public render() { + const { vertical } = this.state; + const options = ( + <> +
Example props
+ + + ); + return ( + + +