From 2315632789c38d387b3a56bc0c03b8ee730e5864 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Tue, 6 Feb 2018 21:20:02 -0800 Subject: [PATCH] Add Timer service. Don't dismiss toasts while the user is interacting with them. --- CHANGELOG.md | 4 +- src-docs/src/views/toast/toast_list.js | 10 +-- src/components/toast/global_toast_list.js | 55 ++++++++++----- .../toast/global_toast_list.test.js | 2 +- src/services/time/index.js | 1 + src/services/time/timer.js | 32 +++++++++ src/services/time/timer.test.js | 68 +++++++++++++++++++ 7 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 src/services/time/index.js create mode 100644 src/services/time/timer.js create mode 100644 src/services/time/timer.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ae417bb0d..ed312af8270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # [`master`](https://github.com/elastic/eui/tree/master) +- `EuiGlobalToastList` now prevents toasts from disappearing while the user's mouse is over the list. Added `timer/Timer` service ([#370](https://github.com/elastic/eui/pull/370)) + **Bug fixes** - `EuiTableOfRecords` selection bugs ([#365](https://github.com/elastic/eui/pull/365)) @@ -8,7 +10,7 @@ **Breaking changes** -- Changed `` to be responsible for instantiating toasts, tracking their lifetimes, and dismissing them. It now acepts `toasts`, `dismissToast`, and `toastLifeTimeMs` props. It no longer accepts `children`. ([#370](https://github.com/elastic/eui/pull/370)) +- Changed `EuiGlobalToastList` to be responsible for instantiating toasts, tracking their lifetimes, and dismissing them. It now acepts `toasts`, `dismissToast`, and `toastLifeTimeMs` props. It no longer accepts `children`. ([#370](https://github.com/elastic/eui/pull/370)) # [`0.0.18`](https://github.com/elastic/eui/tree/v0.0.18) diff --git a/src-docs/src/views/toast/toast_list.js b/src-docs/src/views/toast/toast_list.js index fe38428e6f4..095bf13a379 100644 --- a/src-docs/src/views/toast/toast_list.js +++ b/src-docs/src/views/toast/toast_list.js @@ -40,11 +40,11 @@ export default class extends Component { }); }; - removeToast(toastId) { - this.setState({ - toasts: this.state.toasts.filter(toast => toast.key !== toastId), - }); - } + removeToast = (removedToast) => { + this.setState(prevState => ({ + toasts: prevState.toasts.filter(toast => toast.id !== removedToast.id), + })); + }; removeAllToasts = () => { this.setState({ diff --git a/src/components/toast/global_toast_list.js b/src/components/toast/global_toast_list.js index 646dcac8605..33b38e86e6d 100644 --- a/src/components/toast/global_toast_list.js +++ b/src/components/toast/global_toast_list.js @@ -4,6 +4,7 @@ import React, { import PropTypes from 'prop-types'; import classNames from 'classnames'; +import { Timer } from '../../services/time'; import { EuiGlobalToastListItem } from './global_toast_list_item'; import { EuiToast } from './toast'; @@ -17,8 +18,8 @@ export class EuiGlobalToastList extends Component { toastIdToDismissedMap: {}, }; - this.timeoutIds = []; - this.toastIdToScheduledForDismissalMap = {}; + this.dismissTimeoutIds = []; + this.toastIdToTimerMap = {}; this.isScrollingToBottom = false; this.isScrolledToBottom = true; @@ -65,10 +66,24 @@ export class EuiGlobalToastList extends Component { // the list. this.isScrollingToBottom = false; this.isUserInteracting = true; + + // Don't let toasts dismiss themselves while the user is interacting with them. + for (const toastId in this.toastIdToTimerMap) { + if (this.toastIdToTimerMap.hasOwnProperty(toastId)) { + const timer = this.toastIdToTimerMap[toastId]; + timer.pause(); + } + } }; onMouseLeave = () => { this.isUserInteracting = false; + for (const toastId in this.toastIdToTimerMap) { + if (this.toastIdToTimerMap.hasOwnProperty(toastId)) { + const timer = this.toastIdToTimerMap[toastId]; + timer.resume(); + } + } }; onScroll = () => { @@ -78,37 +93,35 @@ export class EuiGlobalToastList extends Component { scheduleAllToastsForDismissal = () => { this.props.toasts.forEach(toast => { - if (!this.toastIdToScheduledForDismissalMap[toast.id]) { + if (!this.toastIdToTimerMap[toast.id]) { this.scheduleToastForDismissal(toast); } }); }; - scheduleToastForDismissal = (toast, isImmediate = false) => { - this.toastIdToScheduledForDismissalMap[toast.id] = true; - const toastLifeTimeMs = isImmediate ? 0 : this.props.toastLifeTimeMs; - + scheduleToastForDismissal = (toast) => { // Start fading the toast out once its lifetime elapses. - this.timeoutIds.push(setTimeout(() => { - this.startDismissingToast(toast); - }, toastLifeTimeMs)); + this.toastIdToTimerMap[toast.id] = + new Timer(this.dismissToast.bind(this, toast), this.props.toastLifeTimeMs); + }; + dismissToast = (toast) => { // Remove the toast after it's done fading out. - this.timeoutIds.push(setTimeout(() => { + this.dismissTimeoutIds.push(setTimeout(() => { this.props.dismissToast(toast); + this.toastIdToTimerMap[toast.id].clear(); + delete this.toastIdToTimerMap[toast.id]; + this.setState(prevState => { const toastIdToDismissedMap = { ...prevState.toastIdToDismissedMap }; delete toastIdToDismissedMap[toast.id]; - delete this.toastIdToScheduledForDismissalMap[toast.id]; return { toastIdToDismissedMap, }; }); - }, toastLifeTimeMs + TOAST_FADE_OUT_MS)); - }; + }, TOAST_FADE_OUT_MS)); - startDismissingToast(toast) { this.setState(prevState => { const toastIdToDismissedMap = { ...prevState.toastIdToDismissedMap, @@ -119,7 +132,7 @@ export class EuiGlobalToastList extends Component { toastIdToDismissedMap, }; }); - } + }; componentDidMount() { this.listElement.addEventListener('scroll', this.onScroll); @@ -146,7 +159,13 @@ export class EuiGlobalToastList extends Component { this.listElement.removeEventListener('scroll', this.onScroll); this.listElement.removeEventListener('mouseenter', this.onMouseEnter); this.listElement.removeEventListener('mouseleave', this.onMouseLeave); - this.timeoutIds.forEach(clearTimeout); + this.dismissTimeoutIds.forEach(clearTimeout); + for (const toastId in this.toastIdToTimerMap) { + if (this.toastIdToTimerMap.hasOwnProperty(toastId)) { + const timer = this.toastIdToTimerMap[toastId]; + timer.clear(); + } + } } render() { @@ -170,7 +189,7 @@ export class EuiGlobalToastList extends Component { isDismissed={this.state.toastIdToDismissedMap[toast.id]} > {text} diff --git a/src/components/toast/global_toast_list.test.js b/src/components/toast/global_toast_list.test.js index 4ce964973a3..2117dab7884 100644 --- a/src/components/toast/global_toast_list.test.js +++ b/src/components/toast/global_toast_list.test.js @@ -97,7 +97,7 @@ describe('EuiGlobalToastList', () => { setTimeout(() => { expect(dismissToastSpy.called).toBe(true); done(); - }, TOAST_LIFE_TIME_MS + TOAST_FADE_OUT_MS); + }, TOAST_LIFE_TIME_MS + TOAST_FADE_OUT_MS + 10); }); }); }); diff --git a/src/services/time/index.js b/src/services/time/index.js new file mode 100644 index 00000000000..91b3f08b96c --- /dev/null +++ b/src/services/time/index.js @@ -0,0 +1 @@ +export { Timer } from './timer'; diff --git a/src/services/time/timer.js b/src/services/time/timer.js new file mode 100644 index 00000000000..1b227f2f54b --- /dev/null +++ b/src/services/time/timer.js @@ -0,0 +1,32 @@ +export class Timer { + constructor(callback, timeMs) { + this.id = setTimeout(this.finish, timeMs); + this.callback = callback; + this.finishTime = Date.now() + timeMs; + this.timeRemaining = undefined; + } + + pause = () => { + clearTimeout(this.id); + this.id = undefined; + this.timeRemaining = this.finishTime - Date.now(); + }; + + resume = () => { + this.id = setTimeout(this.finish, this.timeRemaining); + this.timeRemaining = undefined; + }; + + clear = () => { + clearTimeout(this.id); + this.id = undefined; + this.callback = undefined; + this.finishTime = undefined; + this.timeRemaining = undefined; + }; + + finish = () => { + this.callback(); + this.clear(); + }; +} diff --git a/src/services/time/timer.test.js b/src/services/time/timer.test.js new file mode 100644 index 00000000000..58a81f6b649 --- /dev/null +++ b/src/services/time/timer.test.js @@ -0,0 +1,68 @@ +import sinon from 'sinon'; + +import { + Timer, +} from './timer'; + +describe('Timer', () => { + describe('constructor', () => { + test('counts down until time elapses and calls callback', done => { + const callbackSpy = sinon.spy(); + const timer = new Timer(callbackSpy, 5); // eslint-disable-line no-unused-vars + + setTimeout(() => { + expect(callbackSpy.called).toBe(true); + done(); + }, 8); + }); + }); + + describe('pause', () => { + test('stops timer', done => { + const callbackSpy = sinon.spy(); + const timer = new Timer(callbackSpy, 5); + timer.pause(0); + + setTimeout(() => { + expect(callbackSpy.called).toBe(false); + done(); + }, 8); + }); + }); + + describe('resume', () => { + test('starts timer again', done => { + const callbackSpy = sinon.spy(); + const timer = new Timer(callbackSpy, 5); + timer.pause(0); + timer.resume(); + + setTimeout(() => { + expect(callbackSpy.called).toBe(true); + done(); + }, 8); + }); + }); + + describe('clear', () => { + test('prevents timer from being called', done => { + const callbackSpy = sinon.spy(); + const timer = new Timer(callbackSpy, 5); + timer.clear(0); + + setTimeout(() => { + expect(callbackSpy.called).toBe(false); + done(); + }, 8); + }); + }); + + describe('finish', () => { + test('calls callback immediately', () => { + const callbackSpy = sinon.spy(); + const timer = new Timer(callbackSpy, 5); + timer.finish(); + expect(callbackSpy.called).toBe(true); + }); + }); +});