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
This commit is contained in:
Ben Wheeler 2019-09-23 15:17:02 -04:00
parent ae04860160
commit 5805d8a0fe
6 changed files with 155 additions and 23 deletions

View file

@ -99,6 +99,7 @@
"keymirror": "0.1.1", "keymirror": "0.1.1",
"lodash.bindall": "4.4.0", "lodash.bindall": "4.4.0",
"lodash.clone": "3.0.3", "lodash.clone": "3.0.3",
"lodash.debounce": "4.0.8",
"lodash.defaultsdeep": "3.10.0", "lodash.defaultsdeep": "3.10.0",
"lodash.isarray": "3.0.4", "lodash.isarray": "3.0.4",
"lodash.merge": "3.3.2", "lodash.merge": "3.3.2",

View file

@ -2,6 +2,7 @@ const bindAll = require('lodash.bindall');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const React = require('react'); const React = require('react');
const MediaQuery = require('react-responsive').default; const MediaQuery = require('react-responsive').default;
const debounce = require('lodash.debounce');
const frameless = require('../../lib/frameless'); const frameless = require('../../lib/frameless');
@ -11,18 +12,48 @@ class InfoButton extends React.Component {
constructor (props) { constructor (props) {
super(props); super(props);
bindAll(this, [ bindAll(this, [
'handleHideMessage', 'handleClick',
'handleShowMessage' 'handleMouseOut',
'handleShowMessage',
'setButtonRef'
]); ]);
this.state = { this.state = {
requireClickToClose: false, // default to closing on mouseout
visible: false visible: false
}; };
this.setVisibleWithDebounce = debounce(this.setVisible, 100);
} }
handleHideMessage () { componentWillMount () {
this.setState({visible: false}); 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 () { handleShowMessage () {
this.setState({visible: true}); this.setVisibleWithDebounce(true);
}
setButtonRef (element) {
this.buttonRef = element;
}
setVisible (newVisibleState) {
this.setState({visible: newVisibleState});
} }
render () { render () {
const messageJsx = this.state.visible && ( const messageJsx = this.state.visible && (
@ -34,8 +65,8 @@ class InfoButton extends React.Component {
<React.Fragment> <React.Fragment>
<div <div
className="info-button" className="info-button"
onClick={this.handleShowMessage} ref={this.setButtonRef}
onMouseOut={this.handleHideMessage} onMouseOut={this.handleMouseOut}
onMouseOver={this.handleShowMessage} onMouseOver={this.handleShowMessage}
> >
<MediaQuery minWidth={frameless.desktop}> <MediaQuery minWidth={frameless.desktop}>

View file

@ -4,23 +4,21 @@
.info-button { .info-button {
position: relative; position: relative;
display: inline-block; display: inline-block;
width: 1rem; width: 2rem;
height: 1rem; height: 2rem;
margin-left: .375rem; margin-left: -.125rem;
margin-top: -.25rem; margin-top: -.75rem;
border-radius: 50%;
background-color: $type-gray-60percent;
background-image: url("/svgs/info-button/info-button.svg"); background-image: url("/svgs/info-button/info-button.svg");
background-size: cover; background-size: cover;
top: .1875rem; top: .6875rem;
} }
.info-button-message { .info-button-message {
$arrow-border-width: 1rem; $arrow-border-width: 1rem;
display: block; display: block;
position: absolute; position: absolute;
top: 0; top: .375rem;
left: 0; left: .5rem;
transform: translate(1rem, -1rem); transform: translate(1rem, -1rem);
width: 16.5rem; width: 16.5rem;
min-height: 1rem; min-height: 1rem;
@ -66,6 +64,7 @@
we need to center this element within its width. */ we need to center this element within its width. */
margin: 0 calc((100% - 16.5rem) / 2);; margin: 0 calc((100% - 16.5rem) / 2);;
top: .125rem; top: .125rem;
left: 0;
&:before { &:before {
display: none; display: none;

View file

@ -127,10 +127,11 @@
} }
.join-flow-privacy-message { .join-flow-privacy-message {
margin: 1rem auto; margin: .5rem auto 1rem;
font-size: .75rem; font-size: .75rem;
font-weight: 500; font-weight: 500;
color: $type-gray-60percent; color: $type-gray-60percent;
text-align: center;
} }
.join-flow-inner-username-step { .join-flow-inner-username-step {

View file

@ -1 +1 @@
<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="20" height="20"><g fill-rule="evenodd"><path d="M10 0a10 10 0 1 0 10 10A10 10 0 0 0 10 0" fill="#9a9eac"/><path d="M10 13.39a1.33 1.33 0 1 1-1.33 1.33A1.33 1.33 0 0 1 10 13.39zm2.68-8.77a3 3 0 0 1 1.42 2.31 3.15 3.15 0 0 1-.95 2.56 8.37 8.37 0 0 1-1.59 1.1c-.7.39-.7.41-.7.77 0 .55 0 1-1 1s-1-.45-1-1a2.65 2.65 0 0 1 1.72-2.52A6.61 6.61 0 0 0 11.79 8a1.22 1.22 0 0 0 .3-.91 1 1 0 0 0-.5-.79 2.8 2.8 0 0 0-2.87.2c-.98.64-.81 1.55-1.72 1.3s-.92-1-.53-1.7a3.94 3.94 0 0 1 1.9-1.66 4.67 4.67 0 0 1 4.3.18z" fill="#fff"/></g></svg> <svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="40" height="40"><g fill-rule="evenodd"><path d="M20 10a10 10 0 1 0 10 10 10 10 0 0 0-10-10" fill="#9a9eac"/><path d="M20 23.39a1.33 1.33 0 1 1-1.33 1.33A1.34 1.34 0 0 1 20 23.39zm2.68-8.77a3 3 0 0 1 1.42 2.31 3.14 3.14 0 0 1-1 2.56 8.2 8.2 0 0 1-1.59 1.1c-.7.39-.7.41-.7.77 0 .55 0 1-1 1s-1-.45-1-1a2.64 2.64 0 0 1 1.72-2.52 6.71 6.71 0 0 0 1.26-.84 1.22 1.22 0 0 0 .3-.91 1 1 0 0 0-.5-.79 2.8 2.8 0 0 0-2.87.2c-1 .64-.81 1.55-1.72 1.3s-.92-1-.53-1.7a3.9 3.9 0 0 1 1.9-1.66 4.67 4.67 0 0 1 4.3.18z" fill="#fff"/></g></svg>

Before

Width:  |  Height:  |  Size: 593 B

After

Width:  |  Height:  |  Size: 589 B

View file

@ -3,6 +3,15 @@ import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import InfoButton from '../../../src/components/info-button/info-button'; import InfoButton from '../../../src/components/info-button/info-button';
describe('InfoButton', () => { 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', () => { test('Info button defaults to not visible', () => {
const component = mountWithIntl( const component = mountWithIntl(
<InfoButton <InfoButton
@ -11,33 +20,124 @@ describe('InfoButton', () => {
); );
expect(component.find('div.info-button-message').exists()).toEqual(false); 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( const component = mountWithIntl(
<InfoButton <InfoButton
message="Here is some info about something!" message="Here is some info about something!"
/> />
); );
// mouseOver info button
component.find('div.info-button').simulate('mouseOver'); 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', () => { test('clicking on info button makes info message visible', () => {
const component = mountWithIntl( const component = mountWithIntl(
<InfoButton <InfoButton
message="Here is some info about something!" message="Here is some info about something!"
/> />
); );
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); 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( const component = mountWithIntl(
<InfoButton <InfoButton
message="Here is some info about something!" message="Here is some info about something!"
/> />
); );
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); expect(component.find('div.info-button-message').exists()).toEqual(true);
// mouseOut from info button
component.find('div.info-button').simulate('mouseOut'); 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(
<InfoButton
message="Here is some info about something!"
/>
);
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); expect(component.find('div.info-button-message').exists()).toEqual(false);
}); });
test('clicking on info button, then clicking somewhere else', () => {
const component = mountWithIntl(
<InfoButton
message="Here is some info about something!"
/>
);
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(
<InfoButton
message="Here is some info about something!"
/>
);
// 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);
});
}); });