Implement the modal style popover

This commit is contained in:
Kamran Ahmed
2023-06-11 13:19:11 +01:00
parent edd7dca2df
commit 9bde4cc7e4
9 changed files with 124 additions and 68 deletions
+1 -1
View File
@@ -292,7 +292,7 @@ npm install driver.js</pre
}, 2000);
window.setTimeout(() => {
driverObj.highlight({ element: ".buttons button:nth-child(5)" });
driverObj.highlight({});
}, 4000);
window.setTimeout(() => {
+2
View File
@@ -5,6 +5,7 @@ export type Config = {
opacity?: number;
stagePadding?: number;
stageRadius?: number;
popoverOffset?: number;
};
let currentConfig: Config = {};
@@ -17,6 +18,7 @@ export function configure(config: Config = {}) {
smoothScroll: false,
stagePadding: 10,
stageRadius: 5,
popoverOffset: 10,
...config,
};
}
+7 -14
View File
@@ -6,14 +6,13 @@ import { destroyHighlight, highlight } from "./highlight";
import { destroyEmitter, listen } from "./emitter";
import "./style.css";
import { getState, setState } from "./state";
export type DriveStep = {
element?: string | Element;
popover?: Popover;
};
let isInitialized = false;
export function driver(options: Config = {}) {
configure(options);
@@ -26,15 +25,12 @@ export function driver(options: Config = {}) {
}
function init() {
if (isInitialized) {
if (getState("isInitialized")) {
return;
}
isInitialized = true;
document.body.classList.add(
"driver-active",
getConfig("animate") ? "driver-fade" : "driver-simple"
);
setState("isInitialized", true);
document.body.classList.add("driver-active", getConfig("animate") ? "driver-fade" : "driver-simple");
initEvents();
@@ -44,11 +40,9 @@ export function driver(options: Config = {}) {
}
function destroy() {
isInitialized = false;
document.body.classList.remove(
"driver-active",
getConfig("animate") ? "driver-fade" : "driver-simple"
);
setState("isInitialized", false);
document.body.classList.remove("driver-active", "driver-fade", "driver-simple");
destroyEvents();
destroyPopover();
@@ -57,7 +51,6 @@ export function driver(options: Config = {}) {
destroyEmitter();
}
// @todo make popover selectable
return {
drive: (steps: DriveStep[]) => console.log(steps),
highlight: (step: DriveStep) => {
+4 -7
View File
@@ -1,14 +1,14 @@
import { refreshActiveHighlight } from "./highlight";
import { emit } from "./emitter";
let resizeTimeout: number;
import { getState, setState } from "./state";
function requireRefresh() {
const resizeTimeout = getState("resizeTimeout");
if (resizeTimeout) {
window.cancelAnimationFrame(resizeTimeout);
}
resizeTimeout = window.requestAnimationFrame(refreshActiveHighlight);
setState("resizeTimeout", window.requestAnimationFrame(refreshActiveHighlight));
}
function onKeyup(e: KeyboardEvent) {
@@ -32,10 +32,7 @@ export function onDriverClick(
listener: (pointer: MouseEvent | PointerEvent) => void,
shouldPreventDefault?: (target: HTMLElement) => boolean
) {
const listenerWrapper = (
e: MouseEvent | PointerEvent,
listener?: (pointer: MouseEvent | PointerEvent) => void
) => {
const listenerWrapper = (e: MouseEvent | PointerEvent, listener?: (pointer: MouseEvent | PointerEvent) => void) => {
const target = e.target as HTMLElement;
if (!element.contains(target)) {
return;
+21 -15
View File
@@ -3,10 +3,7 @@ import { refreshStage, trackActiveElement, transitionStage } from "./stage";
import { getConfig } from "./config";
import { repositionPopover, renderPopover, hidePopover } from "./popover";
import { bringInView } from "./utils";
let previousHighlight: Element | undefined;
let activeHighlight: Element | undefined;
let currentTransitionCallback: undefined | (() => void);
import { getState, setState } from "./state";
function mountDummyElement(): Element {
const existingDummy = document.getElementById("driver-dummy-element");
@@ -38,13 +35,19 @@ export function highlight(step: DriveStep) {
elemObj = mountDummyElement();
}
previousHighlight = activeHighlight;
activeHighlight = elemObj;
const previousHighlight = getState("activeHighlight");
transferHighlight(previousHighlight || elemObj, elemObj);
const transferHighlightFrom = previousHighlight || elemObj;
const transferHighlightTo = elemObj;
transferHighlight(transferHighlightFrom, transferHighlightTo);
setState("previousHighlight", transferHighlightFrom);
setState("activeHighlight", transferHighlightTo);
}
export function refreshActiveHighlight() {
const activeHighlight = getState("activeHighlight");
if (!activeHighlight) {
return;
}
@@ -61,15 +64,17 @@ function transferHighlight(from: Element, to: Element) {
// If it's the first time we're highlighting an element, we show
// the popover immediately. Otherwise, we wait for the animation
// to finish before showing the popover.
const hasDelayedPopover = !from || from !== to;
const hasDelayedPopover = to && (!from || from !== to);
hidePopover();
const animate = () => {
const transitionCallback = getState("transitionCallback");
// This makes sure that the repeated calls to transferHighlight
// don't interfere with each other. Only the last call will be
// executed.
if (currentTransitionCallback !== animate) {
if (transitionCallback !== animate) {
return;
}
@@ -84,13 +89,13 @@ function transferHighlight(from: Element, to: Element) {
renderPopover(to);
}
currentTransitionCallback = undefined;
setState("transitionCallback", undefined);
}
window.requestAnimationFrame(animate);
};
currentTransitionCallback = animate;
setState("transitionCallback", animate);
window.requestAnimationFrame(animate);
bringInView(to);
@@ -103,10 +108,11 @@ function transferHighlight(from: Element, to: Element) {
}
export function destroyHighlight() {
activeHighlight = undefined;
currentTransitionCallback = undefined;
previousHighlight = undefined;
activeHighlight = undefined;
setState("activeHighlight", undefined);
setState("previousHighlight", undefined);
setState("transitionCallback", undefined);
document.getElementById("driver-dummy-element")?.remove();
document.querySelectorAll(".driver-active-element").forEach(element => {
+26 -11
View File
@@ -1,11 +1,10 @@
import { bringInView } from "./utils";
import { getConfig } from "./config";
import { getState, setState } from "./state";
export type Side = "top" | "right" | "bottom" | "left";
export type Side = "top" | "right" | "bottom" | "left" | "over";
export type Alignment = "start" | "center" | "end";
const POPOVER_OFFSET = 10;
export type Popover = {
title?: string;
description: string;
@@ -13,7 +12,7 @@ export type Popover = {
align?: Alignment;
};
type PopoverDOM = {
export type PopoverDOM = {
wrapper: HTMLElement;
arrow: HTMLElement;
title: HTMLElement;
@@ -25,9 +24,8 @@ type PopoverDOM = {
footerButtons: HTMLElement;
};
let popover: PopoverDOM | undefined;
export function hidePopover() {
const popover = getState("popover");
if (!popover) {
return;
}
@@ -36,6 +34,7 @@ export function hidePopover() {
}
export function renderPopover(element: Element) {
let popover = getState("popover");
if (!popover) {
popover = createPopover();
document.body.appendChild(popover.wrapper);
@@ -53,6 +52,8 @@ export function renderPopover(element: Element) {
const popoverArrow = popover.arrow;
popoverArrow.className = "driver-popover-arrow";
setState("popover", popover);
repositionPopover(element);
bringInView(popoverWrapper);
}
@@ -65,16 +66,19 @@ type PopoverDimensions = {
};
function getPopoverDimensions(): PopoverDimensions | undefined {
const popover = getState("popover");
if (!popover?.wrapper) {
return;
}
const boundingClientRect = popover.wrapper.getBoundingClientRect();
const stagePadding = getConfig("stagePadding") || 0;
const popoverOffset = getConfig("popoverOffset") || 0;
return {
width: boundingClientRect.width + stagePadding + POPOVER_OFFSET,
height: boundingClientRect.height + stagePadding + POPOVER_OFFSET,
width: boundingClientRect.width + stagePadding + popoverOffset,
height: boundingClientRect.height + stagePadding + popoverOffset,
realWidth: boundingClientRect.width,
realHeight: boundingClientRect.height,
@@ -171,6 +175,7 @@ function calculateLeftForTopBottom(
}
export function repositionPopover(element: Element) {
const popover = getState("popover");
if (!popover) {
return;
}
@@ -178,7 +183,7 @@ export function repositionPopover(element: Element) {
// @TODO These values will come from the config
// Configure the popover positioning
const requiredAlignment: Alignment = "start";
const requiredSide: Side = "left" as Side;
const requiredSide: Side = element.id === "driver-dummy-element" ? "over" : "left" as Side;
const popoverPadding = getConfig('stagePadding') || 0;
const popoverDimensions = getPopoverDimensions()!;
@@ -210,7 +215,15 @@ export function repositionPopover(element: Element) {
isLeftOptimal = isTopOptimal = isBottomOptimal = false;
}
if (noneOptimal) {
if (requiredSide === "over") {
const leftToSet = window.innerWidth / 2 - popoverDimensions!.realWidth / 2;
const topToSet = window.innerHeight / 2 - popoverDimensions!.realHeight / 2;
popover.wrapper.style.left = `${leftToSet}px`;
popover.wrapper.style.right = `auto`;
popover.wrapper.style.top = `${topToSet}px`;
popover.wrapper.style.bottom = `auto`;
} else if (noneOptimal) {
const leftValue = window.innerWidth / 2 - popoverDimensions?.realWidth! / 2;
const bottomValue = 10;
@@ -303,6 +316,7 @@ export function repositionPopover(element: Element) {
}
function renderPopoverArrow(alignment: Alignment, side: Side, element: Element) {
const popover = getState("popover");
if (!popover) {
return;
}
@@ -458,10 +472,11 @@ function createPopover(): PopoverDOM {
}
export function destroyPopover() {
const popover = getState("popover");
if (!popover) {
return;
}
popover.wrapper.parentElement?.removeChild(popover.wrapper);
popover = undefined;
setState("popover", undefined);
}
+29 -19
View File
@@ -2,6 +2,7 @@ import { easeInOutQuad } from "./utils";
import { onDriverClick } from "./events";
import { emit } from "./emitter";
import { getConfig } from "./config";
import { getState, setState } from "./state";
export type StageDefinition = {
x: number;
@@ -10,14 +11,12 @@ export type StageDefinition = {
height: number;
};
let activeStagePosition: StageDefinition | undefined;
let stageSvg: SVGSVGElement | undefined;
// This method calculates the animated new position of the
// stage (called for each frame by requestAnimationFrame)
export function transitionStage(elapsed: number, duration: number, from: Element, to: Element) {
const fromDefinition = activeStagePosition ? activeStagePosition : from.getBoundingClientRect();
let activeStagePosition = getState("activeStagePosition");
const fromDefinition = activeStagePosition ? activeStagePosition : from.getBoundingClientRect();
const toDefinition = to.getBoundingClientRect();
const x = easeInOutQuad(elapsed, fromDefinition.x, toDefinition.x - fromDefinition.x, duration);
@@ -33,6 +32,7 @@ export function transitionStage(elapsed: number, duration: number, from: Element
};
renderStage(activeStagePosition);
setState("activeStagePosition", activeStagePosition);
}
export function trackActiveElement(element: Element) {
@@ -42,17 +42,22 @@ export function trackActiveElement(element: Element) {
const definition = element.getBoundingClientRect();
activeStagePosition = {
const activeStagePosition: StageDefinition = {
x: definition.x,
y: definition.y,
width: definition.width,
height: definition.height,
};
setState("activeStagePosition", activeStagePosition);
renderStage(activeStagePosition);
}
export function refreshStage() {
const activeStagePosition = getState("activeStagePosition");
const stageSvg = getState("stageSvg");
if (!activeStagePosition) {
return;
}
@@ -69,7 +74,7 @@ export function refreshStage() {
}
function mountStage(stagePosition: StageDefinition) {
stageSvg = createStageSvg(stagePosition);
const stageSvg = createStageSvg(stagePosition);
document.body.appendChild(stageSvg);
onDriverClick(stageSvg, e => {
@@ -80,9 +85,13 @@ function mountStage(stagePosition: StageDefinition) {
emit("overlayClick");
});
setState("stageSvg", stageSvg);
}
function renderStage(stagePosition: StageDefinition) {
const stageSvg = getState("stageSvg");
// TODO: cancel rendering if element is not visible
if (!stageSvg) {
mountStage(stagePosition);
@@ -95,7 +104,7 @@ function renderStage(stagePosition: StageDefinition) {
throw new Error("no path element found in stage svg");
}
pathElement.setAttribute("d", generateSvgCutoutPathString(stagePosition));
pathElement.setAttribute("d", generateStageSvgPathString(stagePosition));
}
function createStageSvg(stage: StageDefinition): SVGSVGElement {
@@ -122,26 +131,26 @@ function createStageSvg(stage: StageDefinition): SVGSVGElement {
svg.style.width = "100%";
svg.style.height = "100%";
const cutoutPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
const stagePath = document.createElementNS("http://www.w3.org/2000/svg", "path");
cutoutPath.setAttribute("d", generateSvgCutoutPathString(stage));
stagePath.setAttribute("d", generateStageSvgPathString(stage));
cutoutPath.style.fill = "rgb(0,0,0)";
cutoutPath.style.opacity = `${getConfig("opacity")}`;
cutoutPath.style.pointerEvents = "auto";
cutoutPath.style.cursor = "auto";
stagePath.style.fill = "rgb(0,0,0)";
stagePath.style.opacity = `${getConfig("opacity")}`;
stagePath.style.pointerEvents = "auto";
stagePath.style.cursor = "auto";
svg.appendChild(cutoutPath);
svg.appendChild(stagePath);
return svg;
}
function generateSvgCutoutPathString(stage: StageDefinition) {
function generateStageSvgPathString(stage: StageDefinition) {
const windowX = window.innerWidth;
const windowY = window.innerHeight;
const stagePadding = getConfig('stagePadding') || 0;
const stageRadius = getConfig('stageRadius') || 0;
const stagePadding = getConfig("stagePadding") || 0;
const stageRadius = getConfig("stageRadius") || 0;
const stageWidth = stage.width + stagePadding * 2;
const stageHeight = stage.height + stagePadding * 2;
@@ -162,10 +171,11 @@ function generateSvgCutoutPathString(stage: StageDefinition) {
}
export function destroyStage() {
const stageSvg = getState("stageSvg");
if (stageSvg) {
stageSvg.remove();
stageSvg = undefined;
setState("stageSvg", undefined);
}
activeStagePosition = undefined;
setState("activeStagePosition", undefined);
}
+28
View File
@@ -0,0 +1,28 @@
import { StageDefinition } from "./stage";
import { PopoverDOM } from "./popover";
export type State = {
isInitialized?: boolean;
resizeTimeout?: number;
previousHighlight?: Element;
activeHighlight?: Element;
transitionCallback?: () => void;
activeStagePosition?: StageDefinition;
stageSvg?: SVGSVGElement;
popover?: PopoverDOM;
};
let currentState: State = {};
export function setState<K extends keyof State>(key: K, value: State[K]) {
currentState[key] = value;
}
export function getState(): State;
export function getState<K extends keyof State>(key: K): State[K];
export function getState<K extends keyof State>(key?: K) {
return key ? currentState[key] : currentState;
}
+6 -1
View File
@@ -8,7 +8,8 @@
.driver-active .driver-active-element,
.driver-active .driver-active-element *,
.driver-popover, .driver-popover *{
.driver-popover,
.driver-popover * {
pointer-events: auto;
}
@@ -51,6 +52,10 @@
border: 5px solid #fff;
}
.driver-popover-arrow-side-over {
display: none;
}
/** Popover Arrow Sides **/
.driver-popover-arrow-side-left {
left: 100%;