UNPKG

@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
/** * 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