From dabd58087830027627f9061a0a0c5fd7766b9a80 Mon Sep 17 00:00:00 2001 From: rockfield Date: Thu, 16 May 2019 12:59:06 +0300 Subject: [PATCH 01/13] toasts live region --- src/components/toast/global_toast_list.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/toast/global_toast_list.js b/src/components/toast/global_toast_list.js index cf9edeada0b..e9801d7b180 100644 --- a/src/components/toast/global_toast_list.js +++ b/src/components/toast/global_toast_list.js @@ -251,6 +251,8 @@ export class EuiGlobalToastList extends Component { this.listElement = element; }} className={classes} + role="region" + aria-live="polite" {...rest}> {renderedToasts} From ba2bed2d067015866e94d18bd378cf1b57897eb8 Mon Sep 17 00:00:00 2001 From: rockfield Date: Thu, 23 May 2019 17:09:22 +0300 Subject: [PATCH 02/13] update snapshost --- .../toast/__snapshots__/global_toast_list.test.js.snap | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/toast/__snapshots__/global_toast_list.test.js.snap b/src/components/toast/__snapshots__/global_toast_list.test.js.snap index 9e229edf0a5..10e5fa43eba 100644 --- a/src/components/toast/__snapshots__/global_toast_list.test.js.snap +++ b/src/components/toast/__snapshots__/global_toast_list.test.js.snap @@ -3,14 +3,18 @@ exports[`EuiGlobalToastList is rendered 1`] = `
`; exports[`EuiGlobalToastList props toasts is rendered 1`] = `
Date: Tue, 28 May 2019 08:48:32 +0300 Subject: [PATCH 03/13] WIP: new notification logger; excludes 'dismiss toast' voice announcement as a part of title; make it working in NVDA --- src/components/toast/global_toast_list.js | 51 +++++++++++++++++++++-- src/components/toast/toast.js | 12 +----- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/components/toast/global_toast_list.js b/src/components/toast/global_toast_list.js index e9801d7b180..2381de1426f 100644 --- a/src/components/toast/global_toast_list.js +++ b/src/components/toast/global_toast_list.js @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { Fragment, Component } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -6,6 +6,8 @@ import { Timer } from '../../services/time'; import { IconPropType } from '../icon'; import { EuiGlobalToastListItem } from './global_toast_list_item'; import { EuiToast } from './toast'; +import { EuiScreenReaderOnly } from '../accessibility'; +import { EuiI18n } from '../i18n'; export const TOAST_FADE_OUT_MS = 250; @@ -27,6 +29,7 @@ export class EuiGlobalToastList extends Component { // for information on initial value of 0 this.isScrollingAnimationFrame = 0; this.startScrollingAnimationFrame = 0; + this.renderedForScreenReaderToasts = []; } static propTypes = { @@ -172,6 +175,43 @@ export class EuiGlobalToastList extends Component { }); }; + getRenderedForScreenReaderToasts = () => { + return this.renderedForScreenReaderToasts.map(toast => toast.reactElement); + }; + + filterNewOnlyToasts = toasts => { + return toasts.filter(toastFromProp => { + const withTheSameID = this.renderedForScreenReaderToasts.filter( + existToast => existToast.id === toastFromProp.id + ); + return !withTheSameID.length; + }); + }; + + logSetOfToasts = toasts => { + // map and filter incoming toasts to get only new ones + const newToasts = this.filterNewOnlyToasts(toasts).map(newToast => ({ + id: newToast.id, + reactElement: ( + +

+ +

+

{newToast.title}

+ {newToast.text} +
+ ), + })); // returns element, if element is false, then it excludes the one + + this.renderedForScreenReaderToasts.push(...newToasts); + if (newToasts.length) { + console.log(newToasts.length, 'another new toast'); + } + }; + componentDidMount() { this.listElement.addEventListener('scroll', this.onScroll); this.listElement.addEventListener('mouseenter', this.onMouseEnter); @@ -221,6 +261,8 @@ export class EuiGlobalToastList extends Component { ...rest } = this.props; + this.logSetOfToasts(toasts); + const renderedToasts = toasts.map(toast => { const { text, @@ -251,10 +293,13 @@ export class EuiGlobalToastList extends Component { this.listElement = element; }} className={classes} - role="region" - aria-live="polite" {...rest}> {renderedToasts} + +
+ {this.getRenderedForScreenReaderToasts()} +
+
); } diff --git a/src/components/toast/toast.js b/src/components/toast/toast.js index 87cac47e24f..66eafc36ca7 100644 --- a/src/components/toast/toast.js +++ b/src/components/toast/toast.js @@ -2,7 +2,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { EuiScreenReaderOnly } from '../accessibility'; import { EuiI18n } from '../i18n'; import { IconPropType, EuiIcon } from '../icon'; @@ -75,16 +74,7 @@ export const EuiToast = ({ } return ( -
- -

- -

-
- +
{notification => (
Date: Tue, 28 May 2019 08:52:00 +0300 Subject: [PATCH 04/13] WIP: new notification logger; excludes 'dismiss toast' voice announcement as a part of title; make it working in NVDA --- src/components/toast/global_toast_list.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/toast/global_toast_list.js b/src/components/toast/global_toast_list.js index 2381de1426f..0d31142f62b 100644 --- a/src/components/toast/global_toast_list.js +++ b/src/components/toast/global_toast_list.js @@ -196,7 +196,7 @@ export class EuiGlobalToastList extends Component {

@@ -207,9 +207,6 @@ export class EuiGlobalToastList extends Component { })); // returns element, if element is false, then it excludes the one this.renderedForScreenReaderToasts.push(...newToasts); - if (newToasts.length) { - console.log(newToasts.length, 'another new toast'); - } }; componentDidMount() { From f27f8af95f69f2f4f6c883aca949adcbef0b40d1 Mon Sep 17 00:00:00 2001 From: rockfield Date: Tue, 28 May 2019 08:53:00 +0300 Subject: [PATCH 05/13] update snapshots --- .../global_toast_list.test.js.snap | 44 ++++++---- .../toast/__snapshots__/toast.test.js.snap | 84 ------------------- 2 files changed, 27 insertions(+), 101 deletions(-) diff --git a/src/components/toast/__snapshots__/global_toast_list.test.js.snap b/src/components/toast/__snapshots__/global_toast_list.test.js.snap index 10e5fa43eba..ec1af1ba73f 100644 --- a/src/components/toast/__snapshots__/global_toast_list.test.js.snap +++ b/src/components/toast/__snapshots__/global_toast_list.test.js.snap @@ -3,30 +3,26 @@ exports[`EuiGlobalToastList is rendered 1`] = `
+> +
+
`; exports[`EuiGlobalToastList props toasts is rendered 1`] = `
-

- A new notification appears -

-

- A new notification appears -

+
+

+ A new notification appears +

+

+ A +

+ a +

+ A new notification appears +

+

+ B +

+ b +
`; diff --git a/src/components/toast/__snapshots__/toast.test.js.snap b/src/components/toast/__snapshots__/toast.test.js.snap index 7fa051fe776..0f1c45ef654 100644 --- a/src/components/toast/__snapshots__/toast.test.js.snap +++ b/src/components/toast/__snapshots__/toast.test.js.snap @@ -5,21 +5,8 @@ exports[`EuiToast Props color danger is rendered 1`] = ` color="danger" >
- -

- - A new notification appears - -

-
- -

- - A new notification appears - -

-
- -

- - A new notification appears - -

-
- -

- - A new notification appears - -

-
- -

- - A new notification appears - -

-
- -

- - A new notification appears - -

-
-

- A new notification appears -

Date: Wed, 29 May 2019 13:06:10 +0300 Subject: [PATCH 06/13] WIP: clearing notification stack after an idle time --- src/components/toast/global_toast_list.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/components/toast/global_toast_list.js b/src/components/toast/global_toast_list.js index 0d31142f62b..cb6a9bda0b3 100644 --- a/src/components/toast/global_toast_list.js +++ b/src/components/toast/global_toast_list.js @@ -29,7 +29,9 @@ export class EuiGlobalToastList extends Component { // for information on initial value of 0 this.isScrollingAnimationFrame = 0; this.startScrollingAnimationFrame = 0; + this.renderedForScreenReaderToasts = []; + this.clearScreenReaderToastStorageID = null; } static propTypes = { @@ -175,6 +177,11 @@ export class EuiGlobalToastList extends Component { }); }; + clearScreenReaderToastStorage = () => { + this.renderedForScreenReaderToasts = []; + this.forceUpdate(); + }; + getRenderedForScreenReaderToasts = () => { return this.renderedForScreenReaderToasts.map(toast => toast.reactElement); }; @@ -206,7 +213,18 @@ export class EuiGlobalToastList extends Component { ), })); // returns element, if element is false, then it excludes the one + // add new incoming toasts to the stack this.renderedForScreenReaderToasts.push(...newToasts); + // skip previous stack clearing + clearTimeout(this.clearScreenReaderToastStorageID); + // Set it to wait 27 seconds after the last notification before clear the stack + // 27s is the time chosen approx. That time is that Screen Reader needs to finish reading + // at least the last one notifications. + // It strictly depends on how long that notifications is. + this.clearScreenReaderToastStorageID = setTimeout( + this.clearScreenReaderToastStorage, + 25000 + ); }; componentDidMount() { From 5c3381a403409460bc369aad0a047f6313e6fac9 Mon Sep 17 00:00:00 2001 From: rockfield Date: Thu, 30 May 2019 10:32:14 +0300 Subject: [PATCH 07/13] updated CHANGEDLOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index edb432f5db2..91601ba1f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Added ability to update `EuiInMemoryTable` `sorting` prop and remove columns after sorting is applied ([#1972](https://github.com/elastic/eui/pull/1972)) - Added `onToggle` callback to `EuiAccordion` ([#1974](https://github.com/elastic/eui/pull/1974)) - Removed `options` `defaultProps` value from `EuiSuperSelect` ([#1975](https://github.com/elastic/eui/pull/1975)) +- Added a logger based on `Aria Live Region` to `EuiGlobalToastList` ([#1958](https://github.com/elastic/eui/pull/1958)) **Bug fixes** From 852c54a97368026413f985af513ec4a61125afcf Mon Sep 17 00:00:00 2001 From: rockfield Date: Thu, 30 May 2019 10:45:58 +0300 Subject: [PATCH 08/13] adjusted time period --- src/components/toast/global_toast_list.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/toast/global_toast_list.js b/src/components/toast/global_toast_list.js index cb6a9bda0b3..87a8d728a99 100644 --- a/src/components/toast/global_toast_list.js +++ b/src/components/toast/global_toast_list.js @@ -217,13 +217,13 @@ export class EuiGlobalToastList extends Component { this.renderedForScreenReaderToasts.push(...newToasts); // skip previous stack clearing clearTimeout(this.clearScreenReaderToastStorageID); - // Set it to wait 27 seconds after the last notification before clear the stack + // Set it to wait 57 seconds after the last notification before clear the stack // 27s is the time chosen approx. That time is that Screen Reader needs to finish reading // at least the last one notifications. // It strictly depends on how long that notifications is. this.clearScreenReaderToastStorageID = setTimeout( this.clearScreenReaderToastStorage, - 25000 + 57000 ); }; From dfa834c18bde0c263bc0c3ac2be40ecd2f01975a Mon Sep 17 00:00:00 2001 From: rockfield Date: Fri, 31 May 2019 20:04:36 +0300 Subject: [PATCH 09/13] review comments --- src/components/toast/global_toast_list.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/toast/global_toast_list.js b/src/components/toast/global_toast_list.js index 87a8d728a99..5b565d2c111 100644 --- a/src/components/toast/global_toast_list.js +++ b/src/components/toast/global_toast_list.js @@ -10,6 +10,7 @@ import { EuiScreenReaderOnly } from '../accessibility'; import { EuiI18n } from '../i18n'; export const TOAST_FADE_OUT_MS = 250; +export const TOAST_LOGGER_TIMEOUT_MS = 57000; export class EuiGlobalToastList extends Component { constructor(props) { @@ -188,10 +189,10 @@ export class EuiGlobalToastList extends Component { filterNewOnlyToasts = toasts => { return toasts.filter(toastFromProp => { - const withTheSameID = this.renderedForScreenReaderToasts.filter( + const withTheSameID = this.renderedForScreenReaderToasts.some( existToast => existToast.id === toastFromProp.id ); - return !withTheSameID.length; + return !withTheSameID; }); }; @@ -223,7 +224,7 @@ export class EuiGlobalToastList extends Component { // It strictly depends on how long that notifications is. this.clearScreenReaderToastStorageID = setTimeout( this.clearScreenReaderToastStorage, - 57000 + TOAST_LOGGER_TIMEOUT_MS ); }; From 2595b38d169d16db4d0ee7122bd009201f97df6c Mon Sep 17 00:00:00 2001 From: rockfield Date: Tue, 4 Jun 2019 23:52:30 +0300 Subject: [PATCH 10/13] Annoncing only the last toast notification with the help of and Aria live region --- .../global_toast_list.test.js.snap | 17 +-- src/components/toast/global_toast_list.js | 104 +++++++++--------- 2 files changed, 56 insertions(+), 65 deletions(-) diff --git a/src/components/toast/__snapshots__/global_toast_list.test.js.snap b/src/components/toast/__snapshots__/global_toast_list.test.js.snap index ec1af1ba73f..a2134b16f6a 100644 --- a/src/components/toast/__snapshots__/global_toast_list.test.js.snap +++ b/src/components/toast/__snapshots__/global_toast_list.test.js.snap @@ -116,21 +116,6 @@ exports[`EuiGlobalToastList props toasts is rendered 1`] = ` aria-live="polite" class="euiScreenReaderOnly" role="region" - > -

- A new notification appears -

-

- A -

- a -

- A new notification appears -

-

- B -

- b -
+ />
`; diff --git a/src/components/toast/global_toast_list.js b/src/components/toast/global_toast_list.js index 825dac6a2c0..15f4605b075 100644 --- a/src/components/toast/global_toast_list.js +++ b/src/components/toast/global_toast_list.js @@ -7,6 +7,7 @@ import { IconPropType } from '../icon'; import { EuiGlobalToastListItem } from './global_toast_list_item'; import { EuiToast } from './toast'; import { EuiScreenReaderOnly } from '../accessibility'; +import { EuiDelayRender } from '../delay_render'; import { EuiI18n } from '../i18n'; export const TOAST_FADE_OUT_MS = 250; @@ -31,8 +32,7 @@ export class EuiGlobalToastList extends Component { this.isScrollingAnimationFrame = 0; this.startScrollingAnimationFrame = 0; - this.renderedForScreenReaderToasts = []; - this.clearScreenReaderToastStorageID = null; + this.lastRenderedForScreenReaderToast = { id: -1 }; } static propTypes = { @@ -178,53 +178,61 @@ export class EuiGlobalToastList extends Component { }); }; - clearScreenReaderToastStorage = () => { - this.renderedForScreenReaderToasts = []; - this.forceUpdate(); - }; + handleScreenReaderToastLoggerUpdating = toasts => { - getRenderedForScreenReaderToasts = () => { - return this.renderedForScreenReaderToasts.map(toast => toast.reactElement); - }; + if (!toasts.length) { + // show what we have now to avoid node re-rendering + return this.lastRenderedForScreenReaderToast.reactElement; + } - filterNewOnlyToasts = toasts => { - return toasts.filter(toastFromProp => { - const withTheSameID = this.renderedForScreenReaderToasts.some( - existToast => existToast.id === toastFromProp.id - ); - return !withTheSameID; - }); - }; + // get the highest (latest) ID + // TODO: should we update the docs for using only numberic IDs? + // Numberic IDs are used all across the Kibana + // Otherwise, it's hard to decide between too toasts + // where one of them has number ID while the other -- string + // Also, documentation example uses numberic IDs + const toastIDS = toasts.filter(({id}) => typeof id === 'number').map(({id}) => id); + console.info('toastIDS', toastIDS); + const latestToastID = Math.max(...toastIDS); + const lastToast = this.lastRenderedForScreenReaderToast; + + // check the local toast + const locallyStored = lastToast.id >= latestToastID; + + console.info('locallyStored', locallyStored, latestToastID, lastToast, toasts); + + if (locallyStored) { + // if locally stored, then render without delay + return lastToast.reactElement || null; + } + + // if not locally stored, then + // update local copy + // and render with delay + const newLastToasts = toasts + .filter(toast => toast.id === latestToastID) + .map(toast => ({ + id: toast.id, + reactElement: ( + +

+ +

+

{toast.title}

+
{toast.text}
+
+ ), + })); + + this.lastRenderedForScreenReaderToast = newLastToasts.slice(-1)[0]; - logSetOfToasts = toasts => { - // map and filter incoming toasts to get only new ones - const newToasts = this.filterNewOnlyToasts(toasts).map(newToast => ({ - id: newToast.id, - reactElement: ( - -

- -

-

{newToast.title}

- {newToast.text} -
- ), - })); // returns element, if element is false, then it excludes the one - - // add new incoming toasts to the stack - this.renderedForScreenReaderToasts.push(...newToasts); - // skip previous stack clearing - clearTimeout(this.clearScreenReaderToastStorageID); - // Set it to wait 57 seconds after the last notification before clear the stack - // 27s is the time chosen approx. That time is that Screen Reader needs to finish reading - // at least the last one notifications. - // It strictly depends on how long that notifications is. - this.clearScreenReaderToastStorageID = setTimeout( - this.clearScreenReaderToastStorage, - TOAST_LOGGER_TIMEOUT_MS + return ( + + {this.lastRenderedForScreenReaderToast.reactElement} + ); }; @@ -277,8 +285,6 @@ export class EuiGlobalToastList extends Component { ...rest } = this.props; - this.logSetOfToasts(toasts); - const renderedToasts = toasts.map(toast => { const { text, toastLifeTimeMs, ...rest } = toast; @@ -309,7 +315,7 @@ export class EuiGlobalToastList extends Component { {renderedToasts}
- {this.getRenderedForScreenReaderToasts()} + {this.handleScreenReaderToastLoggerUpdating(toasts)}
From 257afef5c347c939cce1c8e56117d4c50a8a0359 Mon Sep 17 00:00:00 2001 From: rockfield Date: Wed, 5 Jun 2019 13:37:33 +0300 Subject: [PATCH 11/13] Add screenReaderOnly way to announce a toast but not show it --- src/components/toast/global_toast_list.js | 9 ++++----- src/components/toast/toast.js | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/toast/global_toast_list.js b/src/components/toast/global_toast_list.js index 15f4605b075..22892b96dc0 100644 --- a/src/components/toast/global_toast_list.js +++ b/src/components/toast/global_toast_list.js @@ -46,6 +46,7 @@ export class EuiGlobalToastList extends Component { color: PropTypes.string, iconType: IconPropType, toastLifeTimeMs: PropTypes.number, + screenReaderOnly: PropTypes.bool, }).isRequired ), dismissToast: PropTypes.func.isRequired, @@ -192,15 +193,12 @@ export class EuiGlobalToastList extends Component { // where one of them has number ID while the other -- string // Also, documentation example uses numberic IDs const toastIDS = toasts.filter(({id}) => typeof id === 'number').map(({id}) => id); - console.info('toastIDS', toastIDS); const latestToastID = Math.max(...toastIDS); const lastToast = this.lastRenderedForScreenReaderToast; // check the local toast const locallyStored = lastToast.id >= latestToastID; - console.info('locallyStored', locallyStored, latestToastID, lastToast, toasts); - if (locallyStored) { // if locally stored, then render without delay return lastToast.reactElement || null; @@ -288,7 +286,7 @@ export class EuiGlobalToastList extends Component { const renderedToasts = toasts.map(toast => { const { text, toastLifeTimeMs, ...rest } = toast; - return ( + return toast.screenReaderOnly ? null : ( @@ -311,7 +309,8 @@ export class EuiGlobalToastList extends Component { this.listElement = element; }} className={classes} - {...rest}> + {...rest} + > {renderedToasts}
diff --git a/src/components/toast/toast.js b/src/components/toast/toast.js index 66eafc36ca7..e28219e93f1 100644 --- a/src/components/toast/toast.js +++ b/src/components/toast/toast.js @@ -100,4 +100,5 @@ EuiToast.propTypes = { color: PropTypes.oneOf(COLORS), onClose: PropTypes.func, children: PropTypes.node, + screenReaderOnly: PropTypes.bool, }; From 6277412caa8e958dd7a64cdefa6ff97079b5d28b Mon Sep 17 00:00:00 2001 From: rockfield Date: Wed, 5 Jun 2019 13:45:49 +0300 Subject: [PATCH 12/13] fix eslint review --- src/components/toast/global_toast_list.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/toast/global_toast_list.js b/src/components/toast/global_toast_list.js index 22892b96dc0..1f3ca58248c 100644 --- a/src/components/toast/global_toast_list.js +++ b/src/components/toast/global_toast_list.js @@ -180,7 +180,6 @@ export class EuiGlobalToastList extends Component { }; handleScreenReaderToastLoggerUpdating = toasts => { - if (!toasts.length) { // show what we have now to avoid node re-rendering return this.lastRenderedForScreenReaderToast.reactElement; @@ -192,7 +191,9 @@ export class EuiGlobalToastList extends Component { // Otherwise, it's hard to decide between too toasts // where one of them has number ID while the other -- string // Also, documentation example uses numberic IDs - const toastIDS = toasts.filter(({id}) => typeof id === 'number').map(({id}) => id); + const toastIDS = toasts + .filter(({ id }) => typeof id === 'number') + .map(({ id }) => id); const latestToastID = Math.max(...toastIDS); const lastToast = this.lastRenderedForScreenReaderToast; @@ -309,8 +310,7 @@ export class EuiGlobalToastList extends Component { this.listElement = element; }} className={classes} - {...rest} - > + {...rest}> {renderedToasts}
From 6adbf30bd92be6763f75feab7d136097787e8415 Mon Sep 17 00:00:00 2001 From: rockfield Date: Thu, 13 Jun 2019 13:41:09 +0300 Subject: [PATCH 13/13] WIP: need to figure out how to re-render a toast in the logger without re-announcing that from screen reader --- .../global_toast_list.test.js.snap | 121 -------- src/components/toast/global_toast_list.js | 148 +++++---- .../toast/global_toast_list.test.js | 286 +++++++++++------- 3 files changed, 273 insertions(+), 282 deletions(-) delete mode 100644 src/components/toast/__snapshots__/global_toast_list.test.js.snap diff --git a/src/components/toast/__snapshots__/global_toast_list.test.js.snap b/src/components/toast/__snapshots__/global_toast_list.test.js.snap deleted file mode 100644 index a2134b16f6a..00000000000 --- a/src/components/toast/__snapshots__/global_toast_list.test.js.snap +++ /dev/null @@ -1,121 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EuiGlobalToastList is rendered 1`] = ` -
-
-
-`; - -exports[`EuiGlobalToastList props toasts is rendered 1`] = ` -
-
-
- - - A - -
- -
- a -
-
-
-
- - - B - -
- -
- b -
-
-
-
-`; diff --git a/src/components/toast/global_toast_list.js b/src/components/toast/global_toast_list.js index 1f3ca58248c..5aad2e1a616 100644 --- a/src/components/toast/global_toast_list.js +++ b/src/components/toast/global_toast_list.js @@ -1,4 +1,4 @@ -import React, { Fragment, Component } from 'react'; +import React, { Fragment, PureComponent } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -11,14 +11,14 @@ import { EuiDelayRender } from '../delay_render'; import { EuiI18n } from '../i18n'; export const TOAST_FADE_OUT_MS = 250; -export const TOAST_LOGGER_TIMEOUT_MS = 57000; -export class EuiGlobalToastList extends Component { +export class EuiGlobalToastList extends PureComponent { constructor(props) { super(props); this.state = { toastIdToDismissedMap: {}, + lastRenderedForScreenReaderToast: { id: -1, timestamp: -1 }, }; this.dismissTimeoutIds = []; @@ -32,7 +32,10 @@ export class EuiGlobalToastList extends Component { this.isScrollingAnimationFrame = 0; this.startScrollingAnimationFrame = 0; - this.lastRenderedForScreenReaderToast = { id: -1 }; + // this.state.lastRenderedForScreenReaderToast = { id: -1 }; + this._ariaLiveToastTempStorage = []; + // 'keep' or 'update' + this._updatingLiveRegionStrategy = 'update'; } static propTypes = { @@ -179,62 +182,95 @@ export class EuiGlobalToastList extends Component { }); }; - handleScreenReaderToastLoggerUpdating = toasts => { + logToastsForScreenReader = toasts => { if (!toasts.length) { - // show what we have now to avoid node re-rendering - return this.lastRenderedForScreenReaderToast.reactElement; + // return what we have now to avoid node re-rendering + return; } - // get the highest (latest) ID - // TODO: should we update the docs for using only numberic IDs? - // Numberic IDs are used all across the Kibana - // Otherwise, it's hard to decide between too toasts - // where one of them has number ID while the other -- string - // Also, documentation example uses numberic IDs - const toastIDS = toasts - .filter(({ id }) => typeof id === 'number') - .map(({ id }) => id); - const latestToastID = Math.max(...toastIDS); - const lastToast = this.lastRenderedForScreenReaderToast; - - // check the local toast - const locallyStored = lastToast.id >= latestToastID; - - if (locallyStored) { - // if locally stored, then render without delay - return lastToast.reactElement || null; - } - - // if not locally stored, then - // update local copy - // and render with delay - const newLastToasts = toasts - .filter(toast => toast.id === latestToastID) - .map(toast => ({ - id: toast.id, - reactElement: ( - -

- -

-

{toast.title}

-
{toast.text}
-
- ), + const toastStorage = this._ariaLiveToastTempStorage; + + // 1: we should filter new toasts + const notStoredPassedToasts = toasts + .filter( + passedToast => + !toastStorage.some(storedToast => storedToast.id === passedToast.id) + ) + // 2: We should update each with a timestamp + .map(newToast => ({ + ...newToast, + timestamp: Date.now(), })); - this.lastRenderedForScreenReaderToast = newLastToasts.slice(-1)[0]; + // console.info('notStoredPassedToasts', notStoredPassedToasts); - return ( - - {this.lastRenderedForScreenReaderToast.reactElement} - - ); + // 3: We should store everything in the private storage + this._ariaLiveToastTempStorage.push(...notStoredPassedToasts); + + // // Clear the storage from unpassed (and already deleted toasts) + // this._ariaLiveToastTempStorage = this._ariaLiveToastTempStorage.filter( + // storedToast => + // toasts.some(passedToast => passedToast.id === storedToast.id) + // ); + + // console.log('_ariaLiveToastTempStorage', this._ariaLiveToastTempStorage); + + // 4: We should store the latest in the state + const theLatestCameToasts = this._ariaLiveToastTempStorage + .map(x => x) + .sort((prev, next) => next.timestamp - prev.timestamp); + + // console.info('theLatestCameToasts', theLatestCameToasts.map(x => x.timestamp)); + + const theLatest = theLatestCameToasts[0]; + + console.info(theLatest.timestamp > this.state.lastRenderedForScreenReaderToast.timestamp ? 'update' : 'keep'); + + if ( + theLatest && + theLatest.timestamp > + this.state.lastRenderedForScreenReaderToast.timestamp + ) { + this._updatingLiveRegionStrategy = 'update'; + this.setState({ + lastRenderedForScreenReaderToast: { ...theLatest }, + }); + } else { + this._updatingLiveRegionStrategy = 'keep'; + } }; + renderScreenReaderLogArea() { + const toastNotification = ( + +

+ +

+

{this.state.lastRenderedForScreenReaderToast.title}

+
{this.state.lastRenderedForScreenReaderToast.text}
+
+ ); + + switch (this._updatingLiveRegionStrategy) { + case 'update': // with delay + return ( + + {toastNotification} + + ); + break; + case 'keep': // without delay + // return null; + return toastNotification; + break; + default: + return null; + } + } + componentDidMount() { this.listElement.addEventListener('scroll', this.onScroll); this.listElement.addEventListener('mouseenter', this.onMouseEnter); @@ -242,7 +278,7 @@ export class EuiGlobalToastList extends Component { this.scheduleAllToastsForDismissal(); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps, prevState) { this.scheduleAllToastsForDismissal(); if (!this.isUserInteracting) { @@ -254,6 +290,10 @@ export class EuiGlobalToastList extends Component { } } } + if (this.props.toasts.length) { + console.info('didUpdate()'); + this.logToastsForScreenReader(this.props.toasts, prevState); + } } componentWillUnmount() { @@ -314,7 +354,7 @@ export class EuiGlobalToastList extends Component { {renderedToasts}
- {this.handleScreenReaderToastLoggerUpdating(toasts)} + {this.renderScreenReaderLogArea()}
diff --git a/src/components/toast/global_toast_list.test.js b/src/components/toast/global_toast_list.test.js index 9882a4a8fb2..16156d3354b 100644 --- a/src/components/toast/global_toast_list.test.js +++ b/src/components/toast/global_toast_list.test.js @@ -1,134 +1,206 @@ import React from 'react'; -import { render, mount } from 'enzyme'; +import { render, mount, shallow } from 'enzyme'; import sinon from 'sinon'; import { requiredProps, findTestSubject } from '../../test'; import { EuiGlobalToastList, TOAST_FADE_OUT_MS } from './global_toast_list'; describe('EuiGlobalToastList', () => { - test('is rendered', () => { - const component = render( - {}} - toastLifeTimeMs={5} - /> - ); - - expect(component).toMatchSnapshot(); - }); + // test('is rendered', () => { + // const component = render( + // {}} + // toastLifeTimeMs={5} + // /> + // ); + + // expect(component).toMatchSnapshot(); + // }); describe('props', () => { describe('toasts', () => { - test('is rendered', () => { - const toasts = [ - { - title: 'A', - text: 'a', - color: 'success', - iconType: 'check', - 'data-test-subj': 'a', - id: 'a', - }, - { - title: 'B', - text: 'b', - color: 'danger', - iconType: 'alert', - 'data-test-subj': 'b', - id: 'b', - }, - ]; - - const component = render( + describe('in aria live region', () => { + const toast_a = { + title: 'A', + text: 'a', + color: 'success', + iconType: 'check', + 'data-test-subj': 'a', + id: 'a', + }; + const toast_b = { + title: 'B', + text: 'b', + color: 'danger', + iconType: 'alert', + 'data-test-subj': 'b', + id: 'b', + }; + + const renderedToastList = mount( {}} toastLifeTimeMs={5} /> ); - expect(component).toMatchSnapshot(); - }); - }); + test('adding nothing', () => { + renderedToastList.setProps({ toasts: [] }); + expect( + renderedToastList.state().lastRenderedForScreenReaderToast + ).toEqual({ + id: -1, + }); + }); - describe('dismissToast', () => { - test('is called when a toast is clicked', done => { - const dismissToastSpy = sinon.spy(); - const component = mount( - - ); + test('adding one toast', () => { + const teststamp = Date.now(); + renderedToastList.setProps({ toasts: [toast_a] }); + expect( + renderedToastList.state().lastRenderedForScreenReaderToast.timestamp + ).toBeGreaterThanOrEqual(teststamp); + }); - const toastB = findTestSubject(component, 'b'); - const closeButton = findTestSubject(toastB, 'toastCloseButton'); - closeButton.simulate('click'); + test('adding two toasts', () => { + renderedToastList.setProps({ toasts: [toast_a, toast_b] }); + expect( + renderedToastList.state().lastRenderedForScreenReaderToast.id + ).toBe(toast_b.id); + }); - // The callback is invoked once the toast fades from view. - setTimeout(() => { - expect(dismissToastSpy.called).toBe(true); - done(); - }, TOAST_FADE_OUT_MS + 1); - }); + test('adding the second toast once again', () => { + renderedToastList.setProps({ toasts: [toast_b] }); + expect( + renderedToastList.state().lastRenderedForScreenReaderToast.id + ).toBe(toast_b.id); + }); - test('is called when the toast lifetime elapses', done => { - const TOAST_LIFE_TIME_MS = 5; - const dismissToastSpy = sinon.spy(); - mount( - - ); + test('adding two another toasts', () => { + renderedToastList.setProps({ toasts: [toast_a, toast_b] }); + expect( + renderedToastList.state().lastRenderedForScreenReaderToast.id + ).toBe(toast_a.id); + }); - // The callback is invoked once the toast fades from view. - setTimeout(() => { - expect(dismissToastSpy.called).toBe(true); - done(); - }, TOAST_LIFE_TIME_MS + TOAST_FADE_OUT_MS + 10); + test('adding two toasts', () => { + renderedToastList.setProps({ toasts: [] }); + expect( + renderedToastList.state().lastRenderedForScreenReaderToast.id + ).toBe(toast_a.id); + }); }); - test('toastLifeTimeMs is overrideable by individidual toasts', done => { - const TOAST_LIFE_TIME_MS = 10; - const TOAST_LIFE_TIME_MS_OVERRIDE = 100; - const dismissToastSpy = sinon.spy(); - mount( - - ); + // test('is rendered', () => { + // const toasts = [ + // { + // title: 'A', + // text: 'a', + // color: 'success', + // iconType: 'check', + // 'data-test-subj': 'a', + // id: 'a', + // }, + // { + // title: 'B', + // text: 'b', + // color: 'danger', + // iconType: 'alert', + // 'data-test-subj': 'b', + // id: 'b', + // }, + // ]; - // The callback is invoked once the toast fades from view. - setTimeout(() => { - expect(dismissToastSpy.called).toBe(false); - }, TOAST_LIFE_TIME_MS + TOAST_FADE_OUT_MS + 10); - setTimeout(() => { - expect(dismissToastSpy.called).toBe(true); - done(); - }, TOAST_LIFE_TIME_MS_OVERRIDE + TOAST_FADE_OUT_MS + 10); - }); + // const component = render( + // {}} + // toastLifeTimeMs={5} + // /> + // ); + + // expect(component).toMatchSnapshot(); + // }); }); + + // describe('dismissToast', () => { + // test('is called when a toast is clicked', done => { + // const dismissToastSpy = sinon.spy(); + // const component = mount( + // + // ); + + // const toastB = findTestSubject(component, 'b'); + // const closeButton = findTestSubject(toastB, 'toastCloseButton'); + // closeButton.simulate('click'); + + // // The callback is invoked once the toast fades from view. + // setTimeout(() => { + // expect(dismissToastSpy.called).toBe(true); + // done(); + // }, TOAST_FADE_OUT_MS + 1); + // }); + + // test('is called when the toast lifetime elapses', done => { + // const TOAST_LIFE_TIME_MS = 5; + // const dismissToastSpy = sinon.spy(); + // mount( + // + // ); + + // // The callback is invoked once the toast fades from view. + // setTimeout(() => { + // expect(dismissToastSpy.called).toBe(true); + // done(); + // }, TOAST_LIFE_TIME_MS + TOAST_FADE_OUT_MS + 10); + // }); + + // test('toastLifeTimeMs is overrideable by individidual toasts', done => { + // const TOAST_LIFE_TIME_MS = 10; + // const TOAST_LIFE_TIME_MS_OVERRIDE = 100; + // const dismissToastSpy = sinon.spy(); + // mount( + // + // ); + + // // The callback is invoked once the toast fades from view. + // setTimeout(() => { + // expect(dismissToastSpy.called).toBe(false); + // }, TOAST_LIFE_TIME_MS + TOAST_FADE_OUT_MS + 10); + // setTimeout(() => { + // expect(dismissToastSpy.called).toBe(true); + // done(); + // }, TOAST_LIFE_TIME_MS_OVERRIDE + TOAST_FADE_OUT_MS + 10); + // }); + // }); }); });