@rogieking/figui3 2.23.0 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/components.css CHANGED
@@ -466,7 +466,6 @@ input[type="text"][list] {
466
466
  }
467
467
  }
468
468
 
469
- max-inline-size: var(--max-width);
470
469
  padding-block: 0;
471
470
  will-change: scale;
472
471
 
@@ -599,7 +598,9 @@ fig-dropdown,
599
598
  display: none !important;
600
599
  }
601
600
  > select {
602
- display: block;
601
+ display: flex;
602
+ align-items: center;
603
+ flex: 1;
603
604
  width: 100%;
604
605
  }
605
606
 
@@ -721,6 +722,7 @@ fig-button {
721
722
 
722
723
  &:active {
723
724
  background-color: var(--figma-color-bg-secondary);
725
+ color: var(--figma-color-text);
724
726
  }
725
727
 
726
728
  &:focus-visible {
@@ -2985,6 +2987,10 @@ fig-shimmer:not([playing="false"]) > * {
2985
2987
  /* Fill Picker */
2986
2988
  fig-fill-picker {
2987
2989
  display: contents;
2990
+
2991
+ > [slot^="mode-"] {
2992
+ display: none;
2993
+ }
2988
2994
  }
2989
2995
 
2990
2996
  .fig-fill-picker-dialog {
package/fig.js CHANGED
@@ -3384,16 +3384,29 @@ class FigInputColor extends HTMLElement {
3384
3384
  return this.getAttribute("picker") || "native";
3385
3385
  }
3386
3386
 
3387
+ #buildFillPickerAttrs() {
3388
+ const attrs = {};
3389
+ const experimental = this.getAttribute("experimental");
3390
+ if (experimental) attrs["experimental"] = experimental;
3391
+ // picker-* attributes forwarded to fill picker (except anchor, handled programmatically)
3392
+ for (const { name, value } of this.attributes) {
3393
+ if (name.startsWith("picker-") && name !== "picker-anchor") {
3394
+ attrs[name.slice(7)] = value;
3395
+ }
3396
+ }
3397
+ if (!attrs["dialog-position"]) attrs["dialog-position"] = "left";
3398
+ return Object.entries(attrs)
3399
+ .map(([k, v]) => `${k}="${v}"`)
3400
+ .join(" ");
3401
+ }
3402
+
3387
3403
  connectedCallback() {
3388
3404
  this.#setValues(this.getAttribute("value"));
3389
3405
 
3390
3406
  const useFigmaPicker = this.picker === "figma";
3391
3407
  const hidePicker = this.picker === "false";
3392
3408
  const showAlpha = this.getAttribute("alpha") === "true";
3393
- const experimental = this.getAttribute("experimental");
3394
- const expAttr = experimental ? `experimental="${experimental}"` : "";
3395
- const dialogPos = this.getAttribute("dialog-position") || "left";
3396
- const dialogPosAttr = `dialog-position="${dialogPos}"`;
3409
+ const fpAttrs = this.#buildFillPickerAttrs();
3397
3410
 
3398
3411
  let html = ``;
3399
3412
  if (this.getAttribute("text")) {
@@ -3418,7 +3431,7 @@ class FigInputColor extends HTMLElement {
3418
3431
  let swatchElement = "";
3419
3432
  if (!hidePicker) {
3420
3433
  swatchElement = useFigmaPicker
3421
- ? `<fig-fill-picker mode="solid" ${dialogPosAttr} ${expAttr} ${
3434
+ ? `<fig-fill-picker mode="solid" ${fpAttrs} ${
3422
3435
  showAlpha ? "" : 'alpha="false"'
3423
3436
  } value='{"type":"solid","color":"${this.hexOpaque}","opacity":${
3424
3437
  this.alpha
@@ -3436,7 +3449,7 @@ class FigInputColor extends HTMLElement {
3436
3449
  html = ``;
3437
3450
  } else {
3438
3451
  html = useFigmaPicker
3439
- ? `<fig-fill-picker mode="solid" ${dialogPosAttr} ${expAttr} ${
3452
+ ? `<fig-fill-picker mode="solid" ${fpAttrs} ${
3440
3453
  showAlpha ? "" : 'alpha="false"'
3441
3454
  } value='{"type":"solid","color":"${this.hexOpaque}","opacity":${
3442
3455
  this.alpha
@@ -3460,6 +3473,13 @@ class FigInputColor extends HTMLElement {
3460
3473
 
3461
3474
  // Setup fill picker (figma picker)
3462
3475
  if (this.#fillPicker) {
3476
+ const anchor = this.getAttribute("picker-anchor");
3477
+ if (anchor === "self") {
3478
+ this.#fillPicker.anchorElement = this;
3479
+ } else if (anchor) {
3480
+ const el = document.querySelector(anchor);
3481
+ if (el) this.#fillPicker.anchorElement = el;
3482
+ }
3463
3483
  if (this.hasAttribute("disabled")) {
3464
3484
  this.#fillPicker.setAttribute("disabled", "");
3465
3485
  }
@@ -3628,7 +3648,7 @@ class FigInputColor extends HTMLElement {
3628
3648
  }
3629
3649
 
3630
3650
  static get observedAttributes() {
3631
- return ["value", "style", "mode", "picker", "experimental", "dialog-position"];
3651
+ return ["value", "style", "mode", "picker", "experimental"];
3632
3652
  }
3633
3653
 
3634
3654
  get mode() {
@@ -3863,6 +3883,25 @@ class FigInputFill extends HTMLElement {
3863
3883
  }
3864
3884
  }
3865
3885
 
3886
+ #buildFillPickerAttrs() {
3887
+ const attrs = {};
3888
+ // Backward-compat: direct attributes forwarded to fill picker
3889
+ const mode = this.getAttribute("mode");
3890
+ if (mode) attrs["mode"] = mode;
3891
+ const experimental = this.getAttribute("experimental");
3892
+ if (experimental) attrs["experimental"] = experimental;
3893
+ // picker-* overrides (except anchor, handled programmatically)
3894
+ for (const { name, value } of this.attributes) {
3895
+ if (name.startsWith("picker-") && name !== "picker-anchor") {
3896
+ attrs[name.slice(7)] = value;
3897
+ }
3898
+ }
3899
+ if (!attrs["dialog-position"]) attrs["dialog-position"] = "left";
3900
+ return Object.entries(attrs)
3901
+ .map(([k, v]) => `${k}="${v}"`)
3902
+ .join(" ");
3903
+ }
3904
+
3866
3905
  #render() {
3867
3906
  const disabled = this.hasAttribute("disabled");
3868
3907
  const fillPickerValue = JSON.stringify(this.value);
@@ -3960,13 +3999,12 @@ class FigInputFill extends HTMLElement {
3960
3999
  break;
3961
4000
  }
3962
4001
 
3963
- const modeAttr = this.getAttribute("mode");
3964
- const experimentalAttr = this.getAttribute("experimental");
4002
+ const fpAttrs = this.#buildFillPickerAttrs();
3965
4003
  this.innerHTML = `
3966
4004
  <div class="input-combo">
3967
- <fig-fill-picker dialog-position="left" value='${fillPickerValue}' ${
4005
+ <fig-fill-picker ${fpAttrs} value='${fillPickerValue}' ${
3968
4006
  disabled ? "disabled" : ""
3969
- } ${modeAttr ? `mode="${modeAttr}"` : ""} ${experimentalAttr ? `experimental="${experimentalAttr}"` : ""}></fig-fill-picker>
4007
+ }></fig-fill-picker>
3970
4008
  ${controlsHtml}
3971
4009
  </div>`;
3972
4010
 
@@ -3991,6 +4029,14 @@ class FigInputFill extends HTMLElement {
3991
4029
  }
3992
4030
 
3993
4031
  if (this.#fillPicker) {
4032
+ const anchor = this.getAttribute("picker-anchor");
4033
+ if (!anchor || anchor === "self") {
4034
+ this.#fillPicker.anchorElement = this;
4035
+ } else {
4036
+ const el = document.querySelector(anchor);
4037
+ if (el) this.#fillPicker.anchorElement = el;
4038
+ }
4039
+
3994
4040
  this.#fillPicker.addEventListener("input", (e) => {
3995
4041
  e.stopPropagation();
3996
4042
  const detail = e.detail;
@@ -5076,6 +5122,8 @@ customElements.define("fig-chit", FigChit);
5076
5122
  */
5077
5123
  class FigImage extends HTMLElement {
5078
5124
  #src = null;
5125
+ #boundHandleFileInput = this.#handleFileInput.bind(this);
5126
+ #boundHandleDownload = this.#handleDownload.bind(this);
5079
5127
  constructor() {
5080
5128
  super();
5081
5129
  }
@@ -5108,10 +5156,8 @@ class FigImage extends HTMLElement {
5108
5156
  this.#updateRefs();
5109
5157
  }
5110
5158
  disconnectedCallback() {
5111
- this.fileInput.removeEventListener(
5112
- "change",
5113
- this.#handleFileInput.bind(this)
5114
- );
5159
+ this.fileInput?.removeEventListener("change", this.#boundHandleFileInput);
5160
+ this.downloadButton?.removeEventListener("click", this.#boundHandleDownload);
5115
5161
  }
5116
5162
 
5117
5163
  #updateRefs() {
@@ -5120,25 +5166,13 @@ class FigImage extends HTMLElement {
5120
5166
  if (this.upload) {
5121
5167
  this.uploadButton = this.querySelector("fig-button[type='upload']");
5122
5168
  this.fileInput = this.uploadButton?.querySelector("input");
5123
- this.fileInput.removeEventListener(
5124
- "change",
5125
- this.#handleFileInput.bind(this)
5126
- );
5127
- this.fileInput.addEventListener(
5128
- "change",
5129
- this.#handleFileInput.bind(this)
5130
- );
5169
+ this.fileInput?.removeEventListener("change", this.#boundHandleFileInput);
5170
+ this.fileInput?.addEventListener("change", this.#boundHandleFileInput);
5131
5171
  }
5132
5172
  if (this.download) {
5133
5173
  this.downloadButton = this.querySelector("fig-button[type='download']");
5134
- this.downloadButton.removeEventListener(
5135
- "click",
5136
- this.#handleDownload.bind(this)
5137
- );
5138
- this.downloadButton.addEventListener(
5139
- "click",
5140
- this.#handleDownload.bind(this)
5141
- );
5174
+ this.downloadButton?.removeEventListener("click", this.#boundHandleDownload);
5175
+ this.downloadButton?.addEventListener("click", this.#boundHandleDownload);
5142
5176
  }
5143
5177
  });
5144
5178
  }
@@ -6235,6 +6269,7 @@ class FigFillPicker extends HTMLElement {
6235
6269
  #chit = null;
6236
6270
  #dialog = null;
6237
6271
  #activeTab = "solid";
6272
+ anchorElement = null;
6238
6273
 
6239
6274
  // Fill state
6240
6275
  #fillType = "solid";
@@ -6253,6 +6288,10 @@ class FigFillPicker extends HTMLElement {
6253
6288
  #video = { url: null, scaleMode: "fill", scale: 50 };
6254
6289
  #webcam = { stream: null, snapshot: null };
6255
6290
 
6291
+ // Custom mode slots and data
6292
+ #customSlots = {};
6293
+ #customData = {};
6294
+
6256
6295
  // DOM references for solid tab
6257
6296
  #colorArea = null;
6258
6297
  #colorAreaHandle = null;
@@ -6287,7 +6326,9 @@ class FigFillPicker extends HTMLElement {
6287
6326
  }
6288
6327
 
6289
6328
  #setupTrigger() {
6290
- const child = this.firstElementChild;
6329
+ const child = Array.from(this.children).find(
6330
+ (el) => !el.getAttribute("slot")?.startsWith("mode-")
6331
+ );
6291
6332
 
6292
6333
  if (!child) {
6293
6334
  // Scenario 1: Empty - create fig-chit
@@ -6327,6 +6368,8 @@ class FigFillPicker extends HTMLElement {
6327
6368
  const valueAttr = this.getAttribute("value");
6328
6369
  if (!valueAttr) return;
6329
6370
 
6371
+ const builtinTypes = ["solid", "gradient", "image", "video", "webcam"];
6372
+
6330
6373
  try {
6331
6374
  const parsed = JSON.parse(valueAttr);
6332
6375
  if (parsed.type) this.#fillType = parsed.type;
@@ -6349,6 +6392,12 @@ class FigFillPicker extends HTMLElement {
6349
6392
  this.#gradient = { ...this.#gradient, ...parsed.gradient };
6350
6393
  if (parsed.image) this.#image = { ...this.#image, ...parsed.image };
6351
6394
  if (parsed.video) this.#video = { ...this.#video, ...parsed.video };
6395
+
6396
+ // Store full parsed data for custom (non-built-in) types
6397
+ if (parsed.type && !builtinTypes.includes(parsed.type)) {
6398
+ const { type, ...rest } = parsed;
6399
+ this.#customData[parsed.type] = rest;
6400
+ }
6352
6401
  } catch (e) {
6353
6402
  // If not JSON, treat as hex color
6354
6403
  if (valueAttr.startsWith("#")) {
@@ -6399,7 +6448,8 @@ class FigFillPicker extends HTMLElement {
6399
6448
  }
6400
6449
  break;
6401
6450
  default:
6402
- bg = "#D9D9D9";
6451
+ const slot = this.#customSlots[this.#fillType];
6452
+ bg = slot?.element?.getAttribute("chit-background") || "#D9D9D9";
6403
6453
  }
6404
6454
 
6405
6455
  this.#chit.setAttribute("background", bg);
@@ -6446,6 +6496,18 @@ class FigFillPicker extends HTMLElement {
6446
6496
  }
6447
6497
 
6448
6498
  #createDialog() {
6499
+ // Collect slotted custom mode content before any DOM changes
6500
+ this.#customSlots = {};
6501
+ this.querySelectorAll('[slot^="mode-"]').forEach((el) => {
6502
+ const modeName = el.getAttribute("slot").slice(5);
6503
+ this.#customSlots[modeName] = {
6504
+ element: el,
6505
+ label:
6506
+ el.getAttribute("label") ||
6507
+ modeName.charAt(0).toUpperCase() + modeName.slice(1),
6508
+ };
6509
+ });
6510
+
6449
6511
  this.#dialog = document.createElement("dialog", { is: "fig-popup" });
6450
6512
  this.#dialog.setAttribute("is", "fig-popup");
6451
6513
  this.#dialog.setAttribute("drag", "true");
@@ -6453,14 +6515,12 @@ class FigFillPicker extends HTMLElement {
6453
6515
  this.#dialog.setAttribute("autoresize", "false");
6454
6516
  this.#dialog.classList.add("fig-fill-picker-dialog");
6455
6517
 
6456
- this.#dialog.anchor = this.#trigger;
6518
+ this.#dialog.anchor = this.anchorElement || this.#trigger;
6457
6519
  const dialogPosition = this.getAttribute("dialog-position") || "left";
6458
6520
  this.#dialog.setAttribute("position", dialogPosition);
6459
6521
 
6460
- // Check for allowed modes (supports comma-separated values like "solid,gradient")
6461
- const mode = this.getAttribute("mode");
6462
- const allModes = ["solid", "gradient", "image", "video", "webcam"];
6463
- const modeLabels = {
6522
+ const builtinModes = ["solid", "gradient", "image", "video", "webcam"];
6523
+ const builtinLabels = {
6464
6524
  solid: "Solid",
6465
6525
  gradient: "Gradient",
6466
6526
  image: "Image",
@@ -6468,24 +6528,33 @@ class FigFillPicker extends HTMLElement {
6468
6528
  webcam: "Webcam",
6469
6529
  };
6470
6530
 
6471
- // Parse allowed modes
6472
- let allowedModes = allModes;
6531
+ // Build allowed modes: built-ins filtered normally, custom names accepted if slot exists
6532
+ const mode = this.getAttribute("mode");
6533
+ let allowedModes;
6473
6534
  if (mode) {
6474
- const requestedModes = mode.split(",").map((m) => m.trim().toLowerCase());
6475
- allowedModes = requestedModes.filter((m) => allModes.includes(m));
6476
- if (allowedModes.length === 0) allowedModes = allModes;
6535
+ const requested = mode.split(",").map((m) => m.trim().toLowerCase());
6536
+ allowedModes = requested.filter(
6537
+ (m) => builtinModes.includes(m) || this.#customSlots[m]
6538
+ );
6539
+ if (allowedModes.length === 0) allowedModes = [...builtinModes];
6540
+ } else {
6541
+ allowedModes = [...builtinModes];
6542
+ }
6543
+
6544
+ // Build labels map: built-in labels + custom slot labels
6545
+ const modeLabels = { ...builtinLabels };
6546
+ for (const [name, { label }] of Object.entries(this.#customSlots)) {
6547
+ modeLabels[name] = label;
6477
6548
  }
6478
6549
 
6479
- // If current fillType not in allowed modes, switch to first allowed
6480
6550
  if (!allowedModes.includes(this.#fillType)) {
6481
6551
  this.#fillType = allowedModes[0];
6482
6552
  this.#activeTab = allowedModes[0];
6483
6553
  }
6484
6554
 
6485
- // Build header content - label if single mode, dropdown if multiple
6486
6555
  const experimental = this.getAttribute("experimental");
6487
6556
  const expAttr = experimental ? `experimental="${experimental}"` : "";
6488
-
6557
+
6489
6558
  let headerContent;
6490
6559
  if (allowedModes.length === 1) {
6491
6560
  headerContent = `<h3 class="fig-fill-picker-type-label">${modeLabels[allowedModes[0]]}</h3>`;
@@ -6498,6 +6567,11 @@ class FigFillPicker extends HTMLElement {
6498
6567
  </fig-dropdown>`;
6499
6568
  }
6500
6569
 
6570
+ // Generate tab containers for all allowed modes
6571
+ const tabDivs = allowedModes
6572
+ .map((m) => `<div class="fig-fill-picker-tab" data-tab="${m}"></div>`)
6573
+ .join("\n ");
6574
+
6501
6575
  this.#dialog.innerHTML = `
6502
6576
  <fig-header>
6503
6577
  ${headerContent}
@@ -6506,16 +6580,33 @@ class FigFillPicker extends HTMLElement {
6506
6580
  </fig-button>
6507
6581
  </fig-header>
6508
6582
  <div class="fig-fill-picker-content">
6509
- <div class="fig-fill-picker-tab" data-tab="solid"></div>
6510
- <div class="fig-fill-picker-tab" data-tab="gradient"></div>
6511
- <div class="fig-fill-picker-tab" data-tab="image"></div>
6512
- <div class="fig-fill-picker-tab" data-tab="video"></div>
6513
- <div class="fig-fill-picker-tab" data-tab="webcam"></div>
6583
+ ${tabDivs}
6514
6584
  </div>
6515
6585
  `;
6516
6586
 
6517
6587
  document.body.appendChild(this.#dialog);
6518
6588
 
6589
+ // Populate custom tab containers and emit modeready
6590
+ for (const [modeName, { element }] of Object.entries(this.#customSlots)) {
6591
+ const container = this.#dialog.querySelector(
6592
+ `[data-tab="${modeName}"]`
6593
+ );
6594
+ if (!container) continue;
6595
+
6596
+ // Move children (not the element itself) for vanilla HTML usage
6597
+ while (element.firstChild) {
6598
+ container.appendChild(element.firstChild);
6599
+ }
6600
+
6601
+ // Emit modeready so frameworks can render into the container
6602
+ this.dispatchEvent(
6603
+ new CustomEvent("modeready", {
6604
+ bubbles: true,
6605
+ detail: { mode: modeName, container },
6606
+ })
6607
+ );
6608
+ }
6609
+
6519
6610
  // Setup type dropdown switching (only if not locked)
6520
6611
  const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
6521
6612
  if (typeDropdown) {
@@ -6530,34 +6621,50 @@ class FigFillPicker extends HTMLElement {
6530
6621
  this.#dialog.open = false;
6531
6622
  });
6532
6623
 
6533
- // Emit change on close
6534
6624
  this.#dialog.addEventListener("close", () => {
6535
6625
  this.#emitChange();
6536
6626
  });
6537
6627
 
6538
- // Initialize tabs
6539
- this.#initSolidTab();
6540
- this.#initGradientTab();
6541
- this.#initImageTab();
6542
- this.#initVideoTab();
6543
- this.#initWebcamTab();
6544
- }
6545
-
6546
- #switchTab(tabName) {
6547
- // Check for allowed modes - prevent switching to disallowed mode
6548
- const mode = this.getAttribute("mode");
6549
- const allModes = ["solid", "gradient", "image", "video", "webcam"];
6550
-
6551
- let allowedModes = allModes;
6552
- if (mode) {
6553
- const requestedModes = mode.split(",").map((m) => m.trim().toLowerCase());
6554
- allowedModes = requestedModes.filter((m) => allModes.includes(m));
6555
- if (allowedModes.length === 0) allowedModes = allModes;
6628
+ // Initialize built-in tabs (skip any overridden by custom slots)
6629
+ const builtinInits = {
6630
+ solid: () => this.#initSolidTab(),
6631
+ gradient: () => this.#initGradientTab(),
6632
+ image: () => this.#initImageTab(),
6633
+ video: () => this.#initVideoTab(),
6634
+ webcam: () => this.#initWebcamTab(),
6635
+ };
6636
+ for (const [name, init] of Object.entries(builtinInits)) {
6637
+ if (!this.#customSlots[name] && allowedModes.includes(name)) init();
6556
6638
  }
6557
6639
 
6558
- if (!allowedModes.includes(tabName)) {
6559
- return; // Don't allow switching to disallowed mode
6640
+ // Listen for input/change from custom tab content
6641
+ for (const modeName of Object.keys(this.#customSlots)) {
6642
+ if (builtinModes.includes(modeName)) continue;
6643
+ const container = this.#dialog.querySelector(
6644
+ `[data-tab="${modeName}"]`
6645
+ );
6646
+ if (!container) continue;
6647
+ container.addEventListener("input", (e) => {
6648
+ if (e.target === this) return;
6649
+ e.stopPropagation();
6650
+ if (e.detail) this.#customData[modeName] = e.detail;
6651
+ this.#emitInput();
6652
+ });
6653
+ container.addEventListener("change", (e) => {
6654
+ if (e.target === this) return;
6655
+ e.stopPropagation();
6656
+ if (e.detail) this.#customData[modeName] = e.detail;
6657
+ this.#emitChange();
6658
+ });
6560
6659
  }
6660
+ }
6661
+
6662
+ #switchTab(tabName) {
6663
+ // Only allow switching to modes that have a tab container in the dialog
6664
+ const tab = this.#dialog?.querySelector(
6665
+ `.fig-fill-picker-tab[data-tab="${tabName}"]`
6666
+ );
6667
+ if (!tab) return;
6561
6668
 
6562
6669
  this.#activeTab = tabName;
6563
6670
  this.#fillType = tabName;
@@ -6578,6 +6685,12 @@ class FigFillPicker extends HTMLElement {
6578
6685
  }
6579
6686
  });
6580
6687
 
6688
+ // Zero out content padding for custom mode tabs
6689
+ const contentEl = this.#dialog.querySelector(".fig-fill-picker-content");
6690
+ if (contentEl) {
6691
+ contentEl.style.padding = this.#customSlots[tabName] ? "0" : "";
6692
+ }
6693
+
6581
6694
  // Update tab-specific UI after visibility change
6582
6695
  if (tabName === "gradient") {
6583
6696
  // Use RAF to ensure layout is complete before updating angle input
@@ -6999,7 +7112,7 @@ class FigFillPicker extends HTMLElement {
6999
7112
  <fig-input-number class="fig-fill-picker-stop-position" min="0" max="100" value="${
7000
7113
  stop.position
7001
7114
  }" units="%"></fig-input-number>
7002
- <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" dialog-position="right" value="${
7115
+ <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${
7003
7116
  stop.color
7004
7117
  }"></fig-input-color>
7005
7118
  <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
@@ -7027,9 +7140,18 @@ class FigFillPicker extends HTMLElement {
7027
7140
  this.#emitInput();
7028
7141
  });
7029
7142
 
7030
- row
7031
- .querySelector(".fig-fill-picker-stop-color")
7032
- .addEventListener("input", (e) => {
7143
+ const stopColor = row.querySelector(".fig-fill-picker-stop-color");
7144
+ const stopFillPicker = stopColor.querySelector("fig-fill-picker");
7145
+ if (stopFillPicker) {
7146
+ stopFillPicker.anchorElement = this.#dialog;
7147
+ } else {
7148
+ requestAnimationFrame(() => {
7149
+ const fp = stopColor.querySelector("fig-fill-picker");
7150
+ if (fp) fp.anchorElement = this.#dialog;
7151
+ });
7152
+ }
7153
+
7154
+ stopColor.addEventListener("input", (e) => {
7033
7155
  this.#gradient.stops[index].color =
7034
7156
  e.target.hexOpaque || e.target.value;
7035
7157
  const parsedAlpha = parseFloat(e.target.alpha);
@@ -7723,7 +7845,7 @@ class FigFillPicker extends HTMLElement {
7723
7845
  image: { url: this.#webcam.snapshot, scaleMode: "fill", scale: 50 },
7724
7846
  };
7725
7847
  default:
7726
- return base;
7848
+ return { ...base, ...this.#customData[this.#fillType] };
7727
7849
  }
7728
7850
  }
7729
7851
 
package/index.html CHANGED
@@ -9,6 +9,8 @@
9
9
  <link rel="stylesheet"
10
10
  type="text/css"
11
11
  href="fig.css">
12
+ <link rel="stylesheet"
13
+ href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css">
12
14
  <script src="fig.js"></script>
13
15
  <style>
14
16
  * {
@@ -161,6 +163,97 @@
161
163
  color: var(--figma-color-text);
162
164
  }
163
165
 
166
+ pre[class*="language-"] {
167
+ background: var(--figma-color-bg-secondary);
168
+ border: 1px solid var(--figma-color-border);
169
+ border-radius: 6px;
170
+ padding: 12px 16px;
171
+ margin: 0;
172
+ overflow-x: auto;
173
+ }
174
+
175
+ pre[class*="language-"]>code[class*="language-"] {
176
+ display: block;
177
+ background: transparent;
178
+ padding: 0;
179
+ font-family: "SF Mono", Monaco, Consolas, monospace;
180
+ font-size: 12px;
181
+ line-height: 1.5;
182
+ color: var(--figma-color-text);
183
+ text-shadow: none;
184
+ }
185
+
186
+ :root {
187
+ --code-token-comment: #6b7280;
188
+ --code-token-punctuation: #374151;
189
+ --code-token-primary: #b42318;
190
+ --code-token-string: #0f766e;
191
+ --code-token-operator: #1d4ed8;
192
+ --code-token-keyword: #6d28d9;
193
+ --code-token-function: #9a3412;
194
+ }
195
+
196
+ html[style*="color-scheme: dark"] {
197
+ --code-token-comment: #8b93a7;
198
+ --code-token-punctuation: #c7cfdd;
199
+ --code-token-primary: #ff8f8f;
200
+ --code-token-string: #6dd3b6;
201
+ --code-token-operator: #7fb4ff;
202
+ --code-token-keyword: #b79bff;
203
+ --code-token-function: #ffc27a;
204
+ }
205
+
206
+ /* Keep token colors aligned with theme and improve contrast in light mode */
207
+ .token.comment,
208
+ .token.prolog,
209
+ .token.doctype,
210
+ .token.cdata {
211
+ color: var(--code-token-comment);
212
+ }
213
+
214
+ .token.punctuation {
215
+ color: var(--code-token-punctuation);
216
+ }
217
+
218
+ .token.property,
219
+ .token.tag,
220
+ .token.boolean,
221
+ .token.number,
222
+ .token.constant,
223
+ .token.symbol,
224
+ .token.deleted {
225
+ color: var(--code-token-primary);
226
+ }
227
+
228
+ .token.selector,
229
+ .token.attr-name,
230
+ .token.string,
231
+ .token.char,
232
+ .token.builtin,
233
+ .token.inserted {
234
+ color: var(--code-token-string);
235
+ }
236
+
237
+ .token.operator,
238
+ .token.entity,
239
+ .token.url,
240
+ .language-css .token.string,
241
+ .style .token.string {
242
+ color: var(--code-token-operator);
243
+ background: transparent;
244
+ }
245
+
246
+ .token.atrule,
247
+ .token.attr-value,
248
+ .token.keyword {
249
+ color: var(--code-token-keyword);
250
+ }
251
+
252
+ .token.function,
253
+ .token.class-name {
254
+ color: var(--code-token-function);
255
+ }
256
+
164
257
  .toolbelt {
165
258
  position: fixed;
166
259
  bottom: 0.75rem;
@@ -1577,7 +1670,8 @@
1577
1670
  </fig-field>
1578
1671
  <fig-field direction="horizontal">
1579
1672
  <label>Dropdown</label>
1580
- <fig-dropdown full>
1673
+ <fig-dropdown full
1674
+ experimental="modern">
1581
1675
  <option>Option One</option>
1582
1676
  <option>Option Two</option>
1583
1677
  <option>Option Three</option>
@@ -1649,9 +1743,208 @@
1649
1743
  </fig-fill-picker>
1650
1744
 
1651
1745
  <h3>Custom Trigger</h3>
1746
+ <p class="description">Any child element can act as the trigger. If the child is not a
1747
+ <code>fig-chit</code>, it becomes click-only — the fill picker won't update its appearance.
1748
+ </p>
1749
+
1750
+ <h4>Button Trigger</h4>
1652
1751
  <fig-fill-picker value='{"type":"solid","color":"#95E1D3"}'>
1653
1752
  <fig-button>Edit Fill</fig-button>
1654
1753
  </fig-fill-picker>
1754
+ <pre
1755
+ style="background: var(--figma-color-bg-secondary); padding: 12px 16px; border-radius: 6px; overflow-x: auto; margin: 0;"><code style="font-family: monospace; font-size: 12px; color: var(--figma-color-text);">&lt;fig-fill-picker value='{"type":"solid","color":"#95E1D3"}'&gt;
1756
+ &lt;fig-button&gt;Edit Fill&lt;/fig-button&gt;
1757
+ &lt;/fig-fill-picker&gt;</code></pre>
1758
+
1759
+ <h4>Icon Button Trigger</h4>
1760
+ <fig-fill-picker value='{"type":"solid","color":"#667eea"}'>
1761
+ <fig-button icon
1762
+ variant="ghost"
1763
+ title="Pick a fill">
1764
+ <span class="fig-mask-icon"
1765
+ style="--icon: var(--icon-eyedropper)"></span>
1766
+ </fig-button>
1767
+ </fig-fill-picker>
1768
+ <pre
1769
+ style="background: var(--figma-color-bg-secondary); padding: 12px 16px; border-radius: 6px; overflow-x: auto; margin: 0;"><code style="font-family: monospace; font-size: 12px; color: var(--figma-color-text);">&lt;fig-fill-picker value='{"type":"solid","color":"#667eea"}'&gt;
1770
+ &lt;fig-button icon variant="ghost" title="Pick a fill"&gt;
1771
+ &lt;span class="fig-mask-icon" style="--icon: var(--icon-eyedropper)"&gt;&lt;/span&gt;
1772
+ &lt;/fig-button&gt;
1773
+ &lt;/fig-fill-picker&gt;</code></pre>
1774
+
1775
+ <h4>Inline Text Trigger</h4>
1776
+ <fig-fill-picker value='{"type":"solid","color":"#F38181"}'>
1777
+ <span style="cursor: pointer; text-decoration: underline; color: var(--figma-color-text-brand)">Change
1778
+ background</span>
1779
+ </fig-fill-picker>
1780
+ <pre
1781
+ style="background: var(--figma-color-bg-secondary); padding: 12px 16px; border-radius: 6px; overflow-x: auto; margin: 0;"><code style="font-family: monospace; font-size: 12px; color: var(--figma-color-text);">&lt;fig-fill-picker value='{"type":"solid","color":"#F38181"}'&gt;
1782
+ &lt;span style="cursor: pointer"&gt;Change background&lt;/span&gt;
1783
+ &lt;/fig-fill-picker&gt;</code></pre>
1784
+
1785
+ <h4>Swatch with Custom Size</h4>
1786
+ <fig-fill-picker id="fill-picker-custom-swatch"
1787
+ value='{"type":"gradient","gradient":{"type":"linear","angle":135,"stops":[{"position":0,"color":"#f093fb","opacity":100},{"position":100,"color":"#f5576c","opacity":100}]}}'>
1788
+ <div id="custom-swatch-preview"
1789
+ style="width: 3rem; height: 3rem; border-radius: 0.5rem; cursor: pointer; background: linear-gradient(135deg, #f093fb, #f5576c)">
1790
+ </div>
1791
+ </fig-fill-picker>
1792
+ <pre
1793
+ style="background: var(--figma-color-bg-secondary); padding: 12px 16px; border-radius: 6px; overflow-x: auto; margin: 0;"><code style="font-family: monospace; font-size: 12px; color: var(--figma-color-text);">&lt;fig-fill-picker value='{"type":"gradient",...}'&gt;
1794
+ &lt;div style="width: 3rem; height: 3rem; border-radius: 0.5rem;
1795
+ cursor: pointer; background: linear-gradient(...)"&gt;
1796
+ &lt;/div&gt;
1797
+ &lt;/fig-fill-picker&gt;</code></pre>
1798
+ <script>
1799
+ (() => {
1800
+ const picker = document.getElementById('fill-picker-custom-swatch');
1801
+ const swatch = document.getElementById('custom-swatch-preview');
1802
+ if (!picker || !swatch) return;
1803
+
1804
+ const getSizingForMedia = (scaleMode, scale) => {
1805
+ switch (scaleMode) {
1806
+ case 'fit':
1807
+ return { size: 'contain', position: 'center' };
1808
+ case 'tile':
1809
+ return { size: `${scale || 50}%`, position: 'top left' };
1810
+ case 'fill':
1811
+ case 'crop':
1812
+ default:
1813
+ return { size: 'cover', position: 'center' };
1814
+ }
1815
+ };
1816
+
1817
+ const updateSwatchPreview = (fill) => {
1818
+ if (!fill) return;
1819
+
1820
+ let background = 'transparent';
1821
+ let backgroundSize = 'cover';
1822
+ let backgroundPosition = 'center';
1823
+
1824
+ if (fill.type === 'solid') {
1825
+ background = fill.color || '#D9D9D9';
1826
+ } else if (fill.type === 'gradient') {
1827
+ background = fill.css || background;
1828
+ } else if (fill.type === 'image' && fill.image?.url) {
1829
+ background = `url(${fill.image.url})`;
1830
+ const sizing = getSizingForMedia(fill.image.scaleMode, fill.image.scale);
1831
+ backgroundSize = sizing.size;
1832
+ backgroundPosition = sizing.position;
1833
+ } else if (fill.type === 'video' && fill.video?.url) {
1834
+ background = `url(${fill.video.url})`;
1835
+ const sizing = getSizingForMedia(fill.video.scaleMode, fill.video.scale);
1836
+ backgroundSize = sizing.size;
1837
+ backgroundPosition = sizing.position;
1838
+ } else if (fill.type === 'webcam' && fill.image?.url) {
1839
+ background = `url(${fill.image.url})`;
1840
+ }
1841
+
1842
+ swatch.style.background = background;
1843
+ swatch.style.backgroundSize = backgroundSize;
1844
+ swatch.style.backgroundPosition = backgroundPosition;
1845
+ };
1846
+
1847
+ picker.addEventListener('input', (e) => updateSwatchPreview(e.detail));
1848
+ picker.addEventListener('change', (e) => updateSwatchPreview(e.detail));
1849
+ updateSwatchPreview(picker.value);
1850
+ })();
1851
+ </script>
1852
+
1853
+ <h3>Custom Mode (Slots)</h3>
1854
+ <p class="description">Add custom modes via <code>slot="mode-{name}"</code> children. The mode name must
1855
+ also appear in the <code>mode</code> attribute. Custom slot content manages its own UI; dispatch
1856
+ <code>input</code>/<code>change</code> events with a <code>detail</code> payload to relay values back.
1857
+ </p>
1858
+
1859
+ <h4>Custom "Shader" Mode</h4>
1860
+ <fig-fill-picker mode="solid,shader"
1861
+ value='{"type":"solid","color":"#95E1D3"}'
1862
+ experimental="modern">
1863
+ <div slot="mode-shader"
1864
+ label="Shader">
1865
+ <fig-field label="Fragment Shader">
1866
+ <fig-input-text multiline
1867
+ placeholder="void main() { ... }"
1868
+ value="void main() {&#10; gl_FragColor = vec4(1.0, 0.0, 0.5, 1.0);&#10;}"></fig-input-text>
1869
+ </fig-field>
1870
+ </div>
1871
+ </fig-fill-picker>
1872
+ <pre
1873
+ style="background: var(--figma-color-bg-secondary); padding: 12px 16px; border-radius: 6px; overflow-x: auto; margin: 0;"><code style="font-family: monospace; font-size: 12px; color: var(--figma-color-text);">&lt;fig-fill-picker mode="solid,shader"&gt;
1874
+ &lt;div slot="mode-shader" label="Shader"&gt;
1875
+ &lt;fig-field label="Fragment Shader"&gt;
1876
+ &lt;fig-input-text multiline
1877
+ placeholder="void main() { ... }"
1878
+ value="..."&gt;&lt;/fig-input-text&gt;
1879
+ &lt;/fig-field&gt;
1880
+ &lt;/div&gt;
1881
+ &lt;/fig-fill-picker&gt;</code></pre>
1882
+
1883
+ <h4>React Custom Mode (via <code>modeready</code>)</h4>
1884
+ <p class="description">Frameworks like React can listen for the <code>modeready</code> event and render
1885
+ directly into the provided container. The DOM is never moved — React owns its tree from the start.</p>
1886
+ <fig-fill-picker id="react-picker" mode="solid,react-demo">
1887
+ <div slot="mode-react-demo" label="React"></div>
1888
+ </fig-fill-picker>
1889
+ <script type="module">
1890
+ import React from 'https://esm.sh/react@18';
1891
+ import { createRoot } from 'https://esm.sh/react-dom@18/client';
1892
+
1893
+ const h = React.createElement;
1894
+ const picker = document.getElementById('react-picker');
1895
+
1896
+ picker.addEventListener('modeready', (e) => {
1897
+ if (e.detail.mode !== 'react-demo') return;
1898
+
1899
+ function ColorButtons() {
1900
+ const [color, setColor] = React.useState('#667eea');
1901
+ const colors = ['#FF6B6B', '#4ECDC4', '#667eea', '#f093fb', '#95E1D3'];
1902
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
1903
+ h('p', {
1904
+ style: {
1905
+ fontSize: 11,
1906
+ color: 'var(--figma-color-text-secondary)',
1907
+ margin: 0,
1908
+ }
1909
+ }, `Selected: ${color}`),
1910
+ h('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap' } },
1911
+ colors.map((c) =>
1912
+ h('button', {
1913
+ key: c,
1914
+ onClick: () => setColor(c),
1915
+ style: {
1916
+ width: 28,
1917
+ height: 28,
1918
+ borderRadius: 4,
1919
+ border: c === color ? '2px solid var(--figma-color-text)' : '1px solid var(--figma-color-border)',
1920
+ background: c,
1921
+ cursor: 'pointer',
1922
+ padding: 0,
1923
+ }
1924
+ })
1925
+ )
1926
+ )
1927
+ );
1928
+ }
1929
+
1930
+ createRoot(e.detail.container).render(h(ColorButtons));
1931
+ });
1932
+ </script>
1933
+ <pre
1934
+ style="background: var(--figma-color-bg-secondary); padding: 12px 16px; border-radius: 6px; overflow-x: auto; margin: 0;"><code style="font-family: monospace; font-size: 12px; color: var(--figma-color-text);">&lt;fig-fill-picker id="react-picker" mode="solid,react-demo"&gt;
1935
+ &lt;div slot="mode-react-demo" label="React"&gt;&lt;/div&gt;
1936
+ &lt;/fig-fill-picker&gt;
1937
+
1938
+ &lt;script type="module"&gt;
1939
+ import React from 'https://esm.sh/react@18';
1940
+ import { createRoot } from 'https://esm.sh/react-dom@18/client';
1941
+
1942
+ const picker = document.getElementById('react-picker');
1943
+ picker.addEventListener('modeready', (e) =&gt; {
1944
+ if (e.detail.mode !== 'react-demo') return;
1945
+ createRoot(e.detail.container).render(&lt;MyComponent /&gt;);
1946
+ });
1947
+ &lt;/script&gt;</code></pre>
1655
1948
 
1656
1949
  <h3>Without Alpha</h3>
1657
1950
  <fig-fill-picker alpha="false"
@@ -3273,9 +3566,11 @@
3273
3566
  &lt;/dialog&gt;</code></pre>
3274
3567
 
3275
3568
  <h3>Viewport Margin</h3>
3276
- <p class="description">Use <code>viewport-margin</code> to define safe areas the popup should avoid (e.g. a bottom toolbar). Uses CSS margin shorthand: <code>top right bottom left</code>.</p>
3569
+ <p class="description">Use <code>viewport-margin</code> to define safe areas the popup should avoid (e.g. a
3570
+ bottom toolbar). Uses CSS margin shorthand: <code>top right bottom left</code>.</p>
3277
3571
  <fig-button id="popup-open-viewport-margin"
3278
- onclick="document.getElementById('popup-viewport-margin').open = true">Open (64px bottom margin)</fig-button>
3572
+ onclick="document.getElementById('popup-viewport-margin').open = true">Open (64px bottom
3573
+ margin)</fig-button>
3279
3574
  <dialog id="popup-viewport-margin"
3280
3575
  is="fig-popup"
3281
3576
  anchor="#popup-open-viewport-margin"
@@ -3284,7 +3579,9 @@
3284
3579
  viewport-margin="8 8 64 8">
3285
3580
  <vstack style="min-width: 14rem;">
3286
3581
  <strong style="padding: 0 var(--spacer-1);">Viewport Margin</strong>
3287
- <p style="padding: 0 var(--spacer-1); margin: 0; font-size: var(--font-size-small); color: var(--figma-color-text-secondary);">This popup won't overlap the bottom 64px of the viewport.</p>
3582
+ <p
3583
+ style="padding: 0 var(--spacer-1); margin: 0; font-size: var(--font-size-small); color: var(--figma-color-text-secondary);">
3584
+ This popup won't overlap the bottom 64px of the viewport.</p>
3288
3585
  <fig-input-text placeholder="Try scrolling down"></fig-input-text>
3289
3586
  </vstack>
3290
3587
  </dialog>
@@ -4564,7 +4861,50 @@ button.addEventListener('click', () => {
4564
4861
  </section>
4565
4862
  </div>
4566
4863
 
4864
+ <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js"></script>
4865
+ <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-markup.min.js"></script>
4866
+ <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-css.min.js"></script>
4867
+ <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-clike.min.js"></script>
4868
+ <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-javascript.min.js"></script>
4567
4869
  <script>
4870
+ function detectCodeLanguage(codeText) {
4871
+ const source = codeText.trim();
4872
+ if (!source) return 'javascript';
4873
+
4874
+ if (
4875
+ source.startsWith('<') ||
4876
+ source.startsWith('&lt;') ||
4877
+ source.includes('&lt;/') ||
4878
+ source.includes('</')
4879
+ ) {
4880
+ return 'markup';
4881
+ }
4882
+
4883
+ if (
4884
+ /(^|[\n\r])\s*[@.#a-zA-Z][\w\s.#:[\]-]*\{/.test(source) ||
4885
+ /--[\w-]+\s*:/.test(source)
4886
+ ) {
4887
+ return 'css';
4888
+ }
4889
+
4890
+ return 'javascript';
4891
+ }
4892
+
4893
+ function highlightCodeBlocks() {
4894
+ const blocks = document.querySelectorAll('pre > code');
4895
+ blocks.forEach((code) => {
4896
+ if (!code.className.includes('language-')) {
4897
+ const language = detectCodeLanguage(code.textContent || '');
4898
+ code.classList.add(`language-${language}`);
4899
+ code.parentElement?.classList.add(`language-${language}`);
4900
+ }
4901
+ });
4902
+
4903
+ if (window.Prism) {
4904
+ window.Prism.highlightAll();
4905
+ }
4906
+ }
4907
+
4568
4908
  // Highlight nav item based on hash
4569
4909
  function updateActiveNav() {
4570
4910
  const hash = location.hash || '#avatar';
@@ -4609,6 +4949,7 @@ button.addEventListener('click', () => {
4609
4949
 
4610
4950
  // Initial state
4611
4951
  window.addEventListener('load', () => {
4952
+ highlightCodeBlocks();
4612
4953
  updateActiveNav();
4613
4954
  if (location.hash) {
4614
4955
  document.querySelector(location.hash)?.scrollIntoView();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "2.23.0",
3
+ "version": "2.25.0",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "MIT",