@rogieking/figui3 8.3.0 → 8.4.1

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
@@ -27,6 +27,51 @@ function createFigIcon(name, options = {}) {
27
27
  return icon;
28
28
  }
29
29
 
30
+ const FIG_SVG_NAMESPACE = "http://www.w3.org/2000/svg";
31
+
32
+ function figAppendChildren(parent, children) {
33
+ const append = (child) => {
34
+ if (child === null || child === undefined || child === false) return;
35
+ if (Array.isArray(child)) {
36
+ child.forEach(append);
37
+ return;
38
+ }
39
+ parent.append(child instanceof Node ? child : String(child));
40
+ };
41
+ append(children);
42
+ return parent;
43
+ }
44
+
45
+ function figSetAttributes(element, attributes = {}) {
46
+ for (const [name, value] of Object.entries(attributes)) {
47
+ if (value === null || value === undefined || value === false) continue;
48
+ if (name === "className") {
49
+ if (element.namespaceURI === FIG_SVG_NAMESPACE) {
50
+ element.setAttribute("class", String(value));
51
+ } else {
52
+ element.className = String(value);
53
+ }
54
+ } else if (value === true) {
55
+ element.setAttribute(name, "");
56
+ } else {
57
+ element.setAttribute(name, String(value));
58
+ }
59
+ }
60
+ return element;
61
+ }
62
+
63
+ function figCreateElement(tagName, attributes, children) {
64
+ const element = document.createElement(tagName);
65
+ figSetAttributes(element, attributes);
66
+ return figAppendChildren(element, children);
67
+ }
68
+
69
+ function figCreateSvgElement(tagName, attributes, children) {
70
+ const element = document.createElementNS(FIG_SVG_NAMESPACE, tagName);
71
+ figSetAttributes(element, attributes);
72
+ return figAppendChildren(element, children);
73
+ }
74
+
30
75
  /** Run callback on the next frame; skip if the host disconnected first. */
31
76
  function figNextFrame(host, callback) {
32
77
  requestAnimationFrame(() => {
@@ -387,46 +432,50 @@ class FigButton extends HTMLElement {
387
432
  // Custom button modes are implemented by the host. Keep the shadow
388
433
  // control inert so invalid native types (for example "toggle") cannot
389
434
  // normalize to "submit" inside a form.
390
- const typeAttr = isControlWrapper ? "" : ' type="button"';
391
- this.shadowRoot.innerHTML = `
392
- <style>
393
- button, button:hover, button:active, .fig-button-control {
394
- padding: 0 var(--spacer-2);
395
- appearance: none;
396
- display: flex;
397
- border: 0;
398
- flex: 1;
399
- text-align: center;
400
- align-items: stretch;
401
- justify-content: center;
402
- font: inherit;
403
- color: inherit;
404
- outline: 0;
405
- place-items: center;
406
- background: transparent;
407
- margin: calc(var(--spacer-2)*-1);
408
- height: var(--spacer-4);
409
- white-space: nowrap;
410
- overflow: hidden;
411
- text-overflow: ellipsis;
412
- width: 100%;
413
- min-width: 0;
414
- }
415
- :host([size="large"]) button,
416
- :host([size="large"]) .fig-button-control {
417
- height: var(--spacer-5);
418
- }
419
- :host([size="large"][icon]) button,
420
- :host([size="large"][icon]) .fig-button-control {
421
- padding: 0;
422
- }
423
- </style>
424
- <${controlTag} class="fig-button-control"${typeAttr}>
425
- <slot></slot>
426
- </${controlTag}>
427
- `;
428
-
429
- this.button = this.shadowRoot.querySelector("button, .fig-button-control");
435
+ const style = document.createElement("style");
436
+ style.textContent = `
437
+ button, button:hover, button:active, .fig-button-control {
438
+ padding: 0 var(--spacer-2);
439
+ appearance: none;
440
+ display: flex;
441
+ border: 0;
442
+ flex: 1;
443
+ text-align: center;
444
+ align-items: stretch;
445
+ justify-content: center;
446
+ font: inherit;
447
+ color: inherit;
448
+ outline: 0;
449
+ place-items: center;
450
+ background: transparent;
451
+ margin: calc(var(--spacer-2)*-1);
452
+ height: var(--spacer-4);
453
+ white-space: nowrap;
454
+ overflow: hidden;
455
+ text-overflow: ellipsis;
456
+ width: 100%;
457
+ min-width: 0;
458
+ }
459
+ :host([size="large"]) button,
460
+ :host([size="large"]) .fig-button-control {
461
+ height: var(--spacer-5);
462
+ }
463
+ :host([size="large"][icon]) button,
464
+ :host([size="large"][icon]) .fig-button-control {
465
+ padding: 0;
466
+ }
467
+ `;
468
+ const control = figCreateElement(
469
+ controlTag,
470
+ {
471
+ className: "fig-button-control",
472
+ type: isControlWrapper ? null : "button",
473
+ },
474
+ document.createElement("slot"),
475
+ );
476
+ this.shadowRoot.replaceChildren(style, control);
477
+
478
+ this.button = control;
430
479
  this.button.addEventListener("click", this.#boundHandleClick);
431
480
  this.button.addEventListener("focus", this.#boundHandleFocus);
432
481
  this.button.addEventListener("blur", this.#boundHandleBlur);
@@ -1573,7 +1622,7 @@ class FigTruncate extends HTMLElement {
1573
1622
  } else {
1574
1623
  splitIndex = Math.ceil(text.length / 2);
1575
1624
  }
1576
- this.innerHTML = "";
1625
+ this.replaceChildren();
1577
1626
  const startSpan = document.createElement("span");
1578
1627
  startSpan.className = "start";
1579
1628
  startSpan.textContent = text.slice(0, splitIndex);
@@ -4960,7 +5009,7 @@ class FigOptions extends HTMLElement {
4960
5009
  }
4961
5010
 
4962
5011
  #renderSegments() {
4963
- this.innerHTML = "";
5012
+ this.replaceChildren();
4964
5013
  if (this.#parsedOptions.length === 0) return;
4965
5014
 
4966
5015
  const sc = document.createElement("fig-segmented-control");
@@ -5019,7 +5068,7 @@ class FigOptions extends HTMLElement {
5019
5068
  }
5020
5069
 
5021
5070
  #renderDropdown() {
5022
- this.innerHTML = "";
5071
+ this.replaceChildren();
5023
5072
  if (this.#parsedOptions.length === 0) return;
5024
5073
 
5025
5074
  const dd = document.createElement("fig-dropdown");
@@ -5207,7 +5256,7 @@ class FigSlider extends HTMLElement {
5207
5256
  constructor() {
5208
5257
  super();
5209
5258
  // Parser-created custom elements do not have their children yet here.
5210
- this.initialInnerHTML = null;
5259
+ this.initialChildren = null;
5211
5260
 
5212
5261
  // Bind the event handlers
5213
5262
  this.#boundHandleInput = (e) => {
@@ -5239,7 +5288,7 @@ class FigSlider extends HTMLElement {
5239
5288
  this.#isInteracting = false;
5240
5289
  if (this.#pendingRegeneration && this.isConnected) {
5241
5290
  this.#pendingRegeneration = false;
5242
- this.#regenerateInnerHTML();
5291
+ this.#regenerateMarkup();
5243
5292
  }
5244
5293
  };
5245
5294
  }
@@ -5372,48 +5421,57 @@ class FigSlider extends HTMLElement {
5372
5421
  }
5373
5422
  }
5374
5423
 
5375
- #regenerateInnerHTML() {
5424
+ #regenerateMarkup() {
5376
5425
  this.#readAttributesFromMarkup();
5377
5426
 
5378
5427
  if (this.color) {
5379
5428
  this.style.setProperty("--color", this.color);
5380
5429
  }
5381
5430
 
5382
- let html = "";
5383
- let slider = `<div class="fig-slider-input-container" role="group">
5384
- <input
5385
- type="range"
5386
- ${this.text ? 'tabindex="-1"' : ""}
5387
- ${this.disabled ? "disabled" : ""}
5388
- min="${this.min}"
5389
- max="${this.max}"
5390
- step="${this.step}"
5391
- class="${this.type}"
5392
- value="${this.value}"
5393
- aria-valuemin="${this.min}"
5394
- aria-valuemax="${this.max}"
5395
- aria-valuenow="${this.value}">
5396
- ${this.initialInnerHTML}
5397
- </div>`;
5431
+ const input = figCreateElement("input", {
5432
+ type: "range",
5433
+ tabindex: this.text ? "-1" : null,
5434
+ disabled: this.disabled,
5435
+ min: this.min,
5436
+ max: this.max,
5437
+ step: this.step,
5438
+ className: this.type,
5439
+ value: this.value,
5440
+ "aria-valuemin": this.min,
5441
+ "aria-valuemax": this.max,
5442
+ "aria-valuenow": this.value,
5443
+ });
5444
+ const inputContainer = figCreateElement(
5445
+ "div",
5446
+ {
5447
+ className: "fig-slider-input-container",
5448
+ role: "group",
5449
+ },
5450
+ input,
5451
+ );
5452
+ for (const child of this.initialChildren ?? []) {
5453
+ inputContainer.appendChild(child);
5454
+ }
5455
+
5456
+ const renderedChildren = [inputContainer];
5398
5457
  if (this.text) {
5399
- html = `${slider}
5400
- <fig-input-number
5401
- placeholder="${figEscapeAttribute(this.placeholder)}"
5402
- min="${this.min}"
5403
- max="${this.max}"
5404
- transform="${this.transform}"
5405
- step="${this.step}"
5406
- value="${this.value}"
5407
- ${this.units ? `units="${figEscapeAttribute(this.units)}"` : ""}
5408
- ${this.precision !== null ? `precision="${this.precision}"` : ""}>
5409
- </fig-input-number>`;
5410
- } else {
5411
- html = slider;
5458
+ renderedChildren.push(
5459
+ figCreateElement("fig-input-number", {
5460
+ placeholder: this.placeholder,
5461
+ min: this.min,
5462
+ max: this.max,
5463
+ transform: this.transform,
5464
+ step: this.step,
5465
+ value: this.value,
5466
+ units: this.units || null,
5467
+ precision: this.precision,
5468
+ }),
5469
+ );
5412
5470
  }
5413
- this.innerHTML = html;
5471
+ this.replaceChildren(...renderedChildren);
5414
5472
 
5415
- this.input = this.querySelector("[type=range]");
5416
- this.inputContainer = this.querySelector(".fig-slider-input-container");
5473
+ this.input = input;
5474
+ this.inputContainer = inputContainer;
5417
5475
  this.figInputNumber = this.querySelector("fig-input-number");
5418
5476
  this.#bindControlListeners();
5419
5477
 
@@ -5449,8 +5507,8 @@ class FigSlider extends HTMLElement {
5449
5507
  this.input.setAttribute("list", this.datalist.getAttribute("id"));
5450
5508
  }
5451
5509
  if (this.datalist) {
5452
- let defaultOption = this.datalist.querySelector(
5453
- `option[value='${this.default}']`,
5510
+ const defaultOption = [...this.datalist.querySelectorAll("option")].find(
5511
+ (option) => option.value === String(this.default),
5454
5512
  );
5455
5513
  if (defaultOption) {
5456
5514
  defaultOption.setAttribute("default", "true");
@@ -5460,14 +5518,16 @@ class FigSlider extends HTMLElement {
5460
5518
  }
5461
5519
 
5462
5520
  connectedCallback() {
5463
- if (this.initialInnerHTML === null) this.initialInnerHTML = this.innerHTML;
5521
+ if (this.initialChildren === null) {
5522
+ this.initialChildren = Array.from(this.childNodes);
5523
+ }
5464
5524
  if (this.#canReuseRenderedMarkup()) {
5465
5525
  this.#updateRenderedMarkup();
5466
5526
  this.#bindControlListeners();
5467
5527
  this.#syncValue();
5468
5528
  return;
5469
5529
  }
5470
- this.#regenerateInnerHTML();
5530
+ this.#regenerateMarkup();
5471
5531
  }
5472
5532
 
5473
5533
  get value() {
@@ -5752,7 +5812,7 @@ class FigSlider extends HTMLElement {
5752
5812
  return;
5753
5813
  }
5754
5814
  this.#pendingRegeneration = false;
5755
- this.#regenerateInnerHTML();
5815
+ this.#regenerateMarkup();
5756
5816
  }
5757
5817
 
5758
5818
  static get observedAttributes() {
@@ -6581,9 +6641,18 @@ class FigInputNumber extends HTMLElement {
6581
6641
  if (hasSteppers && !this.#stepperEl) {
6582
6642
  this.#stepperEl = document.createElement("span");
6583
6643
  this.#stepperEl.className = "fig-steppers";
6584
- this.#stepperEl.innerHTML =
6585
- `<button class="fig-stepper-up" tabindex="-1" aria-label="Increase"></button>` +
6586
- `<button class="fig-stepper-down" tabindex="-1" aria-label="Decrease"></button>`;
6644
+ this.#stepperEl.append(
6645
+ figCreateElement("button", {
6646
+ className: "fig-stepper-up",
6647
+ tabindex: "-1",
6648
+ "aria-label": "Increase",
6649
+ }),
6650
+ figCreateElement("button", {
6651
+ className: "fig-stepper-down",
6652
+ tabindex: "-1",
6653
+ "aria-label": "Decrease",
6654
+ }),
6655
+ );
6587
6656
  this.#stepperEl.addEventListener("pointerdown", (e) => {
6588
6657
  e.preventDefault();
6589
6658
  e.stopPropagation();
@@ -7511,7 +7580,9 @@ class FigInputColor extends HTMLElement {
7511
7580
  name !== "picker-anchor" &&
7512
7581
  name !== "picker-experimental"
7513
7582
  ) {
7514
- attrs[name.slice(7)] = value;
7583
+ const forwardedName = name.slice(7);
7584
+ if (/^on/i.test(forwardedName)) continue;
7585
+ attrs[forwardedName] = value;
7515
7586
  }
7516
7587
  }
7517
7588
  if (!attrs["dialog-position"]) attrs["dialog-position"] = "left";
@@ -7630,41 +7701,49 @@ class FigInputColor extends HTMLElement {
7630
7701
 
7631
7702
  const showAlpha = this.getAttribute("alpha") !== "false";
7632
7703
  const disabled = this.#disabled;
7633
- const disabledAttr = disabled ? " disabled" : "";
7634
-
7635
- let html = ``;
7636
7704
  const showText = this.getAttribute("text") !== "false";
7705
+ const swatch = figCreateElement("fig-swatch", {
7706
+ background: this.hexOpaque,
7707
+ alpha: this.rgba.a,
7708
+ disabled,
7709
+ });
7710
+
7637
7711
  if (showText) {
7638
- let label = `<fig-input-text
7639
- type="text"
7640
- placeholder="000000"
7641
- value="${this.hexOpaque.slice(1).toUpperCase()}"${disabledAttr}>
7642
- </fig-input-text>`;
7712
+ const combo = figCreateElement(
7713
+ "div",
7714
+ { className: "input-combo" },
7715
+ [
7716
+ swatch,
7717
+ figCreateElement("fig-input-text", {
7718
+ type: "text",
7719
+ placeholder: "000000",
7720
+ value: this.hexOpaque.slice(1).toUpperCase(),
7721
+ disabled,
7722
+ }),
7723
+ ],
7724
+ );
7643
7725
  if (showAlpha) {
7644
- label += `<fig-tooltip text="Opacity">
7645
- <fig-input-number
7646
- placeholder="##"
7647
- min="0"
7648
- max="100"
7649
- value="${this.#alphaPercent}"
7650
- units="%"${disabledAttr}>
7651
- </fig-input-number>
7652
- </fig-tooltip>`;
7653
- }
7654
-
7655
- let swatchElement = "";
7656
- swatchElement = `<fig-swatch background="${this.hexOpaque}" alpha="${this.rgba.a}"${disabledAttr}></fig-swatch>`;
7657
-
7658
- html = `<div class="input-combo">
7659
- ${swatchElement}
7660
- ${label}
7661
- </div>`;
7726
+ combo.appendChild(
7727
+ figCreateElement(
7728
+ "fig-tooltip",
7729
+ { text: "Opacity" },
7730
+ figCreateElement("fig-input-number", {
7731
+ placeholder: "##",
7732
+ min: "0",
7733
+ max: "100",
7734
+ value: this.#alphaPercent,
7735
+ units: "%",
7736
+ disabled,
7737
+ }),
7738
+ ),
7739
+ );
7740
+ }
7741
+ this.replaceChildren(combo);
7662
7742
  } else {
7663
- html = `<fig-swatch background="${this.hexOpaque}" alpha="${this.rgba.a}"${disabledAttr}></fig-swatch>`;
7743
+ this.replaceChildren(swatch);
7664
7744
  }
7665
- this.innerHTML = html;
7666
7745
 
7667
- this.#swatch = this.querySelector("fig-swatch");
7746
+ this.#swatch = swatch;
7668
7747
  this.#fillPicker = this.querySelector("fig-fill-picker");
7669
7748
  this.#textInput = this.querySelector("fig-input-text:not([type=number])");
7670
7749
  this.#alphaInput = this.querySelector("fig-input-number");
@@ -7716,7 +7795,7 @@ class FigInputColor extends HTMLElement {
7716
7795
  }
7717
7796
 
7718
7797
  const picker = document.createElement("fig-fill-picker");
7719
- picker.innerHTML = "<span hidden></span>";
7798
+ picker.appendChild(figCreateElement("span", { hidden: true }));
7720
7799
  picker.addEventListener("input", this.#boundFillPickerInput);
7721
7800
  picker.addEventListener("change", this.#boundChange);
7722
7801
  this.appendChild(picker);
@@ -8636,13 +8715,13 @@ class FigInputFill extends HTMLElement {
8636
8715
  name !== "picker-anchor" &&
8637
8716
  name !== "picker-experimental"
8638
8717
  ) {
8639
- attrs[name.slice(7)] = value;
8718
+ const forwardedName = name.slice(7);
8719
+ if (/^on/i.test(forwardedName)) continue;
8720
+ attrs[forwardedName] = value;
8640
8721
  }
8641
8722
  }
8642
8723
  if (!attrs["dialog-position"]) attrs["dialog-position"] = "left";
8643
- return Object.entries(attrs)
8644
- .map(([k, v]) => `${k}="${figEscapeAttribute(v)}"`)
8645
- .join(" ");
8724
+ return attrs;
8646
8725
  }
8647
8726
 
8648
8727
  #fillPickerSwatchBackground() {
@@ -8662,10 +8741,22 @@ class FigInputFill extends HTMLElement {
8662
8741
  })
8663
8742
  .join(", ");
8664
8743
  const interpolation = gradientInterpolationClause(this.#gradient);
8665
- return `linear-gradient(${this.#gradient.angle}deg${interpolation ? ` ${interpolation}` : ""}, ${stops})`;
8744
+ const interpolationSuffix = interpolation ? ` ${interpolation}` : "";
8745
+ switch (this.#gradient.type) {
8746
+ case "radial":
8747
+ return `radial-gradient(circle${interpolationSuffix}, ${stops})`;
8748
+ case "angular":
8749
+ return `conic-gradient(from ${this.#gradient.angle}deg${interpolationSuffix}, ${stops})`;
8750
+ default:
8751
+ return `linear-gradient(${this.#gradient.angle}deg${interpolationSuffix}, ${stops})`;
8752
+ }
8666
8753
  }
8667
8754
  case "image":
8668
8755
  return this.#image.url ? `url(${this.#image.url})` : "#D9D9D9";
8756
+ case "webcam":
8757
+ return this.#webcam.snapshot
8758
+ ? `url(${this.#webcam.snapshot})`
8759
+ : "#D9D9D9";
8669
8760
  default:
8670
8761
  return "#D9D9D9";
8671
8762
  }
@@ -8733,81 +8824,97 @@ class FigInputFill extends HTMLElement {
8733
8824
  syncState(this.#opacityInput, `${name} opacity`);
8734
8825
  }
8735
8826
 
8736
- #render() {
8737
- const disabled =
8738
- this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
8739
- const fillPickerValue = JSON.stringify(this.value);
8740
- const showAlpha = this.getAttribute("alpha") !== "false";
8741
-
8742
- const opacityHtml = (value) =>
8743
- showAlpha
8744
- ? `<fig-tooltip text="Opacity">
8745
- <fig-input-number
8746
- class="fig-input-fill-opacity"
8747
- placeholder="##"
8748
- min="0"
8749
- max="100"
8750
- value="${value}"
8751
- units="%"
8752
- ${disabled ? "disabled" : ""}>
8753
- </fig-input-number>
8754
- </fig-tooltip>`
8755
- : "";
8827
+ #createOpacityControl(value, disabled) {
8828
+ if (this.getAttribute("alpha") === "false") return null;
8829
+ return figCreateElement(
8830
+ "fig-tooltip",
8831
+ { text: "Opacity" },
8832
+ figCreateElement("fig-input-number", {
8833
+ className: "fig-input-fill-opacity",
8834
+ placeholder: "##",
8835
+ min: "0",
8836
+ max: "100",
8837
+ value,
8838
+ units: "%",
8839
+ disabled,
8840
+ }),
8841
+ );
8842
+ }
8756
8843
 
8757
- let controlsHtml = "";
8844
+ #createControls(disabled) {
8845
+ let label = null;
8846
+ let opacity = 1;
8758
8847
 
8759
8848
  switch (this.#fillType) {
8760
8849
  case "solid":
8761
- controlsHtml = `
8762
- <fig-input-text
8763
- type="text"
8764
- class="fig-input-fill-hex"
8765
- placeholder="000000"
8766
- value="${figEscapeAttribute(this.#solid.color.slice(1).toUpperCase())}"
8767
- ${disabled ? "disabled" : ""}>
8768
- </fig-input-text>
8769
- ${opacityHtml(Math.round(this.#solid.alpha * 100))}`;
8770
- break;
8771
-
8772
- case "gradient": {
8773
- const gradientLabel =
8850
+ return [
8851
+ figCreateElement("fig-input-text", {
8852
+ type: "text",
8853
+ className: "fig-input-fill-hex",
8854
+ placeholder: "000000",
8855
+ value: this.#solid.color.slice(1).toUpperCase(),
8856
+ disabled,
8857
+ }),
8858
+ this.#createOpacityControl(
8859
+ Math.round(this.#solid.alpha * 100),
8860
+ disabled,
8861
+ ),
8862
+ ].filter(Boolean);
8863
+ case "gradient":
8864
+ label =
8774
8865
  this.#gradient.type.charAt(0).toUpperCase() +
8775
8866
  this.#gradient.type.slice(1);
8776
- controlsHtml = `
8777
- <label class="fig-input-fill-label">${figEscapeAttribute(gradientLabel)}</label>
8778
- ${opacityHtml(Math.round((this.#gradient.opacity ?? 1) * 100))}`;
8867
+ opacity = this.#gradient.opacity ?? 1;
8779
8868
  break;
8780
- }
8781
-
8782
8869
  case "image":
8783
- controlsHtml = `
8784
- <label class="fig-input-fill-label">Image</label>
8785
- ${opacityHtml(Math.round((this.#image.opacity ?? 1) * 100))}`;
8870
+ label = "Image";
8871
+ opacity = this.#image.opacity ?? 1;
8786
8872
  break;
8787
-
8788
8873
  case "video":
8789
- controlsHtml = `
8790
- <label class="fig-input-fill-label">Video</label>
8791
- ${opacityHtml(Math.round((this.#video.opacity ?? 1) * 100))}`;
8874
+ label = "Video";
8875
+ opacity = this.#video.opacity ?? 1;
8792
8876
  break;
8793
-
8794
8877
  case "webcam":
8795
- controlsHtml = `
8796
- <label class="fig-input-fill-label">Webcam</label>
8797
- ${opacityHtml(Math.round((this.#webcam.opacity ?? 1) * 100))}`;
8878
+ label = "Webcam";
8879
+ opacity = this.#webcam.opacity ?? 1;
8798
8880
  break;
8799
8881
  }
8800
8882
 
8883
+ return [
8884
+ figCreateElement(
8885
+ "label",
8886
+ { className: "fig-input-fill-label" },
8887
+ label,
8888
+ ),
8889
+ this.#createOpacityControl(Math.round(opacity * 100), disabled),
8890
+ ].filter(Boolean);
8891
+ }
8892
+
8893
+ #render() {
8894
+ const disabled =
8895
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
8896
+ const fillPickerValue = JSON.stringify(this.value);
8801
8897
  const fpAttrs = this.#buildFillPickerAttrs();
8802
- this.innerHTML = `
8803
- <div class="input-combo">
8804
- <fig-fill-picker ${fpAttrs} value='${figEscapeAttribute(fillPickerValue)}' ${
8805
- disabled ? "disabled" : ""
8806
- }>
8807
- <fig-swatch background="${figEscapeAttribute(this.#fillPickerSwatchBackground())}" alpha="${this.#fillPickerSwatchAlpha()}"${disabled ? " disabled" : ""}></fig-swatch>
8808
- </fig-fill-picker>
8809
- ${controlsHtml}
8810
- </div>`;
8898
+ const swatch = figCreateElement("fig-swatch", {
8899
+ background: this.#fillPickerSwatchBackground(),
8900
+ alpha: this.#fillPickerSwatchAlpha(),
8901
+ disabled,
8902
+ });
8903
+ const picker = figCreateElement(
8904
+ "fig-fill-picker",
8905
+ {
8906
+ value: fillPickerValue,
8907
+ disabled,
8908
+ ...fpAttrs,
8909
+ },
8910
+ swatch,
8911
+ );
8912
+ const combo = figCreateElement(
8913
+ "div",
8914
+ { className: "input-combo" },
8915
+ [picker, this.#createControls(disabled)],
8916
+ );
8917
+ this.replaceChildren(combo);
8811
8918
 
8812
8919
  this.#setupEventListeners();
8813
8920
  }
@@ -8870,6 +8977,9 @@ class FigInputFill extends HTMLElement {
8870
8977
  case "video":
8871
8978
  if (detail.video) this.#video = detail.video;
8872
8979
  break;
8980
+ case "webcam":
8981
+ if (detail.image?.url) this.#webcam.snapshot = detail.image.url;
8982
+ break;
8873
8983
  }
8874
8984
  // Update controls (don't re-render to keep dialog open)
8875
8985
  if (typeChanged) {
@@ -9002,7 +9112,6 @@ class FigInputFill extends HTMLElement {
9002
9112
  // Update only the controls (not the fill picker) when type changes
9003
9113
  const disabled =
9004
9114
  this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
9005
- const showAlpha = this.getAttribute("alpha") !== "false";
9006
9115
  const combo = this.querySelector(".input-combo");
9007
9116
  if (!combo) return;
9008
9117
 
@@ -9016,98 +9125,8 @@ class FigInputFill extends HTMLElement {
9016
9125
  oldHex?.remove();
9017
9126
  oldTooltips.forEach((t) => t.remove());
9018
9127
 
9019
- // Generate new controls HTML
9020
- let controlsHtml = "";
9021
- switch (this.#fillType) {
9022
- case "solid":
9023
- controlsHtml = `
9024
- <fig-input-text
9025
- type="text"
9026
- class="fig-input-fill-hex"
9027
- placeholder="000000"
9028
- value="${figEscapeAttribute(this.#solid.color.slice(1).toUpperCase())}"
9029
- ${disabled ? "disabled" : ""}>
9030
- </fig-input-text>
9031
- ${showAlpha ? `<fig-tooltip text="Opacity">
9032
- <fig-input-number
9033
- class="fig-input-fill-opacity"
9034
- placeholder="##"
9035
- min="0"
9036
- max="100"
9037
- value="${Math.round(this.#solid.alpha * 100)}"
9038
- units="%"
9039
- ${disabled ? "disabled" : ""}>
9040
- </fig-input-number>
9041
- </fig-tooltip>` : ""}`;
9042
- break;
9043
- case "gradient": {
9044
- const gradientLabel =
9045
- this.#gradient.type.charAt(0).toUpperCase() +
9046
- this.#gradient.type.slice(1);
9047
- controlsHtml = `
9048
- <label class="fig-input-fill-label">${figEscapeAttribute(gradientLabel)}</label>
9049
- ${showAlpha ? `<fig-tooltip text="Opacity">
9050
- <fig-input-number
9051
- class="fig-input-fill-opacity"
9052
- placeholder="##"
9053
- min="0"
9054
- max="100"
9055
- value="${Math.round((this.#gradient.opacity ?? 1) * 100)}"
9056
- units="%"
9057
- ${disabled ? "disabled" : ""}>
9058
- </fig-input-number>
9059
- </fig-tooltip>` : ""}`;
9060
- break;
9061
- }
9062
- case "image":
9063
- controlsHtml = `
9064
- <label class="fig-input-fill-label">Image</label>
9065
- ${showAlpha ? `<fig-tooltip text="Opacity">
9066
- <fig-input-number
9067
- class="fig-input-fill-opacity"
9068
- placeholder="##"
9069
- min="0"
9070
- max="100"
9071
- value="${Math.round((this.#image.opacity ?? 1) * 100)}"
9072
- units="%"
9073
- ${disabled ? "disabled" : ""}>
9074
- </fig-input-number>
9075
- </fig-tooltip>` : ""}`;
9076
- break;
9077
- case "video":
9078
- controlsHtml = `
9079
- <label class="fig-input-fill-label">Video</label>
9080
- ${showAlpha ? `<fig-tooltip text="Opacity">
9081
- <fig-input-number
9082
- class="fig-input-fill-opacity"
9083
- placeholder="##"
9084
- min="0"
9085
- max="100"
9086
- value="${Math.round((this.#video.opacity ?? 1) * 100)}"
9087
- units="%"
9088
- ${disabled ? "disabled" : ""}>
9089
- </fig-input-number>
9090
- </fig-tooltip>` : ""}`;
9091
- break;
9092
- case "webcam":
9093
- controlsHtml = `
9094
- <label class="fig-input-fill-label">Webcam</label>
9095
- ${showAlpha ? `<fig-tooltip text="Opacity">
9096
- <fig-input-number
9097
- class="fig-input-fill-opacity"
9098
- placeholder="##"
9099
- min="0"
9100
- max="100"
9101
- value="${Math.round((this.#webcam.opacity ?? 1) * 100)}"
9102
- units="%"
9103
- ${disabled ? "disabled" : ""}>
9104
- </fig-input-number>
9105
- </fig-tooltip>` : ""}`;
9106
- break;
9107
- }
9108
-
9109
9128
  // Append new controls after the fill picker
9110
- combo.insertAdjacentHTML("beforeend", controlsHtml);
9129
+ combo.append(...this.#createControls(disabled));
9111
9130
 
9112
9131
  // Re-setup event listeners for the new controls
9113
9132
  this.#opacityInput = this.querySelector(".fig-input-fill-opacity");
@@ -9496,7 +9515,7 @@ class FigInputPalette extends HTMLElement {
9496
9515
  this.hasAttribute("disabled") &&
9497
9516
  this.getAttribute("disabled") !== "false";
9498
9517
 
9499
- this.innerHTML = "";
9518
+ this.replaceChildren();
9500
9519
  this.#inlinePickers = [];
9501
9520
  this.#expandedPickers = [];
9502
9521
 
@@ -10079,11 +10098,6 @@ class FigInputGradient extends HTMLElement {
10079
10098
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
10080
10099
  }
10081
10100
 
10082
- #swatchSizeAttr() {
10083
- const size = this.getAttribute("size");
10084
- return size ? ` size="${size}"` : "";
10085
- }
10086
-
10087
10101
  #syncSwatchSize() {
10088
10102
  if (!this.#swatch) return;
10089
10103
  const size = this.getAttribute("size");
@@ -10091,16 +10105,32 @@ class FigInputGradient extends HTMLElement {
10091
10105
  else this.#swatch.removeAttribute("size");
10092
10106
  }
10093
10107
 
10094
- #buildStopHandles() {
10108
+ #createStopHandles() {
10095
10109
  const disabled =
10096
10110
  this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
10097
- const tipAttr = this.#stopHandleMode === "tip" ? ' tip="color"' : "";
10098
10111
  return this.#gradient.stops
10099
10112
  .map(
10100
10113
  (stop, i) =>
10101
- `<fig-tooltip action="manual" text="${Math.round(stop.position)}%"><fig-handle drag drag-axes="x" drag-surface=".fig-input-gradient-track" type="color"${tipAttr} color="${this.#stopColorCSS(stop)}" value="${stop.position}% 50%" hit-area="4" data-stop-index="${i}"${disabled ? " disabled" : ""}></fig-handle></fig-tooltip>`,
10102
- )
10103
- .join("");
10114
+ figCreateElement(
10115
+ "fig-tooltip",
10116
+ {
10117
+ action: "manual",
10118
+ text: `${Math.round(stop.position)}%`,
10119
+ },
10120
+ figCreateElement("fig-handle", {
10121
+ drag: true,
10122
+ "drag-axes": "x",
10123
+ "drag-surface": ".fig-input-gradient-track",
10124
+ type: "color",
10125
+ tip: this.#stopHandleMode === "tip" ? "color" : null,
10126
+ color: this.#stopColorCSS(stop),
10127
+ value: `${stop.position}% 50%`,
10128
+ "hit-area": "4",
10129
+ "data-stop-index": i,
10130
+ disabled,
10131
+ }),
10132
+ ),
10133
+ );
10104
10134
  }
10105
10135
 
10106
10136
  #ghostHandle = null;
@@ -10111,27 +10141,45 @@ class FigInputGradient extends HTMLElement {
10111
10141
  this.#colorObserver = null;
10112
10142
  const disabled = figBooleanAttribute(this, "disabled");
10113
10143
  const mode = this.#editMode;
10144
+ const size = this.getAttribute("size");
10145
+ const swatch = figCreateElement("fig-swatch", {
10146
+ background: this.#buildGradientCSS(),
10147
+ size: size || null,
10148
+ disabled,
10149
+ });
10114
10150
 
10115
10151
  if (mode === "picker" && hasFigFillPicker()) {
10116
10152
  const gradientValue = JSON.stringify(this.value);
10117
- this.innerHTML = `
10118
- <fig-fill-picker mode="gradient" value='${gradientValue}'${disabled ? " disabled" : ""}>
10119
- <fig-swatch background="${this.#buildGradientCSS()}"${this.#swatchSizeAttr()}${disabled ? " disabled" : ""}></fig-swatch>
10120
- </fig-fill-picker>`;
10121
- this.#swatch = this.querySelector("fig-swatch");
10153
+ const picker = figCreateElement(
10154
+ "fig-fill-picker",
10155
+ {
10156
+ mode: "gradient",
10157
+ value: gradientValue,
10158
+ disabled,
10159
+ },
10160
+ swatch,
10161
+ );
10162
+ this.replaceChildren(picker);
10163
+ this.#swatch = swatch;
10122
10164
  this.#track = null;
10123
10165
  this.#setupPickerEvents();
10124
10166
  this.#syncFocusTarget();
10125
10167
  return;
10126
10168
  }
10127
10169
 
10128
- this.innerHTML = `
10129
- <fig-swatch background="${this.#buildGradientCSS()}"${this.#swatchSizeAttr()}${disabled ? " disabled" : ""}></fig-swatch>
10130
- ${mode === "true" || mode === "picker" ? `<div class="fig-input-gradient-track">${this.#buildStopHandles()}</div>` : ""}`;
10131
- this.#swatch = this.querySelector("fig-swatch");
10132
- this.#track = this.querySelector(".fig-input-gradient-track");
10170
+ const editable = mode === "true" || mode === "picker";
10171
+ const track = editable
10172
+ ? figCreateElement(
10173
+ "div",
10174
+ { className: "fig-input-gradient-track" },
10175
+ this.#createStopHandles(),
10176
+ )
10177
+ : null;
10178
+ this.replaceChildren(...(track ? [swatch, track] : [swatch]));
10179
+ this.#swatch = swatch;
10180
+ this.#track = track;
10133
10181
 
10134
- if (mode === "true" || mode === "picker") {
10182
+ if (editable) {
10135
10183
  this.#setupGhostHandle();
10136
10184
  this.#setupEventListeners();
10137
10185
  }
@@ -10345,7 +10393,7 @@ class FigInputGradient extends HTMLElement {
10345
10393
 
10346
10394
  if (handles.length !== stops.length) {
10347
10395
  const ghost = this.#ghostHandle;
10348
- this.#track.innerHTML = this.#buildStopHandles();
10396
+ this.#track.replaceChildren(...this.#createStopHandles());
10349
10397
  if (ghost) this.#track.appendChild(ghost);
10350
10398
  this.#syncHandleMode();
10351
10399
  this.#reobserveHandleColors();
@@ -11096,22 +11144,54 @@ class FigComboInput extends HTMLElement {
11096
11144
  const currentValue = this.value;
11097
11145
  const dropdownLabel = this.#dropdownLabel();
11098
11146
 
11099
- const dropdownHTML = this.#usesCustomDropdown
11100
- ? ""
11101
- : `<fig-dropdown type="dropdown" label="${figEscapeAttribute(dropdownLabel)}">${options.map((o) => `<option>${figEscapeAttribute(o.trim())}</option>`).join("")}</fig-dropdown>`;
11102
-
11103
- this.innerHTML = `<div class="input-combo">
11104
- <fig-input-text placeholder="${figEscapeAttribute(placeholder)}" value="${figEscapeAttribute(currentValue)}"></fig-input-text>
11105
- <fig-button type="select" variant="input" icon>
11106
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
11107
- <path d="M5.87868 7.12132L8 9.24264L10.1213 7.12132" stroke="currentColor" stroke-opacity="0.9" stroke-linecap="round"/>
11108
- </svg>
11109
- ${dropdownHTML}
11110
- </fig-button>
11111
- </div>`;
11147
+ const input = figCreateElement("fig-input-text", {
11148
+ placeholder,
11149
+ value: currentValue,
11150
+ });
11151
+ const icon = figCreateSvgElement(
11152
+ "svg",
11153
+ {
11154
+ width: "16",
11155
+ height: "16",
11156
+ viewBox: "0 0 16 16",
11157
+ fill: "none",
11158
+ },
11159
+ figCreateSvgElement("path", {
11160
+ d: "M5.87868 7.12132L8 9.24264L10.1213 7.12132",
11161
+ stroke: "currentColor",
11162
+ "stroke-opacity": "0.9",
11163
+ "stroke-linecap": "round",
11164
+ }),
11165
+ );
11166
+ const button = figCreateElement(
11167
+ "fig-button",
11168
+ {
11169
+ type: "select",
11170
+ variant: "input",
11171
+ icon: true,
11172
+ },
11173
+ icon,
11174
+ );
11175
+ if (!this.#usesCustomDropdown) {
11176
+ button.appendChild(
11177
+ figCreateElement(
11178
+ "fig-dropdown",
11179
+ {
11180
+ type: "dropdown",
11181
+ label: dropdownLabel,
11182
+ },
11183
+ options.map((option) =>
11184
+ figCreateElement("option", {}, option.trim()),
11185
+ ),
11186
+ ),
11187
+ );
11188
+ }
11189
+ this.replaceChildren(
11190
+ figCreateElement("div", { className: "input-combo" }, [input, button]),
11191
+ );
11112
11192
 
11113
- this.#input = this.querySelector("fig-input-text");
11114
- this.#button = this.querySelector("fig-button");
11193
+ this.#input = input;
11194
+ this.#button = button;
11115
11195
 
11116
11196
  if (this.#usesCustomDropdown && this.#customDropdown && this.#button) {
11117
11197
  if (!this.#customDropdown.hasAttribute("type")) {
@@ -11376,14 +11456,18 @@ class FigSwatch extends HTMLElement {
11376
11456
 
11377
11457
  if (this.#type === "color") {
11378
11458
  const hex = this.#toHex(bg);
11379
- this.innerHTML = `<div></div><input type="color" value="${hex}" />`;
11380
- this.input = this.querySelector("input");
11459
+ const input = figCreateElement("input", {
11460
+ type: "color",
11461
+ value: hex,
11462
+ });
11463
+ this.replaceChildren(document.createElement("div"), input);
11464
+ this.input = input;
11381
11465
  if (!isVar) {
11382
11466
  this.input.addEventListener("input", this.#boundHandleInput);
11383
11467
  }
11384
11468
  this.#syncA11y();
11385
11469
  } else {
11386
- this.innerHTML = "<div></div>";
11470
+ this.replaceChildren(document.createElement("div"));
11387
11471
  this.input = null;
11388
11472
  this.#syncA11y();
11389
11473
  }
@@ -13162,7 +13246,7 @@ class FigInputFile extends HTMLElement {
13162
13246
  !!this.getAttribute("url") ||
13163
13247
  !!this.getAttribute("filename");
13164
13248
 
13165
- this.innerHTML = "";
13249
+ this.replaceChildren();
13166
13250
 
13167
13251
  if (hasFile) {
13168
13252
  const tooltipText = accepts
@@ -13545,10 +13629,10 @@ class FigEasingCurve extends HTMLElement {
13545
13629
  const y = pad + (1 - (pts[i].value - minVal) / range) * s;
13546
13630
  d += (i === 0 ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1);
13547
13631
  }
13548
- return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none"><path d="${d}" stroke="currentColor" stroke-width="1" stroke-linecap="round" fill="none"/></svg>`;
13632
+ return FigEasingCurve.#createSvgIcon(d, size);
13549
13633
  }
13550
13634
 
13551
- static curveIcon(cp1x, cp1y, cp2x, cp2y, size = 24) {
13635
+ static #curveIconPath(cp1x, cp1y, cp2x, cp2y, size = 24) {
13552
13636
  const draw = 12;
13553
13637
  const pad = (size - draw) / 2;
13554
13638
  const samples = 48;
@@ -13594,6 +13678,43 @@ class FigEasingCurve extends HTMLElement {
13594
13678
  const py = toY(points[i].y);
13595
13679
  d += `${i === 0 ? "M" : "L"}${px.toFixed(1)},${py.toFixed(1)}`;
13596
13680
  }
13681
+ return d;
13682
+ }
13683
+
13684
+ static #createSvgIcon(d, size = 24) {
13685
+ return figCreateSvgElement(
13686
+ "svg",
13687
+ {
13688
+ width: size,
13689
+ height: size,
13690
+ viewBox: `0 0 ${size} ${size}`,
13691
+ fill: "none",
13692
+ },
13693
+ figCreateSvgElement("path", {
13694
+ d,
13695
+ stroke: "currentColor",
13696
+ "stroke-width": "1",
13697
+ "stroke-linecap": "round",
13698
+ fill: "none",
13699
+ }),
13700
+ );
13701
+ }
13702
+
13703
+ static #createCurveIcon(cp1x, cp1y, cp2x, cp2y, size = 24) {
13704
+ return FigEasingCurve.#createSvgIcon(
13705
+ FigEasingCurve.#curveIconPath(cp1x, cp1y, cp2x, cp2y, size),
13706
+ size,
13707
+ );
13708
+ }
13709
+
13710
+ static curveIcon(cp1x, cp1y, cp2x, cp2y, size = 24) {
13711
+ const d = FigEasingCurve.#curveIconPath(
13712
+ cp1x,
13713
+ cp1y,
13714
+ cp2x,
13715
+ cp2y,
13716
+ size,
13717
+ );
13597
13718
  return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none"><path d="${d}" stroke="currentColor" stroke-width="1" stroke-linecap="round"/></svg>`;
13598
13719
  }
13599
13720
 
@@ -13607,7 +13728,7 @@ class FigEasingCurve extends HTMLElement {
13607
13728
  this.classList.toggle("spring-mode", this.#mode === "spring");
13608
13729
  this.classList.toggle("bezier-mode", this.#mode !== "spring");
13609
13730
  this.#syncMetricsFromCSS();
13610
- this.innerHTML = this.#getInnerHTML();
13731
+ this.replaceChildren(...this.#createContent());
13611
13732
  this.#cacheRefs();
13612
13733
  if (this.#svg) {
13613
13734
  this.#syncHandleSizes();
@@ -13618,14 +13739,6 @@ class FigEasingCurve extends HTMLElement {
13618
13739
  this.#setupEvents();
13619
13740
  }
13620
13741
 
13621
- static #escapeAttribute(value) {
13622
- return String(value)
13623
- .replace(/&/g, "&amp;")
13624
- .replace(/"/g, "&quot;")
13625
- .replace(/</g, "&lt;")
13626
- .replace(/>/g, "&gt;");
13627
- }
13628
-
13629
13742
  #canUseFigSelect() {
13630
13743
  return ["fig-select", "fig-select-options", "fig-select-option"].every(
13631
13744
  (tag) => {
@@ -13638,38 +13751,43 @@ class FigEasingCurve extends HTMLElement {
13638
13751
  );
13639
13752
  }
13640
13753
 
13641
- #getDropdownHTML() {
13642
- let optionsHTML = "";
13754
+ #createDropdown() {
13755
+ const dropdown = figCreateElement("fig-dropdown", {
13756
+ className: "fig-easing-curve-select",
13757
+ label: "Easing preset",
13758
+ value: this.#presetName,
13759
+ full: true,
13760
+ });
13643
13761
  let currentGroup = undefined;
13644
- let groupOpen = false;
13762
+ let parent = dropdown;
13645
13763
  for (const preset of FigEasingCurve.PRESETS) {
13646
13764
  if (!this.#isEditEnabled() && !preset.value && !preset.spring) continue;
13647
13765
  if (preset.group !== currentGroup) {
13648
- if (groupOpen) optionsHTML += "</optgroup>";
13649
- groupOpen = Boolean(preset.group);
13650
- if (groupOpen) {
13651
- optionsHTML += `<optgroup label="${FigEasingCurve.#escapeAttribute(preset.group)}">`;
13652
- }
13653
13766
  currentGroup = preset.group;
13767
+ parent = currentGroup
13768
+ ? figCreateElement("optgroup", { label: currentGroup })
13769
+ : dropdown;
13770
+ if (parent !== dropdown) dropdown.appendChild(parent);
13654
13771
  }
13655
- const name = FigEasingCurve.#escapeAttribute(preset.name);
13656
- optionsHTML += `<option value="${name}">${name}</option>`;
13772
+ parent.appendChild(
13773
+ figCreateElement("option", { value: preset.name }, preset.name),
13774
+ );
13657
13775
  }
13658
- if (groupOpen) optionsHTML += "</optgroup>";
13659
- const value = FigEasingCurve.#escapeAttribute(this.#presetName);
13660
- return `<fig-dropdown class="fig-easing-curve-select" label="Easing preset" value="${value}" full>${optionsHTML}</fig-dropdown>`;
13776
+ return dropdown;
13661
13777
  }
13662
13778
 
13663
- #getSelectHTML() {
13664
- if (!this.#canUseFigSelect()) return this.#getDropdownHTML();
13779
+ #createSelect() {
13780
+ if (!this.#canUseFigSelect()) return this.#createDropdown();
13665
13781
 
13666
- let optionsHTML = "";
13782
+ const options = document.createElement("fig-select-options");
13667
13783
  let currentGroup = undefined;
13668
13784
  for (const p of FigEasingCurve.PRESETS) {
13669
13785
  if (!this.#isEditEnabled() && !p.value && !p.spring) continue;
13670
13786
  if (p.group !== currentGroup) {
13671
13787
  if (p.group) {
13672
- optionsHTML += `<fig-separator label="${FigEasingCurve.#escapeAttribute(p.group)}"></fig-separator>`;
13788
+ options.appendChild(
13789
+ figCreateElement("fig-separator", { label: p.group }),
13790
+ );
13673
13791
  }
13674
13792
  currentGroup = p.group;
13675
13793
  }
@@ -13684,42 +13802,152 @@ class FigEasingCurve extends HTMLElement {
13684
13802
  this.#cp2.x,
13685
13803
  this.#cp2.y,
13686
13804
  ];
13687
- icon = FigEasingCurve.curveIcon(...v);
13688
- }
13689
- const name = FigEasingCurve.#escapeAttribute(p.name);
13690
- optionsHTML += `<fig-select-option value="${name}" label="${name}"><span slot="prepend">${icon}</span><span>${name}</span></fig-select-option>`;
13805
+ icon = FigEasingCurve.#createCurveIcon(...v);
13806
+ }
13807
+ options.appendChild(
13808
+ figCreateElement(
13809
+ "fig-select-option",
13810
+ {
13811
+ value: p.name,
13812
+ label: p.name,
13813
+ },
13814
+ [
13815
+ figCreateElement("span", { slot: "prepend" }, icon),
13816
+ figCreateElement("span", {}, p.name),
13817
+ ],
13818
+ ),
13819
+ );
13691
13820
  }
13692
- const value = FigEasingCurve.#escapeAttribute(this.#presetName);
13693
- return `<fig-select class="fig-easing-curve-select" label="Easing preset" value="${value}" full><fig-select-options>${optionsHTML}</fig-select-options></fig-select>`;
13821
+ return figCreateElement(
13822
+ "fig-select",
13823
+ {
13824
+ className: "fig-easing-curve-select",
13825
+ label: "Easing preset",
13826
+ value: this.#presetName,
13827
+ full: true,
13828
+ },
13829
+ options,
13830
+ );
13831
+ }
13832
+
13833
+ #createHandle(className, dataHandle, label) {
13834
+ return figCreateSvgElement(
13835
+ "foreignObject",
13836
+ {
13837
+ className,
13838
+ "data-handle": dataHandle,
13839
+ width: "20",
13840
+ height: "20",
13841
+ },
13842
+ figCreateElement("fig-handle", {
13843
+ size: "small",
13844
+ "aria-label": label,
13845
+ }),
13846
+ );
13694
13847
  }
13695
13848
 
13696
- #getInnerHTML() {
13849
+ #createContent() {
13697
13850
  const size = 200;
13698
- const select = this.#getSelectHTML();
13699
- if (!this.#isEditEnabled()) return select;
13700
- const valueInput = `<fig-input-text class="fig-easing-curve-value-input" value="${FigEasingCurve.#escapeAttribute(this.value)}" full></fig-input-text>`;
13851
+ const select = this.#createSelect();
13852
+ if (!this.#isEditEnabled()) return [select];
13853
+ const valueInput = figCreateElement("fig-input-text", {
13854
+ className: "fig-easing-curve-value-input",
13855
+ value: this.value,
13856
+ full: true,
13857
+ });
13858
+ const svgChildren = [
13859
+ figCreateSvgElement("rect", {
13860
+ className: "fig-easing-curve-bounds",
13861
+ x: "0",
13862
+ y: "0",
13863
+ width: size,
13864
+ height: size,
13865
+ }),
13866
+ ];
13701
13867
 
13702
13868
  if (this.#mode === "spring") {
13703
13869
  const targetY = 40;
13704
- return `${select}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
13705
- <rect class="fig-easing-curve-bounds" x="0" y="0" width="${size}" height="${size}"/>
13706
- <line class="fig-easing-curve-target" x1="0" y1="${targetY}" x2="${size}" y2="${targetY}"/>
13707
- <path class="fig-easing-curve-path"/>
13708
- <foreignObject class="fig-easing-curve-handle" data-handle="bounce" width="20" height="20"><fig-handle size="small" aria-label="Spring bounce handle"></fig-handle></foreignObject>
13709
- <foreignObject class="fig-easing-curve-handle fig-easing-curve-duration-bar" data-handle="duration" width="20" height="20"><fig-handle size="small" aria-label="Spring duration handle"></fig-handle></foreignObject>
13710
- </svg></div>${valueInput}`;
13711
- }
13712
-
13713
- return `${select}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
13714
- <rect class="fig-easing-curve-bounds" x="0" y="0" width="${size}" height="${size}"/>
13715
- <line class="fig-easing-curve-boundary" data-boundary="top" x1="0" y1="0" x2="${size}" y2="0"/>
13716
- <line class="fig-easing-curve-boundary" data-boundary="bottom" x1="0" y1="${size}" x2="${size}" y2="${size}"/>
13717
- <path class="fig-easing-curve-path"/>
13718
- <line class="fig-easing-curve-arm" data-arm="1"/>
13719
- <line class="fig-easing-curve-arm" data-arm="2"/>
13720
- <foreignObject class="fig-easing-curve-handle" data-handle="1" width="20" height="20"><fig-handle size="small" aria-label="First easing control point"></fig-handle></foreignObject>
13721
- <foreignObject class="fig-easing-curve-handle" data-handle="2" width="20" height="20"><fig-handle size="small" aria-label="Second easing control point"></fig-handle></foreignObject>
13722
- </svg></div>${valueInput}`;
13870
+ svgChildren.push(
13871
+ figCreateSvgElement("line", {
13872
+ className: "fig-easing-curve-target",
13873
+ x1: "0",
13874
+ y1: targetY,
13875
+ x2: size,
13876
+ y2: targetY,
13877
+ }),
13878
+ figCreateSvgElement("path", {
13879
+ className: "fig-easing-curve-path",
13880
+ }),
13881
+ this.#createHandle(
13882
+ "fig-easing-curve-handle",
13883
+ "bounce",
13884
+ "Spring bounce handle",
13885
+ ),
13886
+ this.#createHandle(
13887
+ "fig-easing-curve-handle fig-easing-curve-duration-bar",
13888
+ "duration",
13889
+ "Spring duration handle",
13890
+ ),
13891
+ );
13892
+ } else {
13893
+ svgChildren.push(
13894
+ figCreateSvgElement("line", {
13895
+ className: "fig-easing-curve-boundary",
13896
+ "data-boundary": "top",
13897
+ x1: "0",
13898
+ y1: "0",
13899
+ x2: size,
13900
+ y2: "0",
13901
+ }),
13902
+ figCreateSvgElement("line", {
13903
+ className: "fig-easing-curve-boundary",
13904
+ "data-boundary": "bottom",
13905
+ x1: "0",
13906
+ y1: size,
13907
+ x2: size,
13908
+ y2: size,
13909
+ }),
13910
+ figCreateSvgElement("path", {
13911
+ className: "fig-easing-curve-path",
13912
+ }),
13913
+ figCreateSvgElement("line", {
13914
+ className: "fig-easing-curve-arm",
13915
+ "data-arm": "1",
13916
+ }),
13917
+ figCreateSvgElement("line", {
13918
+ className: "fig-easing-curve-arm",
13919
+ "data-arm": "2",
13920
+ }),
13921
+ this.#createHandle(
13922
+ "fig-easing-curve-handle",
13923
+ "1",
13924
+ "First easing control point",
13925
+ ),
13926
+ this.#createHandle(
13927
+ "fig-easing-curve-handle",
13928
+ "2",
13929
+ "Second easing control point",
13930
+ ),
13931
+ );
13932
+ }
13933
+
13934
+ const svg = figCreateSvgElement(
13935
+ "svg",
13936
+ {
13937
+ viewBox: `0 0 ${size} ${size}`,
13938
+ className: "fig-easing-curve-svg",
13939
+ },
13940
+ svgChildren,
13941
+ );
13942
+ return [
13943
+ select,
13944
+ figCreateElement(
13945
+ "div",
13946
+ { className: "fig-easing-curve-svg-container" },
13947
+ svg,
13948
+ ),
13949
+ valueInput,
13950
+ ];
13723
13951
  }
13724
13952
 
13725
13953
  #readCssNumber(name, fallback) {
@@ -14054,7 +14282,7 @@ class FigEasingCurve extends HTMLElement {
14054
14282
  for (const option of this.#select.querySelectorAll("fig-select-option")) {
14055
14283
  if (option.value === optionValue) {
14056
14284
  const prepend = option.querySelector(':scope > [slot="prepend"]');
14057
- if (prepend) prepend.innerHTML = icon;
14285
+ if (prepend) prepend.replaceChildren(icon.cloneNode(true));
14058
14286
  }
14059
14287
  }
14060
14288
  }
@@ -14062,7 +14290,7 @@ class FigEasingCurve extends HTMLElement {
14062
14290
  #refreshCustomPresetIcons() {
14063
14291
  if (!this.#select) return;
14064
14292
  if (!this.#isEditEnabled()) return;
14065
- const bezierIcon = FigEasingCurve.curveIcon(
14293
+ const bezierIcon = FigEasingCurve.#createCurveIcon(
14066
14294
  this.#cp1.x,
14067
14295
  this.#cp1.y,
14068
14296
  this.#cp2.x,
@@ -14652,34 +14880,44 @@ class Fig3DRotate extends HTMLElement {
14652
14880
  rotateY: this.#ry,
14653
14881
  rotateZ: this.#rz,
14654
14882
  };
14655
- const fieldsHTML = this.#fields
14656
- .map(
14657
- (axis) =>
14658
- `<fig-input-number
14659
- name="${axis}"
14660
- step="1"
14661
- precision="1"
14662
- value="${axisValues[axis]}"
14663
- units="°">
14664
- <span slot="prepend">${axisLabels[axis]}</span>
14665
- </fig-input-number>`,
14666
- )
14667
- .join("");
14668
-
14669
- this.innerHTML = `<div class="fig-3d-rotate-container" tabindex="0">
14670
- <div class="fig-3d-rotate-scene">
14671
- <div class="fig-3d-rotate-cube">
14672
- <div class="fig-3d-rotate-face front"></div>
14673
- <div class="fig-3d-rotate-face back"></div>
14674
- <div class="fig-3d-rotate-face right"></div>
14675
- <div class="fig-3d-rotate-face left"></div>
14676
- <div class="fig-3d-rotate-face top"></div>
14677
- <div class="fig-3d-rotate-face bottom"></div>
14678
- </div>
14679
- </div>
14680
- </div>${fieldsHTML}`;
14681
- this.#container = this.querySelector(".fig-3d-rotate-container");
14682
- this.#cube = this.querySelector(".fig-3d-rotate-cube");
14883
+ const cube = figCreateElement(
14884
+ "div",
14885
+ { className: "fig-3d-rotate-cube" },
14886
+ ["front", "back", "right", "left", "top", "bottom"].map((face) =>
14887
+ figCreateElement("div", {
14888
+ className: `fig-3d-rotate-face ${face}`,
14889
+ }),
14890
+ ),
14891
+ );
14892
+ const container = figCreateElement(
14893
+ "div",
14894
+ {
14895
+ className: "fig-3d-rotate-container",
14896
+ tabindex: "0",
14897
+ },
14898
+ figCreateElement(
14899
+ "div",
14900
+ { className: "fig-3d-rotate-scene" },
14901
+ cube,
14902
+ ),
14903
+ );
14904
+ const fields = this.#fields.map((axis) =>
14905
+ figCreateElement(
14906
+ "fig-input-number",
14907
+ {
14908
+ name: axis,
14909
+ step: "1",
14910
+ precision: "1",
14911
+ value: axisValues[axis],
14912
+ units: "°",
14913
+ },
14914
+ figCreateElement("span", { slot: "prepend" }, axisLabels[axis]),
14915
+ ),
14916
+ );
14917
+
14918
+ this.replaceChildren(container, ...fields);
14919
+ this.#container = container;
14920
+ this.#cube = cube;
14683
14921
  this.#wireFieldInputs();
14684
14922
  this.#updateCube();
14685
14923
  this.#setupEvents();
@@ -14982,31 +15220,65 @@ class FigOriginGrid extends HTMLElement {
14982
15220
  const cells = Array.from({ length: 9 }, (_, index) => {
14983
15221
  const col = index % 3;
14984
15222
  const row = Math.floor(index / 3);
14985
- return `<span class="origin-grid-cell" data-col="${col}" data-row="${row}">
14986
- <span class="origin-grid-dot"></span>
14987
- </span>`;
14988
- }).join("");
15223
+ return figCreateElement(
15224
+ "span",
15225
+ {
15226
+ className: "origin-grid-cell",
15227
+ "data-col": col,
15228
+ "data-row": row,
15229
+ },
15230
+ figCreateElement("span", { className: "origin-grid-dot" }),
15231
+ );
15232
+ });
14989
15233
 
14990
15234
  const xValue = this.#x.toFixed(this.#precision);
14991
15235
  const yValue = this.#y.toFixed(this.#precision);
14992
- const fieldsMarkup = this.#fieldsEnabled
14993
- ? `<div class="origin-values">
14994
- <fig-input-number name="x" value="${xValue}" step="1" units="%"><span slot="prepend">X</span></fig-input-number>
14995
- <fig-input-number name="y" value="${yValue}" step="1" units="%"><span slot="prepend">Y</span></fig-input-number>
14996
- </div>`
14997
- : "";
14998
-
14999
- this.innerHTML = `<div class="fig-origin-grid-surface">
15000
- <div class="origin-grid" aria-label="Transform origin grid">
15001
- <div class="origin-grid-cells">${cells}</div>
15002
- <fig-handle></fig-handle>
15003
- </div>
15004
- </div>
15005
- ${fieldsMarkup}`;
15236
+ const handle = document.createElement("fig-handle");
15237
+ const grid = figCreateElement(
15238
+ "div",
15239
+ {
15240
+ className: "origin-grid",
15241
+ "aria-label": "Transform origin grid",
15242
+ },
15243
+ [
15244
+ figCreateElement(
15245
+ "div",
15246
+ { className: "origin-grid-cells" },
15247
+ cells,
15248
+ ),
15249
+ handle,
15250
+ ],
15251
+ );
15252
+ const surface = figCreateElement(
15253
+ "div",
15254
+ { className: "fig-origin-grid-surface" },
15255
+ grid,
15256
+ );
15257
+ const rendered = [surface];
15258
+ if (this.#fieldsEnabled) {
15259
+ const createValueInput = (name, value) =>
15260
+ figCreateElement(
15261
+ "fig-input-number",
15262
+ {
15263
+ name,
15264
+ value,
15265
+ step: "1",
15266
+ units: "%",
15267
+ },
15268
+ figCreateElement("span", { slot: "prepend" }, name.toUpperCase()),
15269
+ );
15270
+ rendered.push(
15271
+ figCreateElement("div", { className: "origin-values" }, [
15272
+ createValueInput("x", xValue),
15273
+ createValueInput("y", yValue),
15274
+ ]),
15275
+ );
15276
+ }
15006
15277
 
15007
- this.#grid = this.querySelector(".origin-grid");
15008
- this.#cells = Array.from(this.querySelectorAll(".origin-grid-cell"));
15009
- this.#handle = this.querySelector("fig-handle");
15278
+ this.replaceChildren(...rendered);
15279
+ this.#grid = grid;
15280
+ this.#cells = cells;
15281
+ this.#handle = handle;
15010
15282
  this.#xInput = this.querySelector('fig-input-number[name="x"]');
15011
15283
  this.#yInput = this.querySelector('fig-input-number[name="y"]');
15012
15284
  this.#syncHandlePosition();
@@ -15463,7 +15735,100 @@ class FigInputJoystick extends HTMLElement {
15463
15735
  }
15464
15736
 
15465
15737
  #render() {
15466
- this.innerHTML = this.#getInnerHTML();
15738
+ const axisLabels = this.#getAxisLabels();
15739
+ const planeContainer = figCreateElement("div", {
15740
+ className: "fig-input-joystick-plane-container",
15741
+ });
15742
+ const createAxisLabel = (position, text, noRotate = false) =>
15743
+ text
15744
+ ? figCreateElement(
15745
+ "label",
15746
+ {
15747
+ className: [
15748
+ "fig-joystick-axis-label",
15749
+ position,
15750
+ noRotate ? "no-rotate" : "",
15751
+ ]
15752
+ .filter(Boolean)
15753
+ .join(" "),
15754
+ "aria-hidden": "true",
15755
+ },
15756
+ text,
15757
+ )
15758
+ : null;
15759
+ planeContainer.append(
15760
+ ...[
15761
+ createAxisLabel(
15762
+ "left",
15763
+ axisLabels.left,
15764
+ axisLabels.leftNoRotate,
15765
+ ),
15766
+ createAxisLabel("right", axisLabels.right),
15767
+ createAxisLabel("top", axisLabels.top),
15768
+ createAxisLabel("bottom", axisLabels.bottom),
15769
+ ].filter(Boolean),
15770
+ );
15771
+
15772
+ const plane = figCreateElement(
15773
+ "div",
15774
+ { className: "fig-input-joystick-plane" },
15775
+ [
15776
+ figCreateElement("div", {
15777
+ className: "fig-input-joystick-guides",
15778
+ }),
15779
+ figCreateElement("fig-handle", {
15780
+ drag: true,
15781
+ "drag-surface": ".fig-input-joystick-plane",
15782
+ "drag-axes": "x,y",
15783
+ "drag-snapping": "modifier",
15784
+ }),
15785
+ ],
15786
+ );
15787
+ const reset = figCreateElement(
15788
+ "fig-tooltip",
15789
+ { text: "Reset" },
15790
+ figCreateElement(
15791
+ "fig-button",
15792
+ {
15793
+ variant: "ghost",
15794
+ icon: "true",
15795
+ className: "fig-joystick-reset",
15796
+ "aria-label": "Reset to default",
15797
+ },
15798
+ createFigIcon("reset", { size: "small" }),
15799
+ ),
15800
+ );
15801
+ planeContainer.append(plane, reset);
15802
+
15803
+ const children = [planeContainer];
15804
+ if (this.#fieldsEnabled) {
15805
+ const createValueInput = (name, value) =>
15806
+ figCreateElement(
15807
+ "fig-input-number",
15808
+ {
15809
+ name,
15810
+ step: "1",
15811
+ value,
15812
+ min: "0",
15813
+ max: "100",
15814
+ units: "%",
15815
+ },
15816
+ figCreateElement("span", { slot: "prepend" }, name.toUpperCase()),
15817
+ );
15818
+ children.push(
15819
+ figCreateElement("div", { className: "joystick-values" }, [
15820
+ createValueInput(
15821
+ "x",
15822
+ (this.position.x * 100).toFixed(this.precision),
15823
+ ),
15824
+ createValueInput(
15825
+ "y",
15826
+ (this.position.y * 100).toFixed(this.precision),
15827
+ ),
15828
+ ]),
15829
+ );
15830
+ }
15831
+ this.replaceChildren(...children);
15467
15832
  }
15468
15833
 
15469
15834
  #getAxisLabels() {
@@ -15492,63 +15857,6 @@ class FigInputJoystick extends HTMLElement {
15492
15857
  return { left: "", right: "", top: "", bottom: "", leftNoRotate: false };
15493
15858
  }
15494
15859
 
15495
- #getInnerHTML() {
15496
- const axisLabels = this.#getAxisLabels();
15497
- const labelsMarkup = [
15498
- axisLabels.left
15499
- ? `<label class="fig-joystick-axis-label left${axisLabels.leftNoRotate ? " no-rotate" : ""}" aria-hidden="true">${axisLabels.left}</label>`
15500
- : "",
15501
- axisLabels.right
15502
- ? `<label class="fig-joystick-axis-label right" aria-hidden="true">${axisLabels.right}</label>`
15503
- : "",
15504
- axisLabels.top
15505
- ? `<label class="fig-joystick-axis-label top" aria-hidden="true">${axisLabels.top}</label>`
15506
- : "",
15507
- axisLabels.bottom
15508
- ? `<label class="fig-joystick-axis-label bottom" aria-hidden="true">${axisLabels.bottom}</label>`
15509
- : "",
15510
- ].join("");
15511
-
15512
- return `
15513
- <div class="fig-input-joystick-plane-container">
15514
- ${labelsMarkup}
15515
- <div class="fig-input-joystick-plane">
15516
- <div class="fig-input-joystick-guides"></div>
15517
- <fig-handle drag drag-surface=".fig-input-joystick-plane" drag-axes="x,y" drag-snapping="modifier"></fig-handle>
15518
- </div>
15519
- <fig-tooltip text="Reset">
15520
- <fig-button variant="ghost" icon="true" class="fig-joystick-reset" aria-label="Reset to default">
15521
- <fig-icon name="reset" size="small"></fig-icon>
15522
- </fig-button>
15523
- </fig-tooltip>
15524
- </div>
15525
- ${
15526
- this.#fieldsEnabled
15527
- ? `<div class="joystick-values">
15528
- <fig-input-number
15529
- name="x"
15530
- step="1"
15531
- value="${(this.position.x * 100).toFixed(this.precision)}"
15532
- min="0"
15533
- max="100"
15534
- units="%">
15535
- <span slot="prepend">X</span>
15536
- </fig-input-number>
15537
- <fig-input-number
15538
- name="y"
15539
- step="1"
15540
- min="0"
15541
- max="100"
15542
- value="${(this.position.y * 100).toFixed(this.precision)}"
15543
- units="%">
15544
- <span slot="prepend">Y</span>
15545
- </fig-input-number>
15546
- </div>`
15547
- : ""
15548
- }
15549
- `;
15550
- }
15551
-
15552
15860
  #setupListeners() {
15553
15861
  this.plane = this.querySelector(".fig-input-joystick-plane");
15554
15862
  this.cursor = this.querySelector("fig-handle");
@@ -16078,314 +16386,6 @@ class FigPreview extends HTMLElement {
16078
16386
  }
16079
16387
  figDefineElement("fig-preview", FigPreview);
16080
16388
 
16081
- /**
16082
- * Compact swatch previewing gradient color-space interpolation.
16083
- * Polar: CSS conic-gradient masked (SVG data-URL, round linecaps) to an arc.
16084
- * Non-polar: CSS linear-gradient masked to a horizontal round-capped stroke.
16085
- * Accepts the same `value` shape as fig-input-gradient / fig-fill-picker.
16086
- *
16087
- * @element fig-interpolation-swatch
16088
- * @attr {string} value - JSON `{ type: "gradient", gradient: { … } }` (or a bare gradient object)
16089
- * @attr {string} size - `small` (default, 24px) or `large` (32px)
16090
- */
16091
- class FigInterpolationSwatch extends HTMLElement {
16092
- static #HUE_SPACES = new Set(["oklch", "hsl"]);
16093
- static #CX = 10;
16094
- static #CY = 10;
16095
- static #R = 8;
16096
- static #STROKE = 3;
16097
- // Polar endpoints ≈ 10 o'clock → 2 o'clock (SVG deg: 0 = east, CW).
16098
- // CSS conic `from` is 0 = north; convert with +90.
16099
- static #START_DEG = 210;
16100
- static #DEFAULT_GRADIENT = {
16101
- type: "linear",
16102
- angle: 135,
16103
- interpolationSpace: "srgb",
16104
- hueInterpolation: "shorter",
16105
- stops: [
16106
- { color: "#FF0000", position: 0, opacity: 100 },
16107
- { color: "#4F9EFF", position: 100, opacity: 100 },
16108
- ],
16109
- };
16110
-
16111
- #rendered = false;
16112
- #svgEl = null;
16113
- #fillEl = null;
16114
- #gradient = { ...FigInterpolationSwatch.#DEFAULT_GRADIENT };
16115
-
16116
- static get observedAttributes() {
16117
- return ["value"];
16118
- }
16119
-
16120
- get value() {
16121
- return {
16122
- type: "gradient",
16123
- gradient: { ...this.#gradient },
16124
- };
16125
- }
16126
-
16127
- set value(val) {
16128
- if (val == null || val === "") {
16129
- this.removeAttribute("value");
16130
- return;
16131
- }
16132
- if (typeof val === "string") {
16133
- this.setAttribute("value", val);
16134
- return;
16135
- }
16136
- this.setAttribute("value", JSON.stringify(val));
16137
- }
16138
-
16139
- connectedCallback() {
16140
- this.#ensureA11y();
16141
- this.#parseValue();
16142
- this.#render();
16143
- this.#updatePreview();
16144
- }
16145
-
16146
- attributeChangedCallback(name, oldValue, newValue) {
16147
- if (oldValue === newValue) return;
16148
- if (name !== "value") return;
16149
- this.#parseValue();
16150
- if (this.#rendered) this.#updatePreview();
16151
- }
16152
-
16153
- #ensureA11y() {
16154
- const named =
16155
- this.hasAttribute("aria-label") || this.hasAttribute("aria-labelledby");
16156
- if (!named && !this.hasAttribute("aria-hidden")) {
16157
- this.setAttribute("aria-hidden", "true");
16158
- }
16159
- }
16160
-
16161
- #parseValue() {
16162
- const valueAttr = this.getAttribute("value");
16163
- if (!valueAttr) {
16164
- this.#gradient = {
16165
- ...FigInterpolationSwatch.#DEFAULT_GRADIENT,
16166
- stops: FigInterpolationSwatch.#DEFAULT_GRADIENT.stops.map((s) => ({
16167
- ...s,
16168
- })),
16169
- };
16170
- return;
16171
- }
16172
- try {
16173
- const parsed = JSON.parse(valueAttr);
16174
- const gradient = parsed?.type === "gradient" && parsed.gradient
16175
- ? parsed.gradient
16176
- : parsed?.gradient
16177
- ? parsed.gradient
16178
- : parsed;
16179
- if (!gradient || typeof gradient !== "object") return;
16180
- this.#gradient = this.#normalizeGradient({
16181
- ...FigInterpolationSwatch.#DEFAULT_GRADIENT,
16182
- ...gradient,
16183
- });
16184
- } catch {
16185
- // Keep current/default gradient on invalid JSON.
16186
- }
16187
- }
16188
-
16189
- #normalizeGradient(gradient) {
16190
- const next = { ...(gradient ?? {}) };
16191
- const interpolationSpace = String(
16192
- next.interpolationSpace ?? "srgb",
16193
- ).toLowerCase();
16194
- const hueInterpolation = String(
16195
- next.hueInterpolation ?? "shorter",
16196
- ).toLowerCase();
16197
- const stops = Array.isArray(next.stops)
16198
- ? next.stops.map((stop) => ({
16199
- color: String(stop?.color || "#D9D9D9").replace(
16200
- /^(#(?:[0-9a-f]{6})).*/i,
16201
- "$1",
16202
- ),
16203
- position: stop?.position ?? 0,
16204
- opacity: stop?.opacity ?? 100,
16205
- }))
16206
- : FigInterpolationSwatch.#DEFAULT_GRADIENT.stops.map((s) => ({ ...s }));
16207
- if (stops.length < 2) {
16208
- return {
16209
- ...FigInterpolationSwatch.#DEFAULT_GRADIENT,
16210
- stops: FigInterpolationSwatch.#DEFAULT_GRADIENT.stops.map((s) => ({
16211
- ...s,
16212
- })),
16213
- };
16214
- }
16215
- return {
16216
- type: ["linear", "radial", "angular"].includes(next.type)
16217
- ? next.type
16218
- : "linear",
16219
- angle: Number.isFinite(Number(next.angle)) ? Number(next.angle) : 135,
16220
- interpolationSpace,
16221
- hueInterpolation,
16222
- stops,
16223
- };
16224
- }
16225
-
16226
- #isPolar() {
16227
- return FigInterpolationSwatch.#HUE_SPACES.has(
16228
- this.#gradient.interpolationSpace || "srgb",
16229
- );
16230
- }
16231
-
16232
- #hueForColor(color) {
16233
- const { r, g, b } = figHexToRGB(color);
16234
- if (this.#gradient.interpolationSpace === "hsl") {
16235
- return figRgbToHsl(r, g, b).h;
16236
- }
16237
- const lab = figRGBToOklab(r, g, b);
16238
- const hue = figOklabToOklch(lab.l, lab.a, lab.b).h;
16239
- return ((hue % 360) + 360) % 360;
16240
- }
16241
-
16242
- #polarArcGeometry() {
16243
- const stops = this.#sortedStops();
16244
- const startHue = this.#hueForColor(stops[0]?.color || "#FF0000");
16245
- const endHue = this.#hueForColor(
16246
- stops[stops.length - 1]?.color || "#4F9EFF",
16247
- );
16248
- const startDeg = FigInterpolationSwatch.#START_DEG - startHue;
16249
- const endDeg = FigInterpolationSwatch.#START_DEG - endHue;
16250
- const clockwiseSweep = ((endDeg - startDeg) % 360 + 360) % 360;
16251
- const counterclockwiseSweep =
16252
- clockwiseSweep === 0 ? 0 : clockwiseSweep - 360;
16253
- const method = this.#gradient.hueInterpolation || "shorter";
16254
-
16255
- let sweepDeg;
16256
- if (method === "increasing") {
16257
- sweepDeg = counterclockwiseSweep;
16258
- } else if (method === "decreasing") {
16259
- sweepDeg = clockwiseSweep;
16260
- } else if (method === "longer") {
16261
- sweepDeg =
16262
- clockwiseSweep < 180 ? counterclockwiseSweep : clockwiseSweep;
16263
- } else {
16264
- sweepDeg =
16265
- clockwiseSweep <= 180 ? clockwiseSweep : counterclockwiseSweep;
16266
- }
16267
-
16268
- // A round cap extends beyond the path endpoint. Inset the centerline so
16269
- // the visible cap edges, rather than their centers, land on the hues.
16270
- const direction = Math.sign(sweepDeg);
16271
- const capAngle =
16272
- (Math.asin(FigInterpolationSwatch.#STROKE / 2 / FigInterpolationSwatch.#R) *
16273
- 180) /
16274
- Math.PI;
16275
- const inset = Math.min(
16276
- capAngle,
16277
- Math.max(0, (Math.abs(sweepDeg) - 0.01) / 2),
16278
- );
16279
- return {
16280
- startDeg: startDeg + direction * inset,
16281
- sweepDeg: sweepDeg - direction * inset * 2,
16282
- };
16283
- }
16284
-
16285
- #pointOnCircle(deg) {
16286
- const rad = (deg * Math.PI) / 180;
16287
- return {
16288
- x: FigInterpolationSwatch.#CX + FigInterpolationSwatch.#R * Math.cos(rad),
16289
- y: FigInterpolationSwatch.#CY + FigInterpolationSwatch.#R * Math.sin(rad),
16290
- };
16291
- }
16292
-
16293
- #arcMaskPath(startDeg, sweepDeg) {
16294
- const endDeg = startDeg + sweepDeg;
16295
- const start = this.#pointOnCircle(startDeg);
16296
- const end = this.#pointOnCircle(endDeg);
16297
- const largeArc = Math.abs(sweepDeg) > 180 ? 1 : 0;
16298
- const sweepFlag = sweepDeg >= 0 ? 1 : 0;
16299
- return `M ${start.x} ${start.y} A ${FigInterpolationSwatch.#R} ${FigInterpolationSwatch.#R} 0 ${largeArc} ${sweepFlag} ${end.x} ${end.y}`;
16300
- }
16301
-
16302
- #lineMaskPath() {
16303
- const start = this.#pointOnCircle(180);
16304
- const end = this.#pointOnCircle(0);
16305
- return `M ${start.x} ${start.y} L ${end.x} ${end.y}`;
16306
- }
16307
-
16308
- #maskImageForPath(d) {
16309
- const stroke = FigInterpolationSwatch.#STROKE;
16310
- const svg =
16311
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">` +
16312
- `<path d="${d}" fill="none" stroke="white" stroke-width="${stroke}" ` +
16313
- `stroke-linecap="round" stroke-linejoin="round"/>` +
16314
- `</svg>`;
16315
- return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
16316
- }
16317
-
16318
- #sortedStops() {
16319
- return [...this.#gradient.stops].sort(
16320
- (a, b) => (a.position ?? 0) - (b.position ?? 0),
16321
- );
16322
- }
16323
-
16324
- #cssInterpolationClause() {
16325
- const space = this.#gradient.interpolationSpace || "srgb";
16326
- if (space === "srgb") return "";
16327
- if (FigInterpolationSwatch.#HUE_SPACES.has(space)) {
16328
- return ` in ${space} ${this.#gradient.hueInterpolation || "shorter"} hue`;
16329
- }
16330
- return ` in ${space}`;
16331
- }
16332
-
16333
- #previewBackground() {
16334
- const stops = this.#sortedStops();
16335
- const clause = this.#cssInterpolationClause();
16336
- if (this.#isPolar()) {
16337
- // Fixed hue wheel. The mask maps gradient endpoint hues onto this wheel.
16338
- const cssFrom = (FigInterpolationSwatch.#START_DEG + 90) % 360;
16339
- const space = this.#gradient.interpolationSpace;
16340
- const wheelColor = (hue) =>
16341
- space === "oklch"
16342
- ? `oklch(65% 0.25 ${hue})`
16343
- : `hsl(${hue} 100% 50%)`;
16344
- const wheelStops = [0, 300, 240, 180, 120, 60, 0]
16345
- .map(wheelColor)
16346
- .join(", ");
16347
- return `conic-gradient(from ${cssFrom}deg in ${space} decreasing hue, ${wheelStops})`;
16348
- }
16349
- const stopList = stops
16350
- .map((s) => `${s.color} ${s.position ?? 0}%`)
16351
- .join(", ");
16352
- return `linear-gradient(90deg${clause}, ${stopList})`;
16353
- }
16354
-
16355
- #render() {
16356
- if (this.#rendered) return;
16357
- const stroke = FigInterpolationSwatch.#STROKE;
16358
- this.innerHTML = `
16359
- <svg class="fig-interpolation-swatch-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
16360
- <circle class="fig-interpolation-swatch-rim" cx="10" cy="10" r="8" fill="none" stroke="currentColor" stroke-width="${stroke}"/>
16361
- </svg>
16362
- <div class="fig-interpolation-swatch-fill" aria-hidden="true"></div>
16363
- `;
16364
- this.#svgEl = this.querySelector(".fig-interpolation-swatch-svg");
16365
- this.#fillEl = this.querySelector(".fig-interpolation-swatch-fill");
16366
- this.#rendered = true;
16367
- }
16368
-
16369
- #updatePreview() {
16370
- if (!this.#fillEl) return;
16371
-
16372
- const polar = this.#isPolar();
16373
- if (this.#svgEl) this.#svgEl.style.display = polar ? "" : "none";
16374
-
16375
- const d = polar
16376
- ? (() => {
16377
- const { startDeg, sweepDeg } = this.#polarArcGeometry();
16378
- return this.#arcMaskPath(startDeg, sweepDeg);
16379
- })()
16380
- : this.#lineMaskPath();
16381
- const mask = this.#maskImageForPath(d);
16382
- this.#fillEl.style.setProperty("-webkit-mask-image", mask);
16383
- this.#fillEl.style.maskImage = mask;
16384
- this.#fillEl.style.background = this.#previewBackground();
16385
- }
16386
- }
16387
- figDefineElement("fig-interpolation-swatch", FigInterpolationSwatch);
16388
-
16389
16389
  /** @type {Record<string, string | { medium: string, small: string }>} */
16390
16390
  const FIG_ICON_TOKENS = {
16391
16391
  chevron: { medium: "--icon-24-chevron", small: "--icon-16-chevron" },
@@ -16575,11 +16575,22 @@ class FigColorTip extends HTMLElement {
16575
16575
  }
16576
16576
 
16577
16577
  #render() {
16578
+ this.#teardownListeners();
16578
16579
  const mode = this.#controlMode;
16579
16580
  if (mode === "add" || mode === "remove") {
16580
16581
  const iconName = mode === "add" ? "add" : "minus";
16581
16582
  const label = this.getAttribute("aria-label") || (mode === "add" ? "Add color stop" : "Remove color stop");
16582
- this.innerHTML = `<fig-button icon variant="ghost" aria-label="${figEscapeAttribute(label)}"><fig-icon name="${iconName}"></fig-icon></fig-button>`;
16583
+ this.replaceChildren(
16584
+ figCreateElement(
16585
+ "fig-button",
16586
+ {
16587
+ icon: true,
16588
+ variant: "ghost",
16589
+ "aria-label": label,
16590
+ },
16591
+ createFigIcon(iconName),
16592
+ ),
16593
+ );
16583
16594
  this.#fillPicker = null;
16584
16595
  this.#swatch = null;
16585
16596
  this.addEventListener("click", this.#handleControlClick);
@@ -16591,7 +16602,6 @@ class FigColorTip extends HTMLElement {
16591
16602
  const rawValue = (this.getAttribute("value") || "").trim();
16592
16603
  const color = this.#normalizeColor(rawValue);
16593
16604
  const alpha = this.#extractAlpha(rawValue);
16594
- const alphaAttr = this.#alphaEnabled ? "" : 'alpha="false"';
16595
16605
  const pickerValue =
16596
16606
  alpha < 1
16597
16607
  ? JSON.stringify({
@@ -16600,16 +16610,28 @@ class FigColorTip extends HTMLElement {
16600
16610
  opacity: Math.round(alpha * 100),
16601
16611
  })
16602
16612
  : JSON.stringify({ type: "solid", color });
16603
- const swatchAlphaAttr = alpha < 1 ? ` alpha="${alpha}"` : "";
16604
- this.innerHTML = hasFigFillPicker()
16605
- ? `<fig-fill-picker mode="solid" ${alphaAttr} value='${pickerValue}'>
16606
- <fig-swatch background="${color}"${swatchAlphaAttr}></fig-swatch>
16607
- </fig-fill-picker>`
16608
- : `<fig-swatch background="${color}"${swatchAlphaAttr}></fig-swatch>`;
16613
+ const swatch = figCreateElement("fig-swatch", {
16614
+ background: color,
16615
+ alpha: alpha < 1 ? alpha : null,
16616
+ });
16617
+ let picker = null;
16618
+ if (hasFigFillPicker()) {
16619
+ picker = figCreateElement(
16620
+ "fig-fill-picker",
16621
+ {
16622
+ mode: "solid",
16623
+ alpha: this.#alphaEnabled ? null : "false",
16624
+ value: pickerValue,
16625
+ },
16626
+ swatch,
16627
+ );
16628
+ this.replaceChildren(picker);
16629
+ } else {
16630
+ this.replaceChildren(swatch);
16631
+ }
16609
16632
 
16610
- this.#fillPicker = this.querySelector("fig-fill-picker");
16611
- this.#swatch = this.querySelector("fig-swatch");
16612
- this.#teardownListeners();
16633
+ this.#fillPicker = picker;
16634
+ this.#swatch = swatch;
16613
16635
  this.#fillPicker?.addEventListener("input", this.#boundHandleInput);
16614
16636
  this.#fillPicker?.addEventListener("change", this.#boundHandleChange);
16615
16637
  if (!this.#fillPicker) {