Use dismissible information callouts

This commit is contained in:
Fredrik Adelöw
2020-03-27 14:39:11 +01:00
parent 3d49731dc2
commit bebad80a24
4 changed files with 352 additions and 0 deletions
@@ -0,0 +1,197 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ClickAwayListener, makeStyles, Typography } from '@material-ui/core';
import React, {
FC,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { usePortal } from './lib/usePortal';
import { useShowCallout } from './lib/useShowCallout';
const useStyles = makeStyles({
'@keyframes pulsateSlightly': {
'0%': { transform: 'scale(1.0)' },
'100%': { transform: 'scale(1.1)' },
},
'@keyframes pulsateAndFade': {
'0%': { transform: 'scale(1.0)', opacity: 0.9 },
'100%': { transform: 'scale(1.5)', opacity: 0 },
},
featureWrapper: {
position: 'relative',
},
backdrop: {
zIndex: 2000,
position: 'fixed',
overflow: 'hidden',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
dot: {
position: 'absolute',
backgroundColor: 'transparent',
borderRadius: '100%',
border: '1px solid rgba(103, 146, 180, 0.98)',
boxShadow: '0px 0px 0px 20000px rgba(0, 0, 0, 0.5)',
zIndex: 2001,
transformOrigin: 'center center',
animation:
'$pulsateSlightly 1744ms 1.2s cubic-bezier(0.4, 0, 0.2, 1) alternate infinite',
},
pulseCircle: {
width: '100%',
height: '100%',
backgroundColor: 'transparent',
borderRadius: '100%',
border: '2px solid white',
zIndex: 2001,
transformOrigin: 'center center',
animation:
'$pulsateAndFade 872ms 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite',
},
text: {
position: 'absolute',
color: 'white',
zIndex: 2003,
},
});
export type Props = {
featureId: string;
title: string;
description: string;
};
type Placement = {
dotLeft: number;
dotTop: number;
dotSize: number;
borderWidth: number;
textLeft: number;
textTop: number;
textWidth: number;
};
export const FeatureCalloutCircular: FC<Props> = ({
featureId,
title,
description,
children,
}) => {
const { show, hide } = useShowCallout(featureId);
const portalElement = usePortal('core.callout');
const wrapperRef = useRef<HTMLDivElement>(null);
const [placement, setPlacement] = useState<Placement | undefined>();
const classes = useStyles();
const update = useCallback(() => {
if (wrapperRef.current) {
const wrapperBounds = wrapperRef.current.getBoundingClientRect();
const longest = Math.max(wrapperBounds.width, wrapperBounds.height);
const borderWidth = 800;
const dotLeft =
wrapperBounds.x - (longest - wrapperBounds.width) / 2 - borderWidth;
const dotTop =
wrapperBounds.y - (longest - wrapperBounds.height) / 2 - borderWidth;
const dotSize = longest + 2 * borderWidth;
const textWidth = 450;
const textLeft = wrapperBounds.x + wrapperBounds.width / 2 - textWidth;
const textTop =
wrapperBounds.y - (longest - wrapperBounds.height) / 2 + longest + 20;
setPlacement({
dotLeft,
dotTop,
dotSize,
borderWidth,
textTop,
textLeft,
textWidth,
});
}
}, []);
useEffect(() => {
window.addEventListener('resize', update);
window.addEventListener('scroll', update);
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update);
};
}, []);
useLayoutEffect(update, [wrapperRef.current]);
if (!show) {
return <>{children}</>;
}
return (
<>
<div className={classes.featureWrapper} ref={wrapperRef}>
{children}
</div>
{createPortal(
<div className={classes.backdrop}>
<ClickAwayListener onClickAway={hide}>
<>
<div
className={classes.dot}
style={{
left: placement?.dotLeft,
top: placement?.dotTop,
width: placement?.dotSize,
height: placement?.dotSize,
borderWidth: placement?.borderWidth,
}}
onClick={hide}
onKeyDown={hide}
role="button"
tabIndex={0}
>
<div className={classes.pulseCircle} />
</div>
<div
className={classes.text}
style={{
left: placement?.textLeft,
top: placement?.textTop,
width: placement?.textWidth,
}}
>
<Typography variant="h2" paragraph>
{title}
</Typography>
<Typography>{description}</Typography>
</div>
</>
</ClickAwayListener>
</div>,
portalElement,
)}
</>
);
};
@@ -0,0 +1,96 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useRef, useEffect } from 'react';
/**
* Creates DOM element to be used as React root.
*/
function createRootElement(id: string): Element {
const rootContainer = document.createElement('div');
rootContainer.setAttribute('id', id);
return rootContainer;
}
/**
* Appends element as last child of body.
*/
function addRootElement(rootElem: Element): void {
document.body.insertBefore(
rootElem,
document.body.lastElementChild!.nextElementSibling,
);
}
/**
* Hook to create a React Portal.
*
* Automatically handles creating and tearing-down the root elements (no SRR
* makes this trivial), so there is no need to ensure the parent target already
* exists.
*
* @example
* const target = usePortal(id, [id]);
* return createPortal(children, target);
*
* @param id The id of the target container, e.g 'modal' or 'spotlight'
* @returns The DOM node to use as the Portal target.
*/
export function usePortal(id: string): HTMLElement {
const rootElemRef = useRef<HTMLElement | null>(null);
useEffect(function setupElement() {
// Look for existing target dom element to append to
const existingParent = document.querySelector(`#${id}`);
// Parent is either a new root or the existing dom element
const parentElem = existingParent || createRootElement(id);
// If there is no existing DOM element, add a new one.
if (!existingParent) {
addRootElement(parentElem);
}
// Add the detached element to the parent
parentElem.appendChild(rootElemRef.current!);
return function removeElement() {
rootElemRef.current!.remove();
if (parentElem.childNodes.length === -1) {
parentElem.remove();
}
};
}, []);
/**
* It's important we evaluate this lazily:
* - We need first render to contain the DOM element, so it shouldn't happen
* in useEffect. We would normally put this in the constructor().
* - We can't do 'const rootElemRef = useRef(document.createElement('div))',
* since this will run every single render (that's a lot).
* - We want the ref to consistently point to the same DOM element and only
* ever run once.
* @link https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily
*/
function getRootElem() {
if (!rootElemRef.current) {
rootElemRef.current = document.createElement('div');
}
return rootElemRef.current;
}
return getRootElem();
}
export default usePortal;
@@ -0,0 +1,58 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useState } from 'react';
const STATES_LOCAL_STORAGE_KEY = 'core.calloutSeen';
function useCalloutStates(): {
states: Record<string, boolean>;
setState: (key: string, value: boolean) => void;
} {
const [states, setStates] = useState<Record<string, boolean>>(() => {
const raw = localStorage.getItem(STATES_LOCAL_STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
});
const setState = useCallback((key: string, value: boolean) => {
const raw = localStorage.getItem(STATES_LOCAL_STORAGE_KEY);
const oldStates = raw ? JSON.parse(raw) : {};
const newStates = { ...oldStates, [key]: value };
setStates(newStates);
localStorage.setItem(STATES_LOCAL_STORAGE_KEY, JSON.stringify(newStates));
}, []);
return { states, setState };
}
function useCalloutHasBeenSeen(
featureId: string,
): { seen: boolean | undefined; markSeen: () => void } {
const { states, setState } = useCalloutStates();
const markSeen = useCallback(() => {
setState(featureId, true);
}, [featureId]);
return { seen: states[featureId] === true, markSeen };
}
export function useShowCallout(
featureId: string,
): { show: boolean; hide: () => void } {
const { seen, markSeen } = useCalloutHasBeenSeen(featureId);
return { show: seen === false, hide: markSeen };
}
+1
View File
@@ -32,3 +32,4 @@ export { default as CircleProgress } from './components/CircleProgress';
export { default as Progress } from './components/Progress';
export { default as SupportButton } from './components/SupportButton';
export { default as SortableTable } from './components/SortableTable';
export { FeatureCalloutCircular } from './components/FeatureDiscovery/FeatureCalloutCircular';