diff --git a/ui_framework/dist/ui_framework.css b/ui_framework/dist/ui_framework.css
index 8f36df456519e..54a7cba93f543 100644
--- a/ui_framework/dist/ui_framework.css
+++ b/ui_framework/dist/ui_framework.css
@@ -10,6 +10,9 @@
100% {
opacity: 1; } }
+/**
+ * Set scroll bar appearance on Chrome.
+ */
/**
* Adapted from Eric Meyer's reset (http://meyerweb.com/eric/tools/css/reset/, v2.0 | 20110126).
*
@@ -549,22 +552,22 @@ table {
* 1. Vertically center icon with first line of title.
*/
.kuiCallOutHeader__icon {
- fill: #0079a5;
+ -webkit-box-flex: 0;
+ -webkit-flex: 0 0 auto;
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
-webkit-transform: translateY(2px);
transform: translateY(2px);
/* 1 */ }
-.kuiCallOutHeader__title {
- color: #0079a5; }
-
.kuiHeader {
+ box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.1);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
background: #FFF;
- border-bottom: 1px solid #D9D9D9;
- box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.1); }
+ border-bottom: 1px solid #D9D9D9; }
.kuiHeader__notification {
display: inline-block;
@@ -1647,6 +1650,159 @@ table {
-webkit-transform: scaleX(1);
transform: scaleX(1); } }
+/**
+ * 1. Allow list to expand as items are added, but cap it at the screen height.
+ */
+.kuiGlobalToastList {
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-orient: vertical;
+ -webkit-box-direction: normal;
+ -webkit-flex-direction: column;
+ -ms-flex-direction: column;
+ flex-direction: column;
+ -webkit-box-align: stretch;
+ -webkit-align-items: stretch;
+ -ms-flex-align: stretch;
+ align-items: stretch;
+ position: fixed;
+ z-index: 5000;
+ bottom: 0;
+ right: 0;
+ width: 320px;
+ overflow-x: hidden;
+ overflow-y: scroll;
+ max-height: 100vh;
+ /* 1 */ }
+ .kuiGlobalToastList::-webkit-scrollbar {
+ width: 16px;
+ height: 16px; }
+ .kuiGlobalToastList::-webkit-scrollbar-thumb {
+ background-color: rgba(102, 102, 102, 0.5);
+ border: 6px solid transparent;
+ background-clip: content-box; }
+ .kuiGlobalToastList::-webkit-scrollbar-track {
+ background-color: transparent; }
+
+.kuiGlobalToastListItem {
+ margin-bottom: 12px;
+ -webkit-animation: 0.5s kuiShowToast;
+ animation: 0.5s kuiShowToast;
+ opacity: 1;
+ /**
+ * 1. justify-content: flex-end interferes with overflowing content, so we'll use this to push
+ * items to the bottom instead.
+ */ }
+ .kuiGlobalToastListItem:first-child {
+ margin-top: auto;
+ /* 1 */ }
+ .kuiGlobalToastListItem.kuiGlobalToastListItem-isDismissed {
+ transition: opacity 250ms;
+ opacity: 0; }
+
+@-webkit-keyframes kuiShowToast {
+ from {
+ -webkit-transform: translateX(30px);
+ transform: translateX(30px);
+ opacity: 0; }
+ to {
+ -webkit-transform: translateX(0);
+ transform: translateX(0);
+ opacity: 1; } }
+
+@keyframes kuiShowToast {
+ from {
+ -webkit-transform: translateX(30px);
+ transform: translateX(30px);
+ opacity: 0; }
+ to {
+ -webkit-transform: translateX(0);
+ transform: translateX(0);
+ opacity: 1; } }
+
+.kuiToast {
+ box-shadow: 0 16px 16px -8px rgba(0, 0, 0, 0.1);
+ position: relative;
+ padding: 12px;
+ background-color: #FFF;
+ border: 1px solid #D9D9D9; }
+
+/**
+ * 1. Fit button to icon.
+ */
+.kuiToast__closeButton {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ line-height: 0;
+ /* 1 */
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none; }
+ .kuiToast__closeButton svg {
+ fill: gray; }
+ .kuiToast__closeButton:hover svg {
+ fill: #000; }
+ .kuiToast__closeButton:focus {
+ background-color: #e6f2f6; }
+ .kuiToast__closeButton:focus svg {
+ fill: #0079a5; }
+
+.kuiToast--info {
+ border-top: 2px solid #0079a5; }
+
+.kuiToast--success {
+ border-top: 2px solid #00A69B; }
+
+.kuiToast--warning {
+ border-top: 2px solid #E5830E; }
+
+.kuiToast--danger {
+ border-top: 2px solid #A30000; }
+
+/**
+ * 1. Align icon with first line of title text if it wraps.
+ * 2. Apply margin to all but last item in the flex.
+ */
+.kuiToastHeader {
+ font-size: 16px;
+ font-size: 1rem;
+ line-height: 24px;
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: baseline;
+ -webkit-align-items: baseline;
+ -ms-flex-align: baseline;
+ align-items: baseline;
+ /* 1 */ }
+ .kuiToastHeader > * + * {
+ margin-left: 8px;
+ /* 2 */ }
+
+/**
+ * 1. Vertically center icon with first line of title.
+ */
+.kuiToastHeader__icon {
+ -webkit-box-flex: 0;
+ -webkit-flex: 0 0 auto;
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
+ fill: #000;
+ -webkit-transform: translateY(2px);
+ transform: translateY(2px);
+ /* 1 */ }
+
+.kuiToastHeader__title {
+ color: #000;
+ font-weight: 300; }
+
+.kuiToastHeader--withBody {
+ margin-bottom: 9px; }
+
.kuiTitle {
font-size: 24px;
font-size: 1.5rem;
diff --git a/ui_framework/doc_site/src/services/routes/routes.js b/ui_framework/doc_site/src/services/routes/routes.js
index c2f5ead97aaf3..2e701b73103c8 100644
--- a/ui_framework/doc_site/src/services/routes/routes.js
+++ b/ui_framework/doc_site/src/services/routes/routes.js
@@ -45,6 +45,9 @@ import TableExample
import TabsExample
from '../../views/tabs/tabs_example';
+import ToastExample
+ from '../../views/toast/toast_example';
+
import TypographyExample
from '../../views/typography/typography_example';
@@ -105,6 +108,10 @@ const components = [{
name: 'Tabs',
component: TabsExample,
hasReact: true,
+}, {
+ name: 'Toast',
+ component: ToastExample,
+ hasReact: true,
}, {
name: 'Typography',
component: TypographyExample,
diff --git a/ui_framework/doc_site/src/views/kibana/kibana.js b/ui_framework/doc_site/src/views/kibana/kibana.js
index f7d2015d29f5e..7b7c30b1a79ba 100644
--- a/ui_framework/doc_site/src/views/kibana/kibana.js
+++ b/ui_framework/doc_site/src/views/kibana/kibana.js
@@ -1,4 +1,5 @@
import React, {
+ cloneElement,
Component,
} from 'react';
@@ -12,9 +13,12 @@ import {
KuiHeaderSection,
KuiHeaderSectionItem,
KuiHeaderSectionItemButton,
+ KuiGlobalToastList,
+ KuiGlobalToastListItem,
KuiIcon,
KuiKeyPadMenu,
KuiKeyPadMenuItem,
+ KuiLink,
KuiPage,
KuiPageBody,
KuiPageContent,
@@ -28,9 +32,16 @@ import {
KuiSideNav,
KuiSideNavItem,
KuiSideNavTitle,
+ KuiText,
+ KuiToast,
KuiTitle,
} from '../../../../components';
+const TOAST_LIFE_TIME_MS = 4000;
+const TOAST_FADE_OUT_MS = 250;
+let toastIdCounter = 0;
+const timeoutIds = [];
+
export default class extends Component {
constructor(props) {
super(props);
@@ -39,6 +50,7 @@ export default class extends Component {
isUserMenuOpen: false,
isAppMenuOpen: false,
isSideNavOpenOnMobile: false,
+ toasts: [],
};
}
@@ -54,6 +66,19 @@ export default class extends Component {
});
}
+ onAddToastClick() {
+ const {
+ toast,
+ toastId,
+ } = this.renderRandomToast();
+
+ this.setState({
+ toasts: this.state.toasts.concat(toast),
+ });
+
+ this.scheduleToastForDismissal(toastId);
+ }
+
closeUserMenu() {
this.setState({
isUserMenuOpen: false,
@@ -72,6 +97,48 @@ export default class extends Component {
});
}
+ scheduleToastForDismissal(toastId, isImmediate = false) {
+ const lifeTime = isImmediate ? TOAST_FADE_OUT_MS : TOAST_LIFE_TIME_MS;
+
+ timeoutIds.push(setTimeout(() => {
+ this.dismissToast(toastId);
+ }, lifeTime));
+
+ timeoutIds.push(setTimeout(() => {
+ this.startDismissingToast(toastId);
+ }, lifeTime - TOAST_FADE_OUT_MS));
+ }
+
+ startDismissingToast(toastId) {
+ this.setState({
+ toasts: this.state.toasts.map(toast => {
+ if (toast.key === toastId) {
+ return cloneElement(toast, {
+ isDismissed: true,
+ });
+ }
+
+ return toast;
+ }),
+ });
+ }
+
+ dismissToast(toastId) {
+ this.setState({
+ toasts: this.state.toasts.filter(toast => toast.key !== toastId),
+ });
+ }
+
+ onDeleteAllToasts() {
+ this.setState({
+ toasts: [],
+ });
+ }
+
+ componentWillUnmount() {
+ timeoutIds.forEach(timeoutId => clearTimeout(timeoutId));
+ }
+
renderLogo() {
return (
@@ -413,7 +480,23 @@ export default class extends Component {
- asdf
+
+ Add toast
+
+
+
+
+
+
+ Clear toasts
+
@@ -421,11 +504,98 @@ export default class extends Component {
);
}
+ renderRandomToast() {
+ const toastId = (toastIdCounter++).toString();
+ const dismissToast = this.scheduleToastForDismissal.bind(this, toastId, true);
+
+ const toasts = [(
+
+
+
+ Here’s some stuff that you need to know. We can make this text really long so that,
+ when viewed within a browser that’s fairly narrow, it will wrap, too.
+
+
+
+
+
+ And some other stuff on another line, just for kicks. And here’s a link .
+
+
+
+ ), (
+
+
+
+ Thanks for your patience!
+
+
+
+ ), (
+
+
+
+ This is a security measure.
+
+
+
+
+
+ Please move your mouse to show that you’re still using Kibana.
+
+
+
+ ), (
+
+
+
+ Sorry. We’ll try not to let it happen it again.
+
+
+
+ )];
+
+ const toast = (
+
+ {toasts[Math.floor(Math.random() * toasts.length)]}
+
+ );
+
+ return { toast, toastId };
+ }
+
+ renderToasts() {
+ return (
+
+ {this.state.toasts}
+
+ );
+ }
+
render() {
return (
{this.renderHeader()}
{this.renderPage()}
+ {this.renderToasts()}
);
}
diff --git a/ui_framework/doc_site/src/views/toast/danger.js b/ui_framework/doc_site/src/views/toast/danger.js
new file mode 100644
index 0000000000000..7f45694851be5
--- /dev/null
+++ b/ui_framework/doc_site/src/views/toast/danger.js
@@ -0,0 +1,28 @@
+import React from 'react';
+
+import {
+ KuiLink,
+ KuiText,
+ KuiToast,
+} from '../../../../components';
+
+export default () => (
+
+
+
+ Here’s some stuff that you need to know. We can make this text really long so that,
+ when viewed within a browser that’s fairly narrow, it will wrap, too.
+
+
+
+
+
+ And some other stuff on another line, just for kicks. And here’s a link .
+
+
+
+);
diff --git a/ui_framework/doc_site/src/views/toast/default.js b/ui_framework/doc_site/src/views/toast/default.js
new file mode 100644
index 0000000000000..a5a8d4ca00464
--- /dev/null
+++ b/ui_framework/doc_site/src/views/toast/default.js
@@ -0,0 +1,38 @@
+import React from 'react';
+
+import {
+ KuiLink,
+ KuiText,
+ KuiToast,
+} from '../../../../components';
+
+export default () => (
+
+
window.alert('Dismiss toast')}
+ >
+
+
+ Here’s some stuff that you need to know. We can make this text really long so that,
+ when viewed within a browser that’s fairly narrow, it will wrap, too.
+
+
+
+
+
+ And some other stuff on another line, just for kicks. And here’s a link .
+
+
+
+
+
+
+
window.alert('Dismiss toast')}
+ />
+
+);
diff --git a/ui_framework/doc_site/src/views/toast/info.js b/ui_framework/doc_site/src/views/toast/info.js
new file mode 100644
index 0000000000000..74f23b49c7c59
--- /dev/null
+++ b/ui_framework/doc_site/src/views/toast/info.js
@@ -0,0 +1,29 @@
+import React from 'react';
+
+import {
+ KuiLink,
+ KuiText,
+ KuiToast,
+} from '../../../../components';
+
+export default () => (
+ window.alert('Dismiss toast')}
+ >
+
+
+ Here’s some stuff that you need to know. We can make this text really long so that,
+ when viewed within a browser that’s fairly narrow, it will wrap, too.
+
+
+
+
+
+ And some other stuff on another line, just for kicks. And here’s a link .
+
+
+
+);
diff --git a/ui_framework/doc_site/src/views/toast/success.js b/ui_framework/doc_site/src/views/toast/success.js
new file mode 100644
index 0000000000000..6ed77c84173fa
--- /dev/null
+++ b/ui_framework/doc_site/src/views/toast/success.js
@@ -0,0 +1,28 @@
+import React from 'react';
+
+import {
+ KuiLink,
+ KuiText,
+ KuiToast,
+} from '../../../../components';
+
+export default () => (
+
+
+
+ Here’s some stuff that you need to know. We can make this text really long so that,
+ when viewed within a browser that’s fairly narrow, it will wrap, too.
+
+
+
+
+
+ And some other stuff on another line, just for kicks. And here’s a link .
+
+
+
+);
diff --git a/ui_framework/doc_site/src/views/toast/toast_example.js b/ui_framework/doc_site/src/views/toast/toast_example.js
new file mode 100644
index 0000000000000..4d6a21ff36d3c
--- /dev/null
+++ b/ui_framework/doc_site/src/views/toast/toast_example.js
@@ -0,0 +1,109 @@
+import React from 'react';
+
+import { renderToHtml } from '../../services';
+
+import {
+ GuideDemo,
+ GuidePage,
+ GuideSection,
+ GuideSectionTypes,
+} from '../../components';
+
+import Default from './default';
+const defaultSource = require('!!raw!./default');
+const defaultHtml = renderToHtml(Default);
+
+import Info from './info';
+const infoSource = require('!!raw!./info');
+const infoHtml = renderToHtml(Info);
+
+import Success from './success';
+const successSource = require('!!raw!./success');
+const successHtml = renderToHtml(Success);
+
+import Warning from './warning';
+const warningSource = require('!!raw!./warning');
+const warningHtml = renderToHtml(Warning);
+
+import Danger from './danger';
+const dangerSource = require('!!raw!./danger');
+const dangerHtml = renderToHtml(Danger);
+
+export default props => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/ui_framework/doc_site/src/views/toast/warning.js b/ui_framework/doc_site/src/views/toast/warning.js
new file mode 100644
index 0000000000000..f0636dd519135
--- /dev/null
+++ b/ui_framework/doc_site/src/views/toast/warning.js
@@ -0,0 +1,28 @@
+import React from 'react';
+
+import {
+ KuiLink,
+ KuiText,
+ KuiToast,
+} from '../../../../components';
+
+export default () => (
+
+
+
+ Here’s some stuff that you need to know. We can make this text really long so that,
+ when viewed within a browser that’s fairly narrow, it will wrap, too.
+
+
+
+
+
+ And some other stuff on another line, just for kicks. And here’s a link .
+
+
+
+);
diff --git a/ui_framework/src/components/button/__snapshots__/button.test.js.snap b/ui_framework/src/components/button/__snapshots__/button.test.js.snap
index fea8a987adef4..e4f3179b82fca 100644
--- a/ui_framework/src/components/button/__snapshots__/button.test.js.snap
+++ b/ui_framework/src/components/button/__snapshots__/button.test.js.snap
@@ -3,7 +3,7 @@
exports[`KuiButton is rendered 1`] = `
+`;
diff --git a/ui_framework/src/components/toast/__snapshots__/global_toast_list_item.test.js.snap b/ui_framework/src/components/toast/__snapshots__/global_toast_list_item.test.js.snap
new file mode 100644
index 0000000000000..86b585850e280
--- /dev/null
+++ b/ui_framework/src/components/toast/__snapshots__/global_toast_list_item.test.js.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`KuiGlobalToastListItem is rendered 1`] = `
+
+ Hi
+
+`;
diff --git a/ui_framework/src/components/toast/__snapshots__/toast.test.js.snap b/ui_framework/src/components/toast/__snapshots__/toast.test.js.snap
new file mode 100644
index 0000000000000..868bd39369fe8
--- /dev/null
+++ b/ui_framework/src/components/toast/__snapshots__/toast.test.js.snap
@@ -0,0 +1,17 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`KuiToast is rendered 1`] = `
+
+
+
+`;
diff --git a/ui_framework/src/components/toast/_global_toast_list.scss b/ui_framework/src/components/toast/_global_toast_list.scss
new file mode 100644
index 0000000000000..214dd9d777e9a
--- /dev/null
+++ b/ui_framework/src/components/toast/_global_toast_list.scss
@@ -0,0 +1,49 @@
+/**
+ * 1. Allow list to expand as items are added, but cap it at the screen height.
+ */
+.kuiGlobalToastList {
+ @include kuiScrollBar;
+
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ position: fixed;
+ z-index: $kuiZToastList;
+ bottom: 0;
+ right: 0;
+ width: 320px;
+ overflow-x: hidden;
+ overflow-y: scroll;
+ max-height: 100vh; /* 1 */
+}
+
+.kuiGlobalToastListItem {
+ margin-bottom: $kuiSizeM;
+ animation: 0.5s kuiShowToast;
+ opacity: 1;
+
+ /**
+ * 1. justify-content: flex-end interferes with overflowing content, so we'll use this to push
+ * items to the bottom instead.
+ */
+ &:first-child {
+ margin-top: auto; /* 1 */
+ }
+
+ &.kuiGlobalToastListItem-isDismissed {
+ transition: opacity $kuiAnimSpeedNormal;
+ opacity: 0;
+ }
+}
+
+@keyframes kuiShowToast {
+ from {
+ transform: translateX(30px);
+ opacity: 0;
+ }
+
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
diff --git a/ui_framework/src/components/toast/_index.scss b/ui_framework/src/components/toast/_index.scss
new file mode 100644
index 0000000000000..7d2197e77410d
--- /dev/null
+++ b/ui_framework/src/components/toast/_index.scss
@@ -0,0 +1,2 @@
+@import 'global_toast_list';
+@import 'toast';
diff --git a/ui_framework/src/components/toast/_toast.scss b/ui_framework/src/components/toast/_toast.scss
new file mode 100644
index 0000000000000..f46211d2516df
--- /dev/null
+++ b/ui_framework/src/components/toast/_toast.scss
@@ -0,0 +1,85 @@
+.kuiToast {
+ @include kuiBottomShadow;
+
+ position: relative;
+ padding: $kuiSizeM;
+ background-color: $kuiColorEmptyShade;
+ border: $kuiBorderThin;
+}
+
+ /**
+ * 1. Fit button to icon.
+ */
+ .kuiToast__closeButton {
+ position: absolute;
+ top: $kuiSizeXS;
+ right: $kuiSizeXS;
+ line-height: 0; /* 1 */
+ appearance: none;
+
+ svg {
+ fill: tintOrShade($kuiTitleColor, 50%, 70%);
+ }
+
+ &:hover {
+ svg {
+ fill: $kuiTitleColor;
+ }
+ }
+
+ &:focus {
+ background-color: $kuiFocusBackgroundColor;
+
+ svg {
+ fill: $kuiColorPrimary;
+ }
+ }
+ }
+
+// Modifier naming and colors.
+$toastTypes: (
+ info: $kuiColorPrimary,
+ success: $kuiColorSecondary,
+ warning: $kuiColorWarning,
+ danger: $kuiColorDanger,
+);
+
+// Create button modifiders based upon the map.
+@each $name, $color in $toastTypes {
+ .kuiToast--#{$name} {
+ border-top: 2px solid $color;
+ }
+}
+
+/**
+ * 1. Align icon with first line of title text if it wraps.
+ * 2. Apply margin to all but last item in the flex.
+ */
+.kuiToastHeader {
+ @include kuiFontSizeM;
+
+ display: flex;
+ align-items: baseline; /* 1 */
+
+ > * + * {
+ margin-left: $kuiSizeS; /* 2 */
+ }
+}
+
+ /**
+ * 1. Vertically center icon with first line of title.
+ */
+ .kuiToastHeader__icon {
+ flex: 0 0 auto;
+ fill: $kuiTitleColor;
+ transform: translateY(2px); /* 1 */
+ }
+
+ .kuiToastHeader__title {
+ color: $kuiTitleColor;
+ font-weight: $kuiFontWeightLight;
+ }
+
+.kuiToastHeader--withBody {
+ margin-bottom: $kuiVerticalRhythmS;
+}
diff --git a/ui_framework/src/components/toast/global_toast_list.js b/ui_framework/src/components/toast/global_toast_list.js
new file mode 100644
index 0000000000000..f44c0dbccb9d0
--- /dev/null
+++ b/ui_framework/src/components/toast/global_toast_list.js
@@ -0,0 +1,107 @@
+import React, {
+ Component,
+} from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export class KuiGlobalToastList extends Component {
+ constructor(props) {
+ super(props);
+
+ this.isScrollingToBottom = false;
+ this.isScrolledToBottom = true;
+ this.onScroll = this.onScroll.bind(this);
+ this.onMouseEnter = this.onMouseEnter.bind(this);
+ this.onMouseLeave = this.onMouseLeave.bind(this);
+ }
+
+ startScrollingToBottom() {
+ this.isScrollingToBottom = true;
+
+ const scrollToBottom = () => {
+ const position = this.listElement.scrollTop;
+ const destination = this.listElement.scrollHeight - this.listElement.clientHeight;
+ const distanceToDestination = destination - position;
+
+ if (distanceToDestination < 5) {
+ this.listElement.scrollTop = destination;
+ this.isScrollingToBottom = false;
+ this.isScrolledToBottom = true;
+ return;
+ }
+
+ this.listElement.scrollTop = position + distanceToDestination * 0.25;
+
+ if (this.isScrollingToBottom) {
+ window.requestAnimationFrame(scrollToBottom);
+ }
+ };
+
+ window.requestAnimationFrame(scrollToBottom);
+ }
+
+ onMouseEnter() {
+ // Stop scrolling to bottom if we're in mid-scroll, because the user wants to interact with
+ // the list.
+ this.isScrollingToBottom = false;
+ this.isUserInteracting = true;
+ }
+
+ onMouseLeave() {
+ this.isUserInteracting = false;
+ }
+
+ onScroll() {
+ this.isScrolledToBottom =
+ this.listElement.scrollHeight - this.listElement.scrollTop === this.listElement.clientHeight;
+ }
+
+ componentDidMount() {
+ this.listElement.addEventListener('scroll', this.onScroll);
+ this.listElement.addEventListener('mouseenter', this.onMouseEnter);
+ this.listElement.addEventListener('mouseleave', this.onMouseLeave);
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!this.isUserInteracting) {
+ // If the user has scrolled up the toast list then we don't want to annoy them by scrolling
+ // all the way back to the bottom.
+ if (this.isScrolledToBottom) {
+ if (prevProps.children.length < this.props.children.length) {
+ this.startScrollingToBottom();
+ }
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ this.listElement.removeEventListener('scroll', this.onScroll);
+ this.listElement.removeEventListener('mouseenter', this.onMouseEnter);
+ this.listElement.removeEventListener('mouseleave', this.onMouseLeave);
+ }
+
+ render() {
+ const {
+ children,
+ className,
+ ...rest,
+ } = this.props;
+
+ const classes = classNames('kuiGlobalToastList', className);
+
+ return (
+ { this.listElement = element; }}
+ className={classes}
+ {...rest}
+ >
+ {children}
+
+ );
+ }
+}
+
+KuiGlobalToastList.propTypes = {
+ children: PropTypes.node,
+ className: PropTypes.string,
+};
diff --git a/ui_framework/src/components/toast/global_toast_list.test.js b/ui_framework/src/components/toast/global_toast_list.test.js
new file mode 100644
index 0000000000000..d0b3823ee7b25
--- /dev/null
+++ b/ui_framework/src/components/toast/global_toast_list.test.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import { render } from 'enzyme';
+import { requiredProps } from '../../test/required_props';
+
+import { KuiGlobalToastList } from './global_toast_list';
+
+describe('KuiGlobalToastList', () => {
+ test('is rendered', () => {
+ const component = render(
+
+ );
+
+ expect(component)
+ .toMatchSnapshot();
+ });
+});
diff --git a/ui_framework/src/components/toast/global_toast_list_item.js b/ui_framework/src/components/toast/global_toast_list_item.js
new file mode 100644
index 0000000000000..d7fed8d583340
--- /dev/null
+++ b/ui_framework/src/components/toast/global_toast_list_item.js
@@ -0,0 +1,20 @@
+import {
+ cloneElement,
+} from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export const KuiGlobalToastListItem = ({ isDismissed, children }) => {
+ const classes = classNames('kuiGlobalToastListItem', children.props.className, {
+ 'kuiGlobalToastListItem-isDismissed': isDismissed,
+ });
+
+ return cloneElement(children, Object.assign({}, children.props, {
+ className: classes,
+ }));
+};
+
+KuiGlobalToastListItem.propTypes = {
+ isDismissed: PropTypes.bool,
+ children: PropTypes.node,
+};
diff --git a/ui_framework/src/components/toast/global_toast_list_item.test.js b/ui_framework/src/components/toast/global_toast_list_item.test.js
new file mode 100644
index 0000000000000..511593af75d2b
--- /dev/null
+++ b/ui_framework/src/components/toast/global_toast_list_item.test.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import { render } from 'enzyme';
+
+import { KuiGlobalToastListItem } from './global_toast_list_item';
+
+describe('KuiGlobalToastListItem', () => {
+ test('is rendered', () => {
+ const component = render(
+
+ Hi
+
+ );
+
+ expect(component)
+ .toMatchSnapshot();
+ });
+});
diff --git a/ui_framework/src/components/toast/index.js b/ui_framework/src/components/toast/index.js
new file mode 100644
index 0000000000000..f4c005184a1c6
--- /dev/null
+++ b/ui_framework/src/components/toast/index.js
@@ -0,0 +1,11 @@
+export {
+ KuiToast,
+} from './toast';
+
+export {
+ KuiGlobalToastList,
+} from './global_toast_list';
+
+export {
+ KuiGlobalToastListItem,
+} from './global_toast_list_item';
diff --git a/ui_framework/src/components/toast/toast.js b/ui_framework/src/components/toast/toast.js
new file mode 100644
index 0000000000000..0d07d5e9f4623
--- /dev/null
+++ b/ui_framework/src/components/toast/toast.js
@@ -0,0 +1,82 @@
+import React, {
+ PropTypes,
+} from 'react';
+import classNames from 'classnames';
+
+import {
+ ICON_TYPES,
+ KuiIcon,
+} from '../icon';
+
+const typeToClassNameMap = {
+ info: 'kuiToast--info',
+ success: 'kuiToast--success',
+ warning: 'kuiToast--warning',
+ danger: 'kuiToast--danger',
+};
+
+export const TYPES = Object.keys(typeToClassNameMap);
+
+export const KuiToast = ({ title, type, iconType, onClose, children, className, ...rest }) => {
+ const classes = classNames('kuiToast', typeToClassNameMap[type], className);
+ const headerClasses = classNames('kuiToastHeader', {
+ 'kuiToastHeader--withBody': children,
+ });
+
+ let headerIcon;
+
+ if (iconType) {
+ headerIcon = (
+
+ );
+ }
+
+ let closeButton;
+
+ if (onClose) {
+ closeButton = (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {headerIcon}
+
+
+ {title}
+
+
+
+
+ {closeButton}
+ {children}
+
+ );
+};
+
+KuiToast.propTypes = {
+ title: PropTypes.node,
+ iconType: PropTypes.oneOf(ICON_TYPES),
+ type: PropTypes.oneOf(TYPES),
+ onClose: PropTypes.func,
+};
diff --git a/ui_framework/src/components/toast/toast.test.js b/ui_framework/src/components/toast/toast.test.js
new file mode 100644
index 0000000000000..46b22059b1fd8
--- /dev/null
+++ b/ui_framework/src/components/toast/toast.test.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import { render } from 'enzyme';
+import { requiredProps } from '../../test/required_props';
+
+import { KuiToast } from './toast';
+
+describe('KuiToast', () => {
+ test('is rendered', () => {
+ const component = render(
+
+ );
+
+ expect(component)
+ .toMatchSnapshot();
+ });
+});
diff --git a/ui_framework/src/global_styling/mixins/_helpers.scss b/ui_framework/src/global_styling/mixins/_helpers.scss
index 8a64b8b3931f7..cf30b219b8be8 100644
--- a/ui_framework/src/global_styling/mixins/_helpers.scss
+++ b/ui_framework/src/global_styling/mixins/_helpers.scss
@@ -24,3 +24,22 @@
}
}
+/**
+ * Set scroll bar appearance on Chrome.
+ */
+@mixin kuiScrollBar {
+ &::-webkit-scrollbar {
+ width: 16px;
+ height: 16px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background-color: rgba($kuiColorDarkShade, 0.5);
+ border: 6px solid transparent;
+ background-clip: content-box;
+ }
+
+ &::-webkit-scrollbar-track {
+ background-color: transparent;
+ }
+}
diff --git a/ui_framework/src/global_styling/variables/_animations.scss b/ui_framework/src/global_styling/variables/_animations.scss
index be2292876146d..a0c6e1803dce8 100644
--- a/ui_framework/src/global_styling/variables/_animations.scss
+++ b/ui_framework/src/global_styling/variables/_animations.scss
@@ -9,7 +9,6 @@ $kuiAnimSpeedNormal: 250ms;
$kuiAnimSpeedSlow: 350ms;
$kuiAnimSpeedExtra: 500ms;
-
@keyframes kuiAnimFadeIn {
0% {
opacity: 0;
diff --git a/ui_framework/src/global_styling/variables/_z_index.scss b/ui_framework/src/global_styling/variables/_z_index.scss
index ecbc0867996ec..c1c0cfb88fae7 100644
--- a/ui_framework/src/global_styling/variables/_z_index.scss
+++ b/ui_framework/src/global_styling/variables/_z_index.scss
@@ -14,5 +14,6 @@ $kuiZLevel9: 9000;
$kuiZContent: $kuiZLevel0;
$kuiZContentMenu: $kuiZLevel2;
$kuiZNavigation: $kuiZLevel4;
+$kuiZToastList: $kuiZLevel5;
$kuiZMask: $kuiZLevel6;
$kuiZModal: $kuiZLevel8;