Use dismissible information callouts
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user