From e654b21dae249303363a1357ed2c485a5eac6af0 Mon Sep 17 00:00:00 2001 From: Ethan Selzer Date: Mon, 30 Oct 2017 21:31:28 -0700 Subject: [PATCH] Introduce Hint Feature --- README.md | 17 +- example/src/components/ExampleHint.js | 49 ++++++ example/src/pages/FixedWidthSmallImage.js | 3 +- example/src/pages/FluidWidthSmallImage.js | 3 +- package.json | 4 +- src/ReactImageMagnify.js | 33 +++- src/hint/DefaultHint.js | 48 ++++++ src/hint/DisplayUntilActive.js | 42 +++++ test/react-image-magnify.spec.js | 199 +++++++++++++++++++++- test/support/UserDefinedHint.js | 49 ++++++ yarn.lock | 41 ++++- 11 files changed, 465 insertions(+), 23 deletions(-) create mode 100644 example/src/components/ExampleHint.js create mode 100644 src/hint/DefaultHint.js create mode 100644 src/hint/DisplayUntilActive.js create mode 100644 test/support/UserDefinedHint.js diff --git a/README.md b/README.md index c5bc07b9..b59b89f0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A responsive React image zoom component for touch and mouse. +Includes "hint" instructions feature. + Supports hover intnet, long-press gesture, and fade transitions. Use for shopping sites or anywhere image detail is desired. @@ -72,8 +74,8 @@ If you would like more information on responsive images, please try these resour ### Desktop and Touch | Prop | Type | Required | Default | Description | |-------------------------------|--------|----------|---------|------------------------------------------------------------| -| `smallImage` | Object | Yes | | Small image information. See [Small Image](#small-image) below. | -| `largeImage` | Object | Yes | | Large image information. See [Large Image](#large-image) below. | +| `smallImage` | Object | Yes | | Small image information. See [Small Image](#small-image) below.| +| `largeImage` | Object | Yes | | Large image information. See [Large Image](#large-image) below.| | `className` | String | No | | CSS class applied to root container element. | | `style` | Object | No | | Style applied to root container element. | | `fadeDurationInMs` | Number | No | 300 | Milliseconds duration of magnified image fade in/fade out. | @@ -83,21 +85,26 @@ If you would like more information on responsive images, please try these resour | `enlargedImageContainerStyle` | Object | No | | Style applied to enlarged image container element. | | `enlargedImageClassName` | String | No | | CSS class applied to enlarged image element. | | `enlargedImageStyle` | Object | No | | Style applied to enlarged image element. | +| `hintComponent` |Function| No |(Provided)| Reference to a component class or functional component. A Default is provided.| +| `shouldHideHintAfterFirstActivation`| Boolean | No | true | Only show hint until the first interaction begins. | +| `isHintEnabled` | Boolean| No | false | Enable hint feature. | +| `hintTextMouse` | String | No |Hover to Zoom| Hint text for mouse. | +| `hintTextTouch` | String | No |Long-Touch to Zoom| Hint text for touch. | ### Mouse Specific | Prop | Type | Required | Default | Description | |-------------------------------|--------|----------|---------|------------------------------------------------------------| | `hoverDelayInMs` | Number | No | 250 | Milliseconds to delay hover trigger. | | `hoverOffDelayInMs` | Number | No | 150 | Milliseconds to delay hover-off trigger. | -| `lensStyle` | Object | No | | Style applied to tinted lens. | +| `lensStyle` | Object | No | | Style applied to tinted lens. | | `enlargedImagePosition` | String | No | beside | Enlarged image position. Can be 'beside' or 'over'. | ### Touch Specific | Prop | Type | Required | Default | Description | |-------------------------------|--------|----------|---------|------------------------------------------------------------| | `isActivatedOnTouch` | Boolean| No | false | Activate magnification immediately on touch. May impact scrolling.| -| `pressDuration` | Number | No | 500 | Milliseconds to delay long-press activation (long touch). | -| `pressMoveThreshold` | Number | No | 5 | Pixels of movement allowed during long-press activation. | +| `pressDuration` | Number | No | 500 | Milliseconds to delay long-press activation (long touch). | +| `pressMoveThreshold` | Number | No | 5 | Pixels of movement allowed during long-press activation. | | `enlargedImagePosition` | String | No | over | Enlarged image position. Can be 'beside' or 'over'. | ### Small Image diff --git a/example/src/components/ExampleHint.js b/example/src/components/ExampleHint.js new file mode 100644 index 00000000..7e2a8372 --- /dev/null +++ b/example/src/components/ExampleHint.js @@ -0,0 +1,49 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + + +function ExampleHint({ isTouchDetected }) { + return ( +
+
+ + + { isTouchDetected ? 'Long-Press to Zoom' : 'Rollover to Zoom' } + +
+
+ ); +} + +ExampleHint.displayName = 'ExampleHint' + +ExampleHint.propTypes = { + isTouchDetected: PropTypes.bool, + hintTextMouse: PropTypes.string, + hintTextTouch: PropTypes.string +} + +export default ExampleHint; diff --git a/example/src/pages/FixedWidthSmallImage.js b/example/src/pages/FixedWidthSmallImage.js index 70d6aefe..e79b39b4 100644 --- a/example/src/pages/FixedWidthSmallImage.js +++ b/example/src/pages/FixedWidthSmallImage.js @@ -22,7 +22,8 @@ export default class extends Component { src: watchImg, width: 300, height: 450 - } + }, + isHintEnabled: true }} />
diff --git a/example/src/pages/FluidWidthSmallImage.js b/example/src/pages/FluidWidthSmallImage.js index f4a3013a..e80c8514 100644 --- a/example/src/pages/FluidWidthSmallImage.js +++ b/example/src/pages/FluidWidthSmallImage.js @@ -49,7 +49,8 @@ class App extends Component { src: watchImg1200, srcSet: this.srcSet, sizes: '(min-width: 480px) 30vw, 80vw' - } + }, + isHintEnabled: true }} />
diff --git a/package.json b/package.json index d723dbb0..cfe0a6cb 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "product" ], "main": "dist/ReactImageMagnify.js", + "module": "dist/es/ReactImageMagnify.js", "files": [ "dist", "LICENCE" @@ -92,8 +93,9 @@ }, "dependencies": { "clamp": "1.0.1", + "detect-it": "3.0.3", "object-assign": "4.1.1", - "prop-types": "^15.6.0", + "prop-types": "15.6.0", "react-cursor-position": "2.2.2", "react-required-if": "1.0.1" } diff --git a/src/ReactImageMagnify.js b/src/ReactImageMagnify.js index 44296db9..ae40215c 100644 --- a/src/ReactImageMagnify.js +++ b/src/ReactImageMagnify.js @@ -3,9 +3,12 @@ import PropTypes from 'prop-types'; import requiredIf from 'react-required-if'; import ReactCursorPosition from 'react-cursor-position'; import objectAssign from 'object-assign'; +import detectIt from 'detect-it'; import ImageLensShaded from './ImageLensShaded'; import EnlargedImage from './EnlargedImage'; +import DisplayUntilActive from './hint/DisplayUntilActive'; +import Hint from './hint/DefaultHint'; import ImageShape from './ImageShape'; import noop from './noop'; @@ -18,8 +21,8 @@ class ReactImageMagnify extends React.Component { smallImageWidth: 0, smallImageHeight: 0, detectedEnvironment: { - isMouseDeteced: false, - isTouchDetected: false + isMouseDeteced: detectIt.hasMouse, + isTouchDetected: detectIt.hasTouch }, isActive: false } @@ -37,6 +40,11 @@ class ReactImageMagnify extends React.Component { enlargedImageClassName: PropTypes.string, enlargedImageStyle: PropTypes.object, fadeDurationInMs: PropTypes.number, + hintComponent: PropTypes.func, + shouldHideHintAfterFirstActivation: PropTypes.bool, + isHintEnabled: PropTypes.bool, + hintTextMouse: PropTypes.string, + hinTextTouch: PropTypes.string, hoverDelayInMs: PropTypes.number, hoverOffDelayInMs: PropTypes.number, isActivatedOnTouch: PropTypes.bool, @@ -62,6 +70,11 @@ class ReactImageMagnify extends React.Component { static defaultProps = { fadeDurationInMs: 300, + hintComponent: Hint, + shouldHideHintAfterFirstActivation: true, + isHintEnabled: false, + hintTextMouse: 'Hover to Zoom', + hintTextTouch: 'Long-Touch to Zoom', hoverDelayInMs: 250, hoverOffDelayInMs: 150 }; @@ -140,6 +153,11 @@ class ReactImageMagnify extends React.Component { enlargedImageClassName, enlargedImageStyle, fadeDurationInMs, + hintComponent: HintComponent, + shouldHideHintAfterFirstActivation, + isHintEnabled, + hintTextMouse, + hintTextTouch, hoverDelayInMs, hoverOffDelayInMs, isActivatedOnTouch, @@ -249,6 +267,17 @@ class ReactImageMagnify extends React.Component { ref: (el) => this.smallImageEl = el, onLoad: this.onSmallImageLoad }} /> + {isHintEnabled && + + + + } {shouldShowLens && +
+ + + { isTouchDetected ? hintTextTouch : hintTextMouse } + +
+
+ ); +} + +DefaultHint.displayName = 'DefaultHint'; + +DefaultHint.propTypes = { + isTouchDetected: PropTypes.bool, + hintTextMouse: PropTypes.string, + hintTextTouch: PropTypes.string +} + +export default DefaultHint; diff --git a/src/hint/DisplayUntilActive.js b/src/hint/DisplayUntilActive.js new file mode 100644 index 00000000..9ef6484b --- /dev/null +++ b/src/hint/DisplayUntilActive.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class DisplayUntilActive extends React.Component { + constructor(props) { + super(props); + + this.hasShown = false; + } + + static propTypes = { + children: PropTypes.element, + isActive: PropTypes.bool, + shouldHideAfterFirstActivation: PropTypes.bool + }; + + static defaultProps = { + shouldHideAfterFirstActivation: true + }; + + setHasShown() { + this.hasShown = true; + } + + render () { + const { + props: { + children, + isActive, + shouldHideAfterFirstActivation + }, + hasShown, + } = this; + const shouldShow = !isActive && (!hasShown || !shouldHideAfterFirstActivation); + + if (isActive && !hasShown) { + this.setHasShown(); + } + + return shouldShow ? children : null; + } +} diff --git a/test/react-image-magnify.spec.js b/test/react-image-magnify.spec.js index 3dc04ce9..ed648d1c 100644 --- a/test/react-image-magnify.spec.js +++ b/test/react-image-magnify.spec.js @@ -4,6 +4,8 @@ import { expect } from 'chai'; import sinon from 'sinon'; import ReactImageMagnify from '../src/ReactImageMagnify'; +import Hint from '../src/hint/DefaultHint'; +import UserDefinedHint from './support/UserDefinedHint'; describe('React Image Magnify', () => { const smallImage = { @@ -71,7 +73,12 @@ describe('React Image Magnify', () => { expect(ReactImageMagnify.defaultProps).to.deep.equal({ fadeDurationInMs: 300, hoverDelayInMs: 250, - hoverOffDelayInMs: 150 + hoverOffDelayInMs: 150, + hintComponent: Hint, + shouldHideHintAfterFirstActivation: true, + isHintEnabled: false, + hintTextMouse: 'Hover to Zoom', + hintTextTouch: 'Long-Touch to Zoom' }); }); @@ -144,7 +151,7 @@ describe('React Image Magnify', () => { expect(shallowWrapper.find('ReactCursorPosition').props().style.color).to.equal('red'); }); - it('prioritizes required fluid root component style over user specified style', () => { + it('weights prioritized fluid root component style over user specified style', () => { const props = { style: { width: '1px', @@ -159,7 +166,6 @@ describe('React Image Magnify', () => { }; shallowWrapper.setProps(props); - // Root component renders the root container element const { style } = shallowWrapper.find('ReactCursorPosition').props(); expect(style.width).to.equal('auto'); expect(style.height).to.equal('auto'); @@ -167,7 +173,7 @@ describe('React Image Magnify', () => { expect(style.position).to.equal('relative'); }); - it('prioritizes required fixed width root component style over user specified style', () => { + it('weights prioritized fixed width root component style over user specified style', () => { const props = { style: { width: '1px', @@ -177,7 +183,6 @@ describe('React Image Magnify', () => { }; shallowWrapper.setProps(props); - // Root component renders the root container element const { style } = shallowWrapper.find('ReactCursorPosition').props(); expect(style.width).to.equal('3px'); expect(style.height).to.equal('4px'); @@ -540,5 +545,189 @@ describe('React Image Magnify', () => { expect(shallowWrapper.find('EnlargedImage').props().imagePosition).to.equal('over'); }); + + describe('Hint', () => { + it('is disabled by default', () => { + const mountedWrapper = getMountedWrapper({ enlargedImagePosition: 'over' }); + + const hint = mountedWrapper.find('DefaultHint'); + + expect(hint).to.have.length(0); + }); + + it('supports enabling', () => { + const mountedWrapper = getMountedWrapper({ + isHintEnabled: true, + enlargedImagePosition: 'over' + }); + + const hint = mountedWrapper.find('DefaultHint'); + + expect(hint).to.have.length(1); + }); + + it('is hidden when magnification is active', (done) => { + const mountedWrapper = getMountedWrapper({ + isHintEnabled: true, + fadeDurationInMs: 0, + enlargedImagePosition: 'over' + }); + let hint = mountedWrapper.find('DefaultHint'); + expect(hint).to.have.length(1); + const rootComponent = mountedWrapper.find('ReactCursorPosition'); + + rootComponent.simulate('mouseenter'); + + setTimeout(() => { + mountedWrapper.update(); + hint = mountedWrapper.find('DefaultHint'); + expect(hint).to.have.length(0); + done(); + }, 0); + }); + + it('is hidden after first activation by default', (done) => { + const mountedWrapper = getMountedWrapper({ + isHintEnabled: true, + fadeDurationInMs: 0, + enlargedImagePosition: 'over' + }); + let hint = mountedWrapper.find('DefaultHint'); + expect(hint).to.have.length(1); + const rootComponent = mountedWrapper.find('ReactCursorPosition'); + + rootComponent.simulate('mouseenter'); + + setTimeout(() => { + mountedWrapper.update(); + hint = mountedWrapper.find('DefaultHint'); + expect(hint).to.have.length(0); + + rootComponent.simulate('mouseleave'); + + setTimeout(() => { + mountedWrapper.update(); + hint = mountedWrapper.find('DefaultHint'); + expect(hint).to.have.length(0); + done(); + }, 0); + }, 0); + }); + + it('can be configured to always show when not active', (done) => { + const mountedWrapper = getMountedWrapper({ + isHintEnabled: true, + shouldHideHintAfterFirstActivation: false, + fadeDurationInMs: 0, + enlargedImagePosition: 'over' + }); + let hint = mountedWrapper.find('DefaultHint'); + expect(hint).to.have.length(1); + const rootComponent = mountedWrapper.find('ReactCursorPosition'); + + rootComponent.simulate('mouseenter'); + + setTimeout(() => { + mountedWrapper.update(); + hint = mountedWrapper.find('DefaultHint'); + expect(hint).to.have.length(0); + + rootComponent.simulate('mouseleave'); + + setTimeout(() => { + mountedWrapper.update(); + hint = mountedWrapper.find('DefaultHint'); + expect(hint).to.have.length(1); + done(); + }, 0); + }, 0); + }); + + it('supports default hint text for mouse environments', () => { + const mountedWrapper = getMountedWrapper({ + isHintEnabled: true, + enlargedImagePosition: 'over' + }); + + const hint = mountedWrapper.find('DefaultHint'); + + expect(hint.text()).to.equal('Hover to Zoom'); + }); + + it('supports default hint text for touch environments', () => { + const mountedWrapper = getMountedWrapper({ + isHintEnabled: true, + enlargedImagePosition: 'over' + }); + mountedWrapper.setState({ + detectedEnvironment: { + isMouseDetected: false, + isTouchDetected: true + } + }); + + const hint = mountedWrapper.find('DefaultHint'); + + expect(hint.text()).to.equal('Long-Touch to Zoom'); + }); + + it('supports user defined hint text for mouse environments', () => { + const mountedWrapper = getMountedWrapper({ + isHintEnabled: true, + hintTextMouse: 'foo', + enlargedImagePosition: 'over' + }); + + const hint = mountedWrapper.find('DefaultHint'); + + expect(hint.text()).to.equal('foo'); + }); + + it('supports user defined hint text for touch environments', () => { + const mountedWrapper = getMountedWrapper({ + isHintEnabled: true, + hintTextTouch: 'bar', + enlargedImagePosition: 'over' + }); + mountedWrapper.setState({ + detectedEnvironment: { + isMouseDetected: false, + isTouchDetected: true + } + }); + + const hint = mountedWrapper.find('DefaultHint'); + + expect(hint.text()).to.equal('bar'); + }); + + it('supports user defined hint component', () => { + const mountedWrapper = getMountedWrapper({ + isHintEnabled: true, + hintComponent: UserDefinedHint, + enlargedImagePosition: 'over' + }); + + const hint = mountedWrapper.find('UserDefinedHint'); + + expect(hint.text()).to.equal('User Defined Mouse'); + }); + + it('provides correct props to user defined component', () => { + const mountedWrapper = getMountedWrapper({ + isHintEnabled: true, + hintComponent: UserDefinedHint, + enlargedImagePosition: 'over' + }); + + const hint = mountedWrapper.find('UserDefinedHint'); + + expect(hint.props()).to.deep.equal({ + isTouchDetected: false, + hintTextMouse: 'Hover to Zoom', + hintTextTouch: 'Long-Touch to Zoom' + }); + }); + }); }); }); diff --git a/test/support/UserDefinedHint.js b/test/support/UserDefinedHint.js new file mode 100644 index 00000000..4be5bfaa --- /dev/null +++ b/test/support/UserDefinedHint.js @@ -0,0 +1,49 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + + +function UserDefinedHint({ isTouchDetected }) { + return ( +
+
+ + + { isTouchDetected ? 'User Defined Touch' : 'User Defined Mouse' } + +
+
+ ); +} + +UserDefinedHint.displayName = 'UserDefinedHint' + +UserDefinedHint.propTypes = { + isTouchDetected: PropTypes.bool, + hintTextMouse: PropTypes.string, + hintTextTouch: PropTypes.string +} + +export default UserDefinedHint; diff --git a/yarn.lock b/yarn.lock index 7100bfe6..66e36039 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1747,12 +1747,37 @@ destroy@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" +detect-hover@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/detect-hover/-/detect-hover-1.0.2.tgz#589fb0b469220897a9eee3fa36a917e1eda37a21" + detect-indent@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208" dependencies: repeating "^2.0.0" +detect-it@3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/detect-it/-/detect-it-3.0.3.tgz#8e13daa0b62126150cbf76d083a1d34d1b07d071" + dependencies: + detect-hover "^1.0.2" + detect-passive-events "^1.0.4" + detect-pointer "^1.0.2" + detect-touch-events "^2.0.1" + +detect-passive-events@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/detect-passive-events/-/detect-passive-events-1.0.4.tgz#6ed477e6e5bceb79079735dcd357789d37f9a91a" + +detect-pointer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/detect-pointer/-/detect-pointer-1.0.2.tgz#1e0e4e261dab45055c50c74fb5a4ff09ceb18fbd" + +detect-touch-events@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/detect-touch-events/-/detect-touch-events-2.0.1.tgz#365833cf0c5c40c4090a08096b8a688db00fa337" + diff@3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/diff/-/diff-3.3.1.tgz#aa8567a6eed03c531fc89d3f711cd0e5259dec75" @@ -3977,14 +4002,7 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prop-types@^15.5.10: - version "15.5.10" - resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154" - dependencies: - fbjs "^0.8.9" - loose-envify "^1.3.1" - -prop-types@^15.6.0: +prop-types@15.6.0, prop-types@^15.6.0: version "15.6.0" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856" dependencies: @@ -3992,6 +4010,13 @@ prop-types@^15.6.0: loose-envify "^1.3.1" object-assign "^4.1.1" +prop-types@^15.5.10: + version "15.5.10" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.3.1" + proxy-addr@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.4.tgz#27e545f6960a44a627d9b44467e35c1b6b4ce2f3"