diff --git a/.babelrc b/.babelrc index facd18092..2aecabf0b 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,4 @@ { - "presets": ["es2015", "react"] + "presets": ["es2015", "react", "stage-0"], + "plugins": ["transform-decorators-legacy"], } \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..284c57819 --- /dev/null +++ b/Makefile @@ -0,0 +1,67 @@ +NODE_BIN = node_modules/.bin +EXAMPLE_DIST = example/dist +EXAMPLE_SRC = example/src +STANDALONE = standalone +SRC = src +DIST = dist +TEST = test/*.test.js +MOCHA_OPTS = --compilers js:babel-core/register --require test/setup.js -b --timeout 20000 --reporter spec + +lint: + @echo Linting... + @$(NODE_BIN)/standard --verbose | $(NODE_BIN)/snazzy src/index.js + +test: lint + @echo Start testing... + @$(NODE_BIN)/mocha $(MOCHA_OPTS) $(TEST) + +convertCSS: + @echo Converting css... + @node bin/transferSass.js + +genStand: + @echo Generating standard... + @rm -rf $(STANDALONE) && mkdir $(STANDALONE) + @$(NODE_BIN)/browserify -t babelify -t browserify-shim $(SRC)/index.js --standalone ReactTooltip -o $(STANDALONE)/react-tooltip.js + @$(NODE_BIN)/browserify -t babelify -t browserify-shim $(SRC)/index.js --standalone ReactTooltip | $(NODE_BIN)/uglifyjs > $(STANDALONE)/react-tooltip.min.js + @cp $(DIST)/style.js $(STANDALONE)/style.js + +devJS: + @$(NODE_BIN)/watchify -t babelify $(EXAMPLE_SRC)/index.js -o $(EXAMPLE_DIST)/index.js -dv + +devCSS: + @$(NODE_BIN)/node-sass $(EXAMPLE_SRC)/index.scss $(EXAMPLE_DIST)/index.css + @$(NODE_BIN)/node-sass -w $(EXAMPLE_SRC)/index.scss $(EXAMPLE_DIST)/index.css + +devServer: + @echo Listening 8888... + @$(NODE_BIN)/http-server example -p 8888 -s + +dev: + @echo starting dev server... + @rm -rf $(EXAMPLE_DIST) + @mkdir -p $(EXAMPLE_DIST) + @make convertCSS + @$(NODE_BIN)/concurrently --kill-others "make devJS" "make devCSS" "make devServer" + +deployJS: + @echo Generating deploy JS files... + @$(NODE_BIN)/babel $(SRC)/index.js -o $(DIST)/react-tooltip.js + @$(NODE_BIN)/babel $(SRC)/style.js -o $(DIST)/style.js + @$(NODE_BIN)/babel $(SRC)/index.js | $(NODE_BIN)/uglifyjs > $(DIST)/react-tooltip.min.js + +deployCSS: + @echo Generating deploy CSS files... + @cp $(SRC)/index.scss $(DIST)/react-tooltip.scss + @$(NODE_BIN)/node-sass --output-style compressed $(SRC)/index.scss $(DIST)/react-tooltip.min.css + +deploy: lint + @echo Deploy... + @rm -rf dist && mkdir dist + @make convertCSS + @make deployCSS + @make deployJS + @make genStand + @echo success! + +.PHONY: lint convertCSS genStand devJS devCSS devServer dev deployJS deployCSS deploy diff --git a/bin/transferSass.js b/bin/transferSass.js index 9809a9c23..6f82e7e11 100644 --- a/bin/transferSass.js +++ b/bin/transferSass.js @@ -16,6 +16,7 @@ function transferSass () { console.error(err) } console.log('css file has been transformed successful') + process.exit() }) }) } diff --git a/circle.yml b/circle.yml index 3703a7b0c..40cd00b9c 100644 --- a/circle.yml +++ b/circle.yml @@ -1,6 +1,6 @@ machine: node: - version: 0.12.0 + version: 4.2.1 dependencies: override: diff --git a/example/src/index.js b/example/src/index.js index b3aa94485..c3842f401 100755 --- a/example/src/index.js +++ b/example/src/index.js @@ -2,7 +2,7 @@ import React from 'react' import {render} from 'react-dom' -import ReactTooltip from '../../src/index' +import ReactTooltip from '../../src' const Test = React.createClass({ @@ -152,18 +152,75 @@ const Test = React.createClass({
- ( •̀д•́) - + ( •̀д•́) + +
+ +
+ ( •̀д•́) +

               
-

{"( •̀д•́)\n" + +

{"( •̀д•́)\n" + + ""}

+
+
+

{"( •̀д•́)\n" + ""}

+ +
+

Theme and delay

+

+ +
+
+ (・ω´・ ) + +
+ +
+ (・ω´・ ) + +
+
+
+
+              
+

{"(・ω´・ )́)\n" + + "\n" + + ".extraClass {\n" + + " font-size: 20px !important;\n" + + " pointer-events: auto !important;\n" + + " &:hover {\n" + + "visibility: visible !important;\n" + + "opacity: 1 !important;\n" + + " }\n" + + "}"}

+
+ +
+

{"(・ω´・ )́)\n" + + "\n" + + " .customeTheme {\n" + + " color: #ff6e00 !important;\n" + + " background-color: orange !important;\n" + + " &.place-top {\n" + + " &:after {\n" + + " border-top-color: orange !important;\n" + + " border-top-style: solid !important;\n" + + " border-top-width: 6px !important;\n" + + " }\n" + + " }\n" + + "}"}

+
+
+
) diff --git a/example/src/index.scss b/example/src/index.scss index 43b72fe37..7af637d32 100755 --- a/example/src/index.scss +++ b/example/src/index.scss @@ -206,3 +206,25 @@ html, body{ line-height: 30px; } } + +// Extra class for demonstration +.extraClass { + font-size: 20px !important; + pointer-events: auto !important; + &:hover { + visibility: visible !important; + opacity: 1 !important; + } +} + +.customeTheme { + color: #ff6e00 !important; + background-color: orange !important; + &.place-top { + &:after { + border-top-color: orange !important; + border-top-style: solid !important; + border-top-width: 6px !important; + } + } +} diff --git a/package.json b/package.json index 3d7a1db9d..5771218fe 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,12 @@ { "name": "react-tooltip", - "version": "2.0.3", + "version": "3.0.0", "description": "react tooltip component", "main": "index.js", "scripts": { - "check": "standard --verbose | snazzy src/index.js", - "test": "npm run check", - "devjs": "node bin/transferSass.js & watchify -t babelify ./example/src/index.js -o ./example/dist/index.js -dv", - "devcss": "node-sass example/src/index.scss example/dist/index.css & node-sass -w example/src/index.scss example/dist/index.css", - "predev": "rm -rf example/dist", - "dev": "mkdir -p ./example/dist & npm run devjs & npm run devcss & http-server example -p 8888 -s -o", - "deployjs": "babel src/index.js -o dist/react-tooltip.js && babel src/style.js -o dist/style.js && npm run deployminjs", - "deployminjs": "babel src/index.js | uglifyjs > dist/react-tooltip.min.js", - "predeploycss": "cp src/index.scss dist/react-tooltip.scss", - "deploycss": "node-sass --output-style compressed src/index.scss dist/react-tooltip.min.css", - "predeploy": "rm -rf dist", - "deploy": "mkdir dist & npm run deployjs & npm run deploycss & npm run standjs", - "prestandjs": "rm -rf standalone", - "standjs": "mkdir standalone && browserify -t babelify -t browserify-shim src/index.js --standalone ReactTooltip -o standalone/react-tooltip.js & npm run standminjs", - "standminjs": "browserify -t babelify -t browserify-shim src/index.js --standalone ReactTooltip | uglifyjs > standalone/react-tooltip.min.js", - "poststandjs": "cp ./dist/style.js ./standalone/style.js" + "test": "make test", + "dev": "make start", + "deploy": "make deploy" }, "standard": { "parser": "babel-eslint", @@ -59,15 +46,27 @@ }, "devDependencies": { "babel-cli": "^6.5.1", + "babel-core": "^6.9.1", "babel-eslint": "^4.1.1", + "babel-plugin-transform-decorators-legacy": "^1.3.4", "babel-plugin-transform-runtime": "^6.5.0", "babel-preset-es2015": "^6.5.0", "babel-preset-react": "^6.5.0", + "babel-preset-stage-0": "^6.5.0", "babelify": "^7.2.0", "browserify": "^13.0.0", "browserify-shim": "^3.8.12", + "chai": "^3.5.0", + "chai-enzyme": "^0.5.0", + "cheerio": "^0.20.0", + "concurrently": "^2.1.0", + "enzyme": "^2.3.0", "http-server": "^0.8.0", - "node-sass": "^3.3.2", + "jsdom": "^9.2.1", + "mocha": "^2.5.3", + "node-sass": "^3.7.0", + "react-addons-test-utils": "^15.1.0", + "sinon": "^1.17.4", "snazzy": "^2.0.1", "standard": "^5.2.2", "tape": "^4.2.0", diff --git a/src/constant.js b/src/constant.js new file mode 100644 index 000000000..f6d070f3f --- /dev/null +++ b/src/constant.js @@ -0,0 +1,8 @@ +export default { + + GLOBAL: { + HIDE: '__react_tooltip_hide_event', + REBUILD: '__react_tooltip_rebuild_event', + SHOW: '__react_tooltip_show_event' + } +} diff --git a/src/decorators/customEvent.js b/src/decorators/customEvent.js new file mode 100644 index 000000000..fd4627004 --- /dev/null +++ b/src/decorators/customEvent.js @@ -0,0 +1,68 @@ +/** + * Custom events to control showing and hiding of tooltip + * + * @attributes + * - `event` {String} + * - `eventOff` {String} + */ + +const checkStatus = function (dataEventOff, e) { + const {show} = this.state + const dataIsCapture = e.currentTarget.getAttribute('data-iscapture') + const isCapture = dataIsCapture && dataIsCapture === 'true' || this.props.isCapture + const currentItem = e.currentTarget.getAttribute('currentItem') + + if (!isCapture) e.stopPropagation() + if (show && currentItem === 'true') { + if (!dataEventOff) this.hideTooltip(e) + } else { + e.currentTarget.setAttribute('currentItem', 'true') + setUntargetItems(e.currentTarget, this.getTargetArray()) + this.showTooltip(e) + } +} + +const setUntargetItems = function (currentTarget, targetArray) { + for (let i = 0; i < targetArray.length; i++) { + if (currentTarget !== targetArray[i]) { + targetArray[i].setAttribute('currentItem', 'false') + } else { + targetArray[i].setAttribute('currentItem', 'true') + } + } +} + +export default function (target) { + target.prototype.isCustomEvent = function (ele) { + const {event} = this.state + return event || ele.getAttribute('data-event') + } + + /* Bind listener for custom event */ + target.prototype.customBindListener = function (ele) { + const {event, eventOff} = this.state + const dataEvent = ele.getAttribute('data-event') || event + const dataEventOff = ele.getAttribute('data-event-off') || eventOff + + dataEvent.split(' ').forEach(event => { + ele.removeEventListener(event, checkStatus) + ele.addEventListener(event, checkStatus.bind(this, dataEventOff), false) + }) + if (dataEventOff) { + dataEventOff.split(' ').forEach(event => { + ele.removeEventListener(event, this.hideTooltip) + ele.addEventListener(event, ::this.hideTooltip, false) + }) + } + } + + /* Unbind listener for custom event */ + target.prototype.customUnbindListener = function (ele) { + const {event, eventOff} = this.state + const dataEvent = event || ele.getAttribute('data-event') + const dataEventOff = eventOff || ele.getAttribute('data-event-off') + + ele.removeEventListener(dataEvent, checkStatus) + if (dataEventOff) ele.removeEventListener(dataEventOff, this.hideTooltip) + } +} diff --git a/src/decorators/isCapture.js b/src/decorators/isCapture.js new file mode 100644 index 000000000..5701dac3a --- /dev/null +++ b/src/decorators/isCapture.js @@ -0,0 +1,10 @@ +/** + * Util method to judge if it should follow capture model + */ + +export default function (target) { + target.prototype.isCapture = function (currentTarget) { + const dataIsCapture = currentTarget.getAttribute('data-iscapture') + return dataIsCapture && dataIsCapture === 'true' || this.props.isCapture || false + } +} diff --git a/src/decorators/staticMethods.js b/src/decorators/staticMethods.js new file mode 100644 index 000000000..ebc60a59b --- /dev/null +++ b/src/decorators/staticMethods.js @@ -0,0 +1,44 @@ +/** + * Static methods for react-tooltip + */ +import CONSTANT from '../constant' + +const dispatchGlobalEvent = (eventName) => { + // Compatibale with IE + // @see http://stackoverflow.com/questions/26596123/internet-explorer-9-10-11-event-constructor-doesnt-work + let event + + if (typeof window.Event === 'function') { + event = new window.Event(eventName) + } else { + event = document.createEvent('Event') + event.initEvent(eventName, false, true) + } + + window.dispatchEvent(event) +} + +export default function (target) { + /** + * Hide all tooltip + * @trigger ReactTooltip.hide() + */ + target.hide = () => { + dispatchGlobalEvent(CONSTANT.GLOBAL.HIDE) + } + + /** + * Rebuild all tooltip + * @trigger ReactTooltip.rebuild() + */ + target.rebuild = () => { + dispatchGlobalEvent(CONSTANT.GLOBAL.REBUILD) + } + + target.prototype.globalRebuild = function () { + if (this.mount) { + this.unbindListener() + this.bindListener() + } + } +} diff --git a/src/decorators/windowListener.js b/src/decorators/windowListener.js new file mode 100644 index 000000000..3f5dcf8c2 --- /dev/null +++ b/src/decorators/windowListener.js @@ -0,0 +1,35 @@ +/** + * Events that should be bound to the window + */ +import CONSTANT from '../constant' + +export default function (target) { + target.prototype.bindWindowEvents = function () { + // ReactTooltip.hide + window.removeEventListener(CONSTANT.GLOBAL.HIDE, this.hideTooltip) + window.addEventListener(CONSTANT.GLOBAL.HIDE, ::this.hideTooltip, false) + + // ReactTooltip.rebuild + window.removeEventListener(CONSTANT.GLOBAL.REBUILD, this.globalRebuild) + window.addEventListener(CONSTANT.GLOBAL.REBUILD, ::this.globalRebuild, false) + + // Resize + window.removeEventListener('resize', this.onWindowResize) + window.addEventListener('resize', ::this.onWindowResize, false) + } + + target.prototype.unbindWindowEvents = function () { + window.removeEventListener(CONSTANT.GLOBAL.HIDE, this.hideTooltip) + window.removeEventListener(CONSTANT.GLOBAL.REBUILD, this.globalRebuild) + window.removeEventListener(CONSTANT.GLOBAL.REBUILD, this.globalShow) + window.removeEventListener('resize', this.onWindowResize) + } + + /** + * invoked by resize event of window + */ + target.prototype.onWindowResize = function () { + if (!this.mount) return + this.hideTooltip() + } +} diff --git a/src/index.js b/src/index.js index c618e079a..9eef00a93 100644 --- a/src/index.js +++ b/src/index.js @@ -3,65 +3,52 @@ import React, { Component, PropTypes } from 'react' import ReactDOM from 'react-dom' import classname from 'classnames' -import cssStyle from './style' -class ReactTooltip extends Component { - /** - * Class method - * @see ReactTooltip.hide() && ReactTooltup.rebuild() - */ - static hide () { - /** - * Check for ie - * @see http://stackoverflow.com/questions/26596123/internet-explorer-9-10-11-event-constructor-doesnt-work - */ - if (typeof window.Event === 'function') { - window.dispatchEvent(new window.Event('__react_tooltip_hide_event')) - } else { - let event = document.createEvent('Event') - event.initEvent('__react_tooltip_hide_event', false, true) - window.dispatchEvent(event) - } - } +/* Decoraters */ +import staticMethods from './decorators/staticMethods' +import windowListener from './decorators/windowListener' +import customEvent from './decorators/customEvent' +import isCapture from './decorators/isCapture' - static rebuild () { - if (typeof window.Event === 'function') { - window.dispatchEvent(new window.Event('__react_tooltip_rebuild_event')) - } else { - let event = document.createEvent('Event') - event.initEvent('__react_tooltip_rebuild_event', false, true) - window.dispatchEvent(event) - } - } +/* Utils */ +import getPosition from './utils/getPosition' +import getTipContent from './utils/getTipContent' - globalHide () { - if (this.mount) { - this.hideTooltip() - } - } +/* CSS */ +import cssStyle from './style' - globalRebuild () { - if (this.mount) { - this.unbindListener() - this.bindListener() - } +@staticMethods @windowListener @customEvent @isCapture +class ReactTooltip extends Component { + + static propTypes = { + children: PropTypes.any, + place: PropTypes.string, + type: PropTypes.string, + effect: PropTypes.string, + offset: PropTypes.object, + multiline: PropTypes.bool, + border: PropTypes.bool, + class: PropTypes.string, + id: PropTypes.string, + html: PropTypes.bool, + delayHide: PropTypes.number, + delayShow: PropTypes.number, + event: PropTypes.string, + eventOff: PropTypes.string, + watchWindow: PropTypes.bool, + isCapture: PropTypes.bool, + globalEventOff: PropTypes.string } constructor (props) { super(props) - this._bind('showTooltip', 'updateTooltip', 'hideTooltip', 'checkStatus', 'onWindowResize', 'bindClickListener', 'globalHide', 'globalRebuild') - this.mount = true this.state = { + place: 'top', // Direction of tooltip + type: 'dark', // Color theme of tooltip + effect: 'float', // float or fixed show: false, border: false, - multilineCount: 0, placeholder: '', - x: 'NONE', - y: 'NONE', - place: '', - type: '', - effect: '', - multiline: false, offset: {}, extraClass: '', html: false, @@ -69,209 +56,122 @@ class ReactTooltip extends Component { delayShow: 0, event: props.event || null, eventOff: props.eventOff || null, - isCapture: props.isCapture || false + currentEvent: null, // Current mouse event + currentTarget: null // Current target of mouse event } + + this.mount = true this.delayShowLoop = null this.delayHideLoop = null } - /* Bind this with method */ - _bind (...handlers) { - handlers.forEach(handler => this[handler] = this[handler].bind(this)) - } - componentDidMount () { - this.bindListener() - this.setStyleHeader() - /* Add window event listener for hide and rebuild */ - window.removeEventListener('__react_tooltip_hide_event', this.globalHide) - window.addEventListener('__react_tooltip_hide_event', this.globalHide, false) - - window.removeEventListener('__react_tooltip_rebuild_event', this.globalRebuild) - window.addEventListener('__react_tooltip_rebuild_event', this.globalRebuild, false) - /* Add listener on window resize */ - window.removeEventListener('resize', this.onWindowResize) - window.addEventListener('resize', this.onWindowResize, false) - } - - componentWillUpdate () { - this.unbindListener() - } - - componentDidUpdate () { - this.updatePosition() - this.bindListener() + this.setStyleHeader() // Set the style to the + this.bindListener() // Bind listener for tooltip + this.bindWindowEvents() // Bind global event for static method } componentWillUnmount () { + this.mount = false + clearTimeout(this.delayShowLoop) clearTimeout(this.delayHideLoop) + this.unbindListener() this.removeScrollListener() - this.mount = false - window.removeEventListener('__react_tooltip_hide_event', this.globalHide) - window.removeEventListener('__react_tooltip_rebuild_event', this.globalRebuild) - window.removeEventListener('resize', this.onWindowResize) - } - - /* TODO: optimize, bind has been trigger too many times */ - bindListener () { - let targetArray = this.getTargetArray() - - let dataEvent - let dataEventOff - for (let i = 0; i < targetArray.length; i++) { - if (targetArray[i].getAttribute('currentItem') === null) { - targetArray[i].setAttribute('currentItem', 'false') - } - dataEvent = this.state.event || targetArray[i].getAttribute('data-event') - if (dataEvent) { - // if off event is specified, we will show tip on data-event and hide it on data-event-off - dataEventOff = this.state.eventOff || targetArray[i].getAttribute('data-event-off') - - targetArray[i].removeEventListener(dataEvent, this.checkStatus) - targetArray[i].addEventListener(dataEvent, this.checkStatus, false) - if (dataEventOff) { - targetArray[i].removeEventListener(dataEventOff, this.hideTooltip) - targetArray[i].addEventListener(dataEventOff, this.hideTooltip, false) - } - } else { - targetArray[i].removeEventListener('mouseenter', this.showTooltip) - targetArray[i].addEventListener('mouseenter', this.showTooltip, false) - - if (this.state.effect === 'float') { - targetArray[i].removeEventListener('mousemove', this.updateTooltip) - targetArray[i].addEventListener('mousemove', this.updateTooltip, false) - } - - targetArray[i].removeEventListener('mouseleave', this.hideTooltip) - targetArray[i].addEventListener('mouseleave', this.hideTooltip, false) - } - } - } - - unbindListener () { - let targetArray = document.querySelectorAll('[data-tip]') - let dataEvent - - for (let i = 0; i < targetArray.length; i++) { - dataEvent = this.state.event || targetArray[i].getAttribute('data-event') - if (dataEvent) { - targetArray[i].removeEventListener(dataEvent, this.checkStatus) - } else { - targetArray[i].removeEventListener('mouseenter', this.showTooltip) - targetArray[i].removeEventListener('mousemove', this.updateTooltip) - targetArray[i].removeEventListener('mouseleave', this.hideTooltip) - } - } + this.unbindWindowEvents() } /** - * Get all tooltip targets + * Pick out corresponded target elements */ - getTargetArray () { - const {id} = this.props + getTargetArray (id) { let targetArray - if (id === undefined) { + if (!id) { targetArray = document.querySelectorAll('[data-tip]:not([data-for])') } else { - targetArray = document.querySelectorAll('[data-tip][data-for="' + id + '"]') + targetArray = document.querySelectorAll(`[data-tip][data-for=${id}]`) } - return targetArray + // targetArray is a NodeList, convert it to a real array + // I hope I can use Object.values... + return Object.keys(targetArray).filter(key => key !== 'length').map(key => { + return targetArray[key] + }) } /** - * listener on window resize + * Bind listener to the target elements + * These listeners used to trigger showing or hiding the tooltip */ - onWindowResize () { - if (!this.mount) return - let targetArray = this.getTargetArray() - - for (let i = 0; i < targetArray.length; i++) { - if (targetArray[i].getAttribute('currentItem') === 'true') { - // todo: timer for performance - let {x, y} = this.getPosition(targetArray[i]) - ReactDOM.findDOMNode(this).style.left = x + 'px' - ReactDOM.findDOMNode(this).style.top = y + 'px' - /* this.setState({ - x, - y - }) */ + bindListener () { + const {id, globalEventOff} = this.props + let targetArray = this.getTargetArray(id) + + targetArray.forEach(target => { + const isCaptureMode = this.isCapture(target) + if (target.getAttribute('currentItem') === null) { + target.setAttribute('currentItem', 'false') } - } - } - /** - * Used in customer event - */ - checkStatus (e) { - const {show} = this.state - let isCapture + if (this.isCustomEvent(target)) { + this.customBindListener(target) + return + } - if (e.currentTarget.getAttribute('data-iscapture')) { - isCapture = e.currentTarget.getAttribute('data-iscapture') === 'true' - } else { - isCapture = this.state.isCapture - } + target.removeEventListener('mouseenter', this.showTooltip) + target.addEventListener('mouseenter', ::this.showTooltip, isCaptureMode) + if (this.state.effect === 'float') { + target.removeEventListener('mousemove', this.updateTooltip) + target.addEventListener('mousemove', ::this.updateTooltip, isCaptureMode) + } - if (!isCapture) e.stopPropagation() - if (show && e.currentTarget.getAttribute('currentItem') === 'true') { - this.hideTooltip(e) - } else { - e.currentTarget.setAttribute('currentItem', 'true') - /* when click other place, the tooltip should be removed */ - window.removeEventListener('click', this.bindClickListener) - window.addEventListener('click', this.bindClickListener, isCapture) + target.removeEventListener('mouseleave', this.hideTooltip) + target.addEventListener('mouseleave', ::this.hideTooltip, isCaptureMode) + }) - this.showTooltip(e) - this.setUntargetItems(e.currentTarget) + // Global event to hide tooltip + if (globalEventOff) { + window.removeEventListener(globalEventOff, this.hideTooltip) + window.addEventListener(globalEventOff, ::this.hideTooltip, false) } } - setUntargetItems (currentTarget) { - let targetArray = this.getTargetArray() - for (let i = 0; i < targetArray.length; i++) { - if (currentTarget !== targetArray[i]) { - targetArray[i].setAttribute('currentItem', 'false') - } else { - targetArray[i].setAttribute('currentItem', 'true') + /** + * Unbind listeners on target elements + */ + unbindListener () { + const {id, globalEventOff} = this.props + const targetArray = this.getTargetArray(id) + + targetArray.forEach(target => { + if (this.isCustomEvent(target)) { + this.customUnbindListener(target) + return } - } - } - bindClickListener () { - this.globalHide() - window.removeEventListener('click', this.bindClickListener) + target.removeEventListener('mouseenter', this.showTooltip) + target.removeEventListener('mousemove', this.updateTooltip) + target.removeEventListener('mouseleave', this.hideTooltip) + }) + + if (globalEventOff) window.removeEventListener(globalEventOff, this.hideTooltip) } /** - * When mouse enter, show update + * When mouse enter, show the tooltip */ showTooltip (e) { + // Get the tooltip content + // calculate in this phrase so that tip width height can be detected + const {children, multiline} = this.props const originTooltip = e.currentTarget.getAttribute('data-tip') - /* Detect multiline */ - const regexp = // - const multiline = e.currentTarget.getAttribute('data-multiline') ? e.currentTarget.getAttribute('data-multiline') : (this.props.multiline ? this.props.multiline : false) - let tooltipText - let multilineCount = 0 - if (!multiline || multiline === 'false' || !regexp.test(originTooltip)) { - tooltipText = originTooltip - } else { - tooltipText = originTooltip.split(regexp).map((d, i) => { - multilineCount += 1 - return ( - {d} - ) - }) - } - /* Define extra class */ - let extraClass = e.currentTarget.getAttribute('data-class') ? e.currentTarget.getAttribute('data-class') : '' - extraClass = this.props.class ? this.props.class + ' ' + extraClass : extraClass + const isMultiline = e.currentTarget.getAttribute('data-multiline') || multiline || false + const placeholder = getTipContent(originTooltip, children, isMultiline) + this.setState({ - placeholder: tooltipText, - multilineCount: multilineCount, + placeholder, place: e.currentTarget.getAttribute('data-place') || this.props.place || 'top', type: e.currentTarget.getAttribute('data-type') || this.props.type || 'dark', effect: e.currentTarget.getAttribute('data-effect') || this.props.effect || 'float', @@ -280,12 +180,11 @@ class ReactTooltip extends Component { delayShow: e.currentTarget.getAttribute('data-delay-show') || this.props.delayShow || 0, delayHide: e.currentTarget.getAttribute('data-delay-hide') || this.props.delayHide || 0, border: e.currentTarget.getAttribute('data-border') === 'true' || this.props.border || false, - extraClass, - multiline + extraClass: e.currentTarget.getAttribute('data-class') || this.props.class || '' + }, () => { + this.addScrollListener(e) + this.updateTooltip(e) }) - - this.addScrollListener() - this.updateTooltip(e) } /** @@ -293,26 +192,21 @@ class ReactTooltip extends Component { */ updateTooltip (e) { const {delayShow, show} = this.state + let {placeholder} = this.state const delayTime = show ? 0 : parseInt(delayShow, 10) const eventTarget = e.currentTarget clearTimeout(this.delayShowLoop) this.delayShowLoop = setTimeout(() => { - if (this.trim(this.state.placeholder).length > 0) { - if (this.state.effect === 'float') { - this.setState({ - show: true, - x: e.clientX, - y: e.clientY - }) - } else if (this.state.effect === 'solid') { - let {x, y} = this.getPosition(eventTarget) - this.setState({ - show: true, - x, - y - }) - } + if (typeof placeholder === 'string') placeholder = placeholder.trim() + if (Array.isArray(placeholder) && placeholder.length > 0 || placeholder) { + this.setState({ + currentEvent: e, + currentTarget: eventTarget, + show: true + }, () => { + this.updatePosition() + }) } }, delayTime) } @@ -322,6 +216,9 @@ class ReactTooltip extends Component { */ hideTooltip () { const {delayHide} = this.state + + if (!this.mount) return + clearTimeout(this.delayShowLoop) clearTimeout(this.delayHideLoop) this.delayHideLoop = setTimeout(() => { @@ -334,265 +231,37 @@ class ReactTooltip extends Component { /** * Add scroll eventlistener when tooltip show - * or tooltip will always existed + * automatically hide the tooltip when scrolling */ - addScrollListener () { - window.addEventListener('scroll', this.hideTooltip) + addScrollListener (e) { + const isCaptureMode = this.isCapture(e.currentTarget) + window.addEventListener('scroll', ::this.hideTooltip, isCaptureMode) } removeScrollListener () { window.removeEventListener('scroll', this.hideTooltip) } - /** - * Get tooltip poisition by current target - */ - getPosition (currentTarget) { - const {place} = this.state - const node = ReactDOM.findDOMNode(this) - const boundingClientRect = currentTarget.getBoundingClientRect() - const targetTop = boundingClientRect.top - const targetLeft = boundingClientRect.left - const tipWidth = node.clientWidth - const tipHeight = node.clientHeight - const targetWidth = currentTarget.clientWidth - const targetHeight = currentTarget.clientHeight - const windoWidth = window.innerWidth - const windowHeight = window.innerHeight - let x - let y - const defaultTopY = targetTop - tipHeight - 8 - const defaultBottomY = targetTop + targetHeight + 8 - const defaultLeftX = targetLeft - tipWidth - 6 - const defaultRightX = targetLeft + targetWidth + 6 - - let parentTop = 0 - let parentLeft = 0 - let currentParent = currentTarget.parentElement - - while (currentParent) { - if (currentParent.style.transform.length > 0) { - break - } - currentParent = currentParent.parentElement - } - - if (currentParent) { - parentTop = currentParent.getBoundingClientRect().top - parentLeft = currentParent.getBoundingClientRect().left - } - - const outsideTop = () => { - return defaultTopY - 10 < 0 - } - - const outsideBottom = () => { - return targetTop + targetHeight + tipHeight + 25 > windowHeight - } - - const outsideLeft = () => { - return defaultLeftX - 10 < 0 - } - - const outsideRight = () => { - return targetLeft + targetWidth + tipWidth + 25 > windoWidth - } - - const getTopPositionY = () => { - if (outsideTop(defaultTopY) && !outsideBottom()) { - this.setState({ - place: 'bottom' - }) - return defaultBottomY - } - - return defaultTopY - } - - const getBottomPositionY = () => { - if (outsideBottom() && !outsideTop()) { - this.setState({ - place: 'top' - }) - return defaultTopY - } - - return defaultBottomY - } - - const getLeftPositionX = () => { - if (outsideLeft() && !outsideRight()) { - this.setState({ - place: 'right' - }) - return defaultRightX - } - - return defaultLeftX - } - - const getRightPositionX = () => { - if (outsideRight() && !outsideLeft()) { - this.setState({ - place: 'left' - }) - return defaultLeftX - } - - return defaultRightX - } - - if (place === 'top') { - x = targetLeft - (tipWidth / 2) + (targetWidth / 2) - parentLeft - y = getTopPositionY() - parentTop - } else if (place === 'bottom') { - x = targetLeft - (tipWidth / 2) + (targetWidth / 2) - parentLeft - y = getBottomPositionY() - parentTop - } else if (place === 'left') { - x = getLeftPositionX() - parentLeft - y = targetTop + (targetHeight / 2) - (tipHeight / 2) - parentTop - } else if (place === 'right') { - x = getRightPositionX() - parentLeft - y = targetTop + (targetHeight / 2) - (tipHeight / 2) - parentTop - } - - return { x, y } - } - - /** - * Execute in componentDidUpdate, can't put this into render() to support server rendering - */ + // Calculation the position updatePosition () { - let node = ReactDOM.findDOMNode(this) - - let tipWidth = node.clientWidth - let tipHeight = node.clientHeight - let { effect, place, offset } = this.state - let offsetFromEffect = {} - - /** - * List all situations for different placement, - * then tooltip can judge switch to which side if window space is not enough - * @note only support for float at the moment - */ - const placements = ['top', 'bottom', 'left', 'right'] - placements.forEach(key => { - offsetFromEffect[key] = {x: 0, y: 0} - }) - - if (effect === 'float') { - offsetFromEffect.top = { - x: -(tipWidth / 2), - y: -tipHeight - } - offsetFromEffect.bottom = { - x: -(tipWidth / 2), - y: 15 - } - offsetFromEffect.left = { - x: -(tipWidth + 15), - y: -(tipHeight / 2) - } - offsetFromEffect.right = { - x: 10, - y: -(tipHeight / 2) - } - } - - let xPosition = 0 - let yPosition = 0 - - /* If user set offset attribute, we have to consider it into out position calculating */ - if (Object.prototype.toString.apply(offset) === '[object String]') { - offset = JSON.parse(offset.toString().replace(/\'/g, '\"')) - } - for (let key in offset) { - if (key === 'top') { - yPosition -= parseInt(offset[key], 10) - } else if (key === 'bottom') { - yPosition += parseInt(offset[key], 10) - } else if (key === 'left') { - xPosition -= parseInt(offset[key], 10) - } else if (key === 'right') { - xPosition += parseInt(offset[key], 10) - } - } - - /* If our tooltip goes outside the window we want to try and change its place to be inside the window */ - let x = this.state.x - let y = this.state.y - const windoWidth = window.innerWidth - const windowHeight = window.innerHeight - - const getStyleLeft = (place) => { - const offsetEffectX = effect === 'solid' ? 0 : place ? offsetFromEffect[place].x : 0 - return x + offsetEffectX + xPosition - } - const getStyleTop = (place) => { - const offsetEffectY = effect === 'solid' ? 0 : place ? offsetFromEffect[place].y : 0 - return y + offsetEffectY + yPosition - } - - const outsideLeft = (place) => { - const styleLeft = getStyleLeft(place) - return styleLeft < 0 && x + offsetFromEffect['right'].x + xPosition <= windoWidth - } - const outsideRight = (place) => { - const styleLeft = getStyleLeft(place) - return styleLeft + tipWidth > windoWidth && x + offsetFromEffect['left'].x + xPosition >= 0 - } - const outsideTop = (place) => { - const styleTop = getStyleTop(place) - return styleTop < 0 && y + offsetFromEffect['bottom'].y + yPosition + tipHeight < windowHeight - } - const outsideBottom = (place) => { - var styleTop = getStyleTop(place) - return styleTop + tipHeight >= windowHeight && y + offsetFromEffect['top'].y + yPosition >= 0 - } - - /* We want to make sure the place we switch to will not go outside either */ - const outside = (place) => { - return outsideTop(place) || outsideRight(place) || outsideBottom(place) || outsideLeft(place) - } + const {currentEvent, currentTarget, place, effect, offset} = this.state + const node = ReactDOM.findDOMNode(this) - /* We check each side and switch if the new place will be in bounds */ - if (outsideLeft(place)) { - if (!outside('right')) { - this.setState({ - place: 'right' - }) - return - } - } else if (outsideRight(place)) { - if (!outside('left')) { - this.setState({ - place: 'left' - }) - return - } - } else if (outsideTop(place)) { - if (!outside('bottom')) { - this.setState({ - place: 'bottom' - }) - return - } - } else if (outsideBottom(place)) { - if (!outside('top')) { - this.setState({ - place: 'top' - }) - return - } + const result = getPosition(currentEvent, currentTarget, node, place, effect, offset) + if (result.isNewState) { + // Switch to reverse placement + return this.setState(result.newState, () => { + this.updatePosition() + }) } - - node.style.left = getStyleLeft(place) + 'px' - node.style.top = getStyleTop(place) + 'px' + // Set tooltip position + node.style.left = result.position.left + 'px' + node.style.top = result.position.top + 'px' } /** * Set style tag in header - * Insert style by this way + * in this way we can insert default css */ setStyleHeader () { if (!document.getElementsByTagName('head')[0].querySelector('style[id="react-tooltip"]')) { @@ -623,59 +292,17 @@ class ReactTooltip extends Component { if (html) { return ( -
+
) } else { - const content = this.props.children ? this.props.children : placeholder return ( -
{content}
+
{placeholder}
) } } - - trim (string) { - if (Object.prototype.toString.call(string) !== '[object String]') { - return string - } - let newString = string.split('') - let firstCount = 0 - let lastCount = 0 - for (let i = 0; i < string.length; i++) { - if (string[i] !== ' ') { - break - } - firstCount++ - } - for (let i = string.length - 1; i >= 0; i--) { - if (string[i] !== ' ') { - break - } - lastCount++ - } - newString.splice(0, firstCount) - newString.splice(-lastCount, lastCount) - return newString.join('') - } - -} - -ReactTooltip.propTypes = { - children: PropTypes.any, - place: PropTypes.string, - type: PropTypes.string, - effect: PropTypes.string, - offset: PropTypes.object, - multiline: PropTypes.bool, - border: PropTypes.bool, - class: PropTypes.string, - id: PropTypes.string, - html: PropTypes.bool, - delayHide: PropTypes.number, - delayShow: PropTypes.number, - event: PropTypes.any, - eventOff: PropTypes.any, - watchWindow: PropTypes.bool, - isCapture: PropTypes.bool } /* export default not fit for standalone, it will exports {default:...} */ diff --git a/src/index.scss b/src/index.scss index 53b2af939..fd0c2b7a8 100644 --- a/src/index.scss +++ b/src/index.scss @@ -2,22 +2,30 @@ background-color: $background-color; &.place-top { &:after { - border-top: 6px solid $background-color; + border-top-color: $background-color; + border-top-style: solid; + border-top-width: 6px; } } &.place-bottom { &:after { - border-bottom: 6px solid $background-color; + border-bottom-color: $background-color; + border-bottom-style: solid; + border-bottom-width: 6px; } } &.place-left { &:after { - border-left: 6px solid $background-color; + border-left-color: $background-color; + border-left-style: solid; + border-left-width: 6px; } } &.place-right { &:after { - border-right: 6px solid $background-color; + border-right-color: $background-color; + border-right-style: solid; + border-right-width: 6px; } } } @@ -188,4 +196,4 @@ padding: 2px 0px; text-align: center; } -} +} diff --git a/src/style.js b/src/style.js index b9f64aaf2..cd4bb4da3 100644 --- a/src/style.js +++ b/src/style.js @@ -1 +1 @@ -export default '.__react_component_tooltip{border-radius:3px;display:inline-block;font-size:13px;left:-999em;opacity:0;padding:8px 21px;position:fixed;pointer-events:none;transition:opacity 0.3s ease-out , margin-top 0.3s ease-out, margin-left 0.3s ease-out;top:-999em;visibility:hidden;z-index:999}.__react_component_tooltip:before,.__react_component_tooltip:after{content:"";width:0;height:0;position:absolute}.__react_component_tooltip.show{opacity:0.9;margin-top:0px;margin-left:0px;visibility:visible}.__react_component_tooltip.type-dark{color:#fff;background-color:#222}.__react_component_tooltip.type-dark.place-top:after{border-top:6px solid #222}.__react_component_tooltip.type-dark.place-bottom:after{border-bottom:6px solid #222}.__react_component_tooltip.type-dark.place-left:after{border-left:6px solid #222}.__react_component_tooltip.type-dark.place-right:after{border-right:6px solid #222}.__react_component_tooltip.type-dark.border{border:1px solid #fff}.__react_component_tooltip.type-dark.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-dark.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-dark.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-dark.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-success{color:#fff;background-color:#8DC572}.__react_component_tooltip.type-success.place-top:after{border-top:6px solid #8DC572}.__react_component_tooltip.type-success.place-bottom:after{border-bottom:6px solid #8DC572}.__react_component_tooltip.type-success.place-left:after{border-left:6px solid #8DC572}.__react_component_tooltip.type-success.place-right:after{border-right:6px solid #8DC572}.__react_component_tooltip.type-success.border{border:1px solid #fff}.__react_component_tooltip.type-success.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-success.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-success.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-success.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-warning{color:#fff;background-color:#F0AD4E}.__react_component_tooltip.type-warning.place-top:after{border-top:6px solid #F0AD4E}.__react_component_tooltip.type-warning.place-bottom:after{border-bottom:6px solid #F0AD4E}.__react_component_tooltip.type-warning.place-left:after{border-left:6px solid #F0AD4E}.__react_component_tooltip.type-warning.place-right:after{border-right:6px solid #F0AD4E}.__react_component_tooltip.type-warning.border{border:1px solid #fff}.__react_component_tooltip.type-warning.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-warning.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-warning.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-warning.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-error{color:#fff;background-color:#BE6464}.__react_component_tooltip.type-error.place-top:after{border-top:6px solid #BE6464}.__react_component_tooltip.type-error.place-bottom:after{border-bottom:6px solid #BE6464}.__react_component_tooltip.type-error.place-left:after{border-left:6px solid #BE6464}.__react_component_tooltip.type-error.place-right:after{border-right:6px solid #BE6464}.__react_component_tooltip.type-error.border{border:1px solid #fff}.__react_component_tooltip.type-error.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-error.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-error.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-error.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-info{color:#fff;background-color:#337AB7}.__react_component_tooltip.type-info.place-top:after{border-top:6px solid #337AB7}.__react_component_tooltip.type-info.place-bottom:after{border-bottom:6px solid #337AB7}.__react_component_tooltip.type-info.place-left:after{border-left:6px solid #337AB7}.__react_component_tooltip.type-info.place-right:after{border-right:6px solid #337AB7}.__react_component_tooltip.type-info.border{border:1px solid #fff}.__react_component_tooltip.type-info.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-info.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-info.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-info.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-light{color:#222;background-color:#fff}.__react_component_tooltip.type-light.place-top:after{border-top:6px solid #fff}.__react_component_tooltip.type-light.place-bottom:after{border-bottom:6px solid #fff}.__react_component_tooltip.type-light.place-left:after{border-left:6px solid #fff}.__react_component_tooltip.type-light.place-right:after{border-right:6px solid #fff}.__react_component_tooltip.type-light.border{border:1px solid #222}.__react_component_tooltip.type-light.border.place-top:before{border-top:8px solid #222}.__react_component_tooltip.type-light.border.place-bottom:before{border-bottom:8px solid #222}.__react_component_tooltip.type-light.border.place-left:before{border-left:8px solid #222}.__react_component_tooltip.type-light.border.place-right:before{border-right:8px solid #222}.__react_component_tooltip.place-top{margin-top:-10px}.__react_component_tooltip.place-top:before{border-left:10px solid transparent;border-right:10px solid transparent;bottom:-8px;left:50%;margin-left:-10px}.__react_component_tooltip.place-top:after{border-left:8px solid transparent;border-right:8px solid transparent;bottom:-6px;left:50%;margin-left:-8px}.__react_component_tooltip.place-bottom{margin-top:10px}.__react_component_tooltip.place-bottom:before{border-left:10px solid transparent;border-right:10px solid transparent;top:-8px;left:50%;margin-left:-10px}.__react_component_tooltip.place-bottom:after{border-left:8px solid transparent;border-right:8px solid transparent;top:-6px;left:50%;margin-left:-8px}.__react_component_tooltip.place-left{margin-left:-10px}.__react_component_tooltip.place-left:before{border-top:6px solid transparent;border-bottom:6px solid transparent;right:-8px;top:50%;margin-top:-5px}.__react_component_tooltip.place-left:after{border-top:5px solid transparent;border-bottom:5px solid transparent;right:-6px;top:50%;margin-top:-4px}.__react_component_tooltip.place-right{margin-left:10px}.__react_component_tooltip.place-right:before{border-top:6px solid transparent;border-bottom:6px solid transparent;left:-8px;top:50%;margin-top:-5px}.__react_component_tooltip.place-right:after{border-top:5px solid transparent;border-bottom:5px solid transparent;left:-6px;top:50%;margin-top:-4px}.__react_component_tooltip .multi-line{display:block;padding:2px 0px;text-align:center}' \ No newline at end of file +export default '.__react_component_tooltip{border-radius:3px;display:inline-block;font-size:13px;left:-999em;opacity:0;padding:8px 21px;position:fixed;pointer-events:none;transition:opacity 0.3s ease-out , margin-top 0.3s ease-out, margin-left 0.3s ease-out;top:-999em;visibility:hidden;z-index:999}.__react_component_tooltip:before,.__react_component_tooltip:after{content:"";width:0;height:0;position:absolute}.__react_component_tooltip.show{opacity:0.9;margin-top:0px;margin-left:0px;visibility:visible}.__react_component_tooltip.type-dark{color:#fff;background-color:#222}.__react_component_tooltip.type-dark.place-top:after{border-top-color:#222;border-top-style:solid;border-top-width:6px}.__react_component_tooltip.type-dark.place-bottom:after{border-bottom-color:#222;border-bottom-style:solid;border-bottom-width:6px}.__react_component_tooltip.type-dark.place-left:after{border-left-color:#222;border-left-style:solid;border-left-width:6px}.__react_component_tooltip.type-dark.place-right:after{border-right-color:#222;border-right-style:solid;border-right-width:6px}.__react_component_tooltip.type-dark.border{border:1px solid #fff}.__react_component_tooltip.type-dark.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-dark.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-dark.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-dark.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-success{color:#fff;background-color:#8DC572}.__react_component_tooltip.type-success.place-top:after{border-top-color:#8DC572;border-top-style:solid;border-top-width:6px}.__react_component_tooltip.type-success.place-bottom:after{border-bottom-color:#8DC572;border-bottom-style:solid;border-bottom-width:6px}.__react_component_tooltip.type-success.place-left:after{border-left-color:#8DC572;border-left-style:solid;border-left-width:6px}.__react_component_tooltip.type-success.place-right:after{border-right-color:#8DC572;border-right-style:solid;border-right-width:6px}.__react_component_tooltip.type-success.border{border:1px solid #fff}.__react_component_tooltip.type-success.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-success.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-success.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-success.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-warning{color:#fff;background-color:#F0AD4E}.__react_component_tooltip.type-warning.place-top:after{border-top-color:#F0AD4E;border-top-style:solid;border-top-width:6px}.__react_component_tooltip.type-warning.place-bottom:after{border-bottom-color:#F0AD4E;border-bottom-style:solid;border-bottom-width:6px}.__react_component_tooltip.type-warning.place-left:after{border-left-color:#F0AD4E;border-left-style:solid;border-left-width:6px}.__react_component_tooltip.type-warning.place-right:after{border-right-color:#F0AD4E;border-right-style:solid;border-right-width:6px}.__react_component_tooltip.type-warning.border{border:1px solid #fff}.__react_component_tooltip.type-warning.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-warning.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-warning.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-warning.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-error{color:#fff;background-color:#BE6464}.__react_component_tooltip.type-error.place-top:after{border-top-color:#BE6464;border-top-style:solid;border-top-width:6px}.__react_component_tooltip.type-error.place-bottom:after{border-bottom-color:#BE6464;border-bottom-style:solid;border-bottom-width:6px}.__react_component_tooltip.type-error.place-left:after{border-left-color:#BE6464;border-left-style:solid;border-left-width:6px}.__react_component_tooltip.type-error.place-right:after{border-right-color:#BE6464;border-right-style:solid;border-right-width:6px}.__react_component_tooltip.type-error.border{border:1px solid #fff}.__react_component_tooltip.type-error.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-error.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-error.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-error.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-info{color:#fff;background-color:#337AB7}.__react_component_tooltip.type-info.place-top:after{border-top-color:#337AB7;border-top-style:solid;border-top-width:6px}.__react_component_tooltip.type-info.place-bottom:after{border-bottom-color:#337AB7;border-bottom-style:solid;border-bottom-width:6px}.__react_component_tooltip.type-info.place-left:after{border-left-color:#337AB7;border-left-style:solid;border-left-width:6px}.__react_component_tooltip.type-info.place-right:after{border-right-color:#337AB7;border-right-style:solid;border-right-width:6px}.__react_component_tooltip.type-info.border{border:1px solid #fff}.__react_component_tooltip.type-info.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-info.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-info.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-info.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-light{color:#222;background-color:#fff}.__react_component_tooltip.type-light.place-top:after{border-top-color:#fff;border-top-style:solid;border-top-width:6px}.__react_component_tooltip.type-light.place-bottom:after{border-bottom-color:#fff;border-bottom-style:solid;border-bottom-width:6px}.__react_component_tooltip.type-light.place-left:after{border-left-color:#fff;border-left-style:solid;border-left-width:6px}.__react_component_tooltip.type-light.place-right:after{border-right-color:#fff;border-right-style:solid;border-right-width:6px}.__react_component_tooltip.type-light.border{border:1px solid #222}.__react_component_tooltip.type-light.border.place-top:before{border-top:8px solid #222}.__react_component_tooltip.type-light.border.place-bottom:before{border-bottom:8px solid #222}.__react_component_tooltip.type-light.border.place-left:before{border-left:8px solid #222}.__react_component_tooltip.type-light.border.place-right:before{border-right:8px solid #222}.__react_component_tooltip.place-top{margin-top:-10px}.__react_component_tooltip.place-top:before{border-left:10px solid transparent;border-right:10px solid transparent;bottom:-8px;left:50%;margin-left:-10px}.__react_component_tooltip.place-top:after{border-left:8px solid transparent;border-right:8px solid transparent;bottom:-6px;left:50%;margin-left:-8px}.__react_component_tooltip.place-bottom{margin-top:10px}.__react_component_tooltip.place-bottom:before{border-left:10px solid transparent;border-right:10px solid transparent;top:-8px;left:50%;margin-left:-10px}.__react_component_tooltip.place-bottom:after{border-left:8px solid transparent;border-right:8px solid transparent;top:-6px;left:50%;margin-left:-8px}.__react_component_tooltip.place-left{margin-left:-10px}.__react_component_tooltip.place-left:before{border-top:6px solid transparent;border-bottom:6px solid transparent;right:-8px;top:50%;margin-top:-5px}.__react_component_tooltip.place-left:after{border-top:5px solid transparent;border-bottom:5px solid transparent;right:-6px;top:50%;margin-top:-4px}.__react_component_tooltip.place-right{margin-left:10px}.__react_component_tooltip.place-right:before{border-top:6px solid transparent;border-bottom:6px solid transparent;left:-8px;top:50%;margin-top:-5px}.__react_component_tooltip.place-right:after{border-top:5px solid transparent;border-bottom:5px solid transparent;left:-6px;top:50%;margin-top:-4px}.__react_component_tooltip .multi-line{display:block;padding:2px 0px;text-align:center}' \ No newline at end of file diff --git a/src/utils/getPosition.js b/src/utils/getPosition.js new file mode 100644 index 000000000..109eb7fc9 --- /dev/null +++ b/src/utils/getPosition.js @@ -0,0 +1,151 @@ +/** + * Calculate the position of tooltip + * + * @params + * - `e` {Event} the event of current mouse + * - `target` {Element} the currentTarget of the event + * - `node` {DOM} the react-tooltip object + * - `place` {String} top / right / bottom / left + * - `effect` {String} float / solid + * - `offset` {Object} the offset to default position + * + * @return {Object + * - `isNewState` {Bool} required + * - `newState` {Object} + * - `position` {OBject} {left: {Number}, top: {Number}} + */ +export default function (e, target, node, place, effect, offset) { + const tipWidth = node.clientWidth + const tipHeight = node.clientHeight + const {mouseX, mouseY} = getCurrentOffset(e, target, effect) + const defaultOffset = getDefaultPosition(effect, target.clientWidth, target.clientHeight, tipWidth, tipHeight) + const {extraOffset_X, extraOffset_Y} = calculateOffset(offset) + + const widnowWidth = window.innerWidth + const windowHeight = window.innerHeight + + // Get the edge offset of the tooltip + const getTipOffsetLeft = (place) => { + const offset_X = defaultOffset[place].x + return mouseX + offset_X + extraOffset_X + } + const getTipOffsetTop = (place) => { + const offset_Y = defaultOffset[place].y + return mouseY + offset_Y + extraOffset_Y + } + + // Judge if the tooltip has over the window(screen) + const outsideLeft = () => { + // if switch to right will out of screen, return false because switch placement doesn't make sense + return getTipOffsetLeft('left') < 0 && getTipOffsetLeft('right') <= widnowWidth + } + const outsideRight = () => { + return getTipOffsetLeft('right') > widnowWidth && getTipOffsetLeft('left') >= 0 + } + const outsideTop = () => { + return getTipOffsetTop('top') < 0 && getTipOffsetTop('bottom') + tipHeight <= windowHeight + } + const outsideBottom = () => { + return getTipOffsetTop('bottom') + tipHeight > windowHeight && getTipOffsetTop('top') >= 0 + } + + // Return new state to change the placement to the reverse if possible + if (place === 'left' && outsideLeft()) { + return { + isNewState: true, + newState: {place: 'right'} + } + } else if (place === 'right' && outsideRight()) { + return { + isNewState: true, + newState: {place: 'left'} + } + } else if (place === 'top' && outsideTop()) { + return { + isNewState: true, + newState: {place: 'bottom'} + } + } else if (place === 'bottom' && outsideBottom()) { + return { + isNewState: true, + newState: {place: 'top'} + } + } + + // Return tooltip offset position + return { + isNewState: false, + position: { + left: getTipOffsetLeft(place), + top: getTipOffsetTop(place) + } + } +} + +// Get current mouse offset +const getCurrentOffset = (e, currentTarget, effect) => { + const boundingClientRect = currentTarget.getBoundingClientRect() + const targetTop = boundingClientRect.top + const targetLeft = boundingClientRect.left + const targetWidth = currentTarget.clientWidth + const targetHeight = currentTarget.clientHeight + + if (effect === 'float') { + return { + mouseX: e.clientX, + mouseY: e.clientY + } + } + return { + mouseX: targetLeft + (targetWidth / 2), + mouseY: targetTop + (targetHeight / 2) + } +} + +// List all possibility of tooltip final offset +// This is useful in judging if it is necessary for tooltip to switch position when out of window +const getDefaultPosition = (effect, targetWidth, targetHeight, tipWidth, tipHeight) => { + let top + let right + let bottom + let left + const disToMouse = 15 + const triangleHeight = 5 + + if (effect === 'float') { + top = {x: -(tipWidth / 2), y: -(tipHeight + disToMouse - triangleHeight)} + bottom = {x: -(tipWidth / 2), y: disToMouse} + left = {x: -(tipWidth + disToMouse - triangleHeight), y: -(tipHeight / 2)} + right = {x: disToMouse, y: -(tipHeight / 2)} + } else if (effect === 'solid') { + top = {x: -(tipWidth / 2), y: -(targetHeight / 2 + tipHeight)} + bottom = {x: -(tipWidth / 2), y: targetHeight / 2} + left = {x: -(tipWidth + targetWidth / 2), y: -(tipHeight / 2)} + right = {x: targetWidth / 2, y: -(tipHeight / 2)} + } + + return {top, bottom, left, right} +} + +// Consider additional offset into position calculation +const calculateOffset = (offset) => { + let extraOffset_X = 0 + let extraOffset_Y = 0 + + if (Object.prototype.toString.apply(offset) === '[object String]') { + offset = JSON.parse(offset.toString().replace(/\'/g, '\"')) + } + for (let key in offset) { + if (key === 'top') { + extraOffset_Y -= parseInt(offset[key], 10) + } else if (key === 'bottom') { + extraOffset_Y += parseInt(offset[key], 10) + } else if (key === 'left') { + extraOffset_X -= parseInt(offset[key], 10) + } else if (key === 'right') { + extraOffset_X += parseInt(offset[key], 10) + } + } + + return {extraOffset_X, extraOffset_Y} +} diff --git a/src/utils/getTipContent.js b/src/utils/getTipContent.js new file mode 100644 index 000000000..774d77eb8 --- /dev/null +++ b/src/utils/getTipContent.js @@ -0,0 +1,30 @@ +/** + * To get the tooltip content + * it may comes from data-tip or this.props.children + * it should support multiline + * + * @params + * - `tip` {String} value of data-tip + * - `children` {ReactElement} this.props.children + * - `multiline` {Any} could be Bool(true/false) or String('true'/'false') + * + * @return + * - String or react component + */ +import React from 'react' + +export default function (tip, children, multiline) { + if (children) return children + + const regexp = // + if (!multiline || multiline === 'false' || !regexp.test(tip)) { + return tip + } + + // Multiline tooltip content + return tip.split(regexp).map((d, i) => { + return ( + {d} + ) + }) +} diff --git a/standalone/style.js b/standalone/style.js new file mode 100644 index 000000000..62c4793b3 --- /dev/null +++ b/standalone/style.js @@ -0,0 +1,6 @@ +'use strict'; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = '.__react_component_tooltip{border-radius:3px;display:inline-block;font-size:13px;left:-999em;opacity:0;padding:8px 21px;position:fixed;pointer-events:none;transition:opacity 0.3s ease-out , margin-top 0.3s ease-out, margin-left 0.3s ease-out;top:-999em;visibility:hidden;z-index:999}.__react_component_tooltip:before,.__react_component_tooltip:after{content:"";width:0;height:0;position:absolute}.__react_component_tooltip.show{opacity:0.9;margin-top:0px;margin-left:0px;visibility:visible}.__react_component_tooltip.type-dark{color:#fff;background-color:#222}.__react_component_tooltip.type-dark.place-top:after{border-top:6px solid #222}.__react_component_tooltip.type-dark.place-bottom:after{border-bottom:6px solid #222}.__react_component_tooltip.type-dark.place-left:after{border-left:6px solid #222}.__react_component_tooltip.type-dark.place-right:after{border-right:6px solid #222}.__react_component_tooltip.type-dark.border{border:1px solid #fff}.__react_component_tooltip.type-dark.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-dark.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-dark.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-dark.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-success{color:#fff;background-color:#8DC572}.__react_component_tooltip.type-success.place-top:after{border-top:6px solid #8DC572}.__react_component_tooltip.type-success.place-bottom:after{border-bottom:6px solid #8DC572}.__react_component_tooltip.type-success.place-left:after{border-left:6px solid #8DC572}.__react_component_tooltip.type-success.place-right:after{border-right:6px solid #8DC572}.__react_component_tooltip.type-success.border{border:1px solid #fff}.__react_component_tooltip.type-success.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-success.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-success.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-success.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-warning{color:#fff;background-color:#F0AD4E}.__react_component_tooltip.type-warning.place-top:after{border-top:6px solid #F0AD4E}.__react_component_tooltip.type-warning.place-bottom:after{border-bottom:6px solid #F0AD4E}.__react_component_tooltip.type-warning.place-left:after{border-left:6px solid #F0AD4E}.__react_component_tooltip.type-warning.place-right:after{border-right:6px solid #F0AD4E}.__react_component_tooltip.type-warning.border{border:1px solid #fff}.__react_component_tooltip.type-warning.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-warning.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-warning.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-warning.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-error{color:#fff;background-color:#BE6464}.__react_component_tooltip.type-error.place-top:after{border-top:6px solid #BE6464}.__react_component_tooltip.type-error.place-bottom:after{border-bottom:6px solid #BE6464}.__react_component_tooltip.type-error.place-left:after{border-left:6px solid #BE6464}.__react_component_tooltip.type-error.place-right:after{border-right:6px solid #BE6464}.__react_component_tooltip.type-error.border{border:1px solid #fff}.__react_component_tooltip.type-error.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-error.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-error.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-error.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-info{color:#fff;background-color:#337AB7}.__react_component_tooltip.type-info.place-top:after{border-top:6px solid #337AB7}.__react_component_tooltip.type-info.place-bottom:after{border-bottom:6px solid #337AB7}.__react_component_tooltip.type-info.place-left:after{border-left:6px solid #337AB7}.__react_component_tooltip.type-info.place-right:after{border-right:6px solid #337AB7}.__react_component_tooltip.type-info.border{border:1px solid #fff}.__react_component_tooltip.type-info.border.place-top:before{border-top:8px solid #fff}.__react_component_tooltip.type-info.border.place-bottom:before{border-bottom:8px solid #fff}.__react_component_tooltip.type-info.border.place-left:before{border-left:8px solid #fff}.__react_component_tooltip.type-info.border.place-right:before{border-right:8px solid #fff}.__react_component_tooltip.type-light{color:#222;background-color:#fff}.__react_component_tooltip.type-light.place-top:after{border-top:6px solid #fff}.__react_component_tooltip.type-light.place-bottom:after{border-bottom:6px solid #fff}.__react_component_tooltip.type-light.place-left:after{border-left:6px solid #fff}.__react_component_tooltip.type-light.place-right:after{border-right:6px solid #fff}.__react_component_tooltip.type-light.border{border:1px solid #222}.__react_component_tooltip.type-light.border.place-top:before{border-top:8px solid #222}.__react_component_tooltip.type-light.border.place-bottom:before{border-bottom:8px solid #222}.__react_component_tooltip.type-light.border.place-left:before{border-left:8px solid #222}.__react_component_tooltip.type-light.border.place-right:before{border-right:8px solid #222}.__react_component_tooltip.place-top{margin-top:-10px}.__react_component_tooltip.place-top:before{border-left:10px solid transparent;border-right:10px solid transparent;bottom:-8px;left:50%;margin-left:-10px}.__react_component_tooltip.place-top:after{border-left:8px solid transparent;border-right:8px solid transparent;bottom:-6px;left:50%;margin-left:-8px}.__react_component_tooltip.place-bottom{margin-top:10px}.__react_component_tooltip.place-bottom:before{border-left:10px solid transparent;border-right:10px solid transparent;top:-8px;left:50%;margin-left:-10px}.__react_component_tooltip.place-bottom:after{border-left:8px solid transparent;border-right:8px solid transparent;top:-6px;left:50%;margin-left:-8px}.__react_component_tooltip.place-left{margin-left:-10px}.__react_component_tooltip.place-left:before{border-top:6px solid transparent;border-bottom:6px solid transparent;right:-8px;top:50%;margin-top:-5px}.__react_component_tooltip.place-left:after{border-top:5px solid transparent;border-bottom:5px solid transparent;right:-6px;top:50%;margin-top:-4px}.__react_component_tooltip.place-right{margin-left:10px}.__react_component_tooltip.place-right:before{border-top:6px solid transparent;border-bottom:6px solid transparent;left:-8px;top:50%;margin-top:-5px}.__react_component_tooltip.place-right:after{border-top:5px solid transparent;border-bottom:5px solid transparent;left:-6px;top:50%;margin-top:-4px}.__react_component_tooltip .multi-line{display:block;padding:2px 0px;text-align:center}'; diff --git a/test/globalMethods.test.js b/test/globalMethods.test.js new file mode 100644 index 000000000..fdcdb3f34 --- /dev/null +++ b/test/globalMethods.test.js @@ -0,0 +1,37 @@ +/* For Standard.js lint checking */ +/* eslint-env mocha */ +import React from 'react' +import { mount } from 'enzyme' +import chai, { expect } from 'chai' +import chaiEnzyme from 'chai-enzyme' +import sinon from 'sinon' +import ReactTooltip from '../src' + +/* Initial test tools */ +chai.use(chaiEnzyme()) + +describe('Global methods', () => { + before(() => { + sinon.spy(ReactTooltip.prototype, 'hideTooltip') + sinon.spy(ReactTooltip.prototype, 'globalRebuild') + }) + + it('should be hided by invoking ReactTooltip.hide', done => { + const wrapper = mount() + wrapper.setState({ show: true }) + ReactTooltip.hide() + setImmediate(() => { + expect(ReactTooltip.prototype.hideTooltip.calledOnce).to.equal(true) + expect(wrapper).to.have.state('show', false) + done() + }) + }) + + it('should invoke globalRebuild when using ReactTooltip.rebuild', done => { + ReactTooltip.rebuild() + setImmediate(() => { + expect(ReactTooltip.prototype.globalRebuild.calledOnce).to.equal(true) + done() + }) + }) +}) diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 000000000..ccdca868b --- /dev/null +++ b/test/setup.js @@ -0,0 +1,20 @@ +/** + * Setup jsdom for enzyme mount + * @see https://github.com/airbnb/enzyme/blob/master/docs/guides/jsdom.md#using-enzyme-with-jsdom + */ +import {jsdom} from 'jsdom' + +const exposedProperties = ['window', 'navigator', 'document'] + +global.document = jsdom('') +global.window = document.defaultView +Object.keys(document.defaultView).forEach((property) => { + if (typeof global[property] === 'undefined') { + exposedProperties.push(property) + global[property] = document.defaultView[property] + } +}) + +global.navigator = { + userAgent: 'node.js' +}