@rogieking/figui3 4.13.1 → 4.15.3

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/fig.js CHANGED
@@ -16,6 +16,15 @@ function figIsWebKitOrIOSBrowser() {
16
16
  return isIOSBrowser || isDesktopWebKit;
17
17
  }
18
18
 
19
+ /** @param {string} name @param {{ size?: string, className?: string }} [options] */
20
+ function createFigIcon(name, options = {}) {
21
+ const icon = document.createElement("fig-icon");
22
+ if (name) icon.setAttribute("name", name);
23
+ if (options.size) icon.setAttribute("size", options.size);
24
+ if (options.className) icon.className = options.className;
25
+ return icon;
26
+ }
27
+
19
28
  function figSupportsCustomizedBuiltIns() {
20
29
  if (
21
30
  typeof window === "undefined" ||
@@ -1234,10 +1243,7 @@ class FigDialog extends HTMLDialogElement {
1234
1243
  btn.setAttribute("variant", "ghost");
1235
1244
  btn.setAttribute("icon", "");
1236
1245
  btn.setAttribute("close-dialog", "");
1237
- const icon = document.createElement("span");
1238
- icon.className = "fig-mask-icon";
1239
- icon.style.setProperty("--icon", "var(--icon-close)");
1240
- btn.appendChild(icon);
1246
+ btn.appendChild(createFigIcon("close"));
1241
1247
  tooltip.appendChild(btn);
1242
1248
  header.appendChild(h3);
1243
1249
  header.appendChild(tooltip);
@@ -1778,6 +1784,10 @@ class FigPopup extends HTMLDialogElement {
1778
1784
  this.hasAttribute("popover") &&
1779
1785
  typeof this.showPopover === "function" &&
1780
1786
  !this.matches?.(":popover-open");
1787
+ const positionBeforeReveal = this.shouldAutoReposition();
1788
+ if (positionBeforeReveal) {
1789
+ this.style.visibility = "hidden";
1790
+ }
1781
1791
  if (usePopover) {
1782
1792
  try {
1783
1793
  this.showPopover();
@@ -1792,6 +1802,10 @@ class FigPopup extends HTMLDialogElement {
1792
1802
  // Ignore when dialog cannot be shown yet.
1793
1803
  }
1794
1804
  }
1805
+ if (positionBeforeReveal && (this.matches?.(":open") || this.matches?.(":popover-open"))) {
1806
+ this.positionPopup();
1807
+ this.style.visibility = "";
1808
+ }
1795
1809
 
1796
1810
  this.setupObservers();
1797
1811
  document.addEventListener(
@@ -1811,6 +1825,7 @@ class FigPopup extends HTMLDialogElement {
1811
1825
  const anchor = this.resolveAnchor();
1812
1826
  if (anchor?.classList) anchor.classList.remove("has-popup-open");
1813
1827
 
1828
+ this.style.visibility = "";
1814
1829
  this._isPopupActive = false;
1815
1830
  this._wasDragged = false;
1816
1831
  this.teardownObservers();
@@ -1831,7 +1846,10 @@ class FigPopup extends HTMLDialogElement {
1831
1846
  // Ignore.
1832
1847
  }
1833
1848
  }
1834
- if (super.open) {
1849
+ // Use :open, not super.open — the custom open getter reads the attribute
1850
+ // removed just before hidePopup(), so super.open can be false while the
1851
+ // native dialog is still open and no "close" event fires.
1852
+ if (this.matches?.(":open")) {
1835
1853
  try {
1836
1854
  this.close();
1837
1855
  } catch (e) {
@@ -2478,7 +2496,7 @@ class FigPopup extends HTMLDialogElement {
2478
2496
  }
2479
2497
 
2480
2498
  positionPopup() {
2481
- if (!this.open || !super.open) return;
2499
+ if (!this.open || !this.matches?.(":open")) return;
2482
2500
 
2483
2501
  const popupRect = this.getBoundingClientRect();
2484
2502
  const offset = this.parseOffset();
@@ -4907,8 +4925,10 @@ class FigField extends HTMLElement {
4907
4925
  this.#toggleable = !!(this.input && "open" in this.input);
4908
4926
 
4909
4927
  if (this.#toggleable && this.label) {
4910
- this.#chevron = document.createElement("span");
4911
- this.#chevron.className = "fig-mask-icon fig-field-chevron";
4928
+ this.#chevron = createFigIcon("chevron", {
4929
+ size: "small",
4930
+ className: "fig-field-chevron",
4931
+ });
4912
4932
  this.insertBefore(this.#chevron, this.label);
4913
4933
 
4914
4934
  this.#boundToggle = (e) => {
@@ -5115,6 +5135,14 @@ class FigInputColor extends HTMLElement {
5115
5135
  // Setup swatch (native picker)
5116
5136
  if (this.#swatch) {
5117
5137
  this.#swatch.disabled = this.hasAttribute("disabled");
5138
+ const swatchInput = this.#swatch.querySelector('input[type="color"]');
5139
+ if (this.#textInput || this.hasAttribute("swatch-disabled")) {
5140
+ swatchInput?.setAttribute("tabindex", "-1");
5141
+ }
5142
+ if (this.hasAttribute("swatch-disabled")) {
5143
+ swatchInput?.setAttribute("disabled", "");
5144
+ if (swatchInput) swatchInput.style.pointerEvents = "none";
5145
+ }
5118
5146
  this.#swatch.addEventListener("input", this.#handleInput.bind(this));
5119
5147
  }
5120
5148
 
@@ -5232,7 +5260,11 @@ class FigInputColor extends HTMLElement {
5232
5260
  }
5233
5261
 
5234
5262
  focus() {
5235
- this.#swatch.focus();
5263
+ if (this.#textInput) {
5264
+ this.#textInput.focus();
5265
+ return;
5266
+ }
5267
+ this.#swatch?.focus();
5236
5268
  }
5237
5269
 
5238
5270
  #handleInput(event) {
@@ -6423,9 +6455,10 @@ customElements.define("fig-input-fill", FigInputFill);
6423
6455
  /* Input Palette */
6424
6456
  /**
6425
6457
  * A palette of solid colors, each rendered as a fig-input-color swatch.
6426
- * Manages an internal array of colors with add support.
6458
+ * Manages an internal array of colors with optional add/remove support.
6427
6459
  * @attr {string} value - JSON array of hex strings or {color,alpha} objects, or comma-separated hex
6428
6460
  * @attr {boolean} disabled - Whether the palette is disabled
6461
+ * @attr {boolean} fixed - When set (or `fixed="true"`), palette length is locked — no add or remove
6429
6462
  * @attr {number} min - Minimum number of colors (default: 2)
6430
6463
  * @attr {number} max - Maximum number of colors (default: 8); add button hidden at max
6431
6464
  * @fires input - During color editing (detail: full color array)
@@ -6438,7 +6471,7 @@ class FigInputPalette extends HTMLElement {
6438
6471
  #renderRAF = null;
6439
6472
 
6440
6473
  static get observedAttributes() {
6441
- return ["value", "disabled", "min", "max", "open", "add"];
6474
+ return ["value", "disabled", "min", "max", "open", "fixed"];
6442
6475
  }
6443
6476
 
6444
6477
  get open() {
@@ -6462,8 +6495,10 @@ class FigInputPalette extends HTMLElement {
6462
6495
  }
6463
6496
  }
6464
6497
 
6465
- get #showAdd() {
6466
- return !this.hasAttribute("add") || this.getAttribute("add") !== "false";
6498
+ get #isFixed() {
6499
+ return (
6500
+ this.hasAttribute("fixed") && this.getAttribute("fixed") !== "false"
6501
+ );
6467
6502
  }
6468
6503
 
6469
6504
  get #min() {
@@ -6508,7 +6543,7 @@ class FigInputPalette extends HTMLElement {
6508
6543
  break;
6509
6544
  case "min":
6510
6545
  case "max":
6511
- case "add":
6546
+ case "fixed":
6512
6547
  this.#render();
6513
6548
  break;
6514
6549
  case "open":
@@ -6607,6 +6642,14 @@ class FigInputPalette extends HTMLElement {
6607
6642
 
6608
6643
  const inlineWrap = document.createElement("div");
6609
6644
  inlineWrap.className = "palette-colors-inline";
6645
+ inlineWrap.addEventListener("click", () => {
6646
+ if (
6647
+ this.hasAttribute("disabled") &&
6648
+ this.getAttribute("disabled") !== "false"
6649
+ )
6650
+ return;
6651
+ this.open = true;
6652
+ });
6610
6653
 
6611
6654
  const wrap = document.createElement("div");
6612
6655
  wrap.className = "palette-colors";
@@ -6618,13 +6661,15 @@ class FigInputPalette extends HTMLElement {
6618
6661
  inlineWrap.appendChild(wrap);
6619
6662
  this.appendChild(inlineWrap);
6620
6663
 
6621
- if (this.#showAdd) this.#createAddButton(disabled, this);
6664
+ if (!this.#isFixed) this.#createAddButton(disabled, this);
6622
6665
 
6623
6666
  const expandedWrap = document.createElement("div");
6624
6667
  expandedWrap.className = "palette-colors-expanded";
6625
6668
  this.#colors.forEach((entry, i) => {
6626
6669
  expandedWrap.appendChild(this.#createPicker(entry, i, disabled));
6627
- expandedWrap.appendChild(this.#createRemoveButton(i, disabled));
6670
+ if (!this.#isFixed) {
6671
+ expandedWrap.appendChild(this.#createRemoveButton(i, disabled));
6672
+ }
6628
6673
  });
6629
6674
  this.appendChild(expandedWrap);
6630
6675
  }
@@ -6639,14 +6684,13 @@ class FigInputPalette extends HTMLElement {
6639
6684
  : entry.color;
6640
6685
  const ic = document.createElement("fig-input-color");
6641
6686
  ic.setAttribute("value", hexAlpha);
6642
- ic.setAttribute("picker", "figma");
6643
- ic.setAttribute("picker-anchor", "self");
6644
6687
  if (inline) {
6645
6688
  ic.setAttribute("text", "false");
6646
6689
  ic.setAttribute("alpha", "true");
6690
+ ic.setAttribute("swatch-disabled", "");
6647
6691
  } else {
6648
6692
  ic.setAttribute("text", "true");
6649
- ic.setAttribute("alpha", "false");
6693
+ ic.setAttribute("alpha", this.#isFixed ? "true" : "false");
6650
6694
  ic.setAttribute("full", "");
6651
6695
  }
6652
6696
  if (disabled) ic.setAttribute("disabled", "");
@@ -6696,15 +6740,19 @@ class FigInputPalette extends HTMLElement {
6696
6740
  btn.setAttribute("aria-label", "Remove color");
6697
6741
  btn.className = "palette-remove-btn";
6698
6742
  if (disabled || this.#colors.length <= this.#min) btn.setAttribute("disabled", "");
6699
- btn.innerHTML = `<span class="fig-mask-icon" style="--icon: var(--icon-minus)"></span>`;
6743
+ btn.appendChild(createFigIcon("minus"));
6700
6744
  btn.addEventListener("click", () => {
6701
6745
  if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
6702
6746
  this.#removeColor(index);
6703
6747
  });
6704
- return btn;
6748
+ const tooltip = document.createElement("fig-tooltip");
6749
+ tooltip.setAttribute("text", "Remove color");
6750
+ tooltip.appendChild(btn);
6751
+ return tooltip;
6705
6752
  }
6706
6753
 
6707
6754
  #removeColor(index) {
6755
+ if (this.#isFixed) return;
6708
6756
  if (index < 0 || index >= this.#colors.length) return;
6709
6757
  if (this.#colors.length <= this.#min) return;
6710
6758
  this.#colors.splice(index, 1);
@@ -6722,7 +6770,7 @@ class FigInputPalette extends HTMLElement {
6722
6770
  addBtn.setAttribute("aria-label", "Add color");
6723
6771
  addBtn.className = "palette-add-btn";
6724
6772
  if (disabled || atMax) addBtn.setAttribute("disabled", "");
6725
- addBtn.innerHTML = `<span class="fig-mask-icon" style="--icon: var(--icon-add)"></span>`;
6773
+ addBtn.appendChild(createFigIcon("add"));
6726
6774
  addBtn.addEventListener("click", () => {
6727
6775
  if (
6728
6776
  this.hasAttribute("disabled") &&
@@ -6730,6 +6778,7 @@ class FigInputPalette extends HTMLElement {
6730
6778
  )
6731
6779
  return;
6732
6780
  if (this.#colors.length >= this.#max) return;
6781
+ this.open = true;
6733
6782
  this.#addColor({ color: "#D9D9D9", alpha: 1 });
6734
6783
  });
6735
6784
  const tooltip = document.createElement("fig-tooltip");
@@ -6739,6 +6788,7 @@ class FigInputPalette extends HTMLElement {
6739
6788
  }
6740
6789
 
6741
6790
  #addColor(entry) {
6791
+ if (this.#isFixed) return;
6742
6792
  this.#colors.push(entry);
6743
6793
  const disabled =
6744
6794
  this.hasAttribute("disabled") &&
@@ -6762,6 +6812,7 @@ class FigInputPalette extends HTMLElement {
6762
6812
  const addBtn = this.querySelector(".palette-add-btn");
6763
6813
  if (addBtn) addBtn.setAttribute("disabled", "");
6764
6814
  }
6815
+ this.#syncRemoveButtons(disabled);
6765
6816
  this.#emitChange();
6766
6817
  }
6767
6818
 
@@ -6801,9 +6852,18 @@ class FigInputPalette extends HTMLElement {
6801
6852
  });
6802
6853
  const addBtn = this.querySelector(".palette-add-btn");
6803
6854
  if (addBtn) {
6804
- if (disabled) addBtn.setAttribute("disabled", "");
6855
+ if (disabled || this.#colors.length >= this.#max) addBtn.setAttribute("disabled", "");
6805
6856
  else addBtn.removeAttribute("disabled");
6806
6857
  }
6858
+ this.#syncRemoveButtons(disabled);
6859
+ }
6860
+
6861
+ #syncRemoveButtons(disabled = this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") {
6862
+ const shouldDisable = disabled || this.#colors.length <= this.#min;
6863
+ this.querySelectorAll(".palette-remove-btn").forEach((btn) => {
6864
+ if (shouldDisable) btn.setAttribute("disabled", "");
6865
+ else btn.removeAttribute("disabled");
6866
+ });
6807
6867
  }
6808
6868
 
6809
6869
  #emitInput() {
@@ -7025,7 +7085,7 @@ class FigInputGradient extends HTMLElement {
7025
7085
  return this.#gradient.stops
7026
7086
  .map(
7027
7087
  (stop, i) =>
7028
- `<fig-tooltip action="manual" text="${Math.round(stop.position)}%"><fig-handle drag drag-axes="x" drag-surface=".fig-input-gradient-track" type="color" color="${this.#stopColorCSS(stop)}" value="${stop.position}% 50%" hit-area="4" data-stop-index="${i}"${disabled ? " disabled" : ""}></fig-handle></fig-tooltip>`,
7088
+ `<fig-tooltip action="manual" text="${Math.round(stop.position)}%"><fig-handle drag drag-axes="x" drag-surface=".fig-input-gradient-track" type="color" color-tip color="${this.#stopColorCSS(stop)}" value="${stop.position}% 50%" hit-area="4" data-stop-index="${i}"${disabled ? " disabled" : ""}></fig-handle></fig-tooltip>`,
7029
7089
  )
7030
7090
  .join("");
7031
7091
  }
@@ -7106,6 +7166,7 @@ class FigInputGradient extends HTMLElement {
7106
7166
  const ghost = document.createElement("fig-handle");
7107
7167
  ghost.classList.add("fig-input-gradient-ghost");
7108
7168
  ghost.setAttribute("type", "color");
7169
+ ghost.setAttribute("color-tip", "");
7109
7170
  ghost.setAttribute("control", "add");
7110
7171
  ghost.style.position = "absolute";
7111
7172
  ghost.style.top = "50%";
@@ -9024,10 +9085,9 @@ class FigMediaControls extends HTMLElement {
9024
9085
  btn.setAttribute("size", "small");
9025
9086
  btn.setAttribute("icon", "true");
9026
9087
  btn.setAttribute("aria-label", "Play");
9027
- const icon = document.createElement("span");
9028
- icon.className = "fig-mask-icon fig-media-controls-play-icon";
9029
- icon.style.setProperty("--icon", "var(--icon-play)");
9030
- icon.style.setProperty("--size", "1.5rem");
9088
+ const icon = createFigIcon("play", {
9089
+ className: "fig-media-controls-play-icon",
9090
+ });
9031
9091
  btn.append(icon);
9032
9092
  tooltip.append(btn);
9033
9093
  btn.addEventListener("click", (e) => {
@@ -9090,10 +9150,7 @@ class FigMediaControls extends HTMLElement {
9090
9150
  this.#playTooltip?.setAttribute("text", playing ? "Pause" : "Play");
9091
9151
  const icon = this.#playBtn.querySelector(".fig-media-controls-play-icon");
9092
9152
  if (icon) {
9093
- icon.style.setProperty(
9094
- "--icon",
9095
- playing ? "var(--icon-pause)" : "var(--icon-play)",
9096
- );
9153
+ icon.setAttribute("name", playing ? "pause" : "play");
9097
9154
  }
9098
9155
  }
9099
9156
 
@@ -9338,7 +9395,7 @@ class FigInputFile extends HTMLElement {
9338
9395
  this.#clearBtn.setAttribute("icon", "true");
9339
9396
  this.#clearBtn.className = "fig-input-file-clear";
9340
9397
  if (disabled) this.#clearBtn.setAttribute("disabled", "");
9341
- this.#clearBtn.innerHTML = `<span class="fig-mask-icon" style="--icon: var(--icon-minus);"></span>`;
9398
+ this.#clearBtn.replaceChildren(createFigIcon("minus"));
9342
9399
  this.#clearBtn.addEventListener("click", this.#onClear);
9343
9400
  clearTooltip.appendChild(this.#clearBtn);
9344
9401
  this.appendChild(clearTooltip);
@@ -11194,7 +11251,7 @@ class FigInputJoystick extends HTMLElement {
11194
11251
  </div>
11195
11252
  <fig-tooltip text="Reset">
11196
11253
  <fig-button variant="ghost" icon="true" class="fig-joystick-reset" aria-label="Reset to default">
11197
- <span class="fig-mask-icon" style="--icon: var(--icon-reset)"></span>
11254
+ <fig-icon name="reset" size="small"></fig-icon>
11198
11255
  </fig-button>
11199
11256
  </fig-tooltip>
11200
11257
  </div>
@@ -11709,8 +11766,10 @@ class FigGroup extends HTMLElement {
11709
11766
 
11710
11767
  if (isCollapsible) {
11711
11768
  if (!h3.querySelector(".fig-group-chevron")) {
11712
- const chevron = document.createElement("span");
11713
- chevron.className = "fig-mask-icon fig-group-chevron";
11769
+ const chevron = createFigIcon("chevron", {
11770
+ size: "small",
11771
+ className: "fig-group-chevron",
11772
+ });
11714
11773
  h3.prepend(chevron);
11715
11774
  }
11716
11775
  this.#chevron = h3.querySelector(".fig-group-chevron");
@@ -11752,7 +11811,71 @@ customElements.define("fig-footer", FigFooter);
11752
11811
  class FigSpinner extends HTMLElement {}
11753
11812
  customElements.define("fig-spinner", FigSpinner);
11754
11813
 
11755
- class FigIcon extends HTMLElement {}
11814
+ /** @type {Record<string, string>} */
11815
+ const FIG_ICON_TOKENS = {
11816
+ chevron: "--icon-16-chevron",
11817
+ checkmark: "--icon-16-checkmark",
11818
+ reset: "--icon-16-reset",
11819
+ "arrow-left": "--icon-16-arrow-left",
11820
+ steppers: "--icon-24-steppers",
11821
+ eyedropper: "--icon-24-eyedropper",
11822
+ add: "--icon-24-add",
11823
+ minus: "--icon-24-minus",
11824
+ back: "--icon-24-back",
11825
+ forward: "--icon-24-forward",
11826
+ close: "--icon-24-close",
11827
+ rotate: "--icon-24-rotate",
11828
+ swap: "--icon-24-swap",
11829
+ play: "--icon-24-play",
11830
+ pause: "--icon-24-pause",
11831
+ };
11832
+
11833
+ function figIconCssVar(name) {
11834
+ const token = name && FIG_ICON_TOKENS[name];
11835
+ return token ? `var(${token})` : "";
11836
+ }
11837
+
11838
+ /**
11839
+ * Masked icon using design-token SVGs from :root.
11840
+ * @attr {string} name - Icon name (chevron, add, close, …)
11841
+ * @attr {'small'|'medium'} size - Display size; medium (default) uses --spacer-4, small uses --spacer-3
11842
+ * @attr {string} color - Icon fill color (applied as background-color for the mask)
11843
+ */
11844
+ class FigIcon extends HTMLElement {
11845
+ static get observedAttributes() {
11846
+ return ["name", "size", "color"];
11847
+ }
11848
+
11849
+ connectedCallback() {
11850
+ this.#sync();
11851
+ }
11852
+
11853
+ attributeChangedCallback(name, oldValue, newValue) {
11854
+ if (oldValue !== newValue) this.#sync();
11855
+ }
11856
+
11857
+ #sync() {
11858
+ const iconName = this.getAttribute("name");
11859
+ const cssVar = figIconCssVar(iconName);
11860
+ if (cssVar) this.style.setProperty("--icon", cssVar);
11861
+ else this.style.removeProperty("--icon");
11862
+
11863
+ const size = this.getAttribute("size") || "medium";
11864
+ if (size === "small") {
11865
+ this.style.setProperty("--size", "var(--spacer-3)");
11866
+ } else {
11867
+ this.style.removeProperty("--size");
11868
+ }
11869
+
11870
+ const color = this.getAttribute("color");
11871
+ if (color) this.style.backgroundColor = color;
11872
+ else this.style.removeProperty("background-color");
11873
+
11874
+ if (!this.hasAttribute("aria-hidden")) {
11875
+ this.setAttribute("aria-hidden", "true");
11876
+ }
11877
+ }
11878
+ }
11756
11879
  customElements.define("fig-icon", FigIcon);
11757
11880
 
11758
11881
  class FigContent extends HTMLElement {}
@@ -12048,6 +12171,14 @@ class FigFillPicker extends HTMLElement {
12048
12171
  });
12049
12172
  }
12050
12173
 
12174
+ open() {
12175
+ this.#openDialog();
12176
+ }
12177
+
12178
+ close() {
12179
+ if (this.#dialog) this.#dialog.open = false;
12180
+ }
12181
+
12051
12182
  #createDialog() {
12052
12183
  // Collect slotted custom mode content before any DOM changes
12053
12184
  this.#customSlots = {};
@@ -12135,7 +12266,7 @@ class FigFillPicker extends HTMLElement {
12135
12266
  ${headerContent}
12136
12267
  ${gamutDropdown}
12137
12268
  <fig-button icon variant="ghost" class="fig-fill-picker-close">
12138
- <span class="fig-mask-icon" style="--icon: var(--icon-close)"></span>
12269
+ <fig-icon name="close"></fig-icon>
12139
12270
  </fig-button>
12140
12271
  </fig-header>
12141
12272
  <fig-content>
@@ -12195,6 +12326,7 @@ class FigFillPicker extends HTMLElement {
12195
12326
  const onDialogClose = () => {
12196
12327
  if (this.#chit) this.#chit.removeAttribute("selected");
12197
12328
  this.#emitChange();
12329
+ this.dispatchEvent(new CustomEvent("close"));
12198
12330
  };
12199
12331
  this.#dialog.addEventListener("close", onDialogClose);
12200
12332
 
@@ -12305,7 +12437,7 @@ class FigFillPicker extends HTMLElement {
12305
12437
  ></fig-handle>
12306
12438
  </fig-preview>
12307
12439
  <div class="fig-fill-picker-sliders">
12308
- <fig-tooltip text="Sample color"><fig-button icon variant="ghost" class="fig-fill-picker-eyedropper"><span class="fig-mask-icon" style="--icon: var(--icon-eyedropper)"></span></fig-button></fig-tooltip>
12440
+ <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>
12309
12441
  <fig-slider type="hue" variant="neue" min="0" max="360" value="${
12310
12442
  this.#color.h
12311
12443
  }"></fig-slider>
@@ -12821,7 +12953,7 @@ class FigFillPicker extends HTMLElement {
12821
12953
  </div>
12822
12954
  <fig-tooltip text="Flip gradient">
12823
12955
  <fig-button icon variant="ghost" class="fig-fill-picker-gradient-flip">
12824
- <span class="fig-mask-icon" style="--icon: var(--icon-swap)"></span>
12956
+ <fig-icon name="swap"></fig-icon>
12825
12957
  </fig-button>
12826
12958
  </fig-tooltip>
12827
12959
  </fig-field>
@@ -12853,7 +12985,7 @@ class FigFillPicker extends HTMLElement {
12853
12985
  <fig-header class="fig-fill-picker-gradient-stops-header" borderless>
12854
12986
  <span>Stops</span>
12855
12987
  <fig-button icon variant="ghost" class="fig-fill-picker-gradient-add" title="Add stop">
12856
- <span class="fig-mask-icon" style="--icon: var(--icon-add)"></span>
12988
+ <fig-icon name="add"></fig-icon>
12857
12989
  </fig-button>
12858
12990
  </fig-header>
12859
12991
  <div class="fig-fill-picker-gradient-stops-list"></div>
@@ -13090,7 +13222,7 @@ class FigFillPicker extends HTMLElement {
13090
13222
  <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
13091
13223
  this.#gradient.stops.length <= 2 ? "disabled" : ""
13092
13224
  }>
13093
- <span class="fig-mask-icon" style="--icon: var(--icon-minus)"></span>
13225
+ <fig-icon name="minus"></fig-icon>
13094
13226
  </fig-button>
13095
13227
  </fig-field>
13096
13228
  `,
@@ -14004,8 +14136,8 @@ class FigColorTip extends HTMLElement {
14004
14136
  #render() {
14005
14137
  const mode = this.#controlMode;
14006
14138
  if (mode === "add" || mode === "remove") {
14007
- const icon = mode === "add" ? "var(--icon-add)" : "var(--icon-minus)";
14008
- this.innerHTML = `<fig-button icon variant="ghost"><span class="fig-mask-icon" style="--icon: ${icon}"></span></fig-button>`;
14139
+ const iconName = mode === "add" ? "add" : "minus";
14140
+ this.innerHTML = `<fig-button icon variant="ghost"><fig-icon name="${iconName}"></fig-icon></fig-button>`;
14009
14141
  this.#fillPicker = null;
14010
14142
  this.#chit = null;
14011
14143
  this.addEventListener("click", this.#handleControlClick);
@@ -14758,11 +14890,10 @@ class FigChooser extends HTMLElement {
14758
14890
  if (this.#navStart) return;
14759
14891
 
14760
14892
  const makeChevron = () => {
14761
- const icon = document.createElement("span");
14762
- icon.className = "fig-mask-icon fig-chooser-nav-chevron";
14763
- icon.style.setProperty("--icon", "var(--icon-chevron)");
14764
- icon.style.setProperty("--size", "1rem");
14765
- return icon;
14893
+ return createFigIcon("chevron", {
14894
+ size: "small",
14895
+ className: "fig-chooser-nav-chevron",
14896
+ });
14766
14897
  };
14767
14898
 
14768
14899
  this.#navStart = document.createElement("button");
@@ -14862,6 +14993,7 @@ class FigHandle extends HTMLElement {
14862
14993
  "value",
14863
14994
  "type",
14864
14995
  "control",
14996
+ "color-tip",
14865
14997
  "hit-area",
14866
14998
  "hit-area-mode",
14867
14999
  ];
@@ -14871,6 +15003,7 @@ class FigHandle extends HTMLElement {
14871
15003
  #boundPointerDown = null;
14872
15004
  #applyingValue = false;
14873
15005
  #colorTip = null;
15006
+ #directColorPicker = null;
14874
15007
  #hitAreaEl = null;
14875
15008
 
14876
15009
  get #controlMode() {
@@ -14881,6 +15014,14 @@ class FigHandle extends HTMLElement {
14881
15014
  return this.#controlMode === "add" || this.#controlMode === "remove";
14882
15015
  }
14883
15016
 
15017
+ get #usesColorTip() {
15018
+ return (
15019
+ this.#hasControlMode ||
15020
+ (this.hasAttribute("color-tip") &&
15021
+ this.getAttribute("color-tip") !== "false")
15022
+ );
15023
+ }
15024
+
14884
15025
  get #isGhost() {
14885
15026
  return this.classList.contains("fig-input-gradient-ghost");
14886
15027
  }
@@ -15087,7 +15228,6 @@ class FigHandle extends HTMLElement {
15087
15228
  }
15088
15229
 
15089
15230
  connectedCallback() {
15090
- if (!this.hasAttribute("type")) this.setAttribute("type", "canvas");
15091
15231
  this.#syncDrag();
15092
15232
  this.#syncHitArea();
15093
15233
  this.addEventListener("click", this.#handleSelect);
@@ -15101,6 +15241,7 @@ class FigHandle extends HTMLElement {
15101
15241
  disconnectedCallback() {
15102
15242
  this.#teardownDrag();
15103
15243
  this.#hideColorTip();
15244
+ this.#removeDirectColorPicker();
15104
15245
  if (this.#hitAreaEl) {
15105
15246
  this.#hitAreaEl.remove();
15106
15247
  this.#hitAreaEl = null;
@@ -15113,7 +15254,7 @@ class FigHandle extends HTMLElement {
15113
15254
  select() {
15114
15255
  if (this.hasAttribute("disabled")) return;
15115
15256
  this.setAttribute("selected", "");
15116
- if (this.getAttribute("type") === "color" && !this.#isDragging)
15257
+ if (this.getAttribute("type") === "color" && !this.#isDragging && this.#usesColorTip)
15117
15258
  this.#showColorTip();
15118
15259
  }
15119
15260
 
@@ -15128,23 +15269,30 @@ class FigHandle extends HTMLElement {
15128
15269
  this.#didDrag = false;
15129
15270
  return;
15130
15271
  }
15272
+ if (this.getAttribute("type") === "color" && !this.#usesColorTip) {
15273
+ this.#openDirectColorPicker();
15274
+ return;
15275
+ }
15131
15276
  this.select();
15132
15277
  };
15133
15278
 
15134
15279
  #handleDeselect = (e) => {
15135
15280
  if (this.#hasControlMode) return;
15136
15281
  if (this.contains(e.target)) return;
15137
- if (this.#colorTip && e.target.closest?.("dialog, [popover]")) return;
15282
+ if ((this.#colorTip || this.#directColorPicker) && e.target.closest?.("dialog, [popover]")) return;
15138
15283
  this.deselect();
15139
15284
  };
15140
15285
 
15141
15286
  #handleKeyDown = (e) => {
15142
- if (e.key !== "Enter") return;
15287
+ if (e.key !== "Enter" && e.key !== " ") return;
15143
15288
  if (!this.hasAttribute("selected")) return;
15144
15289
  if (this.getAttribute("type") !== "color") return;
15145
- if (this.#colorTip) return;
15146
15290
  e.preventDefault();
15147
- this.#showColorTip();
15291
+ if (this.#usesColorTip) {
15292
+ if (!this.#colorTip) this.#showColorTip();
15293
+ } else {
15294
+ this.#openDirectColorPicker();
15295
+ }
15148
15296
  };
15149
15297
 
15150
15298
  attributeChangedCallback(name, _old, value) {
@@ -15157,6 +15305,7 @@ class FigHandle extends HTMLElement {
15157
15305
  if (this.#colorTip && value) {
15158
15306
  this.#colorTip.setAttribute("value", value);
15159
15307
  }
15308
+ this.#syncDirectColorPickerValue();
15160
15309
  }
15161
15310
  if (name === "drag") this.#syncDrag();
15162
15311
  if (name === "hit-area") this.#syncHitArea();
@@ -15165,12 +15314,20 @@ class FigHandle extends HTMLElement {
15165
15314
  }
15166
15315
  if (name === "control") {
15167
15316
  if (this.#hasControlMode) {
15317
+ this.#removeDirectColorPicker();
15168
15318
  this.#hideColorTip();
15169
15319
  this.#showColorTip();
15170
15320
  } else {
15171
15321
  this.#hideColorTip();
15172
15322
  }
15173
15323
  }
15324
+ if (name === "color-tip") {
15325
+ if (this.#usesColorTip) {
15326
+ this.#removeDirectColorPicker();
15327
+ } else {
15328
+ this.#hideColorTip();
15329
+ }
15330
+ }
15174
15331
  }
15175
15332
 
15176
15333
  #syncDrag() {
@@ -15327,6 +15484,117 @@ class FigHandle extends HTMLElement {
15327
15484
  this.#colorTip.style.display = "none";
15328
15485
  }
15329
15486
 
15487
+ #normalizeColorForPicker(rawValue = this.getAttribute("color")) {
15488
+ const fallback = { color: "#D9D9D9", opacity: 100 };
15489
+ const value = String(rawValue || "").trim();
15490
+ if (!value) return fallback;
15491
+
15492
+ const normalizeHex = (hex) => {
15493
+ const raw = hex.replace("#", "").trim();
15494
+ if (raw.length === 3 || raw.length === 4) {
15495
+ const [r, g, b, a] = raw;
15496
+ return {
15497
+ color: `#${r}${r}${g}${g}${b}${b}`.toUpperCase(),
15498
+ opacity: a ? Math.round((parseInt(`${a}${a}`, 16) / 255) * 100) : 100,
15499
+ };
15500
+ }
15501
+ if (raw.length === 6 || raw.length === 8) {
15502
+ return {
15503
+ color: `#${raw.slice(0, 6)}`.toUpperCase(),
15504
+ opacity:
15505
+ raw.length === 8
15506
+ ? Math.round((parseInt(raw.slice(6, 8), 16) / 255) * 100)
15507
+ : 100,
15508
+ };
15509
+ }
15510
+ return fallback;
15511
+ };
15512
+
15513
+ const rgbToHex = (r, g, b) => {
15514
+ const toHex = (v) =>
15515
+ Math.max(0, Math.min(255, Math.round(Number(v))))
15516
+ .toString(16)
15517
+ .padStart(2, "0");
15518
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
15519
+ };
15520
+
15521
+ if (value.startsWith("#")) return normalizeHex(value);
15522
+
15523
+ try {
15524
+ const { ctx } = figGetSharedCanvas(1, 1);
15525
+ ctx.fillStyle = "#000000";
15526
+ ctx.fillStyle = value;
15527
+ const resolved = ctx.fillStyle;
15528
+ if (resolved.startsWith("#")) return normalizeHex(resolved);
15529
+ const rgb = resolved.match(
15530
+ /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?/i,
15531
+ );
15532
+ if (rgb) {
15533
+ return {
15534
+ color: rgbToHex(rgb[1], rgb[2], rgb[3]),
15535
+ opacity: rgb[4] !== undefined ? Math.round(parseFloat(rgb[4]) * 100) : 100,
15536
+ };
15537
+ }
15538
+ } catch {
15539
+ // Fall through to fallback.
15540
+ }
15541
+
15542
+ return fallback;
15543
+ }
15544
+
15545
+ #directColorPickerValue() {
15546
+ const { color, opacity } = this.#normalizeColorForPicker();
15547
+ return JSON.stringify(
15548
+ opacity < 100 ? { type: "solid", color, opacity } : { type: "solid", color },
15549
+ );
15550
+ }
15551
+
15552
+ #syncDirectColorPickerValue() {
15553
+ if (!this.#directColorPicker) return;
15554
+ this.#directColorPicker.setAttribute("value", this.#directColorPickerValue());
15555
+ }
15556
+
15557
+ #ensureDirectColorPicker() {
15558
+ if (this.#directColorPicker) return this.#directColorPicker;
15559
+
15560
+ const picker = document.createElement("fig-fill-picker");
15561
+ picker.setAttribute("mode", "solid");
15562
+ picker.setAttribute("alpha", "true");
15563
+ picker.setAttribute("value", this.#directColorPickerValue());
15564
+ picker.anchorElement = this;
15565
+
15566
+ const trigger = document.createElement("span");
15567
+ trigger.hidden = true;
15568
+ picker.appendChild(trigger);
15569
+
15570
+ picker.addEventListener("input", this.#handleDirectColorPickerInput);
15571
+ picker.addEventListener("change", this.#handleDirectColorPickerChange);
15572
+ picker.addEventListener("close", this.#handleDirectColorPickerClose);
15573
+ this.appendChild(picker);
15574
+ this.#directColorPicker = picker;
15575
+ return picker;
15576
+ }
15577
+
15578
+ #openDirectColorPicker() {
15579
+ if (this.hasAttribute("disabled")) return;
15580
+ this.#hideColorTip();
15581
+ const picker = this.#ensureDirectColorPicker();
15582
+ this.setAttribute("selected", "");
15583
+ this.#syncDirectColorPickerValue();
15584
+ picker.open();
15585
+ }
15586
+
15587
+ #removeDirectColorPicker() {
15588
+ if (!this.#directColorPicker) return;
15589
+ this.#directColorPicker.removeEventListener("input", this.#handleDirectColorPickerInput);
15590
+ this.#directColorPicker.removeEventListener("change", this.#handleDirectColorPickerChange);
15591
+ this.#directColorPicker.removeEventListener("close", this.#handleDirectColorPickerClose);
15592
+ this.#directColorPicker.close();
15593
+ this.#directColorPicker.remove();
15594
+ this.#directColorPicker = null;
15595
+ this.removeAttribute("selected");
15596
+ }
15597
+
15330
15598
  #showColorTip() {
15331
15599
  if (this.#colorTip) return;
15332
15600
  const tip = document.createElement("fig-color-tip");
@@ -15361,6 +15629,47 @@ class FigHandle extends HTMLElement {
15361
15629
  return `rgba(${r}, ${g}, ${b}, ${opacity / 100})`;
15362
15630
  }
15363
15631
 
15632
+ #detailFromPicker(detail) {
15633
+ if (!detail?.color) return null;
15634
+ const opacity =
15635
+ detail.opacity !== undefined
15636
+ ? detail.opacity
15637
+ : detail.alpha !== undefined
15638
+ ? Math.round(detail.alpha * 100)
15639
+ : undefined;
15640
+ return { color: detail.color, opacity };
15641
+ }
15642
+
15643
+ #handleDirectColorPickerInput = (e) => {
15644
+ e.stopPropagation();
15645
+ const detail = this.#detailFromPicker(e.detail);
15646
+ if (!detail) return;
15647
+ this.setAttribute("color", this.#colorWithOpacity(detail.color, detail.opacity));
15648
+ this.dispatchEvent(
15649
+ new CustomEvent("input", {
15650
+ bubbles: true,
15651
+ detail,
15652
+ }),
15653
+ );
15654
+ };
15655
+
15656
+ #handleDirectColorPickerChange = (e) => {
15657
+ e.stopPropagation();
15658
+ const detail = this.#detailFromPicker(e.detail);
15659
+ if (!detail) return;
15660
+ this.setAttribute("color", this.#colorWithOpacity(detail.color, detail.opacity));
15661
+ this.dispatchEvent(
15662
+ new CustomEvent("change", {
15663
+ bubbles: true,
15664
+ detail,
15665
+ }),
15666
+ );
15667
+ };
15668
+
15669
+ #handleDirectColorPickerClose = () => {
15670
+ this.removeAttribute("selected");
15671
+ };
15672
+
15364
15673
  #handleColorTipInput = (e) => {
15365
15674
  e.stopPropagation();
15366
15675
  if (e.detail?.color) {
@@ -15455,7 +15764,7 @@ class FigMenu extends HTMLElement {
15455
15764
  #observer = null;
15456
15765
  #boundTriggerClick;
15457
15766
  #boundPopupClick;
15458
- #boundPopupKeydown;
15767
+ #boundMenuKeydown;
15459
15768
  #boundPopupClose;
15460
15769
  #focusedIndex = -1;
15461
15770
 
@@ -15467,7 +15776,7 @@ class FigMenu extends HTMLElement {
15467
15776
  super();
15468
15777
  this.#boundTriggerClick = this.#handleTriggerClick.bind(this);
15469
15778
  this.#boundPopupClick = this.#handlePopupClick.bind(this);
15470
- this.#boundPopupKeydown = this.#handlePopupKeydown.bind(this);
15779
+ this.#boundMenuKeydown = this.#handleMenuKeydown.bind(this);
15471
15780
  this.#boundPopupClose = this.#handlePopupClose.bind(this);
15472
15781
  }
15473
15782
 
@@ -15588,6 +15897,7 @@ class FigMenu extends HTMLElement {
15588
15897
  }
15589
15898
 
15590
15899
  #setupListeners() {
15900
+ this.addEventListener("keydown", this.#boundMenuKeydown);
15591
15901
  if (this.#trigger) {
15592
15902
  this.#trigger.addEventListener("click", this.#boundTriggerClick);
15593
15903
  this.#trigger.setAttribute("aria-haspopup", "menu");
@@ -15595,17 +15905,16 @@ class FigMenu extends HTMLElement {
15595
15905
  }
15596
15906
  if (this.#popup) {
15597
15907
  this.#popup.addEventListener("click", this.#boundPopupClick);
15598
- this.#popup.addEventListener("keydown", this.#boundPopupKeydown);
15599
15908
  }
15600
15909
  }
15601
15910
 
15602
15911
  #teardownListeners() {
15912
+ this.removeEventListener("keydown", this.#boundMenuKeydown);
15603
15913
  if (this.#trigger) {
15604
15914
  this.#trigger.removeEventListener("click", this.#boundTriggerClick);
15605
15915
  }
15606
15916
  if (this.#popup) {
15607
15917
  this.#popup.removeEventListener("click", this.#boundPopupClick);
15608
- this.#popup.removeEventListener("keydown", this.#boundPopupKeydown);
15609
15918
  }
15610
15919
  }
15611
15920
 
@@ -15640,6 +15949,27 @@ class FigMenu extends HTMLElement {
15640
15949
  return Array.from(this.#popup.querySelectorAll("fig-menu-item:not([disabled]):not([disabled='true'])"));
15641
15950
  }
15642
15951
 
15952
+ #syncFocusedIndex() {
15953
+ const items = this.#getItems();
15954
+ if (!items.length) {
15955
+ this.#focusedIndex = -1;
15956
+ return;
15957
+ }
15958
+ const active = document.activeElement;
15959
+ const idx = items.findIndex(
15960
+ (item) => item === active || item.contains(active),
15961
+ );
15962
+ this.#focusedIndex = idx >= 0 ? idx : -1;
15963
+ }
15964
+
15965
+ #focusItemAt(index) {
15966
+ const items = this.#getItems();
15967
+ if (!items.length) return;
15968
+ const clamped = Math.max(0, Math.min(index, items.length - 1));
15969
+ this.#focusedIndex = clamped;
15970
+ items[clamped].focus();
15971
+ }
15972
+
15643
15973
  #syncDisabled() {
15644
15974
  if (!this.#trigger) return;
15645
15975
  const disabled = this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
@@ -15653,7 +15983,12 @@ class FigMenu extends HTMLElement {
15653
15983
  #handleTriggerClick(e) {
15654
15984
  if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
15655
15985
  e.stopPropagation();
15656
- if (this.open) {
15986
+ const popupShowing = this.#popup?.matches?.(":open") ?? false;
15987
+ if (this.open && !popupShowing) {
15988
+ this.removeAttribute("open");
15989
+ }
15990
+ const effectiveOpen = this.open && popupShowing;
15991
+ if (effectiveOpen) {
15657
15992
  this.open = false;
15658
15993
  } else {
15659
15994
  this.open = true;
@@ -15668,40 +16003,42 @@ class FigMenu extends HTMLElement {
15668
16003
  this.#selectItem(item);
15669
16004
  }
15670
16005
 
15671
- #handlePopupKeydown(e) {
16006
+ #handleMenuKeydown(e) {
16007
+ if (!this.open || !this.#popup?.matches?.(":open")) return;
16008
+
15672
16009
  const items = this.#getItems();
15673
16010
  if (!items.length) return;
15674
16011
 
15675
16012
  switch (e.key) {
15676
16013
  case "ArrowDown": {
15677
16014
  e.preventDefault();
15678
- this.#focusedIndex = Math.min(this.#focusedIndex + 1, items.length - 1);
15679
- items[this.#focusedIndex]?.focus();
16015
+ this.#syncFocusedIndex();
16016
+ this.#focusItemAt(this.#focusedIndex + 1);
15680
16017
  break;
15681
16018
  }
15682
16019
  case "ArrowUp": {
15683
16020
  e.preventDefault();
15684
- this.#focusedIndex = Math.max(this.#focusedIndex - 1, 0);
15685
- items[this.#focusedIndex]?.focus();
16021
+ this.#syncFocusedIndex();
16022
+ this.#focusItemAt(this.#focusedIndex - 1);
15686
16023
  break;
15687
16024
  }
15688
16025
  case "Home": {
15689
16026
  e.preventDefault();
15690
- this.#focusedIndex = 0;
15691
- items[0]?.focus();
16027
+ this.#focusItemAt(0);
15692
16028
  break;
15693
16029
  }
15694
16030
  case "End": {
15695
16031
  e.preventDefault();
15696
- this.#focusedIndex = items.length - 1;
15697
- items[this.#focusedIndex]?.focus();
16032
+ this.#focusItemAt(items.length - 1);
15698
16033
  break;
15699
16034
  }
15700
16035
  case "Enter":
15701
16036
  case " ": {
15702
- e.preventDefault();
16037
+ this.#syncFocusedIndex();
15703
16038
  const focused = items[this.#focusedIndex];
15704
- if (focused) this.#selectItem(focused);
16039
+ if (!focused) return;
16040
+ e.preventDefault();
16041
+ this.#selectItem(focused);
15705
16042
  break;
15706
16043
  }
15707
16044
  }
@@ -15736,10 +16073,10 @@ class FigMenu extends HTMLElement {
15736
16073
  if (this.#trigger) {
15737
16074
  this.#trigger.setAttribute("aria-expanded", "true");
15738
16075
  }
15739
- this.#focusedIndex = 0;
16076
+ this.#focusedIndex = -1;
15740
16077
  requestAnimationFrame(() => {
15741
- const items = this.#getItems();
15742
- if (items.length) items[0].focus();
16078
+ if (!this.#trigger?.matches?.(":focus-visible")) return;
16079
+ this.#focusItemAt(0);
15743
16080
  });
15744
16081
  }
15745
16082