@rogieking/figui3
Version:
A lightweight web components library for building Figma plugin and widget UIs with native look and feel
1,509 lines (1,354 loc) • 72 kB
JavaScript
// FigFillPicker
const GRADIENT_INTERPOLATION_SPACES = [
"srgb",
"srgb-linear",
"display-p3",
"oklab",
"oklch",
];
const GRADIENT_HUE_INTERPOLATIONS = [
"shorter",
"longer",
"increasing",
"decreasing",
];
function normalizeGradientConfig(gradient) {
const next = { ...(gradient ?? {}) };
let interpolationSpace = String(
next.interpolationSpace ?? "oklab",
).toLowerCase();
if (!GRADIENT_INTERPOLATION_SPACES.includes(interpolationSpace)) {
interpolationSpace = "oklab";
}
if (interpolationSpace === "srgb" || interpolationSpace === "display-p3") {
interpolationSpace = "oklab";
}
next.interpolationSpace = interpolationSpace;
const hueInterpolation = String(
next.hueInterpolation ?? "shorter",
).toLowerCase();
next.hueInterpolation = GRADIENT_HUE_INTERPOLATIONS.includes(hueInterpolation)
? hueInterpolation
: "shorter";
return next;
}
function gradientToValueShape(gradient) {
const normalized = normalizeGradientConfig(gradient);
const output = {
...normalized,
interpolationSpace: normalized.interpolationSpace,
};
if (normalized.interpolationSpace === "oklch") {
output.hueInterpolation = normalized.hueInterpolation;
} else {
delete output.hueInterpolation;
}
return output;
}
function gradientInterpolationClause(gradient) {
const normalized = normalizeGradientConfig(gradient);
if (normalized.interpolationSpace === "oklch") {
return `in oklch ${normalized.hueInterpolation} hue`;
}
return `in ${normalized.interpolationSpace}`;
}
/**
* A comprehensive fill picker component supporting solid colors, gradients, images, video, and webcam.
* Uses display: contents and wraps a trigger element that opens a dialog picker.
*
* @attr {string} value - JSON-encoded fill value
* @attr {boolean} disabled - Whether the picker is disabled
* @attr {boolean} alpha - Whether to show alpha/opacity controls (default: true)
* @attr {string} dialog-position - Position of the popup (default: "left")
*/
class FigFillPicker extends HTMLElement {
#trigger = null;
#chit = null;
#dialog = null;
#activeTab = "solid";
anchorElement = null;
// Fill state
#fillType = "solid";
#gamut = "srgb"; // "srgb" or "display-p3"
#color = { h: 0, s: 0, v: 85, a: 1 }; // Default gray #D9D9D9
#colorInputMode = "hex";
#gradient = {
type: "linear",
angle: 0,
centerX: 50,
centerY: 50,
interpolationSpace: "oklab",
hueInterpolation: "shorter",
stops: [
{ position: 0, color: "#D9D9D9", opacity: 100 },
{ position: 100, color: "#737373", opacity: 100 },
],
};
#image = { url: null, scaleMode: "fill", scale: 50 };
#video = { url: null, scaleMode: "fill", scale: 50 };
#webcam = { stream: null, snapshot: null };
// Custom mode slots and data
#customSlots = {};
#customData = {};
// DOM references for solid tab
#colorArea = null;
#colorAreaHandle = null;
#hueSlider = null;
#opacitySlider = null;
#isDraggingColor = false;
#teardownColorAreaEvents = null;
#dialogOpenObserver = null;
#webcamTabObserver = null;
constructor() {
super();
}
static get observedAttributes() {
return ["value", "disabled", "alpha", "mode", "experimental"];
}
connectedCallback() {
// Use display: contents
this.style.display = "contents";
requestAnimationFrame(() => {
this.#setupTrigger();
this.#parseValue();
this.#updateChit();
});
}
disconnectedCallback() {
if (this.#teardownColorAreaEvents) {
this.#teardownColorAreaEvents();
this.#teardownColorAreaEvents = null;
}
if (this.#dialogOpenObserver) {
this.#dialogOpenObserver.disconnect();
this.#dialogOpenObserver = null;
}
if (this.#webcamTabObserver) {
this.#webcamTabObserver.disconnect();
this.#webcamTabObserver = null;
}
if (this.#webcam.stream) {
this.#webcam.stream.getTracks().forEach((track) => track.stop());
this.#webcam.stream = null;
}
if (this.#webcam.snapshot?.startsWith("blob:")) {
URL.revokeObjectURL(this.#webcam.snapshot);
this.#webcam.snapshot = null;
}
if (this.#video.url && this.#video.url.startsWith("blob:")) {
URL.revokeObjectURL(this.#video.url);
}
if (this.#chit) this.#chit.removeAttribute("selected");
if (this.#dialog) {
this.#dialog.close();
this.#dialog.remove();
this.#dialog = null;
}
}
#setupTrigger() {
const child = Array.from(this.children).find(
(el) => !el.getAttribute("slot")?.startsWith("mode-"),
);
if (!child) {
// Scenario 1: Empty - create fig-chit
this.#chit = document.createElement("fig-chit");
this.#chit.setAttribute("background", "#D9D9D9");
this.appendChild(this.#chit);
this.#trigger = this.#chit;
} else if (child.tagName === "FIG-CHIT") {
// Scenario 2: Has fig-chit - use and populate it
this.#chit = child;
this.#trigger = child;
} else {
// Scenario 3: Other element - trigger only, no populate
this.#trigger = child;
this.#chit = null;
}
this.#trigger.addEventListener("click", (e) => {
if (this.hasAttribute("disabled")) return;
e.stopPropagation();
e.preventDefault();
this.#openDialog();
});
// Prevent fig-chit's internal color input from opening system picker
if (this.#chit) {
requestAnimationFrame(() => {
const input = this.#chit.querySelector('input[type="color"]');
if (input) {
input.style.pointerEvents = "none";
}
});
}
}
#parseValue() {
const valueAttr = this.getAttribute("value");
if (!valueAttr) return;
const builtinTypes = ["solid", "gradient", "image", "video", "webcam"];
try {
const parsed = JSON.parse(valueAttr);
if (parsed.type) this.#fillType = parsed.type;
if (parsed.color) {
// Handle both hex string and HSV object
if (typeof parsed.color === "string") {
this.#color = this.#hexToHSV(parsed.color);
} else if (
typeof parsed.color === "object" &&
parsed.color.h !== undefined
) {
this.#color = parsed.color;
}
}
// Parse opacity (0-100) and convert to alpha (0-1)
if (parsed.opacity !== undefined) {
this.#color.a = parsed.opacity / 100;
}
if (parsed.colorSpace === "display-p3" || parsed.colorSpace === "srgb") {
this.#gamut = parsed.colorSpace;
}
if (parsed.gradient) {
this.#gradient = normalizeGradientConfig({
...this.#gradient,
...parsed.gradient,
});
}
if (parsed.image) this.#image = { ...this.#image, ...parsed.image };
if (parsed.video) this.#video = { ...this.#video, ...parsed.video };
// Store full parsed data for custom (non-built-in) types
if (parsed.type && !builtinTypes.includes(parsed.type)) {
const { type, ...rest } = parsed;
this.#customData[parsed.type] = rest;
}
} catch (e) {
// If not JSON, treat as hex color
if (valueAttr.startsWith("#")) {
this.#fillType = "solid";
this.#color = this.#hexToHSV(valueAttr);
}
}
}
#updateChit() {
if (!this.#chit) return;
let bg;
let bgSize = "cover";
let bgPosition = "center";
switch (this.#fillType) {
case "solid":
bg = this.#hsvToHex(this.#color);
break;
case "gradient":
bg = this.#getGradientCSS();
break;
case "image":
if (this.#image.url) {
bg = `url(${this.#image.url})`;
const sizing = this.#getBackgroundSizing(
this.#image.scaleMode,
this.#image.scale,
);
bgSize = sizing.size;
bgPosition = sizing.position;
} else {
bg = "";
}
break;
case "video":
if (this.#video.url) {
bg = `url(${this.#video.url})`;
const sizing = this.#getBackgroundSizing(
this.#video.scaleMode,
this.#video.scale,
);
bgSize = sizing.size;
bgPosition = sizing.position;
} else {
bg = "";
}
break;
default:
const slot = this.#customSlots[this.#fillType];
bg = slot?.element?.getAttribute("chit-background") || "#D9D9D9";
}
this.#chit.setAttribute("background", bg);
this.#chit.style.setProperty("--chit-bg-size", bgSize);
this.#chit.style.setProperty("--chit-bg-position", bgPosition);
if (this.#fillType === "solid") {
this.#chit.setAttribute("alpha", this.#color.a);
} else {
this.#chit.removeAttribute("alpha");
}
}
#getBackgroundSizing(scaleMode, scale) {
switch (scaleMode) {
case "fill":
return { size: "cover", position: "center" };
case "fit":
return { size: "contain", position: "center" };
case "crop":
return { size: "cover", position: "center" };
case "tile":
return { size: `${scale}%`, position: "top left" };
default:
return { size: "cover", position: "center" };
}
}
#openDialog() {
if (!this.#dialog) {
this.#createDialog();
}
this.#switchTab(this.#fillType);
const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
if (gamutEl) gamutEl.value = this.#gamut;
if (this.#chit) this.#chit.setAttribute("selected", "true");
this.#dialog.open = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.#drawColorArea();
this.#updateHandlePosition();
});
});
}
open() {
this.#openDialog();
}
close() {
if (this.#dialog) this.#dialog.open = false;
}
#createDialog() {
// Collect slotted custom mode content before any DOM changes
this.#customSlots = {};
this.querySelectorAll('[slot^="mode-"]').forEach((el) => {
const modeName = el.getAttribute("slot").slice(5);
this.#customSlots[modeName] = {
element: el,
label:
el.getAttribute("label") ||
modeName.charAt(0).toUpperCase() + modeName.slice(1),
};
});
this.#dialog = document.createElement("dialog", { is: "fig-popup" });
this.#dialog.setAttribute("is", "fig-popup");
this.#dialog.setAttribute("drag", "true");
this.#dialog.setAttribute("handle", "fig-header");
this.#dialog.setAttribute("autoresize", "false");
this.#dialog.classList.add("fig-fill-picker-dialog");
this.#dialog.anchor = this.anchorElement || this.#trigger;
const dialogPosition = this.getAttribute("dialog-position") || "left";
this.#dialog.setAttribute("position", dialogPosition);
this.#dialog.setAttribute("offset", this.getAttribute("dialog-offset") || "8 8");
const builtinModes = ["solid", "gradient", "image", "video", "webcam"];
const builtinLabels = {
solid: "Solid",
gradient: "Gradient",
image: "Image",
video: "Video",
webcam: "Webcam",
};
// Build allowed modes: built-ins filtered normally, custom names accepted if slot exists
const mode = this.getAttribute("mode");
let allowedModes;
if (mode) {
const requested = mode.split(",").map((m) => m.trim().toLowerCase());
allowedModes = requested.filter(
(m) => builtinModes.includes(m) || this.#customSlots[m],
);
if (allowedModes.length === 0) allowedModes = [...builtinModes];
} else {
allowedModes = [...builtinModes];
}
// Build labels map: built-in labels + custom slot labels
const modeLabels = { ...builtinLabels };
for (const [name, { label }] of Object.entries(this.#customSlots)) {
modeLabels[name] = label;
}
if (!allowedModes.includes(this.#fillType)) {
this.#fillType = allowedModes[0];
this.#activeTab = allowedModes[0];
}
const experimental = this.getAttribute("experimental");
const expAttr = experimental ? `experimental="${experimental}"` : "";
let headerContent;
if (allowedModes.length === 1) {
headerContent = `<h3 class="fig-fill-picker-type-label">${modeLabels[allowedModes[0]]}</h3>`;
} else {
const options = allowedModes
.map((m) => `<option value="${m}">${modeLabels[m]}</option>`)
.join("\n ");
headerContent = `<fig-dropdown class="fig-fill-picker-type" ${expAttr} value="${this.#fillType}">
${options}
</fig-dropdown>`;
}
// Generate tab containers for all allowed modes
const tabDivs = allowedModes
.map((m) => `<div class="fig-fill-picker-tab" data-tab="${m}"></div>`)
.join("\n ");
const gamutDropdown = `<fig-dropdown class="fig-fill-picker-gamut" ${expAttr} value="${this.#gamut}">
<option value="srgb">sRGB</option>
<option value="display-p3">Display P3</option>
</fig-dropdown>`;
this.#dialog.innerHTML = `
<fig-header>
${headerContent}
${gamutDropdown}
<fig-button icon variant="ghost" class="fig-fill-picker-close">
<fig-icon name="close"></fig-icon>
</fig-button>
</fig-header>
<fig-content>
${tabDivs}
</fig-content>
`;
document.body.appendChild(this.#dialog);
// Populate custom tab containers and emit modeready
for (const [modeName, { element }] of Object.entries(this.#customSlots)) {
const container = this.#dialog.querySelector(`[data-tab="${modeName}"]`);
if (!container) continue;
// Move children (not the element itself) for vanilla HTML usage
while (element.firstChild) {
container.appendChild(element.firstChild);
}
// Emit modeready so frameworks can render into the container
this.dispatchEvent(
new CustomEvent("modeready", {
bubbles: true,
detail: { mode: modeName, container },
}),
);
}
// Setup type dropdown switching (only if not locked)
const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
if (typeDropdown) {
typeDropdown.addEventListener("change", (e) => {
this.#switchTab(e.target.value);
});
}
// Setup gamut dropdown
const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
if (gamutEl) {
const handleGamutChange = (e) => {
const val = e.currentTarget?.value ?? e.target?.value ?? e.detail;
if (val && val !== this.#gamut) {
this.#gamut = val;
this.#onGamutChange();
}
};
gamutEl.addEventListener("input", handleGamutChange);
gamutEl.addEventListener("change", handleGamutChange);
}
this.#dialog
.querySelector(".fig-fill-picker-close")
.addEventListener("click", () => {
this.#dialog.open = false;
});
const onDialogClose = () => {
if (this.#chit) this.#chit.removeAttribute("selected");
this.#emitChange();
this.dispatchEvent(new CustomEvent("close"));
};
this.#dialog.addEventListener("close", onDialogClose);
this.#dialogOpenObserver = new MutationObserver(() => {
const isOpen =
this.#dialog.hasAttribute("open") &&
this.#dialog.getAttribute("open") !== "false";
if (!isOpen) onDialogClose();
});
this.#dialogOpenObserver.observe(this.#dialog, {
attributes: true,
attributeFilter: ["open"],
});
// Initialize built-in tabs (skip any overridden by custom slots)
const builtinInits = {
solid: () => this.#initSolidTab(),
gradient: () => this.#initGradientTab(),
image: () => this.#initImageTab(),
video: () => this.#initVideoTab(),
webcam: () => this.#initWebcamTab(),
};
for (const [name, init] of Object.entries(builtinInits)) {
if (!this.#customSlots[name] && allowedModes.includes(name)) init();
}
// Listen for input/change from custom tab content
for (const modeName of Object.keys(this.#customSlots)) {
if (builtinModes.includes(modeName)) continue;
const container = this.#dialog.querySelector(`[data-tab="${modeName}"]`);
if (!container) continue;
container.addEventListener("input", (e) => {
if (e.target === this) return;
e.stopPropagation();
if (e.detail) this.#customData[modeName] = e.detail;
this.#emitInput();
});
container.addEventListener("change", (e) => {
if (e.target === this) return;
e.stopPropagation();
if (e.detail) this.#customData[modeName] = e.detail;
this.#emitChange();
});
}
}
#switchTab(tabName) {
// Only allow switching to modes that have a tab container in the dialog
const tab = this.#dialog?.querySelector(
`.fig-fill-picker-tab[data-tab="${tabName}"]`,
);
if (!tab) return;
this.#activeTab = tabName;
this.#fillType = tabName;
// Update dropdown selection (only exists if not locked)
const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
if (typeDropdown && typeDropdown.value !== tabName) {
typeDropdown.value = tabName;
}
// Show/hide tab content
const tabContents = this.#dialog.querySelectorAll(".fig-fill-picker-tab");
tabContents.forEach((content) => {
if (content.dataset.tab === tabName) {
content.style.display = "block";
} else {
content.style.display = "none";
}
});
// Zero out content padding for custom mode tabs
const contentEl = this.#dialog.querySelector("fig-content");
if (contentEl) {
contentEl.style.padding = this.#customSlots[tabName] ? "0" : "";
}
// Update tab-specific UI after visibility change
if (tabName === "gradient") {
// Use RAF to ensure layout is complete before updating angle input
requestAnimationFrame(() => {
this.#updateGradientUI();
const barInput = tab.querySelector(".fig-fill-picker-gradient-bar-input");
barInput?.refreshLayout?.();
requestAnimationFrame(() => {
barInput?.refreshLayout?.();
});
});
}
this.#updateChit();
this.#emitInput();
}
// ============ SOLID TAB ============
#initSolidTab() {
const container = this.#dialog.querySelector('[data-tab="solid"]');
const showAlpha = this.getAttribute("alpha") !== "false";
const experimental = this.getAttribute("experimental");
const expAttr = experimental ? `experimental="${experimental}"` : "";
container.innerHTML = `
<fig-preview class="fig-fill-picker-color-area">
<canvas width="200" height="200"></canvas>
<fig-handle
type="color"
color="${this.#hsvToHex({ ...this.#color, a: 1 })}"
data-no-color-picker
drag
drag-surface=".fig-fill-picker-color-area"
drag-axes="x,y"
drag-snapping="modifier"
></fig-handle>
</fig-preview>
<div class="fig-fill-picker-sliders">
<fig-tooltip text="Sample color"><fig-button icon variant="ghost" class="fig-fill-picker-eyedropper"><fig-icon name="eyedropper"></fig-icon></fig-button></fig-tooltip>
<fig-slider type="hue" text="false" min="0" max="360" value="${
this.#color.h
}"></fig-slider>
${
showAlpha
? `<fig-slider type="opacity" text="true" units="%" min="0" max="100" value="${
this.#color.a * 100
}" color="${this.#hsvToHex(this.#color)}"></fig-slider>`
: ""
}
</div>
<fig-field class="fig-fill-picker-inputs">
<fig-dropdown class="fig-fill-picker-input-mode" ${expAttr} value="${this.#colorInputMode}">
<option value="hex">Hex</option>
<option value="rgb">RGB</option>
<option value="hsl">HSL</option>
<option value="hsb">HSB</option>
<option value="lab">LAB</option>
<option value="lch">LCH</option>
</fig-dropdown>
<span class="fig-fill-picker-input-fields"></span>
</fig-field>
`;
// Setup color area
this.#colorArea = container.querySelector("canvas");
this.#colorAreaHandle = container.querySelector("fig-handle");
this.#drawColorArea();
this.#updateHandlePosition();
this.#setupColorAreaEvents();
// Setup hue slider
this.#hueSlider = container.querySelector('fig-slider[type="hue"]');
this.#hueSlider.addEventListener("input", (e) => {
this.#color.h = parseFloat(e.target.value);
this.#drawColorArea();
this.#updateHandlePosition();
this.#updateColorInputs();
this.#emitInput();
});
this.#hueSlider.addEventListener("change", () => {
this.#emitChange();
});
// Setup opacity slider
if (showAlpha) {
this.#opacitySlider = container.querySelector(
'fig-slider[type="opacity"]',
);
this.#opacitySlider.addEventListener("input", (e) => {
this.#color.a = parseFloat(e.target.value) / 100;
this.#updateColorInputs();
this.#emitInput();
});
this.#opacitySlider.addEventListener("change", () => {
this.#emitChange();
});
}
// Setup color input mode dropdown
const modeDropdown = container.querySelector(".fig-fill-picker-input-mode");
modeDropdown.addEventListener("input", (e) => {
this.#colorInputMode = e.target.value;
this.#rebuildColorInputFields();
});
// Build initial color input fields
this.#rebuildColorInputFields();
// Setup eyedropper
const eyedropper = container.querySelector(".fig-fill-picker-eyedropper");
if ("EyeDropper" in window) {
eyedropper.addEventListener("click", async () => {
try {
const dropper = new EyeDropper();
const result = await dropper.open();
this.#color = { ...this.#hexToHSV(result.sRGBHex), a: this.#color.a };
this.#drawColorArea();
this.#updateHandlePosition();
this.#updateColorInputs();
this.#emitInput();
} catch (e) {
// User cancelled or error
}
});
} else {
eyedropper.setAttribute("disabled", "");
eyedropper.title = "EyeDropper not supported in this browser";
}
}
#onGamutChange() {
// Recreate the solid canvas with the new color space
const solidContainer = this.#dialog?.querySelector('[data-tab="solid"]');
if (solidContainer) {
const oldCanvas = solidContainer.querySelector("canvas");
if (oldCanvas) {
const newCanvas = document.createElement("canvas");
newCanvas.width = oldCanvas.width;
newCanvas.height = oldCanvas.height;
oldCanvas.replaceWith(newCanvas);
this.#colorArea = newCanvas;
this.#setupColorAreaEvents();
}
this.#drawColorArea();
this.#updateHandlePosition();
}
// Refresh gradient preview if gradient tab exists
this.#updateGradientPreview();
this.#emitInput();
}
#drawColorArea() {
// Refresh canvas reference in case DOM changed
if (!this.#colorArea && this.#dialog) {
this.#colorArea = this.#dialog.querySelector('[data-tab="solid"] canvas');
}
if (!this.#colorArea) return;
const colorSpace = this.#gamut === "display-p3" ? "display-p3" : "srgb";
const ctx = this.#colorArea.getContext("2d", { colorSpace });
if (!ctx) return;
const width = this.#colorArea.width;
const height = this.#colorArea.height;
ctx.clearRect(0, 0, width, height);
const hue = this.#color.h;
const isP3 = this.#gamut === "display-p3";
const gradH = ctx.createLinearGradient(0, 0, width, 0);
if (isP3) {
gradH.addColorStop(0, "color(display-p3 1 1 1)");
const [r, g, b] = hslToP3(hue, 100, 50);
gradH.addColorStop(1, `color(display-p3 ${r} ${g} ${b})`);
} else {
gradH.addColorStop(0, "#FFFFFF");
gradH.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
}
ctx.fillStyle = gradH;
ctx.fillRect(0, 0, width, height);
const gradV = ctx.createLinearGradient(0, 0, 0, height);
gradV.addColorStop(0, "rgba(0,0,0,0)");
gradV.addColorStop(1, "rgba(0,0,0,1)");
ctx.fillStyle = gradV;
ctx.fillRect(0, 0, width, height);
}
#updateHandlePosition(retryCount = 0) {
if (!this.#colorAreaHandle || !this.#colorArea) return;
const rect = this.#colorArea.getBoundingClientRect();
// If the canvas isn't visible yet (0 dimensions), schedule a retry (max 5 attempts)
if ((rect.width === 0 || rect.height === 0) && retryCount < 5) {
requestAnimationFrame(() => this.#updateHandlePosition(retryCount + 1));
return;
}
const xPct = Math.max(0, Math.min(100, this.#color.s));
const yPct = Math.max(0, Math.min(100, 100 - this.#color.v));
this.#colorAreaHandle.setAttribute("value", `${xPct}% ${yPct}%`);
this.#colorAreaHandle.setAttribute(
"color",
this.#hsvToHex({ ...this.#color, a: 1 }),
);
}
#updateColorFromAreaPosition(x, y, opts = {}) {
const { updateHandle = true, emitInput = true, emitChange = false } = opts;
this.#color.s = Math.max(0, Math.min(100, x * 100));
this.#color.v = Math.max(0, Math.min(100, (1 - y) * 100));
if (this.#colorAreaHandle) {
this.#colorAreaHandle.setAttribute(
"color",
this.#hsvToHex({ ...this.#color, a: 1 }),
);
}
if (updateHandle) this.#updateHandlePosition();
this.#updateColorInputs();
if (emitInput) this.#emitInput();
if (emitChange) this.#emitChange();
}
#setupColorAreaEvents() {
if (this.#teardownColorAreaEvents) {
this.#teardownColorAreaEvents();
this.#teardownColorAreaEvents = null;
}
if (!this.#colorArea || !this.#colorAreaHandle) return;
const colorAreaEl = this.#colorArea.parentElement || this.#colorArea;
const colorAreaHandleEl = this.#colorAreaHandle;
let isPlaneDragging = false;
const updatePlaneFromEvent = (e, opts = {}) => {
const rect = colorAreaEl.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
this.#updateColorFromAreaPosition(x / rect.width, y / rect.height, opts);
};
const onPlanePointerDown = (e) => {
if (e.button !== 0) return;
if (
e.target === colorAreaHandleEl ||
colorAreaHandleEl.contains(e.target)
)
return;
isPlaneDragging = true;
this.#isDraggingColor = true;
colorAreaEl.setPointerCapture(e.pointerId);
updatePlaneFromEvent(e, { updateHandle: true, emitInput: true });
};
const onPlanePointerMove = (e) => {
if (!isPlaneDragging) return;
if (e.buttons === 0) {
onPlaneDragEnd();
return;
}
updatePlaneFromEvent(e, { updateHandle: true, emitInput: true });
};
const onPlaneDragEnd = () => {
if (!isPlaneDragging) return;
isPlaneDragging = false;
this.#isDraggingColor = false;
this.#emitChange();
};
const onHandleInput = (e) => {
this.#isDraggingColor = true;
const px = e.detail?.px;
const py = e.detail?.py;
if (!Number.isFinite(px) || !Number.isFinite(py)) return;
colorAreaHandleEl.setAttribute("value", `${px * 100}% ${py * 100}%`);
this.#updateColorFromAreaPosition(px, py, {
updateHandle: false,
emitInput: true,
});
};
const onHandleChange = (e) => {
const px = e.detail?.px;
const py = e.detail?.py;
if (Number.isFinite(px) && Number.isFinite(py)) {
colorAreaHandleEl.setAttribute("value", `${px * 100}% ${py * 100}%`);
this.#updateColorFromAreaPosition(px, py, {
updateHandle: false,
emitInput: false,
});
}
this.#isDraggingColor = false;
this.#emitChange();
};
colorAreaEl.addEventListener("pointerdown", onPlanePointerDown);
colorAreaEl.addEventListener("pointermove", onPlanePointerMove);
colorAreaEl.addEventListener("pointerup", onPlaneDragEnd);
colorAreaEl.addEventListener("pointercancel", onPlaneDragEnd);
colorAreaEl.addEventListener("lostpointercapture", onPlaneDragEnd);
colorAreaHandleEl.addEventListener("input", onHandleInput);
colorAreaHandleEl.addEventListener("change", onHandleChange);
this.#teardownColorAreaEvents = () => {
colorAreaEl.removeEventListener("pointerdown", onPlanePointerDown);
colorAreaEl.removeEventListener("pointermove", onPlanePointerMove);
colorAreaEl.removeEventListener("pointerup", onPlaneDragEnd);
colorAreaEl.removeEventListener("pointercancel", onPlaneDragEnd);
colorAreaEl.removeEventListener("lostpointercapture", onPlaneDragEnd);
colorAreaHandleEl.removeEventListener("input", onHandleInput);
colorAreaHandleEl.removeEventListener("change", onHandleChange);
this.#isDraggingColor = false;
};
}
#rebuildColorInputFields() {
const container = this.#dialog?.querySelector(
".fig-fill-picker-input-fields",
);
if (!container) return;
const wrap = (tooltip, html) =>
`<fig-tooltip text="${tooltip}">${html}</fig-tooltip>`;
const num = (cls, min, max, step) =>
`<fig-input-number class="${cls}" min="${min}" max="${max}"${step != null ? ` step="${step}"` : ""}></fig-input-number>`;
let html;
switch (this.#colorInputMode) {
case "rgb":
html = `<div class="input-combo">
${wrap("Red", num("fig-fill-picker-ci-r", 0, 255))}
${wrap("Green", num("fig-fill-picker-ci-g", 0, 255))}
${wrap("Blue", num("fig-fill-picker-ci-b", 0, 255))}
</div>`;
break;
case "hsl":
html = `<div class="input-combo">
${wrap("Hue", num("fig-fill-picker-ci-h", 0, 360))}
${wrap("Saturation", num("fig-fill-picker-ci-s", 0, 100))}
${wrap("Lightness", num("fig-fill-picker-ci-l", 0, 100))}
</div>`;
break;
case "hsb":
html = `<div class="input-combo">
${wrap("Hue", num("fig-fill-picker-ci-h", 0, 360))}
${wrap("Saturation", num("fig-fill-picker-ci-s", 0, 100))}
${wrap("Brightness", num("fig-fill-picker-ci-v", 0, 100))}
</div>`;
break;
case "lab":
html = `<div class="input-combo">
${wrap("Lightness", num("fig-fill-picker-ci-okl", 0, 100))}
${wrap("Green-Red axis", num("fig-fill-picker-ci-oka", -0.4, 0.4, 0.001))}
${wrap("Blue-Yellow axis", num("fig-fill-picker-ci-okb", -0.4, 0.4, 0.001))}
</div>`;
break;
case "lch":
html = `<div class="input-combo">
${wrap("Lightness", num("fig-fill-picker-ci-okl", 0, 100))}
${wrap("Chroma", num("fig-fill-picker-ci-okc", 0, 0.4, 0.001))}
${wrap("Hue", num("fig-fill-picker-ci-okh", 0, 360))}
</div>`;
break;
default: // hex
html = `<fig-input-text class="fig-fill-picker-ci-hex" placeholder="FFFFFF"></fig-input-text>`;
break;
}
container.innerHTML = html;
this.#wireColorInputEvents();
requestAnimationFrame(() => this.#updateColorInputs());
}
#wireColorInputEvents() {
const container = this.#dialog?.querySelector(
".fig-fill-picker-input-fields",
);
if (!container) return;
const onInput = () => {
if (this.#isDraggingColor) return;
const color = this.#readColorFromInputs();
if (!color) return;
this.#color = { ...color, a: this.#color.a };
this.#drawColorArea();
this.#updateHandlePosition();
if (this.#hueSlider) {
this.#hueSlider.setAttribute("value", this.#color.h);
}
this.#emitInput();
};
const onChange = () => this.#emitChange();
const inputs = container.querySelectorAll(
"fig-input-number, fig-input-text",
);
inputs.forEach((el) => {
el.addEventListener("input", onInput);
el.addEventListener("change", onChange);
});
}
#readColorFromInputs() {
const q = (cls) => this.#dialog?.querySelector(`.${cls}`);
const val = (cls) => parseFloat(q(cls)?.value ?? 0);
switch (this.#colorInputMode) {
case "rgb":
return this.#rgbToHSV({
r: val("fig-fill-picker-ci-r"),
g: val("fig-fill-picker-ci-g"),
b: val("fig-fill-picker-ci-b"),
});
case "hsl": {
const rgb = this.#hslToRGB({
h: val("fig-fill-picker-ci-h"),
s: val("fig-fill-picker-ci-s"),
l: val("fig-fill-picker-ci-l"),
});
return this.#rgbToHSV(rgb);
}
case "hsb":
return {
h: val("fig-fill-picker-ci-h"),
s: val("fig-fill-picker-ci-s"),
v: val("fig-fill-picker-ci-v"),
a: 1,
};
case "lab": {
const rgb = this.#oklabToRGB({
l: val("fig-fill-picker-ci-okl") / 100,
a: val("fig-fill-picker-ci-oka"),
b: val("fig-fill-picker-ci-okb"),
});
return this.#rgbToHSV(rgb);
}
case "lch": {
const rgb = this.#oklchToRGB({
l: val("fig-fill-picker-ci-okl") / 100,
c: val("fig-fill-picker-ci-okc"),
h: val("fig-fill-picker-ci-okh"),
});
return this.#rgbToHSV(rgb);
}
default: {
// hex
const hexEl = q("fig-fill-picker-ci-hex");
if (!hexEl) return null;
let hex = hexEl.value.replace(/^#/, "");
if (hex.length === 3)
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
if (hex.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(hex)) return null;
return this.#hexToHSV(`#${hex}`);
}
}
}
#updateColorInputs() {
if (!this.#dialog) return;
const hex = this.#hsvToHex(this.#color);
const rgb = this.#hsvToRGB(this.#color);
const q = (cls) => this.#dialog.querySelector(`.${cls}`);
const set = (cls, v) => {
const el = q(cls);
if (el) el.setAttribute("value", v);
};
switch (this.#colorInputMode) {
case "rgb":
set("fig-fill-picker-ci-r", rgb.r);
set("fig-fill-picker-ci-g", rgb.g);
set("fig-fill-picker-ci-b", rgb.b);
break;
case "hsl": {
const hsl = this.#rgbToHSL(rgb);
set("fig-fill-picker-ci-h", Math.round(hsl.h));
set("fig-fill-picker-ci-s", Math.round(hsl.s));
set("fig-fill-picker-ci-l", Math.round(hsl.l));
break;
}
case "hsb":
set("fig-fill-picker-ci-h", Math.round(this.#color.h));
set("fig-fill-picker-ci-s", Math.round(this.#color.s));
set("fig-fill-picker-ci-v", Math.round(this.#color.v));
break;
case "lab": {
const lab = this.#rgbToOKLAB(rgb);
set("fig-fill-picker-ci-okl", Math.round(lab.l * 100));
set("fig-fill-picker-ci-oka", +lab.a.toFixed(3));
set("fig-fill-picker-ci-okb", +lab.b.toFixed(3));
break;
}
case "lch": {
const lch = this.#rgbToOKLCH(rgb);
set("fig-fill-picker-ci-okl", Math.round(lch.l * 100));
set("fig-fill-picker-ci-okc", +lch.c.toFixed(3));
set("fig-fill-picker-ci-okh", Math.round(lch.h));
break;
}
default: // hex
set("fig-fill-picker-ci-hex", hex.replace(/^#/, "").toUpperCase());
break;
}
if (this.#opacitySlider) {
this.#opacitySlider.setAttribute("color", hex);
}
this.#updateChit();
}
// ============ GRADIENT TAB ============
#initGradientTab() {
const container = this.#dialog.querySelector('[data-tab="gradient"]');
const experimental = this.getAttribute("experimental");
const expAttr = experimental ? `experimental="${experimental}"` : "";
container.innerHTML = `
<fig-field class="fig-fill-picker-gradient-header">
<fig-dropdown class="fig-fill-picker-gradient-type" ${expAttr} value="${
this.#gradient.type
}">
<option value="linear" selected>Linear</option>
<option value="radial">Radial</option>
<option value="angular">Angular</option>
</fig-dropdown>
<fig-tooltip text="Rotate gradient">
<fig-input-number class="fig-fill-picker-gradient-angle" value="${
(this.#gradient.angle - 90 + 360) % 360
}" min="0" max="360" units="°" wrap></fig-input-number>
</fig-tooltip>
<div class="fig-fill-picker-gradient-center input-combo" style="display: none;">
<fig-input-number min="0" max="100" value="${
this.#gradient.centerX
}" units="%" class="fig-fill-picker-gradient-cx"></fig-input-number>
<fig-input-number min="0" max="100" value="${
this.#gradient.centerY
}" units="%" class="fig-fill-picker-gradient-cy"></fig-input-number>
</div>
<fig-tooltip text="Flip gradient">
<fig-button icon variant="ghost" class="fig-fill-picker-gradient-flip">
<fig-icon name="swap"></fig-icon>
</fig-button>
</fig-tooltip>
</fig-field>
<fig-preview class="fig-fill-picker-gradient-preview">
<fig-input-gradient class="fig-fill-picker-gradient-bar-input" edit="true" size="large" value='${JSON.stringify({ type: "gradient", gradient: gradientToValueShape(this.#gradient) })}'></fig-input-gradient>
</fig-preview>
<fig-field class="fig-fill-picker-gradient-interpolation">
<label>Mixing</label>
<fig-dropdown class="fig-fill-picker-gradient-space" full ${expAttr} value="${
this.#gradient.interpolationSpace === "oklch"
? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
: this.#gradient.interpolationSpace
}">
<optgroup label="sRGB">
<option value="srgb-linear">Linear</option>
</optgroup>
<optgroup label="OKLab">
<option value="oklab">Perceptual</option>
</optgroup>
<optgroup label="OKLCH">
<option value="oklch-shorter">Shorter hue</option>
<option value="oklch-longer">Longer hue</option>
<option value="oklch-increasing">Increasing hue</option>
<option value="oklch-decreasing">Decreasing hue</option>
</optgroup>
</fig-dropdown>
</fig-field>
<div class="fig-fill-picker-gradient-stops">
<fig-header class="fig-fill-picker-gradient-stops-header" borderless>
<span>Stops</span>
<fig-button icon variant="ghost" class="fig-fill-picker-gradient-add" title="Add stop">
<fig-icon name="add"></fig-icon>
</fig-button>
</fig-header>
<div class="fig-fill-picker-gradient-stops-list"></div>
</div>
`;
this.#updateGradientUI();
this.#setupGradientEvents(container);
}
#setupGradientEvents(container) {
// Type dropdown
const typeDropdown = container.querySelector(
".fig-fill-picker-gradient-type",
);
const getDropdownValue = (event) =>
event.currentTarget?.value ?? event.target?.value ?? event.detail;
const handleTypeChange = (e) => {
this.#gradient.type = getDropdownValue(e);
this.#updateGradientUI();
this.#emitInput();
};
typeDropdown.addEventListener("input", handleTypeChange);
typeDropdown.addEventListener("change", handleTypeChange);
const interpolationDropdown = container.querySelector(
".fig-fill-picker-gradient-space",
);
const handleInterpolationChange = (e) => {
const val = getDropdownValue(e);
let space = val;
let hue = "shorter";
if (val.startsWith("oklch-")) {
space = "oklch";
hue = val.slice(6);
}
this.#gradient = normalizeGradientConfig({
...this.#gradient,
interpolationSpace: space,
hueInterpolation: hue,
});
this.#updateGradientUI();
this.#emitInput();
};
interpolationDropdown?.addEventListener("input", handleInterpolationChange);
interpolationDropdown?.addEventListener(
"change",
handleInterpolationChange,
);
// Angle input
const angleInput = container.querySelector(
".fig-fill-picker-gradient-angle",
);
angleInput.addEventListener("input", (e) => {
const pickerAngle = parseFloat(e.target.value) || 0;
this.#gradient.angle = (pickerAngle + 90) % 360;
this.#updateGradientPreview();
this.#emitInput();
});
// Center X/Y inputs
const cxInput = container.querySelector(".fig-fill-picker-gradient-cx");
const cyInput = container.querySelector(".fig-fill-picker-gradient-cy");
cxInput?.addEventListener("input", (e) => {
this.#gradient.centerX = parseFloat(e.target.value) || 50;
this.#updateGradientPreview();
this.#emitInput();
});
cyInput?.addEventListener("input", (e) => {
this.#gradient.centerY = parseFloat(e.target.value) || 50;
this.#updateGradientPreview();
this.#emitInput();
});
// Flip button
container
.querySelector(".fig-fill-picker-gradient-flip")
.addEventListener("click", () => {
this.#gradient.stops.forEach((stop) => {
stop.position = 100 - stop.position;
});
this.#gradient.stops.sort((a, b) => a.position - b.position);
this.#updateGradientUI();
this.#emitInput();
});
// Add stop button
container
.querySelector(".fig-fill-picker-gradient-add")
.addEventListener("click", () => {
const midPosition = 50;
this.#gradient.stops.push({
position: midPosition,
color: "#888888",
opacity: 100,
});
this.#gradient.stops.sort((a, b) => a.position - b.position);
this.#updateGradientUI();
this.#emitInput();
});
// Embedded gradient bar input
const gradientBarInput = container.querySelector(
".fig-fill-picker-gradient-bar-input",
);
if (gradientBarInput) {
const syncFromBarInput = (e) => {
e.stopPropagation();
const detail = e.detail;
if (!detail?.gradient) return;
this.#gradient = normalizeGradientConfig({
...this.#gradient,
...detail.gradient,
});
this.#updateChit();
this.#updateGradientStopsList();
};
gradientBarInput.addEventListener("input", (e) => {
syncFromBarInput(e);
this.#emitInput();
});
gradientBarInput.addEventListener("change", (e) => {
syncFromBarInput(e);
this.#emitChange();
});
}
}
#updateGradientUI() {
if (!this.#dialog) return;
const container = this.#dialog.querySelector('[data-tab="gradient"]');
if (!container) return;
this.#gradient = normalizeGradientConfig(this.#gradient);
// Show/hide angle vs center inputs
const angleInput = container.querySelector(
".fig-fill-picker-gradient-angle",
);
const centerInputs = container.querySelector(
".fig-fill-picker-gradient-center",
);
if (this.#gradient.type === "radial") {
angleInput.style.display = "none";
centerInputs.style.display = "flex";
} else {
angleInput.style.display = "block";
centerInputs.style.display = "none";
// Sync angle input value (convert CSS angle to picker angle)
const pickerAngle = (this.#gradient.angle - 90 + 360) % 360;
angleInput.setAttribute("value", pickerAngle);
}
const interpolationDropdown = container.querySelector(
".fig-fill-picker-gradient-space",
);
if (interpolationDropdown) {
interpolationDropdown.value =
this.#gradient.interpolationSpace === "oklch"
? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
: this.#gradient.interpolationSpace;
}
this.#updateGradientPreview();
this.#updateGradientStopsList();
}
#updateGradientPreview() {
if (!this.#dialog) return;
const barInput = this.#dialog.querySelector(
".fig-fill-picker-gradient-bar-input",
);
if (barInput) {
barInput.setAttribute(
"value",
JSON.stringify({
type: "gradient",
gradient: gradientToValueShape(this.#gradient),
}),
);
}
this.#updateChit();
}
#updateGradientStopsList() {
if (!this.#dialog) return;
const list = this.#dialog.querySelector(
".fig-fill-picker-gradient-stops-list",
);
if (!list) return;
const existingRows = list.querySelectorAll(
".fig-fill-picker-gradient-stop-row",
);
if (existingRows.length === this.#gradient.stops.length) {
this.#gradient.stops.forEach((stop, index) => {
const row = existingRows[index];
row.dataset.index = index;
const posInput = row.querySelector(".fig-fill-picker-stop-position");
if (posInput) posInput.setAttribute("value", stop.position);
const colorInput = row.querySelector(".fig-fill-picker-stop-color");
if (colorInput) colorInput.setAttribute("value", stop.color);
const removeBtn = row.querySelector(".fig-fill-picker-stop-remove");
if (removeBtn) {
if (this.#gradient.stops.length <= 2)
removeBtn.setAttribute("disabled", "");
else removeBtn.removeAttribute("disabled");
}
});
return;
}
this.#rebuildGradientStopsList(list);
}
#rebuildGradientStopsList(list) {
list.innerHTML = this.#gradient.stops
.map(
(stop, index) => `
<fig-field class="fig-fill-picker-gradient-stop-row" data-index="${index}">
<fig-input-number class="fig-fill-picker-stop-position" min="0" max="100" value="${
stop.position
}" units="%"></fig-input-number>
<fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${
stop.color
}"></fig-input-color>
<fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
this.#gradient.stops.length <= 2 ? "disabled" : ""
}>
<fig-icon name="minus"></fig-icon>
</fig-button>
</fig-field>
`,
)
.join("");
list
.querySelectorAll(".fig-fill-picker-gradient-stop-row")
.forEach((row) => {
const index = parseInt(row.dataset.index);
row
.querySelector(".fig-fill-picker-stop-position")
.addEventListener("input", (e) => {
this.#gradient.stops[index].position =
parseFloat(e.target.value) || 0;
this.#updateGradientPreview();
this.#emitInput();
});
const stopColor = row.querySelector(".fig-fill-picker-stop-color");
const stopFillPicker = stopColor.querySelector("fig-fill-picker");
if (stopFillPicker) {
stopFillPicker.anchorElement = this.#dialog;
} else {
requestAnimationFrame(() => {
const fp = stopColor.querySelector("fig-fill-picker");
if (fp) fp.anchorElement = this.#dialog;
});
}
stopColor.addEventListener("input", (e) => {
this.#gradient.stops[index].color =
e.target.hexOpaque || e.target.value;
const a = e.detail?.rgba?.a;
if (a !== undefined) {
this.#gradient.stops[index].opacity = Math.round(a * 100);
}
this.#updateGradientPreview();
this.#emitInput();
});
row
.querySelector(".fig-fill-picker-stop-remove")
.addEventListener("click", () => {
if (this.#gradient.stops.length > 2) {
this.#gradient.stops.splice(index, 1);
this.#updateGradientUI();
this.#emitInput();
}
});
});
}
#buildGradientCSS(interpolationSpaceOverride, includeInterpolation = true) {
const gradient = normalizeGradientConfig({
...this.#gradient,
interpolationSpace:
interpolationSpaceOverride ?? this.#gradient.interpolationSpace,
});
const isP3 = this.#gamut === "display-p3";
const stops = gradient.stops
.map((s) => {
const alpha = (s.opacity ?? 100) / 100;
const color = isP3
? this.#hexToP3(s.color, alpha)
: this.#hexToRGBA(s.color, alpha);
return `${color} ${s.position}%`;
})
.join(", ");
const interpolation = includeInterpolation
? ` ${gradientInterpolationClause(gradient)}`
: "";
switch (gradient.type) {
case "linear":
return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`;
case "radial":
return `radial-gradient(circle at ${gradient.centerX}% ${gradient.centerY}%${interpolation}, ${stops})`;
case "angular":
return `conic-gradient(from ${gradient.angle}deg${interpolation}, ${stops})`;
default:
return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`;
}
}
static #gradientSupportCache = new Map();
#testGradientSupport(css) {
const cached = FigFillPicker.#gradientSupportCache.get(css);
if (cached !== undefined) return cached;
const el = document.createElement("div");
el.style.background = css;
const result = !!el.style.background;
FigFillPicker.#gradientSupportCache.set(css, result);
return result;
}
#getGradientCSS() {
const preferred = this.#buildGradientCSS(undefined, true);
if (this.#testGradientSupport(preferred)) return preferred;
const oklabFallback = this.#buildGradientCSS("oklab", true);
if (this.#testGradientSupport(oklabFallback)) return oklabFallback;
return this.#buildGradientCSS("oklab", false);
}
// ===