From 5805d8a0fe35e2406d1cfa369065299221e3353a Mon Sep 17 00:00:00 2001 From: Ben Wheeler Date: Mon, 23 Sep 2019 15:17:02 -0400 Subject: [PATCH] first take on toggleable open close state for info messages make info button have large, invisible boundary fix info button ref setting WIP: experiment with more sophisticated mouseout handling add lodash debounce use lodash debounce to reduce info message flickering tweak info message position per added padding remove leftover function update info button tests, add a bunch more --- package.json | 1 + src/components/info-button/info-button.jsx | 45 +++++-- src/components/info-button/info-button.scss | 17 ++- src/components/join-flow/join-flow-steps.scss | 3 +- static/svgs/info-button/info-button.svg | 2 +- test/unit/components/info-button.test.jsx | 110 +++++++++++++++++- 6 files changed, 155 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index eea303d4c..4177833f5 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "keymirror": "0.1.1", "lodash.bindall": "4.4.0", "lodash.clone": "3.0.3", + "lodash.debounce": "4.0.8", "lodash.defaultsdeep": "3.10.0", "lodash.isarray": "3.0.4", "lodash.merge": "3.3.2", diff --git a/src/components/info-button/info-button.jsx b/src/components/info-button/info-button.jsx index f79890c05..80e2e0490 100644 --- a/src/components/info-button/info-button.jsx +++ b/src/components/info-button/info-button.jsx @@ -2,6 +2,7 @@ const bindAll = require('lodash.bindall'); const PropTypes = require('prop-types'); const React = require('react'); const MediaQuery = require('react-responsive').default; +const debounce = require('lodash.debounce'); const frameless = require('../../lib/frameless'); @@ -11,18 +12,48 @@ class InfoButton extends React.Component { constructor (props) { super(props); bindAll(this, [ - 'handleHideMessage', - 'handleShowMessage' + 'handleClick', + 'handleMouseOut', + 'handleShowMessage', + 'setButtonRef' ]); this.state = { + requireClickToClose: false, // default to closing on mouseout visible: false }; + this.setVisibleWithDebounce = debounce(this.setVisible, 100); } - handleHideMessage () { - this.setState({visible: false}); + componentWillMount () { + window.addEventListener('mousedown', this.handleClick, false); + } + componentWillUnmount () { + window.removeEventListener('mousedown', this.handleClick, false); + } + handleClick (e) { + if (this.buttonRef) { // only handle click if we can tell whether it happened in this component + let newVisibleState = false; // for most clicks, hide the info message + if (this.buttonRef.contains(e.target)) { // if the click was inside the info icon... + newVisibleState = !this.state.requireClickToClose; // toggle it + } + this.setState({ + requireClickToClose: newVisibleState, + visible: newVisibleState + }); + } + } + handleMouseOut () { + if (this.state.visible && !this.state.requireClickToClose) { + this.setVisibleWithDebounce(false); + } } handleShowMessage () { - this.setState({visible: true}); + this.setVisibleWithDebounce(true); + } + setButtonRef (element) { + this.buttonRef = element; + } + setVisible (newVisibleState) { + this.setState({visible: newVisibleState}); } render () { const messageJsx = this.state.visible && ( @@ -34,8 +65,8 @@ class InfoButton extends React.Component {
diff --git a/src/components/info-button/info-button.scss b/src/components/info-button/info-button.scss index f2d93303d..2e9e3e4f4 100644 --- a/src/components/info-button/info-button.scss +++ b/src/components/info-button/info-button.scss @@ -4,23 +4,21 @@ .info-button { position: relative; display: inline-block; - width: 1rem; - height: 1rem; - margin-left: .375rem; - margin-top: -.25rem; - border-radius: 50%; - background-color: $type-gray-60percent; + width: 2rem; + height: 2rem; + margin-left: -.125rem; + margin-top: -.75rem; background-image: url("/svgs/info-button/info-button.svg"); background-size: cover; - top: .1875rem; + top: .6875rem; } .info-button-message { $arrow-border-width: 1rem; display: block; position: absolute; - top: 0; - left: 0; + top: .375rem; + left: .5rem; transform: translate(1rem, -1rem); width: 16.5rem; min-height: 1rem; @@ -66,6 +64,7 @@ we need to center this element within its width. */ margin: 0 calc((100% - 16.5rem) / 2);; top: .125rem; + left: 0; &:before { display: none; diff --git a/src/components/join-flow/join-flow-steps.scss b/src/components/join-flow/join-flow-steps.scss index 3d7e1df30..6caa86d50 100644 --- a/src/components/join-flow/join-flow-steps.scss +++ b/src/components/join-flow/join-flow-steps.scss @@ -127,10 +127,11 @@ } .join-flow-privacy-message { - margin: 1rem auto; + margin: .5rem auto 1rem; font-size: .75rem; font-weight: 500; color: $type-gray-60percent; + text-align: center; } .join-flow-inner-username-step { diff --git a/static/svgs/info-button/info-button.svg b/static/svgs/info-button/info-button.svg index 76edc37d8..a5bbdbd01 100644 --- a/static/svgs/info-button/info-button.svg +++ b/static/svgs/info-button/info-button.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/test/unit/components/info-button.test.jsx b/test/unit/components/info-button.test.jsx index e7e48231d..84f4fcb26 100644 --- a/test/unit/components/info-button.test.jsx +++ b/test/unit/components/info-button.test.jsx @@ -3,6 +3,15 @@ import {mountWithIntl} from '../../helpers/intl-helpers.jsx'; import InfoButton from '../../../src/components/info-button/info-button'; describe('InfoButton', () => { + // mock window.addEventListener + // for more on this technique, see discussion at https://github.com/airbnb/enzyme/issues/426#issuecomment-253515886 + const mockedAddEventListener = {}; + /* eslint-disable no-undef */ + window.addEventListener = jest.fn((event, cb) => { + mockedAddEventListener[event] = cb; + }); + /* eslint-enable no-undef */ + test('Info button defaults to not visible', () => { const component = mountWithIntl( { ); expect(component.find('div.info-button-message').exists()).toEqual(false); }); - test('mouseOver on info button makes info message visible', () => { + + test('mouseOver on info button makes info message visible', done => { const component = mountWithIntl( ); + + // mouseOver info button component.find('div.info-button').simulate('mouseOver'); - expect(component.find('div.info-button-message').exists()).toEqual(true); + setTimeout(function () { // necessary because mouseover uses debounce + // crucial: if we don't call update(), then find() below looks through an OLD + // version of the DOM! see https://github.com/airbnb/enzyme/issues/1233#issuecomment-358915200 + component.update(); + expect(component.find('div.info-button-message').exists()).toEqual(true); + done(); + }, 500); }); + test('clicking on info button makes info message visible', () => { const component = mountWithIntl( ); - component.find('div.info-button').simulate('click'); + const buttonRef = component.instance().buttonRef; + + // click on info button + mockedAddEventListener.mousedown({target: buttonRef}); + component.update(); + expect(component.find('div.info-button').exists()).toEqual(true); expect(component.find('div.info-button-message').exists()).toEqual(true); }); - test('after message is visible, mouseOut makes it vanish', () => { + + test('clicking on info button, then mousing out makes info message still appear', done => { const component = mountWithIntl( ); - component.find('div.info-button').simulate('mouseOver'); + const buttonRef = component.instance().buttonRef; + + // click on info button + mockedAddEventListener.mousedown({target: buttonRef}); + component.update(); + expect(component.find('div.info-button').exists()).toEqual(true); expect(component.find('div.info-button-message').exists()).toEqual(true); + + // mouseOut from info button component.find('div.info-button').simulate('mouseOut'); + setTimeout(function () { // necessary because mouseover uses debounce + component.update(); + expect(component.find('div.info-button-message').exists()).toEqual(true); + done(); + }, 500); + }); + + test('clicking on info button, then clicking on it again makes info message go away', () => { + const component = mountWithIntl( + + ); + const buttonRef = component.instance().buttonRef; + + // click on info button + mockedAddEventListener.mousedown({target: buttonRef}); + component.update(); + expect(component.find('div.info-button').exists()).toEqual(true); + expect(component.find('div.info-button-message').exists()).toEqual(true); + + // click on info button again + mockedAddEventListener.mousedown({target: buttonRef}); + component.update(); expect(component.find('div.info-button-message').exists()).toEqual(false); }); + + test('clicking on info button, then clicking somewhere else', () => { + const component = mountWithIntl( + + ); + const buttonRef = component.instance().buttonRef; + + // click on info button + mockedAddEventListener.mousedown({target: buttonRef}); + component.update(); + expect(component.find('div.info-button').exists()).toEqual(true); + expect(component.find('div.info-button-message').exists()).toEqual(true); + + // click on some other target + mockedAddEventListener.mousedown({target: null}); + component.update(); + expect(component.find('div.info-button-message').exists()).toEqual(false); + }); + + test('after message is visible, mouseOut makes it vanish', done => { + const component = mountWithIntl( + + ); + + // mouseOver info button + component.find('div.info-button').simulate('mouseOver'); + setTimeout(function () { // necessary because mouseover uses debounce + component.update(); + expect(component.find('div.info-button-message').exists()).toEqual(true); + + // mouseOut away from info button + component.find('div.info-button').simulate('mouseOut'); + setTimeout(function () { // necessary because mouseover uses debounce + component.update(); + expect(component.find('div.info-button-message').exists()).toEqual(false); + done(); + }, 500); + + }, 500); + }); });