Created
June 26, 2021 18:13
-
-
Save ephys/a73b09874321ee9c9f36dfb13b7ee780 to your computer and use it in GitHub Desktop.
react-overlay-onboarding
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.holeyOverlay { | |
box-sizing: border-box; | |
position: absolute; | |
z-index: 9999998; | |
border-radius: 4px; | |
box-shadow: rgb(33 33 33 / 0.8) 0 0 1px 2px, rgb(33 33 33 / 0.5) 0 0 0 5000px; | |
background: transparent; | |
transition: top 0.3s linear; | |
transition-property: border-radius, top, left, width, height; | |
transition-duration: 0.2s; | |
transition-timing-function: linear; | |
} | |
.overlay { | |
width: 100%; | |
height: 100%; | |
position: absolute; | |
top: 0; | |
left: 0; | |
&.noElement { | |
display: flex; | |
background: rgb(33 33 33 / 0.5); | |
padding: 8px; | |
& > * { | |
position: relative; | |
margin: auto; | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import classnames from 'classnames'; | |
import { | |
CSSProperties, | |
ReactElement, | |
ReactNode, | |
useCallback, | |
useEffect, | |
useState, | |
} from 'react'; | |
import { createPortal } from 'react-dom'; | |
import { EMPTY_OBJECT, onEvent, useForceRefresh } from './utils'; | |
import css from './overlay-onboarding.module.scss'; | |
export type TOnboardingProps = { | |
defaultOverlayPadding?: number, | |
steps: TOnboardingStep[], | |
container?: HTMLElement, | |
popupComponent: (props: TOnboardingPopupProps) => ReactElement, | |
localization: TLocalization, | |
onRequestClose: () => any, | |
}; | |
export type TOnboardingPopupProps = { | |
localization: TLocalization, | |
element: HTMLElement, | |
onRequestClose: () => any, | |
onNext: (() => any) | null, | |
onPrevious: (() => any) | null, | |
step: TOnboardingStep, | |
style: CSSProperties, | |
}; | |
export type TLocalization = { | |
next: ReactNode, | |
previous: ReactNode, | |
close: ReactNode, | |
}; | |
export type TOnboardingStep = { | |
element?: string | HTMLElement, | |
title: ReactNode, | |
body?: ReactNode, | |
overlayPadding?: number, | |
}; | |
export function Onboarding(props: TOnboardingProps) { | |
if (typeof document === 'undefined') { | |
return null; | |
} | |
/* eslint-disable react-hooks/rules-of-hooks */ | |
const { | |
container = document.body, | |
defaultOverlayPadding, | |
steps, | |
popupComponent: Popup, | |
localization, | |
onRequestClose, | |
} = props; | |
const [currentStepKey, setCurrentStepKey] = useState(0); | |
const step = steps[currentStepKey]; | |
const element = getElement(container, step.element); | |
const hasNext = currentStepKey < steps.length - 1; | |
const hasPrevious = currentStepKey > 0; | |
const onNext = useCallback(() => { | |
setCurrentStepKey(old => old + 1); | |
}, []); | |
const onPrevious = useCallback(() => { | |
setCurrentStepKey(old => old - 1); | |
}, []); | |
useEffect(() => { | |
if (!element) { | |
return; | |
} | |
element.scrollIntoView(); | |
}, [element]); | |
const popupStyle: CSSProperties = {}; | |
if (element) { | |
const bb = element.getBoundingClientRect(); | |
const windowWidth = window.innerWidth; | |
const windowHeight = window.innerHeight; | |
const margin = 8; | |
const minimumWidth = 320 - (margin * 2); | |
const minimumHeight = 320 - (margin * 2); | |
const availableSpace = { | |
left: bb.left - margin, | |
top: bb.top - margin, | |
right: windowWidth - bb.left - bb.width - margin, | |
bottom: windowHeight - bb.top - bb.height - margin, | |
}; | |
let bestSideIsHorizontal; | |
// Primary alignement | |
if (availableSpace.left >= minimumWidth) { | |
// best side is left | |
popupStyle.right = windowWidth - bb.left + margin; | |
popupStyle.maxWidth = availableSpace.left - margin; | |
bestSideIsHorizontal = true; | |
} else if (availableSpace.right >= minimumWidth) { | |
// best side is right | |
popupStyle.left = bb.left + bb.width + margin; | |
popupStyle.maxWidth = availableSpace.right - margin; | |
bestSideIsHorizontal = true; | |
} else if (availableSpace.top >= minimumHeight) { | |
// best side is left | |
popupStyle.bottom = windowHeight - bb.top + margin; | |
popupStyle.maxHeight = availableSpace.top - margin; | |
bestSideIsHorizontal = false; | |
} else { | |
// best side is bottom | |
popupStyle.top = bb.top + bb.height + margin; | |
popupStyle.maxHeight = availableSpace.bottom - margin; | |
bestSideIsHorizontal = false; | |
} | |
// Secondary alignement | |
if (bestSideIsHorizontal) { | |
// popup has been placed to the left or to the right of the item | |
// now we set its vertical alignement | |
if (availableSpace.bottom > availableSpace.top) { | |
// TODO we align the popup to the top of the target element, overflowing to the bottom | |
// if overflowing to the bottom means we go below {minimumHeight}, overflow to the top too | |
// + margin of {margin} on either vertical sides | |
popupStyle.top = bb.top; | |
} else { | |
// TODO we align the popup to the bottom of the target element, overflowing to the top | |
// if overflowing to the top means we go below {minimumHeight}, overflow to the bottom too | |
// + margin of {margin} on either vertical sides | |
popupStyle.bottom = windowHeight - bb.bottom; | |
} | |
} else { | |
// popup has been placed to the left or to the right of the item | |
// now we set its horizontal alignement | |
// eslint-disable-next-line no-lonely-if | |
if (availableSpace.right > availableSpace.left) { | |
// TODO we align the popup to the left of the target element, overflowing to the right | |
// if overflowing to the right means we go below {minimumWidth}, overflow to the left too | |
// + margin of {margin} on either horizontal sides | |
popupStyle.left = bb.left; | |
} else { | |
// we align the popup to the right of the target element, overflowing to the left | |
// if overflowing to the left means we go below {minimumWidth}, overflow to the right too | |
// + margin of {margin} on either horizontal sides | |
let maxWidth = bb.right; | |
if (maxWidth < minimumWidth) { | |
maxWidth = minimumWidth; | |
} | |
popupStyle.marginLeft = margin; | |
popupStyle.marginRight = margin; | |
popupStyle.maxWidth = maxWidth; | |
popupStyle.right = windowWidth - maxWidth - margin * 2; | |
} | |
} | |
} | |
return createPortal(( | |
<> | |
{element && <HoleyOverlay element={element} defaultOverlayPadding={defaultOverlayPadding} />} | |
<div className={classnames(css.overlay, element == null && css.noElement)}> | |
<Popup | |
element={element} | |
onRequestClose={onRequestClose} | |
onNext={hasNext ? onNext : null} | |
onPrevious={hasPrevious ? onPrevious : null} | |
localization={localization} | |
step={step} | |
style={element ? popupStyle : EMPTY_OBJECT} | |
/> | |
</div> | |
</> | |
), container); | |
} | |
type THoleyOverlayProps = { | |
element: HTMLElement, | |
defaultOverlayPadding?: number, | |
}; | |
function HoleyOverlay(props: THoleyOverlayProps) { | |
const { element, defaultOverlayPadding = 0 } = props; | |
const forceRefresh = useForceRefresh(); | |
useEffect(() => { | |
return onEvent(window, 'resize', () => { | |
forceRefresh(); | |
}); | |
}); | |
const bb = element.getBoundingClientRect(); | |
const padding = element.dataset.overlayPadding ? Number(element.dataset.overlayPadding) | |
: defaultOverlayPadding; | |
const style = getComputedStyle(element); | |
return ( | |
<div | |
className={css.holeyOverlay} | |
style={{ | |
width: `${bb.width + padding * 2}px`, | |
height: `${bb.height + padding * 2}px`, | |
top: `${bb.top - padding}px`, | |
left: `${bb.left - padding}px`, | |
borderTopRightRadius: style.borderTopRightRadius, | |
borderTopLeftRadius: style.borderTopLeftRadius, | |
borderBottomRightRadius: style.borderBottomRightRadius, | |
borderBottomLeftRadius: style.borderBottomLeftRadius, | |
}} | |
/> | |
); | |
} | |
function getElement(container: HTMLElement, element: string | HTMLElement | null): HTMLElement | null { | |
if (typeof element === 'string') { | |
return container.querySelector(element); | |
} | |
return element ?? null; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export function useForceRefresh() { | |
const setVal = useState(0)[1]; | |
return useCallback(() => { | |
setVal(old => old + 1); | |
}, [setVal]); | |
} | |
export function onEvent( | |
target: EventTarget, | |
eventName: string, | |
callback: EventListener, | |
options?: AddEventListenerOptions, | |
): () => void { | |
target.addEventListener(eventName, callback, options); | |
return () => { | |
target.removeEventListener(eventName, callback, options); | |
}; | |
} | |
export const EMPTY_OBJECT = Object.freeze({}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Rationale
I tried using intro.js for this but it was a no-go for multiple reasons:
This solution aims to solve all of that, and uses a complete custom solution: