From bebad80a24ef4410e070298bb59b45d59345d065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 27 Mar 2020 14:39:11 +0100 Subject: [PATCH] Use dismissible information callouts --- .../FeatureCalloutCircular.tsx | 197 ++++++++++++++++++ .../FeatureDiscovery/lib/usePortal.ts | 96 +++++++++ .../FeatureDiscovery/lib/useShowCallout.ts | 58 ++++++ packages/core/src/index.ts | 1 + 4 files changed, 352 insertions(+) create mode 100644 packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx create mode 100644 packages/core/src/components/FeatureDiscovery/lib/usePortal.ts create mode 100644 packages/core/src/components/FeatureDiscovery/lib/useShowCallout.ts diff --git a/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx b/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx new file mode 100644 index 0000000000..a3b40d5b88 --- /dev/null +++ b/packages/core/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx @@ -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 = ({ + featureId, + title, + description, + children, +}) => { + const { show, hide } = useShowCallout(featureId); + const portalElement = usePortal('core.callout'); + const wrapperRef = useRef(null); + const [placement, setPlacement] = useState(); + 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 ( + <> +
+ {children} +
+ {createPortal( +
+ + <> +
+
+
+
+ + {title} + + {description} +
+ + +
, + portalElement, + )} + + ); +}; diff --git a/packages/core/src/components/FeatureDiscovery/lib/usePortal.ts b/packages/core/src/components/FeatureDiscovery/lib/usePortal.ts new file mode 100644 index 0000000000..6031a25f1c --- /dev/null +++ b/packages/core/src/components/FeatureDiscovery/lib/usePortal.ts @@ -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(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; diff --git a/packages/core/src/components/FeatureDiscovery/lib/useShowCallout.ts b/packages/core/src/components/FeatureDiscovery/lib/useShowCallout.ts new file mode 100644 index 0000000000..047473a7e2 --- /dev/null +++ b/packages/core/src/components/FeatureDiscovery/lib/useShowCallout.ts @@ -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; + setState: (key: string, value: boolean) => void; +} { + const [states, setStates] = useState>(() => { + 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 }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5dd7564bca..ad9dde477f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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';