@rogieking/figui3
Version:
A lightweight, customizable web component library that uses Figmas UI3 style for modern web applications, but specifically for Figma plugins.
1,703 lines (1,567 loc) • 71.6 kB
JavaScript
function figUniqueId() {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
function figSupportsPopover() {
return HTMLElement.prototype.hasOwnProperty("popover");
}
/**
* A custom button element that supports different types and states.
* @attr {string} type - The button type: "button" (default), "toggle", or "submit"
* @attr {boolean} selected - Whether the button is in a selected state
* @attr {boolean} disabled - Whether the button is disabled
*/
class FigButton extends HTMLElement {
type;
#selected;
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.type = this.getAttribute("type") || "button";
this.shadowRoot.innerHTML = `
<style>
button, button:hover, button:active {
padding: 0 var(--spacer-2);
appearance: none;
display: flex;
border: 0;
flex: 1;
text-align: center;
align-items: stretch;
justify-content: center;
font: inherit;
color: inherit;
outline: 0;
place-items: center;
background: transparent;
margin: calc(var(--spacer-2)*-1);
height: var(--spacer-4);
}
</style>
<button type="${this.type}">
<slot></slot>
</button>
`;
this.#selected =
this.hasAttribute("selected") &&
this.getAttribute("selected") !== "false";
requestAnimationFrame(() => {
this.button = this.shadowRoot.querySelector("button");
this.button.addEventListener("click", this.#handleClick.bind(this));
});
}
get type() {
return this.type;
}
set type(value) {
this.setAttribute("type", value);
}
get selected() {
return this.#selected;
}
set selected(value) {
this.setAttribute("selected", value);
}
#handleClick() {
if (this.type === "toggle") {
this.toggleAttribute("selected", !this.hasAttribute("selected"));
}
if (this.type === "submit") {
this.closest("form").dispatchEvent(new Event("submit"));
}
}
static get observedAttributes() {
return ["disabled", "selected"];
}
attributeChangedCallback(name, oldValue, newValue) {
if (this.button) {
this.button[name] = newValue;
switch (name) {
case "disabled":
this.disabled = this.button.disabled =
newValue === "true" ||
(newValue === undefined && newValue !== null);
break;
case "type":
this.type = newValue;
this.button.type = this.type;
this.button.setAttribute("type", this.type);
break;
case "selected":
this.#selected = newValue === "true";
break;
}
}
}
}
window.customElements.define("fig-button", FigButton);
/**
* A custom dropdown/select element.
* @attr {string} type - The dropdown type: "select" (default) or "dropdown"
* @attr {string} value - The currently selected value
*/
class FigDropdown extends HTMLElement {
constructor() {
super();
this.select = document.createElement("select");
this.optionsSlot = document.createElement("slot");
this.attachShadow({ mode: "open" });
}
#addEventListeners() {
this.select.addEventListener("input", this.#handleSelectInput.bind(this));
this.select.addEventListener("change", this.#handleSelectChange.bind(this));
}
connectedCallback() {
this.type = this.getAttribute("type") || "select";
this.appendChild(this.select);
this.shadowRoot.appendChild(this.optionsSlot);
this.optionsSlot.addEventListener("slotchange", this.slotChange.bind(this));
this.#addEventListeners();
}
slotChange() {
while (this.select.firstChild) {
this.select.firstChild.remove();
}
if (this.type === "dropdown") {
const hiddenOption = document.createElement("option");
hiddenOption.setAttribute("hidden", "true");
hiddenOption.setAttribute("selected", "true");
this.select.appendChild(hiddenOption);
}
this.optionsSlot.assignedNodes().forEach((option) => {
if (option.nodeName === "OPTION" || option.nodeName === "OPTGROUP") {
this.select.appendChild(option.cloneNode(true));
}
});
this.#syncSelectedValue(this.value);
if (this.type === "dropdown") {
this.select.selectedIndex = -1;
}
}
#handleSelectInput(e) {
this.value = e.target.value;
this.setAttribute("value", this.value);
}
#handleSelectChange() {
if (this.type === "dropdown") {
this.select.selectedIndex = -1;
}
}
focus() {
this.select.focus();
}
blur() {
this.select.blur();
}
get value() {
return this.select?.value;
}
set value(value) {
this.setAttribute("value", value);
}
static get observedAttributes() {
return ["value", "type"];
}
#syncSelectedValue(value) {
if (this.select) {
this.select.querySelectorAll("option").forEach((o, i) => {
if (o.value === this.getAttribute("value")) {
this.select.selectedIndex = i;
}
});
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "value") {
this.#syncSelectedValue(newValue);
}
if (name === "type") {
this.type = newValue;
}
}
}
customElements.define("fig-dropdown", FigDropdown);
/* Tooltip */
/**
* A custom tooltip element that displays on hover or click.
* @attr {string} action - The trigger action: "hover" (default) or "click"
* @attr {number} delay - Delay in milliseconds before showing tooltip (default: 500)
* @attr {string} text - The tooltip text content
* @attr {string} offset - Comma-separated offset values: left,top,right,bottom
*/
class FigTooltip extends HTMLElement {
#boundHideOnChromeOpen;
#boundHideOnDragStart;
constructor() {
super();
this.action = this.getAttribute("action") || "hover";
let delay = parseInt(this.getAttribute("delay"));
this.delay = !isNaN(delay) ? delay : 500;
this.isOpen = false;
// Bind methods that will be used as event listeners
this.#boundHideOnChromeOpen = this.#hideOnChromeOpen.bind(this);
this.#boundHideOnDragStart = this.hidePopup.bind(this);
}
connectedCallback() {
this.setup();
this.setupEventListeners();
}
disconnectedCallback() {
this.destroy();
// Remove global listeners
document.removeEventListener(
"mousedown",
this.#boundHideOnChromeOpen,
true
);
// Remove mousedown listener
this.removeEventListener("mousedown", this.#boundHideOnDragStart);
}
setup() {
this.style.display = "contents";
}
render() {
this.destroy();
this.popup = document.createElement("span");
this.popup.setAttribute("class", "fig-tooltip");
this.popup.style.position = "fixed";
this.popup.style.visibility = "hidden";
this.popup.style.display = "inline-flex";
this.popup.style.pointerEvents = "none";
this.popup.innerText = this.getAttribute("text");
document.body.append(this.popup);
}
destroy() {
if (this.popup) {
this.popup.remove();
}
document.body.addEventListener("click", this.hidePopupOutsideClick);
}
setupEventListeners() {
if (this.action === "hover") {
this.addEventListener("pointerenter", this.showDelayedPopup.bind(this));
this.addEventListener("pointerleave", this.hidePopup.bind(this));
// Add mousedown listener instead of dragstart
this.addEventListener("mousedown", this.#boundHideOnDragStart);
} else if (this.action === "click") {
this.addEventListener("click", this.showDelayedPopup.bind(this));
document.body.addEventListener(
"click",
this.hidePopupOutsideClick.bind(this)
);
}
// Add listener for chrome interactions
document.addEventListener("mousedown", this.#boundHideOnChromeOpen, true);
}
getOffset() {
const defaultOffset = { left: 8, top: 4, right: 8, bottom: 4 };
const offsetAttr = this.getAttribute("offset");
if (!offsetAttr) return defaultOffset;
const [left, top, right, bottom] = offsetAttr.split(",").map(Number);
return {
left: isNaN(left) ? defaultOffset.left : left,
top: isNaN(top) ? defaultOffset.top : top,
right: isNaN(right) ? defaultOffset.right : right,
bottom: isNaN(bottom) ? defaultOffset.bottom : bottom,
};
}
showDelayedPopup() {
this.render();
clearTimeout(this.timeout);
this.timeout = setTimeout(this.showPopup.bind(this), this.delay);
}
showPopup() {
const rect = this.firstElementChild.getBoundingClientRect();
const popupRect = this.popup.getBoundingClientRect();
const offset = this.getOffset();
// Position the tooltip above the element
let top = rect.top - popupRect.height - offset.top;
let left = rect.left + (rect.width - popupRect.width) / 2;
this.popup.setAttribute("position", "top");
// Adjust if tooltip would go off-screen
if (top < 0) {
this.popup.setAttribute("position", "bottom");
top = rect.bottom + offset.bottom; // Position below instead
}
if (left < offset.left) {
left = offset.left;
} else if (left + popupRect.width > window.innerWidth - offset.right) {
left = window.innerWidth - popupRect.width - offset.right;
}
this.popup.style.top = `${top}px`;
this.popup.style.left = `${left}px`;
this.popup.style.opacity = "1";
this.popup.style.visibility = "visible";
this.popup.style.display = "block";
this.popup.style.pointerEvents = "all";
this.popup.style.zIndex = parseInt(new Date().getTime() / 1000);
this.isOpen = true;
}
hidePopup() {
clearTimeout(this.timeout);
this.popup.style.opacity = "0";
this.popup.style.display = "block";
this.popup.style.pointerEvents = "none";
this.destroy();
this.isOpen = false;
}
hidePopupOutsideClick(event) {
if (this.isOpen && !this.popup.contains(event.target)) {
this.hidePopup();
}
}
static get observedAttributes() {
return ["action", "delay"];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "action") {
this.action = newValue;
}
if (name === "delay") {
let delay = parseInt(newValue);
this.delay = !isNaN(delay) ? delay : 500;
}
}
#hideOnChromeOpen(e) {
if (!this.isOpen) return;
// Check if the clicked element is a select or opens a dialog
const target = e.target;
if (
target.tagName === "SELECT" ||
target.hasAttribute("popover") ||
target.closest("dialog") ||
target.onclick?.toString().includes("alert")
) {
this.hidePopup();
}
}
}
customElements.define("fig-tooltip", FigTooltip);
/* Popover */
/**
* A custom popover element extending FigTooltip.
* @attr {string} action - The trigger action: "click" (default) or "hover"
* @attr {string} size - The size of the popover
*/
class FigPopover extends FigTooltip {
static observedAttributes = ["action", "size"];
constructor() {
super();
this.action = this.getAttribute("action") || "click";
this.delay = parseInt(this.getAttribute("delay")) || 0;
}
render() {
//this.destroy()
//if (!this.popup) {
this.popup = this.popup || this.querySelector("[popover]");
this.popup.setAttribute("class", "fig-popover");
this.popup.style.position = "fixed";
this.popup.style.display = "block";
this.popup.style.pointerEvents = "none";
document.body.append(this.popup);
//}
}
destroy() {}
}
customElements.define("fig-popover", FigPopover);
/* Dialog */
/**
* A custom dialog element for modal and non-modal dialogs.
* @attr {boolean} open - Whether the dialog is visible
* @attr {boolean} modal - Whether the dialog should be modal
*/
class FigDialog extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.dialog = document.createElement("dialog");
this.contentSlot = document.createElement("slot");
}
connectedCallback() {
this.modal =
this.hasAttribute("modal") && this.getAttribute("modal") !== "false";
this.appendChild(this.dialog);
this.shadowRoot.appendChild(this.contentSlot);
this.contentSlot.addEventListener("slotchange", this.slotChange.bind(this));
requestAnimationFrame(() => {
this.#addCloseListeners();
});
}
#addCloseListeners() {
this.dialog
.querySelectorAll("fig-button[close-dialog]")
.forEach((button) => {
button.removeEventListener("click", this.close);
button.addEventListener("click", this.close.bind(this));
});
}
disconnectedCallback() {
this.contentSlot.removeEventListener("slotchange", this.slotChange);
}
slotChange() {
while (this.dialog.firstChild) {
this.dialog.firstChild.remove();
}
this.contentSlot.assignedNodes().forEach((node) => {
if (node !== this.dialog) {
this.dialog.appendChild(node.cloneNode(true));
}
});
this.#addCloseListeners();
}
static get observedAttributes() {
return ["open", "modal"];
}
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case "open":
this.open = newValue === "true" && newValue !== "false";
if (this?.show) {
this[this.open ? "show" : "close"]();
}
break;
case "modal":
this.modal = newValue === "true" && newValue !== "false";
break;
}
}
/* Public methods */
show() {
if (this.modal) {
this.dialog.showModal();
} else {
this.dialog.show();
}
}
close() {
this.dialog.close();
}
}
customElements.define("fig-dialog", FigDialog);
/*
class FigDialog extends FigTooltip {
constructor() {
super()
this.action = this.getAttribute("action") || "click"
this.delay = parseInt(this.getAttribute("delay")) || 0
this.dialog = document.createElement("dialog")
this.header = document.createElement("fig-header")
this.header.innerHTML = `<span>${this.getAttribute("title") || "Title"}</span>`
if (this.getAttribute("closebutton") !== "false") {
this.closeButton = document.createElement("fig-button")
this.closeButton.setAttribute("icon", "true")
this.closeButton.setAttribute("variant", "ghost")
this.closeButton.setAttribute("fig-dialog-close", "true")
let closeIcon = document.createElement("fig-icon")
closeIcon.setAttribute("class", "close")
this.closeButton.append(closeIcon)
this.header.append(this.closeButton)
}
this.dialog.append(this.header)
}
render() {
this.popup = this.popup || this.dialog
document.body.append(this.popup)
}
setup() {
this.dialog.querySelectorAll("[fig-dialog-close]").forEach(e => e.addEventListener("click", this.hidePopup.bind(this)))
this.dialog.append(this.querySelector("fig-content") || "")
}
hidePopup() {
this.popup.close()
}
showPopup() {
this.popup.style.zIndex = parseInt((new Date()).getTime() / 1000)
if (this.getAttribute("modal") === "true") {
this.popup.showModal()
} else {
this.popup.show()
}
}
destroy() {
}
}
customElements.define("fig-dialog", FigDialog);
*/
class FigPopover2 extends HTMLElement {
#popover;
#trigger;
#id;
#delay;
#timeout;
#action;
constructor() {
super();
}
connectedCallback() {
this.#popover = this.querySelector("[popover]");
this.#trigger = this;
this.#delay = Number(this.getAttribute("delay")) || 0;
this.#action = this.getAttribute("trigger-action") || "click";
this.#id = `tooltip-${figUniqueId()}`;
if (this.#popover) {
this.#popover.setAttribute("id", this.#id);
this.#popover.setAttribute("role", "tooltip");
this.#popover.setAttribute("popover", "manual");
this.#popover.style["position-anchor"] = `--${this.#id}`;
this.#trigger.setAttribute("popovertarget", this.#id);
this.#trigger.setAttribute("popovertargetaction", "toggle");
this.#trigger.style["anchor-name"] = `--${this.#id}`;
if (this.#action === "hover") {
this.#trigger.addEventListener("mouseover", this.handleOpen.bind(this));
this.#trigger.addEventListener("mouseout", this.handleClose.bind(this));
} else {
this.#trigger.addEventListener("click", this.handleToggle.bind(this));
}
document.body.append(this.#popover);
}
}
handleClose() {
clearTimeout(this.#timeout);
this.#popover.hidePopover();
}
handleToggle() {
if (this.#popover.matches(":popover-open")) {
this.handleClose();
} else {
this.handleOpen();
}
}
handleOpen() {
clearTimeout(this.#timeout);
this.#timeout = setTimeout(() => {
this.#popover.showPopover();
}, this.#delay);
}
}
window.customElements.define("fig-popover-2", FigPopover2);
/* Tabs */
/**
* A custom tab element for use within FigTabs.
* @attr {string} label - The text label of the tab
* @attr {boolean} selected - Whether the tab is currently selected
*/
class FigTab extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.setAttribute("label", this.innerText);
this.addEventListener("click", this.handleClick.bind(this));
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
}
handleClick() {
this.setAttribute("selected", "true");
}
}
window.customElements.define("fig-tab", FigTab);
/**
* A custom tabs container element.
* @attr {string} name - Identifier for the tabs group
*/
class FigTabs extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.name = this.getAttribute("name") || "tabs";
this.addEventListener("click", this.handleClick.bind(this));
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
}
handleClick(event) {
const target = event.target;
if (target.nodeName.toLowerCase() === "fig-tab") {
const tabs = this.querySelectorAll("fig-tab");
for (const tab of tabs) {
if (tab === target) {
this.selectedTab = tab;
} else {
tab.removeAttribute("selected");
}
}
}
}
}
window.customElements.define("fig-tabs", FigTabs);
/* Segmented Control */
/**
* A custom segment element for use within FigSegmentedControl.
* @attr {string} value - The value associated with this segment
* @attr {boolean} selected - Whether the segment is currently selected
*/
class FigSegment extends HTMLElement {
#value;
#selected;
constructor() {
super();
}
connectedCallback() {
this.addEventListener("click", this.handleClick.bind(this));
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
}
handleClick() {
this.setAttribute("selected", "true");
}
get value() {
return this.#value;
}
set value(value) {
this.#value = value;
this.setAttribute("value", value);
}
get selected() {
return this.#selected;
}
set selected(value) {
this.#selected = value;
this.setAttribute("selected", value);
}
static get observedAttributes() {
return ["selected", "value"];
}
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case "value":
this.#value = newValue;
break;
case "selected":
this.#selected = newValue;
break;
}
}
}
window.customElements.define("fig-segment", FigSegment);
/**
* A custom segmented control container element.
* @attr {string} name - Identifier for the segmented control group
*/
class FigSegmentedControl extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.name = this.getAttribute("name") || "segmented-control";
this.addEventListener("click", this.handleClick.bind(this));
}
handleClick(event) {
const target = event.target;
if (target.nodeName.toLowerCase() === "fig-segment") {
const segments = this.querySelectorAll("fig-segment");
for (const segment of segments) {
if (segment === target) {
this.selectedSegment = segment;
} else {
segment.removeAttribute("selected");
}
}
}
}
}
window.customElements.define("fig-segmented-control", FigSegmentedControl);
/* Slider */
/**
* A custom slider input element.
* @attr {string} type - The slider type: "range", "hue", "delta", "stepper", or "opacity"
* @attr {number} value - The current value of the slider
* @attr {number} min - The minimum value
* @attr {number} max - The maximum value
* @attr {number} step - The step increment
* @attr {boolean} text - Whether to show a text input alongside the slider
* @attr {string} units - The units to display after the value
* @attr {number} transform - A multiplier for the displayed value
* @attr {boolean} disabled - Whether the slider is disabled
* @attr {string} color - The color for the slider track (for opacity type)
*/
class FigSlider extends HTMLElement {
// Private fields declarations
#typeDefaults = {
range: { min: 0, max: 100, step: 1 },
hue: { min: 0, max: 255, step: 1 },
delta: { min: -100, max: 100, step: 1 },
stepper: { min: 0, max: 100, step: 25 },
opacity: { min: 0, max: 100, step: 0.1, color: "#FF0000" },
};
#boundHandleInput;
#boundHandleTextInput;
constructor() {
super();
// Bind the event handlers
this.#boundHandleInput = (e) => {
e.stopPropagation();
this.#handleInput();
};
this.#boundHandleTextInput = (e) => {
e.stopPropagation();
this.#handleTextInput();
};
}
#regenerateInnerHTML() {
this.value = Number(this.getAttribute("value") || 0);
this.type = this.getAttribute("type") || "range";
this.text = this.getAttribute("text") || false;
this.units = this.getAttribute("units") || "";
this.transform = Number(this.getAttribute("transform") || 1);
this.disabled = this.getAttribute("disabled") ? true : false;
const defaults = this.#typeDefaults[this.type];
this.min = Number(this.getAttribute("min") || defaults.min);
this.max = Number(this.getAttribute("max") || defaults.max);
this.step = Number(this.getAttribute("step") || defaults.step);
this.color = this.getAttribute("color") || defaults?.color;
this.default = this.getAttribute("default") || this.min;
if (this.color) {
this.style.setProperty("--color", this.color);
}
let html = "";
let slider = `<div class="fig-slider-input-container">
<input
type="range"
${this.disabled ? "disabled" : ""}
min="${this.min}"
max="${this.max}"
step="${this.step}"
class="${this.type}"
value="${this.value}">
${this.initialInnerHTML}
</div>`;
if (this.text) {
html = `${slider}
<fig-input-text
placeholder="##"
type="number"
min="${this.min}"
max="${this.max}"
transform="${this.transform}"
step="${this.step}"
value="${this.value}">
${
this.units
? `<span slot="append">${this.units}</span>`
: ""
}
</fig-input-text>`;
} else {
html = slider;
}
this.innerHTML = html;
//child nodes hack
requestAnimationFrame(() => {
this.input = this.querySelector("[type=range]");
this.inputContainer = this.querySelector(".fig-slider-input-container");
this.input.removeEventListener("input", this.#boundHandleInput);
this.input.addEventListener("input", this.#boundHandleInput);
if (this.default) {
this.style.setProperty(
"--default",
this.#calculateNormal(this.default)
);
}
this.datalist = this.querySelector("datalist");
this.figInputText = this.querySelector("fig-input-text");
if (this.datalist) {
this.inputContainer.append(this.datalist);
this.datalist.setAttribute(
"id",
this.datalist.getAttribute("id") || figUniqueId()
);
this.input.setAttribute("list", this.datalist.getAttribute("id"));
} else if (this.type === "stepper") {
this.datalist = document.createElement("datalist");
this.datalist.setAttribute("id", figUniqueId());
let steps = (this.max - this.min) / this.step + 1;
for (let i = 0; i < steps; i++) {
let option = document.createElement("option");
option.setAttribute("value", this.min + i * this.step);
this.datalist.append(option);
}
this.inputContainer.append(this.datalist);
this.input.setAttribute("list", this.datalist.getAttribute("id"));
} else if (this.type === "delta") {
this.datalist = document.createElement("datalist");
this.datalist.setAttribute("id", figUniqueId());
let option = document.createElement("option");
option.setAttribute("value", this.default);
this.datalist.append(option);
this.inputContainer.append(this.datalist);
this.input.setAttribute("list", this.datalist.getAttribute("id"));
}
if (this.datalist) {
let defaultOption = this.datalist.querySelector(
`option[value='${this.default}']`
);
if (defaultOption) {
defaultOption.setAttribute("default", "true");
}
}
if (this.figInputText) {
this.figInputText.removeEventListener(
"input",
this.#boundHandleTextInput
);
this.figInputText.addEventListener("input", this.#boundHandleTextInput);
}
this.#syncValue();
});
}
connectedCallback() {
this.initialInnerHTML = this.innerHTML;
this.#regenerateInnerHTML();
}
#handleTextInput() {
if (this.figInputText) {
this.value = this.input.value = this.figInputText.value;
this.#syncProperties();
this.dispatchEvent(new CustomEvent("input", { bubbles: true }));
}
}
#calculateNormal(value) {
let min = Number(this.min);
let max = Number(this.max);
return (Number(value) - min) / (max - min);
}
#syncProperties() {
let complete = this.#calculateNormal(this.value);
this.style.setProperty("--slider-complete", complete);
let defaultValue = this.#calculateNormal(this.default);
this.style.setProperty("--default", defaultValue);
this.style.setProperty("--unchanged", complete === defaultValue ? 1 : 0);
}
#syncValue() {
let val = this.input.value;
this.value = val;
this.#syncProperties();
if (this.figInputText) {
this.figInputText.setAttribute("value", val);
}
}
#handleInput() {
this.#syncValue();
this.dispatchEvent(new CustomEvent("input", { bubbles: true }));
}
static get observedAttributes() {
return [
"value",
"step",
"min",
"max",
"type",
"disabled",
"color",
"units",
"transform",
];
}
focus() {
this.input.focus();
}
attributeChangedCallback(name, oldValue, newValue) {
if (this.input) {
switch (name) {
case "color":
this.color = newValue;
this.style.setProperty("--color", this.color);
break;
case "disabled":
this.disabled = this.input.disabled =
newValue === "true" ||
(newValue === undefined && newValue !== null);
if (this.figInputText) {
this.figInputText.disabled = this.disabled;
this.figInputText.setAttribute("disabled", this.disabled);
}
break;
case "value":
this.value = newValue;
if (this.figInputText) {
this.figInputText.setAttribute("value", newValue);
}
break;
case "transform":
this.transform = Number(newValue) || 1;
if (this.figInputText) {
this.figInputText.setAttribute("transform", this.transform);
}
break;
case "min":
case "max":
case "step":
case "type":
case "text":
case "units":
this[name] = newValue;
this.#regenerateInnerHTML();
break;
default:
this[name] = this.input[name] = newValue;
this.#syncValue();
break;
}
}
}
}
window.customElements.define("fig-slider", FigSlider);
/**
* A custom text input element.
* @attr {string} type - Input type: "text" (default) or "number"
* @attr {string} value - The current input value
* @attr {string} placeholder - Placeholder text
* @attr {boolean} disabled - Whether the input is disabled
* @attr {boolean} multiline - Whether to use a textarea instead of input
* @attr {number} min - Minimum value (for number type)
* @attr {number} max - Maximum value (for number type)
* @attr {number} step - Step increment (for number type)
* @attr {number} transform - A multiplier for displayed number values
*/
class FigInputText extends HTMLElement {
#boundMouseMove;
#boundMouseUp;
#boundMouseDown;
#boundInputChange;
constructor() {
super();
// Pre-bind the event handlers once
this.#boundMouseMove = this.#handleMouseMove.bind(this);
this.#boundMouseUp = this.#handleMouseUp.bind(this);
this.#boundMouseDown = this.#handleMouseDown.bind(this);
this.#boundInputChange = (e) => {
e.stopPropagation();
this.#handleInputChange(e);
};
}
connectedCallback() {
this.multiline = this.hasAttribute("multiline") || false;
this.value = this.getAttribute("value") || "";
this.type = this.getAttribute("type") || "text";
this.placeholder = this.getAttribute("placeholder") || "";
this.name = this.getAttribute("name") || null;
if (this.type === "number") {
if (this.getAttribute("step")) {
this.step = Number(this.getAttribute("step"));
}
if (this.getAttribute("min")) {
this.min = Number(this.getAttribute("min"));
}
if (this.getAttribute("max")) {
this.max = Number(this.getAttribute("max"));
}
this.transform = Number(this.getAttribute("transform") || 1);
if (this.getAttribute("value")) {
this.value = Number(this.value);
}
}
let html = `<input
type="${this.type}"
${this.name ? `name="${this.name}"` : ""}
placeholder="${this.placeholder}"
value="${
this.type === "number" ? this.#transformNumber(this.value) : this.value
}" />`;
if (this.multiline) {
html = `<textarea
placeholder="${this.placeholder}">${this.value}</textarea>`;
}
//child nodes hack
requestAnimationFrame(() => {
const append = this.querySelector("[slot=append]");
const prepend = this.querySelector("[slot=prepend]");
this.innerHTML = html;
if (prepend) {
prepend.addEventListener("click", this.focus.bind(this));
this.prepend(prepend);
}
if (append) {
append.addEventListener("click", this.focus.bind(this));
this.append(append);
}
this.input = this.querySelector("input,textarea");
if (this.type === "number") {
if (this.getAttribute("min")) {
this.input.setAttribute("min", this.#transformNumber(this.min));
}
if (this.getAttribute("max")) {
this.input.setAttribute("max", this.#transformNumber(this.max));
}
if (this.getAttribute("step")) {
this.input.setAttribute("step", this.#transformNumber(this.step));
}
this.addEventListener("pointerdown", this.#boundMouseDown);
}
this.input.removeEventListener("change", this.#boundInputChange);
this.input.addEventListener("change", this.#boundInputChange);
});
}
focus() {
this.input.focus();
}
#transformNumber(value) {
if (value === "") return "";
let transformed = Number(value) * (this.transform || 1);
transformed = this.#formatNumber(transformed);
return transformed;
}
#handleInputChange(e) {
e.stopPropagation();
let value = e.target.value;
let valueTransformed = value;
if (this.type === "number") {
value = value / (this.transform || 1);
value = this.#sanitizeInput(value, false);
console.log("sanitizeInput", value);
valueTransformed = value * (this.transform || 1);
}
this.value = value;
this.input.value = valueTransformed;
this.dispatchEvent(new CustomEvent("input", { bubbles: true }));
}
#handleMouseMove(e) {
if (this.type !== "number") return;
if (e.altKey) {
let step = (this.step || 1) * e.movementX;
let value = Number(this.input.value);
value = value / (this.transform || 1) + step;
value = this.#sanitizeInput(value, false);
let valueTransformed = value * (this.transform || 1);
value = this.#formatNumber(value);
valueTransformed = this.#formatNumber(valueTransformed);
this.value = value;
this.input.value = valueTransformed;
this.dispatchEvent(new CustomEvent("input", { bubbles: true }));
}
}
#handleMouseDown(e) {
if (this.type !== "number") return;
if (e.altKey) {
this.input.style.cursor =
this.style.cursor =
document.body.style.cursor =
"ew-resize";
this.style.userSelect = "none";
// Use the pre-bound handlers
window.addEventListener("pointermove", this.#boundMouseMove);
window.addEventListener("pointerup", this.#boundMouseUp);
}
}
#handleMouseUp(e) {
if (this.type !== "number") return;
this.input.style.cursor =
this.style.cursor =
document.body.style.cursor =
"";
this.style.userSelect = "all";
// Remove the pre-bound handlers
window.removeEventListener("pointermove", this.#boundMouseMove);
window.removeEventListener("pointerup", this.#boundMouseUp);
}
#sanitizeInput(value, transform = true) {
let sanitized = value;
if (this.type === "number") {
sanitized = Number(sanitized);
if (typeof this.min === "number") {
sanitized = Math.max(
transform ? this.#transformNumber(this.min) : this.min,
sanitized
);
}
if (typeof this.max === "number") {
sanitized = Math.min(
transform ? this.#transformNumber(this.max) : this.max,
sanitized
);
}
sanitized = this.#formatNumber(sanitized);
}
return sanitized;
}
#formatNumber(num, precision = 2) {
// Check if the number has any decimal places after rounding
const rounded = Math.round(num * 100) / 100;
return Number.isInteger(rounded) ? rounded : rounded.toFixed(precision);
}
/*
get value() {
return this.value;
}
set value(val) {
this.value = val;
this.setAttribute("value", val);
}*/
static get observedAttributes() {
return [
"value",
"placeholder",
"label",
"disabled",
"type",
"step",
"min",
"max",
"transform",
"name",
];
}
attributeChangedCallback(name, oldValue, newValue) {
if (this.input) {
switch (name) {
case "disabled":
this.disabled = this.input.disabled =
newValue === "true" ||
(newValue === undefined && newValue !== null);
break;
case "transform":
if (this.type === "number") {
this.transform = Number(newValue) || 1;
this.input.value = this.#transformNumber(this.value);
}
break;
case "value":
let value = newValue;
if (this.type === "number") {
value = this.#sanitizeInput(value, false);
this.value = value;
this.input.value = this.#transformNumber(value);
} else {
this.value = value;
this.input.value = value;
}
break;
case "min":
case "max":
case "step":
this[name] = this.input[name] = Number(newValue);
if (this.input) {
this.input.setAttribute(name, newValue);
}
break;
case "name":
this[name] = this.input[name] = newValue;
this.input.setAttribute("name", newValue);
break;
default:
this[name] = this.input[name] = newValue;
break;
}
}
}
}
window.customElements.define("fig-input-text", FigInputText);
/* Avatar */
class FigAvatar extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.src = this.getAttribute("src");
this.name = this.getAttribute("name");
this.initials = this.getInitials(this.name);
this.setAttribute("initials", this.initials);
this.setSrc(this.src);
requestAnimationFrame(() => {
this.img = this.querySelector("img");
});
}
setSrc(src) {
this.src = src;
if (src) {
this.innerHTML = `<img src="${this.src}" ${
this.name ? `alt="${this.name}"` : ""
} />`;
}
}
getInitials(name) {
return name
? name
.split(" ")
.map((n) => n[0])
.join("")
: "";
}
static get observedAttributes() {
return ["src", "href", "name"];
}
attributeChangedCallback(name, oldValue, newValue) {
this[name] = newValue;
if (name === "name") {
this.img?.setAttribute("alt", newValue);
this.name = newValue;
this.initials = this.getInitials(this.name);
this.setAttribute("initials", this.initials);
} else if (name === "src") {
this.src = newValue;
this.setSrc(this.src);
}
}
}
window.customElements.define("fig-avatar", FigAvatar);
/* Form Field */
class FigField extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
requestAnimationFrame(() => {
this.label = this.querySelector(":scope>label");
this.input = Array.from(this.childNodes).find((node) =>
node.nodeName.toLowerCase().startsWith("fig-")
);
if (this.input && this.label) {
this.label.addEventListener("click", this.focus.bind(this));
let inputId = this.input.getAttribute("id") || figUniqueId();
this.input.setAttribute("id", inputId);
this.label.setAttribute("for", inputId);
}
});
}
focus() {
this.input.focus();
}
}
window.customElements.define("fig-field", FigField);
/* Color swatch */
class FigInputColor extends HTMLElement {
rgba;
hex;
alpha = 100;
#swatch;
#textInput;
#alphaInput;
constructor() {
super();
}
connectedCallback() {
this.#setValues(this.getAttribute("value"));
let html = ``;
if (this.getAttribute("text")) {
let label = `<fig-input-text type="text" placeholder="Text" value="${this.value}"></fig-input-text>`;
if (this.getAttribute("alpha") === "true") {
label += `<fig-tooltip text="Opacity">
<fig-input-text
placeholder="##"
type="number"
min="0"
max="100"
value="${this.alpha}">
<span slot="append">%</slot>
</fig-input-text>
</fig-tooltip>`;
}
html = `<div class="input-combo">
<fig-chit type="color" disabled="false" value="${this.hexOpaque}"></fig-chit>
${label}
</div>`;
} else {
html = `<fig-chit type="color" disabled="false" value="${this.hexOpaque}"></fig-chit>`;
}
this.innerHTML = html;
requestAnimationFrame(() => {
this.#swatch = this.querySelector("fig-chit[type=color]");
this.#textInput = this.querySelector("fig-input-text:not([type=number])");
this.#alphaInput = this.querySelector("fig-input-text[type=number]");
this.#swatch.disabled = this.hasAttribute("disabled");
this.#swatch.addEventListener("input", this.#handleInput.bind(this));
if (this.#textInput) {
this.#textInput.value = this.#swatch.value = this.rgbAlphaToHex(
this.rgba,
1
);
this.#textInput.addEventListener(
"input",
this.#handleTextInput.bind(this)
);
}
if (this.#alphaInput) {
this.#alphaInput.addEventListener(
"input",
this.#handleAlphaInput.bind(this)
);
}
});
}
#setValues(hexValue) {
this.rgba = this.convertToRGBA(hexValue);
this.value = this.rgbAlphaToHex(
{
r: this.rgba.r,
g: this.rgba.g,
b: this.rgba.b,
},
this.rgba.a
);
this.hexWithAlpha = this.value;
this.hexOpaque = this.hexWithAlpha.slice(0, 7);
if (hexValue.length > 7) {
this.alpha = (this.rgba.a * 100).toFixed(0);
}
this.style.setProperty("--alpha", this.rgba.a);
}
#handleTextInput(event) {
//do not propagate to onInput handler for web component
event.stopPropagation();
this.#setValues(event.target.value);
if (this.#alphaInput) {
this.#alphaInput.setAttribute("value", this.alpha);
}
if (this.#swatch) {
this.#swatch.setAttribute("value", this.hexOpaque);
}
this.#emitInputEvent();
}
#handleAlphaInput(event) {
//do not propagate to onInput handler for web component
event.stopPropagation();
const alpha = Math.round((event.target.value / 100) * 255);
const alphaHex = alpha.toString(16).padStart(2, "0");
this.#setValues(this.hexOpaque + alphaHex);
this.#emitInputEvent();
}
focus() {
this.#swatch.focus();
}
#handleInput(event) {
//do not propagate to onInput handler for web component
event.stopPropagation();
this.#setValues(event.target.value);
if (this.#textInput) {
this.#textInput.setAttribute("value", this.value);
}
this.#emitInputEvent();
}
#emitInputEvent() {
const e = new CustomEvent("input", {
bubbles: true,
cancelable: true,
});
this.dispatchEvent(e);
}
static get observedAttributes() {
return ["value", "style"];
}
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case "value":
this.#setValues(newValue);
if (this.#textInput) {
this.#textInput.setAttribute("value", this.value);
}
if (this.#swatch) {
this.#swatch.setAttribute("value", this.hexOpaque);
}
if (this.#alphaInput) {
this.#alphaInput.setAttribute("value", this.alpha);
}
this.#emitInputEvent();
break;
}
}
rgbAlphaToHex({ r, g, b }, a = 1) {
// Ensure r, g, b are integers between 0 and 255
r = Math.max(0, Math.min(255, Math.round(r)));
g = Math.max(0, Math.min(255, Math.round(g)));
b = Math.max(0, Math.min(255, Math.round(b)));
// Ensure alpha is between 0 and 1
a = Math.max(0, Math.min(1, a));
// Convert to hex and pad with zeros if necessary
const hexR = r.toString(16).padStart(2, "0");
const hexG = g.toString(16).padStart(2, "0");
const hexB = b.toString(16).padStart(2, "0");
// If alpha is 1, return 6-digit hex
if (a === 1) {
return `#${hexR}${hexG}${hexB}`.toUpperCase();
}
// Otherwise, include alpha in 8-digit hex
const alpha = Math.round(a * 255);
const hexA = alpha.toString(16).padStart(2, "0");
return `#${hexR}${hexG}${hexB}${hexA}`.toUpperCase();
}
convertToRGBA(color) {
let r,
g,
b,
a = 1;
// Handle hex colors
if (color.startsWith("#")) {
let hex = color.slice(1);
if (hex.length === 8) {
a = parseInt(hex.slice(6), 16) / 255;
hex = hex.slice(0, 6);
}
r = parseInt(hex.slice(0, 2), 16);
g = parseInt(hex.slice(2, 4), 16);
b = parseInt(hex.slice(4, 6), 16);
}
// Handle rgba colors
else if (color.startsWith("rgba") || color.startsWith("rgb")) {
let matches = color.match(
/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)/
);
if (matches) {
r = parseInt(matches[1]);
g = parseInt(matches[2]);
b = parseInt(matches[3]);
a = matches[4] ? parseFloat(matches[4]) : 1;
}
}
// Handle hsla colors
else if (color.startsWith("hsla") || color.startsWith("hsl")) {
let matches = color.match(
/hsla?\((\d+),\s*(\d+)%,\s*(\d+)%(?:,\s*(\d+(?:\.\d+)?))?\)/
);
if (matches) {
let h = parseInt(matches[1]) / 360;
let s = parseInt(matches[2]) / 100;
let l = parseInt(matches[3]) / 100;
a = matches[4] ? parseFloat(matches[4]) : 1;
if (s === 0) {
r = g = b = l; // achromatic
} else {
let hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
let q = l < 0.5 ? l * (1 + s) : l + s - l * s;
let p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
r = Math.round(r * 255);
g = Math.round(g * 255);
b = Math.round(b * 255);
}
}
// If it's not recognized, return null
else {
return null;
}
return { r, g, b, a };
}
}
window.customElements.define("fig-input-color", FigInputColor);
/* Checkbox */
/**
* A custom checkbox input element.
* @attr {boolean} checked - Whether the checkbox is checked
* @attr {boolean} disabled - Whether the checkbox is disabled
* @attr {string} label - The label text
* @attr {string} name - The form field name
* @attr {string} value - The value when checked
*/
class FigCheckbox extends HTMLElement {
constructor() {
super();
this.input = document.createElement("input");
this.name = this.getAttribute("name") || "checkbox";
this.value = this.getAttribute("value") || "";
this.input.setAttribute("id", figUniqueId());
this.input.setAttribute("name", this.name);
this.input.setAttribute("type", "checkbox");
this.labelElement = document.createElement("label");
this.labelElement.setAttribute("for", this.input.id);
}
connectedCallback() {
this.checked = this.input.checked =
this.hasAttribute("checked") && this.getAttribute("checked") !== "false";
this.input.addEventListener("change", this.handleInput.bind(this));
if (this.hasAttribute("disabled")) {
this.input.disabled = true;
}
if (this.hasAttribute("indeterminate")) {
this.input.indeterminate = true;
this.input.setAttribute("indeterminate", "true");
}
this.append(this.input);
this.append(this.labelElement);
this.render();
}
static get observedAttributes() {
return ["disabled", "label", "checked", "name", "value"];
}
render() {}
focus() {
this.input.focus();
}
disconnectedCallback() {
this.input.remove();
}
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case "label":
this.labelElement.innerText = newValue;
break;
case "checked":
this.checked = this.input.checked =
this.hasAttribute("checked") &&
this.getAttribute("checked") !== "false";
break;
default:
this.input[name] = newValue;
this.input.setAttribute(name, newValue);
break;
}
}
handleInput(e) {
this.input.indeterminate = false;
this.input.removeAttribute("indeterminate");
this.value = this.input.value;
}
}
window.customElements.define("fig-checkbox", FigCheckbox);
/* Radio */
/**
* A custom radio input element extending FigCheckbox.
* @attr {boolean} checked - Whether the radio is selected
* @attr {boolean} disabled - Whether the radio is disabled
* @attr {string} label - The label text
* @attr {string} name - The radio group name
* @attr {string} value - The value when selected
*/
class FigRadio extends FigCheckbox {
constructor() {
super();
this.input.setAttribute("type", "radio");
this.input.setAttribute("name", this.getAttribute("name") || "radio");
}
}
window.customElements.define("fig-radio", FigRadio);
/* Switch */
/**
* A custom switch/toggle input element extending FigCheckbox.
* @attr {boolean} checked - Whether the switch is on
* @attr {boolean} disabled - Whether the switch is disabled
* @attr {string} label - The label text
* @attr {string} name - The form field name
* @attr {string} value - The value when on
*/
class FigSwitch extends FigCheckbox {
render() {
this.input.setAttribute("class", "switch");
}
}
window.customElements.define("fig-switch", FigSwitch);
/* Bell */
class FigBell extends HTMLElement {
constructor() {
super();
}
}
window.customElements.define("fig-bell", FigBell);
/* Badge */
class FigBadge extends HTMLElement {
constructor() {
super();
}
}
window.customElements.define("fig-badge", FigBadge);
/* Accordion */
class FigAccordion extends HTMLElement {
constructor() {
super()