@rogieking/figui3
Version:
A lightweight web components library for building Figma plugin and widget UIs with native look and feel
1,625 lines (1,463 loc) • 476 kB
JavaScript
/**
* Generates a unique ID string using timestamp and random values
* @returns {string} A unique identifier
*/
function figIsWebKitOrIOSBrowser() {
if (typeof navigator === "undefined") {
return false;
}
const userAgent = navigator.userAgent || "";
const isIOSBrowser =
/\b(iPad|iPhone|iPod)\b/.test(userAgent) ||
(/\bMacintosh\b/.test(userAgent) && /\bMobile\b/.test(userAgent));
const isDesktopWebKit =
/\bAppleWebKit\b/.test(userAgent) &&
!/\b(Chrome|Chromium|Edg|OPR|SamsungBrowser)\b/.test(userAgent);
return isIOSBrowser || isDesktopWebKit;
}
function figSupportsCustomizedBuiltIns() {
if (
typeof window === "undefined" ||
!window.customElements ||
typeof HTMLButtonElement === "undefined"
) {
return false;
}
const testName = `fig-builtin-probe-${Math.random().toString(36).slice(2)}`;
class FigCustomizedBuiltInProbe extends HTMLButtonElement {}
try {
customElements.define(testName, FigCustomizedBuiltInProbe, {
extends: "button",
});
const probe = document.createElement("button", { is: testName });
return probe instanceof FigCustomizedBuiltInProbe;
} catch (_error) {
return false;
}
}
const figNeedsBuiltInPolyfill =
figIsWebKitOrIOSBrowser() && !figSupportsCustomizedBuiltIns();
const figBuiltInPolyfillReady = (
figNeedsBuiltInPolyfill
? import("./polyfills/custom-elements-webkit.js")
: Promise.resolve()
)
.then(() => {})
.catch((error) => {
throw error;
});
function figDefineCustomizedBuiltIn(name, constructor, options) {
const define = () => {
if (!customElements.get(name)) {
customElements.define(name, constructor, options);
}
};
if (!figNeedsBuiltInPolyfill) {
define();
return;
}
figBuiltInPolyfillReady.then(define).catch((error) => {
console.error(
`[figui3] Failed to load customized built-in polyfill for "${name}".`,
error,
);
});
}
function figUniqueId() {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
let _figZCounter = 10000;
function figGetHighestZIndex() {
return _figZCounter++;
}
function figSyncCssVar(el, prop, value) {
if (value && value.trim()) {
el.style.setProperty(prop, value.trim());
} else {
el.style.removeProperty(prop);
}
}
let _figSharedCanvas = null;
let _figSharedCtx = null;
function figGetSharedCanvas(width = 1, height = 1) {
if (!_figSharedCanvas) {
_figSharedCanvas = document.createElement("canvas");
_figSharedCtx = _figSharedCanvas.getContext("2d");
}
if (_figSharedCanvas.width !== width) _figSharedCanvas.width = width;
if (_figSharedCanvas.height !== height) _figSharedCanvas.height = height;
return { canvas: _figSharedCanvas, ctx: _figSharedCtx };
}
/**
* Checks if the browser supports the native popover API
* @returns {boolean} True if popover is supported
*/
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", "submit", or "link"
* @attr {boolean} selected - Whether the button is in a selected state
* @attr {boolean} disabled - Whether the button is disabled
* @attr {string} href - URL for link type buttons
* @attr {string} target - Target window for link type buttons (e.g., "_blank")
*/
class FigButton extends HTMLElement {
type;
#selected;
constructor() {
super();
this.attachShadow({ mode: "open", delegatesFocus: true });
}
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);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
min-width: 0;
}
:host([size="large"]) button {
height: var(--spacer-5);
}
:host([size="large"][icon]) button {
padding: 0;
}
</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));
// Forward focus-visible state to host element
this.button.addEventListener("focus", () => {
if (this.button.matches(":focus-visible")) {
this.setAttribute("data-focus-visible", "");
}
});
this.button.addEventListener("blur", () => {
this.removeAttribute("data-focus-visible");
});
});
}
get type() {
return this.getAttribute("type") || "button";
}
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") {
let form = this.closest("form");
if (form) {
form.submit();
}
}
if (this.type === "link") {
const href = this.getAttribute("href");
const target = this.getAttribute("target");
if (href) {
if (target) {
window.open(href, target);
} else {
window.location.href = href;
}
}
}
}
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 !== null && newValue !== "false";
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;
}
}
}
}
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 {
#label = "Menu";
#selectedValue = null; // Stores last selected value for dropdown type
#boundHandleSelectInput;
#boundHandleSelectChange;
#selectedContentEnabled = false;
#selectedContentEl = null;
get label() {
return this.#label;
}
set label(value) {
this.#label = value;
}
#boundSlotChange;
constructor() {
super();
this.select = document.createElement("select");
this.optionsSlot = document.createElement("slot");
this.attachShadow({ mode: "open" });
this.#boundHandleSelectInput = this.#handleSelectInput.bind(this);
this.#boundHandleSelectChange = this.#handleSelectChange.bind(this);
this.#boundSlotChange = this.slotChange.bind(this);
}
#supportsSelectedContent() {
if (typeof CSS === "undefined" || typeof CSS.supports !== "function")
return false;
try {
return (
CSS.supports("appearance: base-select") &&
CSS.supports("selector(::picker(select))")
);
} catch {
return false;
}
}
#enableSelectedContentIfNeeded() {
const experimental = this.getAttribute("experimental") || "";
const wantsModern = experimental
.split(/\s+/)
.filter(Boolean)
.includes("modern");
if (!wantsModern || !this.#supportsSelectedContent()) {
this.#selectedContentEnabled = false;
return;
}
const button = document.createElement("button");
button.setAttribute("type", "button");
button.setAttribute("aria-hidden", "true");
const selected = document.createElement("selectedcontent");
button.appendChild(selected);
this.select.appendChild(button);
this.#selectedContentEnabled = true;
this.#selectedContentEl = selected;
}
#syncSelectedContent() {
if (!this.#selectedContentEl) return;
const selectedOption = this.select.selectedOptions?.[0];
if (!selectedOption) {
this.#selectedContentEl.textContent = "";
return;
}
// Fallback mirror for browsers that don't auto-project selectedcontent reliably.
this.#selectedContentEl.innerHTML = selectedOption.innerHTML;
}
#addEventListeners() {
this.select.addEventListener("input", this.#boundHandleSelectInput);
this.select.addEventListener("change", this.#boundHandleSelectChange);
}
#hasPersistentControl(optionEl) {
if (!optionEl || !(optionEl instanceof Element)) return false;
return !!optionEl.querySelector(
'fig-checkbox, fig-switch, input[type="checkbox"]',
);
}
#keepPickerOpen() {
// Keep menu open for interactive controls inside option content.
if (typeof this.select.showPicker === "function") {
requestAnimationFrame(() => {
try {
this.select.showPicker();
} catch {
// Ignore if browser blocks reopening picker
}
});
}
}
connectedCallback() {
this.type = this.getAttribute("type") || "select";
this.#label = this.getAttribute("label") || this.#label;
this.select.setAttribute("aria-label", this.#label);
this.appendChild(this.select);
this.shadowRoot.appendChild(this.optionsSlot);
this.optionsSlot.addEventListener("slotchange", this.#boundSlotChange);
this.#addEventListeners();
}
slotChange() {
while (this.select.firstChild) {
this.select.firstChild.remove();
}
this.#enableSelectedContentIfNeeded();
if (this.type === "dropdown") {
const hiddenOption = document.createElement("option");
hiddenOption.setAttribute("hidden", "true");
hiddenOption.setAttribute("selected", "true");
hiddenOption.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);
this.#syncSelectedContent();
if (this.type === "dropdown") {
this.select.selectedIndex = -1;
}
}
#handleSelectInput(e) {
const selectedOption = e.target.selectedOptions?.[0];
if (this.#hasPersistentControl(selectedOption)) {
if (this.type === "dropdown") {
this.select.selectedIndex = -1;
}
this.#keepPickerOpen();
return;
}
const selectedValue = e.target.value;
// Store the selected value for dropdown type (before select gets reset)
if (this.type === "dropdown") {
this.#selectedValue = selectedValue;
}
this.setAttribute("value", selectedValue);
this.#syncSelectedContent();
this.dispatchEvent(
new CustomEvent("input", {
detail: selectedValue,
bubbles: true,
composed: true,
}),
);
}
#handleSelectChange(e) {
const selectedOption = e.target.selectedOptions?.[0];
if (this.#hasPersistentControl(selectedOption)) {
if (this.type === "dropdown") {
this.select.selectedIndex = -1;
}
this.#keepPickerOpen();
return;
}
// Get the value before resetting (use stored value for dropdown type)
const selectedValue =
this.type === "dropdown" ? this.#selectedValue : this.select.value;
// Reset to hidden option for dropdown type
if (this.type === "dropdown") {
this.select.selectedIndex = -1;
}
this.#syncSelectedContent();
this.dispatchEvent(
new CustomEvent("change", {
detail: selectedValue,
bubbles: true,
composed: true,
}),
);
}
focus() {
this.select.focus();
}
blur() {
this.select.blur();
}
get value() {
// For dropdown type, return the stored value since the select is reset after selection
if (this.type === "dropdown") {
return this.#selectedValue;
}
return this.select?.value;
}
set value(value) {
// Store value for dropdown type
if (this.type === "dropdown") {
this.#selectedValue = value;
}
this.setAttribute("value", value);
}
static get observedAttributes() {
return ["value", "type", "experimental"];
}
#syncSelectedValue(value) {
// For dropdown type, don't sync the visual selection - it should always show the hidden placeholder
if (this.type === "dropdown") {
return;
}
if (this.select) {
this.select.querySelectorAll("option").forEach((o, i) => {
if (o.value === this.getAttribute("value")) {
this.select.selectedIndex = i;
}
});
}
this.#syncSelectedContent();
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "value") {
this.#syncSelectedValue(newValue);
}
if (name === "type") {
this.type = newValue;
}
if (name === "experimental") {
this.slotChange();
}
if (name === "label") {
this.#label = newValue;
this.select.setAttribute("aria-label", this.#label);
}
}
disconnectedCallback() {
this.optionsSlot.removeEventListener("slotchange", this.#boundSlotChange);
this.select.removeEventListener("input", this.#boundHandleSelectInput);
this.select.removeEventListener("change", this.#boundHandleSelectChange);
}
}
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} theme - Optional theme passed to the underlying popup (e.g. "brand").
* @attr {string} pointer - "false" to hide the beak.
* @attr {boolean} show - When set, force-show the tooltip (ignores hide).
*/
class FigTooltip extends HTMLElement {
static #lastShownAt = 0;
static #warmupWindow = 500;
#boundHideOnChromeOpen;
#boundHidePopupOutsideClick;
#boundShowDelayedPopup;
#boundHandlePointerLeave;
#boundHandleTouchStart;
#boundHandleTouchMove;
#boundHandleTouchEnd;
#boundHandleTouchCancel;
#boundHandleDialogClose;
#parentDialog = null;
#touchTimeout;
#isTouching = false;
constructor() {
super();
this.action = this.getAttribute("action") || "hover";
let delay = parseInt(this.getAttribute("delay"));
this.delay = !isNaN(delay) ? delay : 500;
this.#boundHideOnChromeOpen = this.#hideOnChromeOpen.bind(this);
this.#boundHidePopupOutsideClick = this.hidePopupOutsideClick.bind(this);
this.#boundShowDelayedPopup = this.showDelayedPopup.bind(this);
this.#boundHandlePointerLeave = this.#handlePointerLeave.bind(this);
this.#boundHandleTouchStart = this.#handleTouchStart.bind(this);
this.#boundHandleTouchMove = this.#handleTouchMove.bind(this);
this.#boundHandleTouchEnd = this.#handleTouchEnd.bind(this);
this.#boundHandleTouchCancel = this.#handleTouchCancel.bind(this);
this.#boundHandleDialogClose = () => {
clearTimeout(this.timeout);
this.destroy();
this.isOpen = false;
};
}
connectedCallback() {
this.setup();
this.setupEventListeners();
this.#parentDialog = this.closest("dialog");
if (this.#parentDialog) {
this.#parentDialog.addEventListener("close", this.#boundHandleDialogClose);
}
}
disconnectedCallback() {
clearTimeout(this.timeout);
this.destroy();
document.removeEventListener(
"mousedown",
this.#boundHideOnChromeOpen,
true,
);
if (this.#parentDialog) {
this.#parentDialog.removeEventListener("close", this.#boundHandleDialogClose);
this.#parentDialog = null;
}
if (this.action === "click") {
document.body.removeEventListener(
"click",
this.#boundHidePopupOutsideClick,
);
}
clearTimeout(this.#touchTimeout);
if (this.action === "hover") {
this.removeEventListener("pointerenter", this.#boundShowDelayedPopup);
this.removeEventListener("pointerleave", this.#boundHandlePointerLeave);
this.removeEventListener("touchstart", this.#boundHandleTouchStart);
this.removeEventListener("touchmove", this.#boundHandleTouchMove);
this.removeEventListener("touchend", this.#boundHandleTouchEnd);
this.removeEventListener("touchcancel", this.#boundHandleTouchCancel);
} else if (this.action === "click") {
this.removeEventListener("click", this.#boundShowDelayedPopup);
this.removeEventListener("touchstart", this.#boundShowDelayedPopup);
}
}
setup() {
this.style.display = "contents";
}
render() {
this.destroy();
const supportsPopover =
typeof HTMLElement !== "undefined" &&
"popover" in HTMLElement.prototype;
const content = document.createElement("span");
// Customized built-in: `is` MUST be passed via createElement options;
// setAttribute("is", ...) after the fact is a no-op per the HTML spec.
this.popup = document.createElement("dialog", { is: "fig-popup" });
// Also set the `is` attribute explicitly so CSS selectors like
// `dialog[is="fig-popup"]` match. createElement's `is` option upgrades
// the element but doesn't reflect to the attribute in all engines.
this.popup.setAttribute("is", "fig-popup");
this.popup.setAttribute("variant", "tooltip");
this.popup.setAttribute("data-tooltip-managed", "");
this.popup.setAttribute("role", "tooltip");
this.popup.setAttribute("closedby", "none");
if (supportsPopover) this.popup.setAttribute("popover", "manual");
const tooltipId = figUniqueId();
this.popup.setAttribute("id", tooltipId);
const theme = this.getAttribute("theme");
if (theme) this.popup.setAttribute("theme", theme);
const pointer = this.getAttribute("pointer");
if (pointer !== null) this.popup.setAttribute("pointer", pointer);
this.popup.append(content);
content.innerText = this.getAttribute("text") ?? "";
// Set aria-describedby on the trigger element
if (this.firstElementChild) {
this.firstElementChild.setAttribute("aria-describedby", tooltipId);
}
// Attach to DOM.
// - With popover support, body is fine because top-layer promotion handles
// stacking above any open <dialog> (including modal).
// - Without popover support, fall back to today's behavior: nearest open
// <dialog> ancestor if present, else document.body.
if (supportsPopover) {
document.body.append(this.popup);
} else {
const parentDialog = this.closest("dialog");
if (parentDialog && parentDialog.open) {
parentDialog.append(this.popup);
} else {
document.body.append(this.popup);
}
}
// Bind the popup's anchor to this tooltip's trigger child so fig-popup
// can position itself and update its beak via data-beak-side.
this.popup.anchor = this.firstElementChild;
}
destroy() {
if (this.popup) {
this.popup.remove();
this.popup = null;
}
// Remove the click outside listener if it was added
if (this.action === "click") {
document.body.removeEventListener(
"click",
this.#boundHidePopupOutsideClick,
);
}
}
isTouchDevice() {
return (
"ontouchstart" in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
);
}
setupEventListeners() {
if (this.action === "manual") return;
if (this.action === "hover") {
if (!this.isTouchDevice()) {
this.addEventListener("pointerenter", this.#boundShowDelayedPopup);
this.addEventListener("pointerleave", this.#boundHandlePointerLeave);
}
this.addEventListener("touchstart", this.#boundHandleTouchStart, {
passive: true,
});
this.addEventListener("touchmove", this.#boundHandleTouchMove, {
passive: true,
});
this.addEventListener("touchend", this.#boundHandleTouchEnd, {
passive: true,
});
this.addEventListener("touchcancel", this.#boundHandleTouchCancel, {
passive: true,
});
} else if (this.action === "click") {
this.addEventListener("click", this.#boundShowDelayedPopup);
document.body.addEventListener("click", this.#boundHidePopupOutsideClick);
this.addEventListener("touchstart", this.#boundShowDelayedPopup, {
passive: true,
});
}
document.addEventListener("mousedown", this.#boundHideOnChromeOpen, true);
}
get #showPersisted() {
return this.hasAttribute("show") && this.getAttribute("show") !== "false";
}
showDelayedPopup() {
if (this.#showPersisted) return;
this.render();
clearTimeout(this.timeout);
const warm =
Date.now() - FigTooltip.#lastShownAt < FigTooltip.#warmupWindow;
const effectiveDelay = warm ? 0 : this.delay;
this.timeout = setTimeout(this.showPopup.bind(this), effectiveDelay);
}
showPopup() {
if (this.#parentDialog && !this.#parentDialog.open) return;
if (!this.firstElementChild) return;
if (!this.popup) this.render();
// Keep anchor in sync in case the trigger child was swapped between
// creation and show.
this.popup.anchor = this.firstElementChild;
this.popup.open = true;
this.isOpen = true;
FigTooltip.#lastShownAt = Date.now();
}
hidePopup() {
if (this.#showPersisted) return;
clearTimeout(this.timeout);
clearTimeout(this.#touchTimeout);
if (this.popup) {
this.destroy();
}
this.isOpen = false;
FigTooltip.#lastShownAt = Date.now();
}
hidePopupOutsideClick(event) {
if (this.isOpen && !this.popup.contains(event.target)) {
this.hidePopup();
}
}
// Pointer event handlers
#handlePointerLeave(event) {
// Don't hide immediately if we're in a touch interaction
if (!this.#isTouching) {
this.hidePopup();
}
}
// Touch event handlers for mobile support
#handleTouchStart(event) {
if (this.action === "hover") {
this.#isTouching = true;
// Clear any existing touch timeout
clearTimeout(this.#touchTimeout);
// Show popup on touch start for hover action
this.showDelayedPopup();
}
}
#handleTouchMove(event) {
if (this.action === "hover" && this.#isTouching) {
// If user is scrolling/moving, cancel the tooltip after a delay
clearTimeout(this.#touchTimeout);
this.#touchTimeout = setTimeout(() => {
this.#isTouching = false;
this.hidePopup();
}, 150);
}
}
#handleTouchEnd(event) {
if (this.action === "hover" && this.#isTouching) {
// Delay setting isTouching to false to prevent pointerleave from hiding immediately
clearTimeout(this.#touchTimeout);
this.#touchTimeout = setTimeout(() => {
this.#isTouching = false;
this.hidePopup();
}, 300); // Increased delay for better mobile UX
}
}
#handleTouchCancel(event) {
if (this.action === "hover" && this.#isTouching) {
this.#isTouching = false;
clearTimeout(this.#touchTimeout);
this.hidePopup();
}
}
static get observedAttributes() {
return ["action", "delay", "open", "pointer", "show", "text", "theme"];
}
get text() {
return this.getAttribute("text") ?? "";
}
set text(value) {
this.setAttribute("text", value);
}
#updateText(value) {
if (!this.popup) return;
const content = this.popup.firstElementChild ?? this.popup.firstChild;
if (!content) return;
content.innerText = value;
// fig-popup observes content size changes and will reposition itself.
}
get open() {
return this.hasAttribute("open") && this.getAttribute("open") === "true";
}
set open(value) {
this.setAttribute("open", value);
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "action") {
this.action = newValue;
}
if (name === "delay") {
let delay = parseInt(newValue);
this.delay = !isNaN(delay) ? delay : 500;
}
if (name === "open") {
if (newValue === "true") {
requestAnimationFrame(() => {
this.showDelayedPopup();
});
} else {
requestAnimationFrame(() => {
this.hidePopup();
});
}
}
if (name === "show") {
const on = newValue !== null && newValue !== "false";
if (on) {
this.showPopup();
} else {
this.hidePopup();
}
}
if (name === "text") {
this.#updateText(newValue ?? "");
}
if (name === "pointer") {
if (this.popup) {
if (newValue !== null) this.popup.setAttribute("pointer", newValue);
else this.popup.removeAttribute("pointer");
}
}
if (name === "theme") {
if (this.popup) {
if (newValue) this.popup.setAttribute("theme", newValue);
else this.popup.removeAttribute("theme");
}
}
}
#hideOnChromeOpen(e) {
if (!this.isOpen) return;
// Check if the clicked element is a select or opens a dialog
const target = e.target;
// If the target is a child of this.popup, return early
if (this.popup && this.popup.contains(target)) {
return;
}
if (
target.tagName === "SELECT" ||
target.hasAttribute("popover") ||
target.closest("dialog")
) {
this.hidePopup();
}
}
static #programmatic = new WeakMap();
static show(anchor, text, options = {}) {
FigTooltip.hide(anchor);
const delay = options.delay ?? 500;
const warm =
Date.now() - FigTooltip.#lastShownAt < FigTooltip.#warmupWindow;
const effectiveDelay = warm ? 0 : delay;
const state = { timeout: null, popup: null };
FigTooltip.#programmatic.set(anchor, state);
state.timeout = setTimeout(() => {
const supportsPopover =
typeof HTMLElement !== "undefined" &&
"popover" in HTMLElement.prototype;
const popup = document.createElement("dialog", { is: "fig-popup" });
popup.setAttribute("is", "fig-popup");
popup.setAttribute("variant", "tooltip");
popup.setAttribute("data-tooltip-managed", "");
popup.setAttribute("role", "tooltip");
popup.setAttribute("closedby", "none");
if (supportsPopover) popup.setAttribute("popover", "manual");
const content = document.createElement("span");
content.innerText = text;
popup.append(content);
if (supportsPopover) {
document.body.append(popup);
} else {
const parentDialog = anchor.closest?.("dialog");
if (parentDialog && parentDialog.open) {
parentDialog.append(popup);
} else {
document.body.append(popup);
}
}
popup.anchor = anchor;
popup.open = true;
state.popup = popup;
FigTooltip.#lastShownAt = Date.now();
}, effectiveDelay);
}
static hide(anchor) {
const state = FigTooltip.#programmatic.get(anchor);
if (!state) return;
clearTimeout(state.timeout);
if (state.popup) state.popup.remove();
FigTooltip.#programmatic.delete(anchor);
}
}
customElements.define("fig-tooltip", FigTooltip);
/* Text Truncation */
class FigTruncate extends HTMLElement {
static observedAttributes = ["position", "tail"];
#originalText = null;
#boundEnter = null;
#boundLeave = null;
connectedCallback() {
this.#originalText = this.textContent;
requestAnimationFrame(() => {
this.#render();
this.#setupTooltip();
});
}
disconnectedCallback() {
this.#teardownTooltip();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
if (this.#originalText === null) return;
this.#render();
}
#render() {
const position = this.getAttribute("position") || "right";
const text = this.#originalText || "";
if (position === "middle") {
const tail = this.getAttribute("tail");
let splitIndex;
if (tail) {
const idx = text.lastIndexOf(tail);
splitIndex = idx > 0 ? idx : Math.ceil(text.length / 2);
} else {
splitIndex = Math.ceil(text.length / 2);
}
this.innerHTML = "";
const startSpan = document.createElement("span");
startSpan.className = "start";
startSpan.textContent = text.slice(0, splitIndex);
const endSpan = document.createElement("span");
endSpan.className = "end";
endSpan.textContent = text.slice(splitIndex);
this.appendChild(startSpan);
this.appendChild(endSpan);
} else {
this.textContent = text;
}
}
#setupTooltip() {
if (
!this.hasAttribute("tooltip") ||
this.getAttribute("tooltip") === "false"
)
return;
this.#boundEnter = () => {
if (this.scrollWidth <= this.clientWidth) return;
FigTooltip.show(this, this.#originalText);
};
this.#boundLeave = () => {
FigTooltip.hide(this);
};
this.addEventListener("pointerenter", this.#boundEnter);
this.addEventListener("pointerleave", this.#boundLeave);
}
#teardownTooltip() {
if (this.#boundEnter)
this.removeEventListener("pointerenter", this.#boundEnter);
if (this.#boundLeave)
this.removeEventListener("pointerleave", this.#boundLeave);
FigTooltip.hide(this);
}
}
customElements.define("fig-truncate", FigTruncate);
/* 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
* @attr {boolean} drag - Whether the dialog is draggable
* @attr {string} handle - CSS selector for the drag handle element (e.g., "fig-header"). If not specified, the entire dialog is draggable when drag is enabled.
* @attr {string} position - Position of the dialog (e.g., "bottom right", "top left", "center center")
* @attr {string} title - Title text for the auto-generated header. If no fig-header[dialog-header] exists, one is prepended with this title and a close button.
* @attr {boolean} resizable - Whether the dialog can be manually resized by the user (default: false)
* @attr {string} closedby - Controls how the dialog can be dismissed: "any" (default, Escape + light dismiss), "closerequest" (Escape only), "none" (programmatic only)
*/
class FigDialog extends HTMLDialogElement {
constructor() {
super();
this._figInit();
}
// Lazy initializer used by both the native constructor path and the
// Safari `is="..."` polyfill (which prototype-swaps existing nodes
// without invoking the constructor, so class fields are never set).
_figInit() {
if (this._figInitialized) return;
this._figInitialized = true;
this._isDragging = false;
this._dragPending = false;
this._dragStartPos = { x: 0, y: 0 };
this._dragOffset = { x: 0, y: 0 };
this._resizeObserver = null;
this._mutationObserver = null;
this._autoResizeRafId = 0;
this._offset = 16;
this._positionInitialized = false;
this._dragThreshold = 3;
this._boundPointerDown = this._handlePointerDown.bind(this);
this._boundPointerMove = this._handlePointerMove.bind(this);
this._boundPointerUp = this._handlePointerUp.bind(this);
this._boundClose = this.close.bind(this);
this._boundIframeMessage = this._handleIframeMessage.bind(this);
this._boundContentMutation = this._scheduleAutoResize.bind(this);
this._boundContentResize = this._scheduleAutoResize.bind(this);
}
get autoresize() {
return (
this.hasAttribute("autoresize") &&
this.getAttribute("autoresize") !== "false"
);
}
connectedCallback() {
this._figInit();
this.modal =
this.hasAttribute("modal") && this.getAttribute("modal") !== "false";
// Set up drag functionality
this.drag =
this.hasAttribute("drag") && this.getAttribute("drag") !== "false";
this._ensureHeader();
requestAnimationFrame(() => {
this._addCloseListeners();
this._setupDragListeners();
this._applyPosition();
this._syncAutoResize();
});
window.addEventListener("message", this._boundIframeMessage);
}
disconnectedCallback() {
this._figInit();
this._removeDragListeners();
this.querySelectorAll("fig-button[close-dialog]").forEach((button) => {
button.removeEventListener("click", this._boundClose);
});
window.removeEventListener("message", this._boundIframeMessage);
this._teardownAutoResize();
}
_handleIframeMessage(event) {
if (!this.autoresize) return;
const data = event?.data;
if (!data || data.type !== "figui:iframe-resize") return;
const source = event.source;
if (!source) return;
const iframe = Array.from(this.querySelectorAll("iframe")).find(
(el) => el.contentWindow === source,
);
if (!iframe) return;
this._resizeForIframe(iframe, data);
}
_syncAutoResize() {
if (this.autoresize) {
this._setupAutoResize();
this._scheduleAutoResize();
} else {
this._teardownAutoResize();
}
}
_setupAutoResize() {
if (!this._resizeObserver) {
this._resizeObserver = new ResizeObserver(this._boundContentResize);
for (const child of this.children) {
try {
this._resizeObserver.observe(child);
} catch {}
}
}
if (!this._mutationObserver) {
this._mutationObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
m.addedNodes?.forEach((node) => {
if (node instanceof Element && node.parentElement === this) {
try {
this._resizeObserver?.observe(node);
} catch {}
}
});
}
this._scheduleAutoResize();
});
this._mutationObserver.observe(this, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
}
}
_teardownAutoResize() {
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = null;
}
if (this._mutationObserver) {
this._mutationObserver.disconnect();
this._mutationObserver = null;
}
if (this._autoResizeRafId) {
cancelAnimationFrame(this._autoResizeRafId);
this._autoResizeRafId = 0;
}
}
_scheduleAutoResize() {
if (!this.autoresize) return;
if (this._autoResizeRafId) return;
this._autoResizeRafId = requestAnimationFrame(() => {
this._autoResizeRafId = 0;
this._applyAutoResize();
});
}
_applyAutoResize() {
if (!this.autoresize) return;
// When an iframe child is present, defer to the iframe's postMessage
// broadcast (the only reliable source of its content height).
if (this.querySelector(":scope > iframe")) return;
this._resizeToContent(null);
}
_computeChrome(skipChild) {
const cs = window.getComputedStyle(this);
const verticalBoxExtras =
parseFloat(cs.paddingTop || "0") +
parseFloat(cs.paddingBottom || "0") +
parseFloat(cs.borderTopWidth || "0") +
parseFloat(cs.borderBottomWidth || "0");
let siblingsHeight = 0;
const gap = parseFloat(cs.rowGap || cs.gap || "0") || 0;
let visibleChildren = 0;
for (const child of this.children) {
const rect = child.getBoundingClientRect();
if (rect.height === 0) continue;
visibleChildren += 1;
if (child === skipChild) continue;
const childCS = window.getComputedStyle(child);
const marginY =
parseFloat(childCS.marginTop || "0") +
parseFloat(childCS.marginBottom || "0");
siblingsHeight += rect.height + marginY;
}
if (gap && visibleChildren > 1) {
siblingsHeight += gap * (visibleChildren - 1);
}
return verticalBoxExtras + siblingsHeight;
}
_resizeForIframe(iframe, data) {
if (typeof data.height !== "number" || !(data.height > 0)) return;
const chrome = this._computeChrome(iframe);
this.style.height = `${Math.ceil(data.height + chrome)}px`;
}
_resizeToContent() {
// Let CSS handle the sizing via `height: max-content` (applied by the
// [autoresize] rule). Just clear any previously applied inline height
// (e.g. from drag/resize) so the CSS rule wins.
if (this.style.height) this.style.height = "";
}
_ensureHeader() {
if (this.querySelector("fig-header[dialog-header]")) return;
const header = document.createElement("fig-header");
header.setAttribute("dialog-header", "");
header.setAttribute("data-auto", "");
const h3 = document.createElement("h3");
h3.textContent = this.getAttribute("title") || "Dialog";
const tooltip = document.createElement("fig-tooltip");
tooltip.setAttribute("text", "Close");
const btn = document.createElement("fig-button");
btn.setAttribute("variant", "ghost");
btn.setAttribute("icon", "");
btn.setAttribute("close-dialog", "");
const icon = document.createElement("span");
icon.className = "fig-mask-icon";
icon.style.setProperty("--icon", "var(--icon-close)");
btn.appendChild(icon);
tooltip.appendChild(btn);
header.appendChild(h3);
header.appendChild(tooltip);
this.prepend(header);
}
_addCloseListeners() {
this.querySelectorAll("fig-button[close-dialog]").forEach((button) => {
button.removeEventListener("click", this._boundClose);
button.addEventListener("click", this._boundClose);
});
}
_applyPosition() {
const position = this.getAttribute("position") || "";
// Apply common styles
this.style.position = "fixed";
this.style.transform = "none";
// Reset position properties
this.style.top = "auto";
this.style.bottom = "auto";
this.style.left = "auto";
this.style.right = "auto";
this.style.margin = "0";
// Parse position attribute
const hasTop = position.includes("top");
const hasBottom = position.includes("bottom");
const hasLeft = position.includes("left");
const hasRight = position.includes("right");
const hasVCenter = position.includes("center") && !hasTop && !hasBottom;
const hasHCenter = position.includes("center") && !hasLeft && !hasRight;
// Vertical positioning
if (hasTop) {
this.style.top = `${this._offset}px`;
} else if (hasBottom) {
this.style.bottom = `${this._offset}px`;
} else if (hasVCenter) {
this.style.top = "0";
this.style.bottom = "0";
}
// Horizontal positioning
if (hasLeft) {
this.style.left = `${this._offset}px`;
} else if (hasRight) {
this.style.right = `${this._offset}px`;
} else if (hasHCenter) {
this.style.left = "0";
this.style.right = "0";
}
// Apply margin auto for centering (works without knowing dimensions)
if (hasVCenter && hasHCenter) {
this.style.margin = "auto";
} else if (hasVCenter) {
this.style.marginTop = "auto";
this.style.marginBottom = "auto";
} else if (hasHCenter) {
this.style.marginLeft = "auto";
this.style.marginRight = "auto";
}
this._positionInitialized = true;
}
_setupDragListeners() {
if (this.drag) {
this.addEventListener("pointerdown", this._boundPointerDown);
const handleSelector = this.getAttribute("handle");
const handleEl = handleSelector
? this.querySelector(handleSelector)
: this.querySelector("fig-header, header");
if (handleEl) {
handleEl.style.cursor = "grab";
}
}
}
_removeDragListeners() {
this.removeEventListener("pointerdown", this._boundPointerDown);
document.removeEventListener("pointermove", this._boundPointerMove);
document.removeEventListener("pointerup", this._boundPointerUp);
}
_isInteractiveElement(element) {
// Standard HTML interactive elements
const interactiveSelectors = [
"input",
"button",
"select",
"textarea",
"a",
"label",
"details",
"summary",
'[contenteditable="true"]',
"[tabindex]",
];
// Non-interactive fig-* container elements (should allow dragging)
const nonInteractiveFigElements = [
"FIG-HEADER",
"FIG-DIALOG",
"FIG-FIELD",
"FIG-TOOLTIP",
"FIG-CONTENT",
"FIG-TABS",
"FIG-TAB",
"FIG-POPOVER",
"FIG-SHIMMER",
"FIG-LAYER",
"FIG-FILL-PICKER",
];
const isInteractive = (el) =>
interactiveSelectors.some((selector) => el.matches?.(selector)) ||
(el.tagName?.startsWith("FIG-") &&
!nonInteractiveFigElements.includes(el.tagName));
// Check if the element itself is interactive
if (isInteractive(element)) {
return true;
}
// Check if any parent element up to the dialog is interactive
let parent = element.parentElement;
while (parent && parent !== this) {
if (isInteractive(parent)) {
return true;
}
parent = parent.parentElement;
}
return false;
}
_handlePointerDown(e) {
if (!this.drag) {
return;
}
// Don't interfere with interactive elements (inputs, sliders, buttons, etc.)
if (this._isInteractiveElement(e.target)) {
return;
}
// If handle attribute is specified, only allow drag from within that element
// Otherwise, allow dragging from anywhere on the dialog (except interactive elements)
const handleSelector = this.getAttribute("handle");
if (handleSelector && handleSelector.trim()) {
const handleEl = this.querySelector(handleSelector);
if (!handleEl || !handleEl.contains(e.target)) {
return;
}
}
// No handle specified = drag from anywhere (original behavior)
// Don't prevent default yet - just set up pending drag
// This allows clicks on non-interactive elements like <details> to work
this._dragPending = true;
this._dragStartPos.x = e.clientX;
this._dragStartPos.y = e.clientY;
// Get current position from computed style
const rect = this.getBoundingClientRect();
// Store offset from pointer to dialog top-left corner
this._dragOffset.x = e.clientX - rect.left;
this._dragOffset.y = e.clientY - rect.top;
document.addEventListener("pointermove", this._boundPointerMove);
document.addEventListener("pointerup", this._boundPointerUp);
}
_handlePointerMove(e) {
// Check if we should start dragging (threshold exceeded)
if (this._dragPending && !this._isDragging) {
const dx = Math.abs(e.clientX - this._dragStartPos.x);
const dy = Math.abs(e.clientY - this._dragStartPos.y);
if (dx > this._dragThreshold || dy > this._dragThreshold) {
this._isDragging = true;
this._dragPending = false;
this.setPointerCapture(e.pointerId);
this.style.cursor = "grabbing";
const rect = this.getBoundingClientRect();
this.style.top = `${rect.top}px`;
this.style.left = `${rect.left}px`;
this.style.bottom = "auto";
this.style.right = "auto";
this.style.margin = "0";
}
}
if (!this._isDragging) return;
this.style.left = `${e.clientX - this._dragOffset.x}px`;
this.style.top = `${e.clientY - this._dragOffset.y}px`;
e.preventDefault();
}
_handlePointerUp(e) {
if (this._isDragging) {
this.releasePointerCapture(e.pointerId);
this.style.cursor = "";
}
this._isDragging = false;
this._dragPending = false;
document.removeEventListener("pointermove", this._boundPointerMove);
document.removeEventListener("pointerup", this._boundPointerUp);
e.preventDefault();
}
static get observedAttributes() {
return [
"modal",
"drag",
"position",
"handle",
"title",
"resizable",
"closedby",
"autoresize",
];
}
attributeChangedCallback(name, oldValue, newValue) {
this._figInit();
if (name === "autoresize" && this.isConnected) {
this._syncAutoResize();
}
if (name === "drag") {
this.drag = newValue !== null && newValue !== "false";
if (this.drag) {
this._setupDragListeners();
} else {
this._removeDragListeners();
const header = this.querySelector("fig-header, header");
if (header) {
header.style.cursor = "";
}
}
}
if (name === "position" && this._positionInitialized) {
this._applyPosition();
}
if (name === "modal") {
const wasModal = this.modal;
this.modal = newValue !== null && newValue !== "false";
if (this.open && wasModal !== this.modal) {
this.close();
if (this.modal) this.showModal();
else this.show();
}
}
if (name === "closedby") {
this.closedby = newValue || "any";
}
if (name === "title") {
const autoHeader = this.querySelector("fig-header[data-auto] h3");
if (autoHeader) {
autoHeader.textContent = newValue || "Dialog";
}
}
}
}
figDefineCustomizedBuiltIn("fig-dialog", FigDialog, { extends: "dialog" });
/* Popup */
/**
* A floating popup foundation component based on <dialog>.
* @attr {string} anchor - CSS selector used to resolve the anchor element.
* @attr {string} position - Preferred placement as "vertical horizontal" (default: "top center").
* @attr {string} offset - Horizontal and vertical offset as "x y" (default: "0 0").
* @attr {string} variant - Visual variant. Use variant="popover" to show an anchor beak.
* @attr {string} theme - Visual theme: "light", "dark", or "menu".
* @attr {boolean|string} open - Open when present and not "false".
*/
class FigPopup extends HTMLDialogElement {
_anchorObserver = null;
_contentObserver = null;
_mutationObserver = null;
_anchorTrackRAF = null;
_lastAnchorRect = null;
_isPopupActive = false;
_boundReposition;
_boundScroll;
_boundOutsidePointerDown;
_rafId = null;
_anchorRef = null;
_isDragging = false;
_dragPending = false;
_dragStartPos = { x: 0, y: 0 };
_dragOffset = { x: 0, y: 0 };
_dragThreshold = 3;
_boundPointerDown;
_boundPointerMove;
_boundPointerUp;
_wasDragged = false;
constructor() {
super();
this._boundReposition = this.queueReposition.bind(this);
this._boundScroll = (e) => {
if (
this.open &&
!this.contains(e.target) &&
this.shouldAutoReposition()
) {
this.queueReposition();
}
};
this._boundOutsidePointerDown = this.handleOutsidePointerDown.bind(this);
this._boundPointerDown = this.handlePointerDown.bind(this);
this._boundPointerMove = this.handlePointerMove.bind(this);
this._boundPointerUp = this.handlePointerUp.bind(this);
}
ensureInitialized() {
if (typeof this._anchorObserver === "undefined")
this._anchorObserver = null;
if (typeof this._contentObserver === "undefined")
this._contentObserver = null;
if (typeof this._mutationObserver === "undefined")
this._mutationObserver = null;
if (typeof this._anchorTrackRAF === "undefined")
this._anchorTrackRAF = null;
if (typeof this._lastAnchorRect === "undefined")
this._lastAnchorRect = null;
if (typeof this._isPopupActive === "undefined") this._isPopupActive = false;
if (typeof this._rafId === "undefined") this._rafId = null;
if (typeof this._anchorRef === "undefined") this._anchorRef = null;
if (typeof this._isDragging === "undefined") this._isDragging = false;
if (typeof this._dragPending === "undefined") this._dragPending = false;
if (typeof this._dragStartPos === "undefined")
this._dragStartPos = { x: 0, y: 0 };
if (typeof this._dragOffset === "undefined")
this._dragOffset = { x: 0, y: 0 };
if (typeof this._dragThreshold !== "number") this._dragThreshold = 3;
if (typeof this._wasDragged === "undefined") this._wasDragged = false;
if (typeof this._boundReposition !== "function") {
this._boundReposition = this.queueReposition.bind(this);
}
if (typeof this._boundScroll !== "function") {
this._boundScroll = (e) => {
if (
this.open &&
!this.contains(e.target) &&
this.shouldAutoReposition()
) {
this.queueReposition();
}
};
}
if (typeof this._boundOutsidePointerDown !== "function") {
this._boundOutsidePointerDown = this.handleOutsidePointerDown.bind(this);
}
if (typeof this._boundPointerDown !== "function") {
this._boundPointerDown = this.handlePointerDown.bind(this);
}
if (typeof this._boundPointerMove !== "function") {
this._boundPointerMove = this.handlePointerMove.bind(this);
}
if (typeof this._boundPointerUp !== "function") {
this._boundPointerUp = this.handlePointerUp.bind(this);
}
}
static get observ