Skip to content

Commit

Permalink
feat: tooltip arrow
Browse files Browse the repository at this point in the history
  • Loading branch information
VojtechVidra committed Nov 7, 2023
1 parent dcd68a0 commit 7d3f2a7
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 30 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rbnd/flows",
"version": "0.0.15",
"version": "0.0.16",
"description": "A better way to onboard users and drive product adoption.",
"repository": {
"type": "git",
Expand Down
62 changes: 47 additions & 15 deletions public/flows.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
--flows-background-subtle: #f5f5f5;
--flows-background-hover: #ececec;
--flows-text: #222222;
--flows-text-on-primary: #222222;
--flows-primary: #0070f3;
--flows-primary-hover: #0060ce;

Expand All @@ -26,17 +27,20 @@
--flows-heading-font-size: 16px;
--flows-heading-line-height: 24px;
--flows-heading-font-weight: 600;

--flows-close-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Cpath d='M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z'%3E%3C/path%3E%3C/svg%3E");
}

/* Shared styles */

.flows-root {
width: 100%;
top: 0;
left: 0;
position: absolute;
z-index: 1500;
}
.flows-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding-right: 28px;
}
.flows-title {
font-family: var(--flows-font-family);
Expand Down Expand Up @@ -64,12 +68,15 @@
font-family: var(--flows-font-family);
font-size: var(--flows-base-font-size);
line-height: var(--flows-base-line-height);
font-weight: 600;
cursor: pointer;
transition:
background-color 120ms ease-in-out,
border-color 120ms ease-in-out;
}

.flows-button svg {
pointer-events: none;
}
.flows-button:hover {
background-color: var(--flows-background-hover);
}
Expand All @@ -78,7 +85,7 @@
.flows-option {
background-color: var(--flows-primary);
border: 1px solid var(--flows-primary);
color: #ffffff;
color: var(--flows-text-on-primary);
}

.flows-continue:hover,
Expand All @@ -90,7 +97,7 @@
.flows-finish {
background-color: var(--flows-primary);
border: 1px solid var(--flows-primary);
color: #ffffff;
color: var(--flows-text-on-primary);
}

.flows-finish:hover {
Expand All @@ -99,15 +106,14 @@
}

.flows-cancel {
background: var(--flows-close-icon);
background-position: center;
background-repeat: no-repeat;
width: 20px;
height: 20px;
border: none;
color: transparent;
user-select: none;
oveflow: hidden;
position: absolute;
display: grid;
place-items: center;
color: var(--flows-text);
padding: 0;
}

/* Tooltip styles */
Expand All @@ -118,7 +124,6 @@
color: var(--flows-text);
border-radius: var(--flows-borderRadius);
position: absolute;
z-index: 1500;
padding: var(--flows-tooltip-padding);
box-shadow: var(--flows-shadow);

Expand All @@ -128,6 +133,10 @@

max-width: 280px;
}
.flows-tooltip .flows-cancel {
top: var(--flows-tooltip-padding);
right: var(--flows-tooltip-padding);
}

.flows-tooltip-footer {
display: flex;
Expand All @@ -138,6 +147,24 @@
.flows-back-wrap {
flex: 1;
}
.flows-arrow {
position: absolute;
width: 12px;
height: 12px;
transform: rotate(45deg);
border-radius: 2px;
}
.flows-arrow-bottom {
border: var(--flows-border);
z-index: -1;
box-shadow: var(--flows-shadow);
}
.flows-arrow-top {
background-color: var(--flows-background);
border: var(--flows-border);
border-color: transparent;
background-clip: padding-box;
}

/* Modal styles */

Expand All @@ -161,6 +188,11 @@
padding: var(--flows-modal-padding);
min-width: var(--flows-modal-minWidth);
max-width: var(--flows-model-maxWidth);
position: relative;
}
.flows-modal .flows-cancel {
top: var(--flows-tooltip-padding);
right: var(--flows-tooltip-padding);
}

.flows-modal-footer {
Expand Down
8 changes: 8 additions & 0 deletions src/icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const Icons = {
close: `<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16">
<path
fill="currentColor"
d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"
/>
</svg>`,
};
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import "./jsx";

import { FlowState } from "./flow-state";
import { FlowsContext } from "./flows-context";
import { changeWaitMatch, formWaitMatch } from "./form";
import { addHandlers } from "./handlers";
import "./jsx";
import type { Flow, FlowsOptions, TrackingEvent, FlowStep } from "./types";

const instances = new Map<string, FlowState>();
Expand Down
5 changes: 4 additions & 1 deletion src/jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ if (typeof window !== "undefined")
if (value?.__html) el.innerHTML = value.__html;
return;
}
el.setAttribute(key, props[key]);
const value = props[key] as unknown;
if (typeof value === "string") el.setAttribute(key, value);
if (typeof value === "boolean" && value) el.setAttribute(key, "");
if (typeof value === "number") el.setAttribute(key, value.toString());
});
}

Expand Down
75 changes: 63 additions & 12 deletions src/render.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,69 @@
import { computePosition, offset, flip, shift, autoUpdate } from "@floating-ui/dom";
import type { Side } from "@floating-ui/dom";
import { computePosition, offset, flip, shift, autoUpdate, arrow } from "@floating-ui/dom";
import type { FlowModalStep, FlowTooltipStep, Placement } from "./types";
import type { FlowState } from "./flow-state";
import { isModalStep, isTooltipStep } from "./utils";
import { Icons } from "./icons";

const DISTANCE = 4;
const ARROW_SIZE = 6;

const updateTooltip = ({
target,
tooltip,
placement,
arrowEls,
}: {
target: Element;
tooltip: HTMLElement;
placement?: Placement;
}): Promise<void> =>
computePosition(target, tooltip, {
arrowEls?: [HTMLElement, HTMLElement];
}): Promise<void> => {
const offsetDistance = DISTANCE + (arrowEls ? ARROW_SIZE : 0);
const middleware = [
offset(offsetDistance),
flip({ fallbackPlacements: ["top", "bottom", "left", "right"] }),
shift({ padding: 4 }),
];
if (arrowEls) middleware.push(arrow({ element: arrowEls[0] }));

return computePosition(target, tooltip, {
placement: placement ?? "bottom",
middleware: [
offset(4),
flip({ fallbackPlacements: ["top", "bottom", "left", "right"] }),
shift({ padding: 4 }),
],
middleware,
})
.then(({ x, y }) => {
.then(({ x, y, middlewareData, placement: finalPlacement }) => {
Object.assign(tooltip.style, {
left: `${x}px`,
top: `${y}px`,
});
if (arrowEls && middlewareData.arrow) {
const staticSide = ((): Side => {
if (finalPlacement.includes("top")) return "bottom";
if (finalPlacement.includes("bottom")) return "top";
if (finalPlacement.includes("left")) return "right";
return "left";
})();
const arrowX = middlewareData.arrow.x;
const arrowY = middlewareData.arrow.y;
const style = {
// eslint-disable-next-line eqeqeq -- null check is intended here
left: arrowX != null ? `${arrowX}px` : "",
// eslint-disable-next-line eqeqeq -- null check is intended here
top: arrowY != null ? `${arrowY}px` : "",
right: "",
bottom: "",
[staticSide]: `${-ARROW_SIZE}px`,
};
arrowEls.forEach((el) => {
Object.assign(el.style, style);
});
}
})
.catch((err) => {
// eslint-disable-next-line no-console -- Error log
console.warn("Error computing position", err);
});
};

const getStepContinueButton = ({
state,
Expand Down Expand Up @@ -64,13 +98,23 @@ const renderTooltip = ({
</div>
);
const continueBtn = getStepContinueButton({ state, step });
const arrowEls = step.arrow
? ([
<div className="flows-arrow flows-arrow-bottom" />,
<div className="flows-arrow flows-arrow-top" />,
] as [HTMLElement, HTMLElement])
: undefined;

const tooltip = (
<div className="flows-tooltip">
<div className="flows-header">
<h1 className="flows-title" dangerouslySetInnerHTML={{ __html: step.title }} />
{state.hasNextStep && !step.hideClose && (
<button className="flows-cancel flows-button">Close</button>
<button
aria-label="Close"
className="flows-cancel flows-button"
dangerouslySetInnerHTML={{ __html: Icons.close }}
/>
)}
</div>
{step.body && <div className="flows-body" dangerouslySetInnerHTML={{ __html: step.body }} />}
Expand All @@ -80,12 +124,13 @@ const renderTooltip = ({
{continueBtn}
</div>
)}
{arrowEls}
</div>
);
root.appendChild(tooltip);
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- Promise is handled inside the updateTooltip
const cleanup = autoUpdate(target, tooltip, () =>
updateTooltip({ target, tooltip, placement: step.placement }),
updateTooltip({ target, tooltip, placement: step.placement, arrowEls }),
);
return { cleanup };
};
Expand All @@ -104,7 +149,13 @@ const renderModal = ({
<div className="flows-modal">
<div className="flows-header">
<h1 className="flows-title" dangerouslySetInnerHTML={{ __html: step.title }} />
{state.hasNextStep && <button className="flows-cancel flows-button">Close</button>}
{state.hasNextStep && (
<button
aria-label="Close"
className="flows-cancel flows-button"
dangerouslySetInnerHTML={{ __html: Icons.close }}
/>
)}
</div>
{step.body && (
<div className="flows-body" dangerouslySetInnerHTML={{ __html: step.body }} />
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface FlowTooltipStep {
options?: { text: string; action: number }[];
placement?: Placement;
hideClose?: boolean;
arrow?: boolean;
}
export interface FlowModalStep {
key?: string;
Expand Down

0 comments on commit 7d3f2a7

Please sign in to comment.