Skip to content

Commit

Permalink
Merge pull request #142 from kodiak-packages/toaster-component
Browse files Browse the repository at this point in the history
Create toaster component
  • Loading branch information
bramvanhoutte authored Nov 18, 2020
2 parents ed10966 + 078b03c commit 7787105
Show file tree
Hide file tree
Showing 16 changed files with 937 additions and 46 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@types/react": "^16.9.46",
"@types/react-dom": "^16.9.8",
"@types/react-portal": "^4.0.2",
"@types/react-transition-group": "^4.4.0",
"@typescript-eslint/eslint-plugin": "^3.9",
"cpx": "1.5.0",
"docz": "^2.3.1",
Expand Down Expand Up @@ -64,7 +65,8 @@
"classnames": "^2.2.6",
"react-feather": "^2.0.8",
"react-popper": "^2.2.3",
"react-portal": "^4.2.1"
"react-portal": "^4.2.1",
"react-transition-group": "^4.4.1"
},
"keywords": [
"kodiak",
Expand Down
43 changes: 40 additions & 3 deletions src/components/Alert/Alert.module.css
Original file line number Diff line number Diff line change
@@ -1,18 +1,55 @@
.alert {
padding: 14.5px 18px;
background-color: var(--color-error-light);
display: flex;
align-items: center;
justify-content: space-between;
border-radius: var(--border-radius-small);
}

.alert.alertError {
background-color: var(--color-error-light);
}

.alert.alertSuccess {
background-color: var(--color-success-light);
}

.alert .contentContainer {
padding: 14.5px 18px;
display: flex;
align-items: center;
}

.alert .contentContainer.closable {
padding-right: 18px;
}

.alert .closeContainer {
padding: 13.5px 17px 13.5px 13.5px;
display: flex;
align-items: center;
}

.alert .closeAlert {
color: var(--color-neutral-5);
cursor: pointer;
height: 18px;
width: 18px;
}

.message {
display: inline-block;
}

svg.icon {
margin-right: 10px;
color: var(--color-error);
height: 16px;
width: 16px;
}

svg.icon.iconSuccess {
color: var(--color-success);
}

svg.icon.iconError {
color: var(--color-error);
}
2 changes: 1 addition & 1 deletion src/components/Alert/Alert.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('Input', () => {
test('className prop', () => {
const className = 'center';
const { getByText } = render(<Alert {...defaultProps} className={className} />);
const alertElement = getByText(/Something went wrong./).parentElement!;
const alertElement = getByText(/Something went wrong./).parentElement?.parentElement!;

const renderedClassNames = alertElement.className.split(' ');
expect(renderedClassNames).toContain(className);
Expand Down
39 changes: 33 additions & 6 deletions src/components/Alert/Alert.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,50 @@
import React from 'react';
import { AlertCircle } from 'react-feather';
import { AlertCircle, CheckCircle, X } from 'react-feather';
import classNames from 'classnames';

import cssReset from '../../css-reset.module.css';
import styles from './Alert.module.css';

export type AlertIntent = 'error' | 'success';

interface Props {
intent?: 'error';
intent?: AlertIntent;
message: string;
className?: string;
onClose?: () => void;
}

const Alert: React.FC<Props> = ({ intent = 'error', message, className }: Props) => {
const mergedClassNames = classNames(cssReset.ventura, styles.alert, className);
const Alert: React.FC<Props> = ({ intent = 'error', message, className, onClose }: Props) => {
const mergedClassNames = classNames(
cssReset.ventura,
styles.alert,
{
[styles.alertError]: intent === 'error',
[styles.alertSuccess]: intent === 'success',
},
className,
);

const contentContainer = classNames(styles.contentContainer, {
[styles.closable]: Boolean(onClose),
});

return (
<div className={mergedClassNames}>
{intent === 'error' && <AlertCircle className={styles.icon} />}
<span className={styles.message}>{message}</span>
<div className={contentContainer}>
{intent === 'error' && (
<AlertCircle className={classNames(styles.icon, styles.iconError)} />
)}
{intent === 'success' && (
<CheckCircle className={classNames(styles.icon, styles.iconSuccess)} />
)}
<span className={styles.message}>{message}</span>
</div>
{onClose && (
<div className={styles.closeContainer}>
<X onClick={onClose} className={styles.closeAlert} />
</div>
)}
</div>
);
};
Expand Down
74 changes: 39 additions & 35 deletions src/components/Alert/__snapshots__/Alert.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,47 @@
exports[`Input default snapshot 1`] = `
<DocumentFragment>
<div
class="ventura alert"
class="ventura alert alertError"
>
<svg
class="icon"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
<div
class="contentContainer"
>
<circle
cx="12"
cy="12"
r="10"
/>
<line
x1="12"
x2="12"
y1="8"
y2="12"
/>
<line
x1="12"
x2="12.01"
y1="16"
y2="16"
/>
</svg>
<span
class="message"
>
Something went wrong.
</span>
<svg
class="icon iconError"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
/>
<line
x1="12"
x2="12"
y1="8"
y2="12"
/>
<line
x1="12"
x2="12.01"
y1="16"
y2="16"
/>
</svg>
<span
class="message"
>
Something went wrong.
</span>
</div>
</div>
</DocumentFragment>
`;
44 changes: 44 additions & 0 deletions src/components/Toaster/Toast/Toast.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
.toastContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 0;
transition: all 240ms cubic-bezier(0.0, 0.0, 0.2, 1);
pointer-events: all;
}

.toastContainer[data-state="entering"], .toastContainer[data-state="entered"] {
animation: openToast 240ms cubic-bezier(0.175, 0.885, 0.320, 1.175) both;
}

.toastContainer[data-state="exiting"] {
animation: closeToast 120ms cubic-bezier(0.4, 0.0, 1, 1) both;
}

.toastPadding {
padding: 0 0 8px 0;
}

@keyframes openToast {
from {
opacity: 0;
transform: translateY(-120%);
}

to {
transform: translateY(0%);
}
}

@keyframes closeToast {
from {
transform: scale(1);
opacity: 1;
}

to {
transform: scale(0.9);
opacity: 0;
}
}
105 changes: 105 additions & 0 deletions src/components/Toaster/Toast/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React, { ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Transition } from 'react-transition-group';

import Alert, { AlertIntent } from '../../Alert/Alert';

import styles from './Toast.module.css';

interface Props extends Pick<ComponentProps<typeof Alert>, 'intent' | 'onClose' | 'message'> {
durationInSeconds: number;
isShown: boolean;
intent: AlertIntent;
isClosable: boolean;
}

const Toast: React.FC<Props> = ({
durationInSeconds,
onClose,
isShown: isShownProp,
intent,
message,
isClosable,
}: Props) => {
const [isShown, setIsShown] = useState(true);
const [height, setHeight] = useState(0);
const closeTimer = useRef<number | null>(null);

const clearCloseTimer = useCallback(() => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}, [closeTimer]);

const close = useCallback(() => {
clearCloseTimer();
setIsShown(false);
}, [clearCloseTimer]);

const startCloseTimer = useCallback(() => {
if (durationInSeconds) {
clearCloseTimer();
closeTimer.current = window.setTimeout(() => {
close();
}, durationInSeconds * 1000);
}
}, [durationInSeconds, clearCloseTimer, closeTimer, close]);

useEffect(() => {
startCloseTimer();

return () => {
clearCloseTimer();
};
}, [clearCloseTimer, startCloseTimer]);

useEffect(() => {
if (isShownProp !== isShown && typeof isShownProp === 'boolean') {
setIsShown(isShownProp);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isShownProp]);

const handleMouseEnter = useCallback(() => clearCloseTimer(), [clearCloseTimer]);
const handleMouseLeave = useCallback(() => startCloseTimer(), [startCloseTimer]);

const onRef = useCallback(
(ref) => {
if (ref === null) return;

const { height: toastHeight } = ref.getBoundingClientRect();
setHeight(toastHeight);
},
[setHeight],
);

const dynamicStyles = useMemo(
() => ({
height,
marginBottom: isShown ? 0 : -height,
}),
[isShown, height],
);

return (
<Transition appear unmountOnExit timeout={240} in={isShown} onExited={onClose}>
{(state) => {
return (
<div
data-state={state}
className={styles.toastContainer}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={dynamicStyles}
>
<div ref={onRef} className={styles.toastPadding}>
<Alert intent={intent} message={message} onClose={isClosable ? close : undefined} />
</div>
</div>
);
}}
</Transition>
);
};

export default Toast;
10 changes: 10 additions & 0 deletions src/components/Toaster/ToastManager/ToastManager.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.toastManagerContainer {
max-width: 560px;
margin: 0 auto;
bottom: 0;
left: 0;
right: 0;
position: fixed;
z-index: 110;
pointer-events: none;
}
Loading

0 comments on commit 7787105

Please sign in to comment.