Skip to content

Instantly share code, notes, and snippets.

@ephys
Created June 26, 2021 18:13
Show Gist options
  • Save ephys/a73b09874321ee9c9f36dfb13b7ee780 to your computer and use it in GitHub Desktop.
Save ephys/a73b09874321ee9c9f36dfb13b7ee780 to your computer and use it in GitHub Desktop.
react-overlay-onboarding
.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;
}
}
}
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;
}
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({});
@ephys
Copy link
Author

ephys commented Jun 26, 2021

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:

Screen Shot 2021-06-26 at 20 20 39

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment