Move Draftail tooltips portal closer to the editor to prevent background flickering

This commit is contained in:
Thibaud Colas 2018-02-10 00:07:29 +02:00 committed by Matt Westcott
parent 6c714b1537
commit 9861c2a0d4
15 changed files with 258 additions and 92 deletions

View file

@ -71,6 +71,11 @@ $draftail-editor-font-family: $font-serif;
.Draftail-Editor {
border-radius: 0;
&__wrapper {
// Ensure elements within the editor are positioned according to this container.
position: relative;
}
}
// When in a .full container, the editor has a specific appearance

View file

@ -27,13 +27,27 @@ class MediaBlock extends Component {
}
openTooltip(e) {
const trigger = e.target;
const trigger = e.target.closest('[data-draftail-trigger]');
// Click is within the tooltip.
if (!trigger) {
return;
}
const container = trigger.closest('[data-draftail-editor-wrapper]');
const containerRect = container.getBoundingClientRect();
const rect = trigger.getBoundingClientRect();
const maxWidth = trigger.parentNode.offsetWidth - rect.width;
this.setState({
// Warning: overriding native DOM object. Proceed with caution.
showTooltipAt: Object.assign(trigger.getBoundingClientRect(), {
containerWidth: trigger.parentNode.offsetWidth,
}),
showTooltipAt: {
container: container,
top: rect.top - containerRect.top - (document.documentElement.scrollTop || document.body.scrollTop),
left: rect.left - containerRect.left - (document.documentElement.scrollLeft || document.body.scrollLeft),
width: rect.width,
height: rect.height,
direction: maxWidth >= TOOLTIP_MAX_WIDTH ? 'left' : 'top-left',
},
});
}
@ -44,17 +58,16 @@ class MediaBlock extends Component {
renderTooltip() {
const { children } = this.props;
const { showTooltipAt } = this.state;
const maxWidth = showTooltipAt.containerWidth - showTooltipAt.width;
const direction = maxWidth >= TOOLTIP_MAX_WIDTH ? 'left' : 'top-left';
return (
<Portal
node={showTooltipAt.container}
onClose={this.closeTooltip}
closeOnClick
closeOnType
closeOnResize
>
<Tooltip target={showTooltipAt} direction={direction}>
<Tooltip target={showTooltipAt} direction={showTooltipAt.direction}>
<div style={{ maxWidth: OPTIONS_MAX_WIDTH }}>{children}</div>
</Tooltip>
</Portal>
@ -71,7 +84,8 @@ class MediaBlock extends Component {
type="button"
tabIndex={-1}
className="MediaBlock"
onMouseUp={this.openTooltip}
onClick={this.openTooltip}
data-draftail-trigger
>
<span className="MediaBlock__icon-wrapper" aria-hidden>
<Icon icon={entityType.icon} className="MediaBlock__icon" />

View file

@ -1,5 +1,5 @@
import React from 'react';
import { shallow } from 'enzyme';
import { shallow, mount } from 'enzyme';
import MediaBlock from '../blocks/MediaBlock';
@ -49,10 +49,16 @@ describe('MediaBlock', () => {
});
describe('tooltip', () => {
let target;
let wrapper;
beforeEach(() => {
wrapper = shallow(
target = document.createElement('div');
target.setAttribute('data-draftail-trigger', true);
document.body.appendChild(target);
document.body.setAttribute('data-draftail-editor-wrapper', true);
wrapper = mount(
<MediaBlock
src="example.png"
alt=""
@ -67,28 +73,32 @@ describe('MediaBlock', () => {
},
}}
>
Test
<div id="test">Test</div>
</MediaBlock>
);
});
it('opens', () => {
const target = document.createElement('div');
document.body.appendChild(target);
wrapper.simulate('mouseup', { target });
wrapper.simulate('click', { target });
expect(
wrapper
.find('Portal')
.dive()
.instance().portal
).toMatchSnapshot();
});
it('click in tooltip', () => {
wrapper.simulate('click', { target });
jest.spyOn(target, 'getBoundingClientRect');
wrapper.simulate('click', { target: document.querySelector('#test') });
expect(target.getBoundingClientRect).not.toHaveBeenCalled();
});
it('large viewport', () => {
const target = document.createElement('div');
document.body.appendChild(target);
target.getBoundingClientRect = () => ({
top: 0,
left: 0,
@ -96,26 +106,22 @@ describe('MediaBlock', () => {
height: 0,
});
wrapper.simulate('mouseup', { target });
wrapper.simulate('click', { target });
expect(
wrapper
.find('Portal')
.dive()
.instance()
.portal.querySelector('.Tooltip').className
).toBe('Tooltip Tooltip--left');
});
it('closes', () => {
const target = document.createElement('div');
document.body.appendChild(target);
jest.spyOn(target, 'getBoundingClientRect');
expect(wrapper.state('showTooltipAt')).toBe(null);
wrapper.simulate('mouseup', { target });
wrapper.simulate('click', { target });
expect(wrapper.state('showTooltipAt')).toMatchObject({
top: 0,

View file

@ -3,7 +3,8 @@
exports[`MediaBlock no data 1`] = `
<button
className="MediaBlock"
onMouseUp={[Function]}
data-draftail-trigger={true}
onClick={[Function]}
tabIndex={-1}
type="button"
>
@ -29,7 +30,8 @@ exports[`MediaBlock no data 1`] = `
exports[`MediaBlock renders 1`] = `
<button
className="MediaBlock"
onMouseUp={[Function]}
data-draftail-trigger={true}
onClick={[Function]}
tabIndex={-1}
type="button"
>
@ -54,14 +56,16 @@ exports[`MediaBlock renders 1`] = `
exports[`MediaBlock tooltip opens 1`] = `
<div>
<div>
<div
class="Tooltip Tooltip--top-left"
role="tooltip"
style="top: 0px; left: 0px;"
>
<div
class="Tooltip Tooltip--top-left"
role="tooltip"
style="top: 0px; left: 0px;"
style="max-width: 300px;"
>
<div
style="max-width: 300px;"
id="test"
>
Test
</div>

View file

@ -22,13 +22,49 @@ class TooltipEntity extends Component {
showTooltipAt: null,
};
this.onEdit = this.onEdit.bind(this);
this.onRemove = this.onRemove.bind(this);
this.openTooltip = this.openTooltip.bind(this);
this.closeTooltip = this.closeTooltip.bind(this);
}
onEdit(e) {
const { onEdit, entityKey } = this.props;
e.preventDefault();
e.stopPropagation();
onEdit(entityKey);
}
onRemove(e) {
const { onRemove, entityKey } = this.props;
e.preventDefault();
e.stopPropagation();
onRemove(entityKey);
}
openTooltip(e) {
const trigger = e.target;
this.setState({ showTooltipAt: trigger.getBoundingClientRect() });
const trigger = e.target.closest('[data-draftail-trigger]');
// Click is within the tooltip.
if (!trigger) {
return;
}
const container = trigger.closest('[data-draftail-editor-wrapper]');
const containerRect = container.getBoundingClientRect();
const rect = trigger.getBoundingClientRect();
this.setState({
showTooltipAt: {
container: container,
top: rect.top - containerRect.top - (document.documentElement.scrollTop || document.body.scrollTop),
left: rect.left - containerRect.left - (document.documentElement.scrollLeft || document.body.scrollLeft),
width: rect.width,
height: rect.height,
},
});
}
closeTooltip() {
@ -37,10 +73,7 @@ class TooltipEntity extends Component {
render() {
const {
entityKey,
children,
onEdit,
onRemove,
icon,
label,
url,
@ -50,11 +83,18 @@ class TooltipEntity extends Component {
// Contrary to what JSX A11Y says, this should be a button but it shouldn't be focusable.
/* eslint-disable springload/jsx-a11y/interactive-supports-focus */
return (
<a role="button" onMouseUp={this.openTooltip} className="TooltipEntity">
<a
role="button"
// Use onMouseUp to preserve focus in the text even after clicking.
onMouseUp={this.openTooltip}
className="TooltipEntity"
data-draftail-trigger
>
<Icon icon={icon} className="TooltipEntity__icon" />
{children}
{showTooltipAt && (
<Portal
node={showTooltipAt.container}
onClose={this.closeTooltip}
closeOnClick
closeOnType
@ -75,14 +115,14 @@ class TooltipEntity extends Component {
<button
className="button Tooltip__button"
onClick={onEdit.bind(null, entityKey)}
onClick={this.onEdit}
>
Edit
</button>
<button
className="button button-secondary no Tooltip__button"
onClick={onRemove.bind(null, entityKey)}
onClick={this.onRemove}
>
Remove
</button>

View file

@ -1,5 +1,5 @@
import React from 'react';
import { shallow } from 'enzyme';
import { shallow, mount } from 'enzyme';
import TooltipEntity from './TooltipEntity';
@ -51,8 +51,13 @@ describe('TooltipEntity', () => {
</TooltipEntity>
));
const target = document.createElement('div');
target.setAttribute('data-draftail-trigger', true);
document.body.appendChild(target);
document.body.setAttribute('data-draftail-editor-wrapper', true);
wrapper.find('.TooltipEntity').simulate('mouseup', {
target: document.createElement('div'),
target: target,
});
expect(wrapper).toMatchSnapshot();
@ -82,4 +87,46 @@ describe('TooltipEntity', () => {
showTooltipAt: null,
});
});
it('#onEdit', () => {
const onEdit = jest.fn();
const wrapper = shallow((
<TooltipEntity
entityKey="1"
onEdit={onEdit}
onRemove={() => {}}
icon="#icon-test"
url="https://www.example.com/"
label="www.example.com"
>
test
</TooltipEntity>
));
wrapper.instance().onEdit(new Event('click'));
expect(onEdit).toHaveBeenCalled();
});
it('#onRemove', () => {
const onRemove = jest.fn();
const wrapper = shallow((
<TooltipEntity
entityKey="1"
onEdit={() => {}}
onRemove={onRemove}
icon="#icon-test"
url="https://www.example.com/"
label="www.example.com"
>
test
</TooltipEntity>
));
wrapper.instance().onRemove(new Event('click'));
expect(onRemove).toHaveBeenCalled();
});
});

View file

@ -3,6 +3,7 @@
exports[`TooltipEntity #openTooltip 1`] = `
<a
className="TooltipEntity"
data-draftail-trigger={true}
onMouseUp={[Function]}
role="button"
>
@ -16,16 +17,30 @@ exports[`TooltipEntity #openTooltip 1`] = `
closeOnClick={true}
closeOnResize={true}
closeOnType={true}
node={
<body
data-draftail-editor-wrapper="true"
>
<div
data-draftail-trigger="true"
/>
</body>
}
onClose={[Function]}
>
<Tooltip
direction="top"
target={
Object {
"bottom": 0,
"container": <body
data-draftail-editor-wrapper="true"
>
<div
data-draftail-trigger="true"
/>
</body>,
"height": 0,
"left": 0,
"right": 0,
"top": 0,
"width": 0,
}
@ -60,6 +75,7 @@ exports[`TooltipEntity #openTooltip 1`] = `
exports[`TooltipEntity works 1`] = `
<a
className="TooltipEntity"
data-draftail-trigger={true}
onMouseUp={[Function]}
role="button"
>

View file

@ -45,7 +45,11 @@ export const wrapWagtailIcon = type => {
*/
const initEditor = (fieldName, options) => {
const field = document.querySelector(`[name="${fieldName}"]`);
const editorWrapper = document.createElement('div');
editorWrapper.className = 'Draftail-Editor__wrapper';
editorWrapper.setAttribute('data-draftail-editor-wrapper', true);
field.parentNode.appendChild(editorWrapper);
const serialiseInputValue = rawContentState => {

View file

@ -1,11 +1,18 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Component } from 'react';
import { createPortal } from 'react-dom';
/**
* A Portal component which automatically closes itself
* when certain events happen outside.
* See https://reactjs.org/docs/portals.html.
*/
class Portal extends Component {
constructor(props) {
super(props);
this.portal = document.createElement('div');
this.onCloseEvent = this.onCloseEvent.bind(this);
}
@ -18,40 +25,27 @@ class Portal extends Component {
}
componentDidMount() {
const { onClose, closeOnClick, closeOnType, closeOnResize } = this.props;
const { node, onClose, closeOnClick, closeOnType, closeOnResize } = this.props;
if (!this.portal) {
this.portal = document.createElement('div');
document.body.appendChild(this.portal);
node.appendChild(this.portal);
if (onClose) {
if (closeOnClick) {
document.addEventListener('mouseup', this.onCloseEvent);
}
if (closeOnType) {
document.addEventListener('keyup', this.onCloseEvent);
}
if (closeOnResize) {
window.addEventListener('resize', onClose);
}
}
if (closeOnClick) {
document.addEventListener('mouseup', this.onCloseEvent);
}
this.componentDidUpdate();
}
if (closeOnType) {
document.addEventListener('keyup', this.onCloseEvent);
}
componentDidUpdate() {
const { children } = this.props;
ReactDOM.render(<div>{children}</div>, this.portal);
if (closeOnResize) {
window.addEventListener('resize', onClose);
}
}
componentWillUnmount() {
const { onClose } = this.props;
const { node, onClose } = this.props;
document.body.removeChild(this.portal);
node.removeChild(this.portal);
document.removeEventListener('mouseup', this.onCloseEvent);
document.removeEventListener('keyup', this.onCloseEvent);
@ -59,12 +53,15 @@ class Portal extends Component {
}
render() {
return null;
const { children } = this.props;
return createPortal(children, this.portal);
}
}
Portal.propTypes = {
onClose: PropTypes.func,
onClose: PropTypes.func.isRequired,
node: PropTypes.instanceOf(Element),
children: PropTypes.node,
closeOnClick: PropTypes.bool,
closeOnType: PropTypes.bool,
@ -72,7 +69,7 @@ Portal.propTypes = {
};
Portal.defaultProps = {
onClose: null,
node: document.body,
children: null,
closeOnClick: false,
closeOnType: false,

View file

@ -1,5 +1,5 @@
import React from 'react';
import { shallow } from 'enzyme';
import { shallow, mount } from 'enzyme';
import Portal from './Portal';
const func = expect.any(Function);
@ -10,27 +10,21 @@ describe('Portal', () => {
});
it('empty', () => {
expect(shallow(<Portal />)).toMatchSnapshot();
expect(mount(<Portal onClose={() => {}} />)).toMatchSnapshot();
});
it('#children', () => {
expect(shallow(<Portal>Test!</Portal>)).toMatchSnapshot();
expect(mount(<Portal onClose={() => {}}>Test!</Portal>)).toMatchSnapshot();
});
it('component lifecycle', () => {
document.removeEventListener = jest.fn();
window.removeEventListener = jest.fn();
const wrapper = shallow(<Portal onClose={() => {}}>Test!</Portal>);
wrapper.instance().componentDidMount();
const wrapper = mount(<Portal onClose={() => {}}>Test!</Portal>);
expect(document.body.innerHTML).toMatchSnapshot();
expect(wrapper.instance().portal).toBe(document.body.children[0]);
wrapper.instance().componentDidMount();
wrapper.instance().componentWillUnmount();
expect(document.body.innerHTML).toBe('');
@ -81,14 +75,14 @@ describe('Portal', () => {
Test!
</Portal>
);
expect(window.addEventListener).toHaveBeenCalledWith('error', func);
expect(window.addEventListener).toHaveBeenCalledWith('resize', func);
});
});
describe('onCloseEvent', () => {
it('shouldClose', () => {
it('should close', () => {
const onClose = jest.fn();
const wrapper = shallow(<Portal onClose={onClose}>Test!</Portal>);
const wrapper = mount(<Portal onClose={onClose}>Test!</Portal>);
const target = document.createElement('div');
wrapper.instance().onCloseEvent({ target });
@ -96,9 +90,9 @@ describe('Portal', () => {
expect(onClose).toHaveBeenCalled();
});
it('not shouldClose', () => {
it('not should close', () => {
const onClose = jest.fn();
const wrapper = shallow(
const wrapper = mount(
<Portal onClose={onClose}>
<div id="test">Test</div>
</Portal>

View file

@ -1,7 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Portal #children 1`] = `""`;
exports[`Portal #children 1`] = `
<Portal
closeOnClick={false}
closeOnResize={false}
closeOnType={false}
node={
<body>
<div>
Test!
</div>
</body>
}
onClose={[Function]}
>
Test!
</Portal>
`;
exports[`Portal component lifecycle 1`] = `"<div><div>Test!</div></div>"`;
exports[`Portal component lifecycle 1`] = `"<div>Test!</div>"`;
exports[`Portal empty 1`] = `""`;
exports[`Portal empty 1`] = `
<Portal
closeOnClick={false}
closeOnResize={false}
closeOnType={false}
node={
<body>
<div />
</body>
}
onClose={[Function]}
/>
`;

View file

@ -2,5 +2,9 @@
* Polyfills for Wagtail's admin.
*/
// IE11.
import 'core-js/shim';
// IE11, old iOS Safari.
import 'whatwg-fetch';
// IE11.
import 'element-closest';

View file

@ -3,6 +3,7 @@
* Those variables usually come from the back-end via templates.
* See /wagtailadmin/templates/wagtailadmin/admin_base.html.
*/
import 'element-closest';
global.wagtailConfig = {
ADMIN_API: {

5
package-lock.json generated
View file

@ -2688,6 +2688,11 @@
"integrity": "sha1-elgja5VGjD52YAkTSFItZddzazY=",
"dev": true
},
"element-closest": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/element-closest/-/element-closest-2.0.2.tgz",
"integrity": "sha1-cqdAoQdFM4LijfnOXbtajfD5Zuw="
},
"elliptic": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.0.tgz",

View file

@ -88,6 +88,7 @@
"core-js": "^2.5.3",
"draft-js": "0.10.5",
"draftail": "^0.16.0",
"element-closest": "^2.0.2",
"focus-trap-react": "^3.1.0",
"prop-types": "^15.6.0",
"react": "^16.2.0",