Skip to content

Commit

Permalink
feat(js-components): workflow components
Browse files Browse the repository at this point in the history
  • Loading branch information
VojtechVidra committed Jan 8, 2025
1 parent 9332d4a commit f8bc127
Show file tree
Hide file tree
Showing 15 changed files with 429 additions and 15 deletions.
22 changes: 21 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions workspaces/js-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@
"tsc": "tsc -p tsconfig.json",
"version": "pnpm version"
},
"dependencies": {
"@floating-ui/dom": "^1.6.13"
},
"devDependencies": {
"@flows/js": "workspace:*",
"@flows/shared": "workspace:*",
"@types/jest": "^29.5.14",
"@types/node": "^20",
Expand All @@ -44,5 +48,15 @@
"ts-jest": "^29.2.5",
"tsup": "^8.3.5",
"typescript": "^5.7.2"
},
"peerDependencies": {
"@flows/js": "*"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
}
}
1 change: 1 addition & 0 deletions workspaces/js-components/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./modal";
export * from "./tooltip";
78 changes: 66 additions & 12 deletions workspaces/js-components/src/components/modal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { close16 } from "../icons/close-16";
import { type Component } from "../types";

export interface ModalProps {
title: string;
body: string;
Expand All @@ -9,15 +12,66 @@ export interface ModalProps {
close: () => void;
}

// export const Modal = (props: ModalProps): HTMLElement => {
// return `<div class="flows_modal_wrapper">
// <div class="flows_modal_modal">
// <p class="flows_text flows_text_title">${props.title}</p>
// <p class="flows_text flows_text_body">${props.body}</p>

// <div class="flows_modal_footer">
// ${props.continueText && `<button >${props.continueText}</button>`}
// </div>
// </div>
// </div>`;
// };
export const Modal: Component<ModalProps> = (props) => {
const root = document.createElement("div");

let overlay: HTMLDivElement | null = null;
if (!props.hideOverlay) {
overlay = document.createElement("div");
root.appendChild(overlay);
overlay.className = `flows_modal_overlay${
props.showCloseButton ? " flows_modal_clickable" : ""
}`;
overlay.ariaHidden = "true";
overlay.addEventListener("click", props.close);
}

const modalWrapper = document.createElement("div");
root.appendChild(modalWrapper);
modalWrapper.className = "flows_modal_wrapper";

const modal = document.createElement("div");
modalWrapper.appendChild(modal);
modal.className = "flows_modal_modal";

const title = document.createElement("p");
modal.appendChild(title);
title.className = "flows_text flows_text_title";
title.textContent = props.title;

const body = document.createElement("p");
modal.appendChild(body);
body.className = "flows_text flows_text_body";
body.textContent = props.body;

const footer = document.createElement("div");
modal.appendChild(footer);
footer.className = "flows_modal_footer";

let continueButton: HTMLButtonElement | null = null;
if (props.continueText) {
continueButton = document.createElement("button");
footer.appendChild(continueButton);
continueButton.className = "flows_button flows_button_primary";
continueButton.textContent = props.continueText;
continueButton.addEventListener("click", props.continue);
}

let closeButton: HTMLButtonElement | null = null;
if (props.showCloseButton) {
closeButton = document.createElement("button");
modal.appendChild(closeButton);
closeButton.className = "flows_iconButton flows_modal_close";
closeButton.addEventListener("click", props.close);
closeButton.appendChild(close16());
}

return {
element: root,
cleanup: () => {
continueButton?.removeEventListener("click", props.continue);
closeButton?.removeEventListener("click", props.close);
overlay?.removeEventListener("click", props.close);
},
};
};
178 changes: 178 additions & 0 deletions workspaces/js-components/src/components/tooltip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { log, type Placement } from "@flows/shared";
import {
arrow,
autoUpdate,
computePosition,
flip,
offset,
shift,
type Side,
} from "@floating-ui/dom";
import { type Component } from "../types";
import { close16 } from "../icons/close-16";

export interface TooltipProps {
title: string;
body: string;
continueText?: string;
targetElement: string;
showCloseButton: boolean;
placement?: Placement;
hideOverlay?: boolean;

continue: () => void;
close: () => void;
}

export const Tooltip: Component<TooltipProps> = (props) => {
const root = document.createElement("div");
root.className = "flows_tooltip_root";

let overlay: HTMLDivElement | null = null;
if (!props.hideOverlay) {
overlay = document.createElement("div");
root.appendChild(overlay);
overlay.className = "flows_tooltip_overlay";
overlay.ariaHidden = "true";
overlay.addEventListener("click", props.close);
}

const tooltip = document.createElement("div");
root.appendChild(tooltip);
tooltip.className = "flows_tooltip_tooltip";

const title = document.createElement("p");
tooltip.appendChild(title);
title.className = "flows_text flows_text_title flows_tooltip_title";
title.textContent = props.title;

const body = document.createElement("p");
tooltip.appendChild(body);
body.className = "flows_text flows_text_body flows_tooltip_body";
body.innerHTML = props.body;

const footer = document.createElement("div");
tooltip.appendChild(footer);
footer.className = "flows_tooltip_footer";

let continueButton: HTMLButtonElement | null = null;
if (props.continueText) {
continueButton = document.createElement("button");
footer.appendChild(continueButton);
continueButton.className = "flows_button flows_button_primary";
continueButton.textContent = props.continueText;
continueButton.addEventListener("click", props.continue);
}

let closeButton: HTMLButtonElement | null = null;
if (props.showCloseButton) {
closeButton = document.createElement("button");
footer.appendChild(closeButton);
closeButton.className = "flows_iconButton flows_tooltip_close";
closeButton.appendChild(close16());
closeButton.addEventListener("click", props.close);
}

const bottomArrow = document.createElement("div");
tooltip.appendChild(bottomArrow);
bottomArrow.className = "flows_tooltip_arrow flows_tooltip_arrow-bottom";

const topArrow = document.createElement("div");
tooltip.appendChild(topArrow);
topArrow.className = "flows_tooltip_arrow flows_tooltip_arrow-top";

// TODO: setup auto update based on reference element change
const reference = document.querySelector(props.targetElement);
let positionCleanup: (() => void) | null = null;
if (reference) {
positionCleanup = autoUpdate(
reference,
tooltip,
() =>
void updateTooltip({
reference,
tooltip,
arrowEls: [bottomArrow, topArrow],
overlay,
placement: props.placement,
}),
);
}

return {
element: root,
cleanup: () => {
positionCleanup?.();

overlay?.removeEventListener("click", props.close);
continueButton?.removeEventListener("click", props.continue);
closeButton?.removeEventListener("click", props.close);
},
};
};

const DISTANCE = 4;
const ARROW_SIZE = 6;
const OFFSET_DISTANCE = DISTANCE + ARROW_SIZE;
const BOUNDARY_PADDING = 8;
const ARROW_EDGE_PADDING = 8;

export const updateTooltip = ({
reference,
tooltip,
placement,
arrowEls,
overlay,
}: {
reference: Element;
tooltip: HTMLElement;
placement?: Placement;
arrowEls: [HTMLElement, HTMLElement];
overlay: HTMLElement | null;
}): Promise<void> => {
if (overlay) {
const targetPosition = reference.getBoundingClientRect();
overlay.style.top = `${targetPosition.top}px`;
overlay.style.left = `${targetPosition.left}px`;
overlay.style.width = `${targetPosition.width}px`;
overlay.style.height = `${targetPosition.height}px`;
}

return computePosition(reference, tooltip, {
placement,
middleware: [
flip({ fallbackPlacements: ["top", "bottom", "left", "right"] }),
shift({ crossAxis: true, padding: BOUNDARY_PADDING }),
arrow({ element: arrowEls[0], padding: ARROW_EDGE_PADDING }),
offset(OFFSET_DISTANCE),
],
})
.then(({ x, y, middlewareData, placement: finalPlacement }) => {
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;

if (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;

arrowEls.forEach((arrowEl) => {
// eslint-disable-next-line eqeqeq -- null check is intended here
arrowEl.style.left = arrowX != null ? `${arrowX}px` : "";
// eslint-disable-next-line eqeqeq -- null check is intended here
arrowEl.style.top = arrowY != null ? `${arrowY}px` : "";
arrowEl.style.right = "";
arrowEl.style.bottom = "";
arrowEl.style[staticSide] = `${-ARROW_SIZE}px`;
});
}
})
.catch((err: unknown) => {
log.error("Error computing position", err);
});
};
16 changes: 16 additions & 0 deletions workspaces/js-components/src/icons/close-16.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const close16 = (): SVGSVGElement => {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("height", "16");
svg.setAttribute("width", "16");
svg.setAttribute("viewBox", "0 0 16 16");
svg.setAttribute("fill", "currentColor");

const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.appendChild(path);
path.setAttribute(
"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",
);

return svg;
};
3 changes: 3 additions & 0 deletions workspaces/js-components/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export * from "./components";
export * from "./render";
export * from "./slot";
export * from "./types";
Loading

0 comments on commit f8bc127

Please sign in to comment.